import escapeRegexp from 'escape-string-regexp'; import { knexOwner as knex } from './knex.js'; import { fetchScenesById } from './scenes.js'; import { getIdsBySlug } from './cache.js'; import promiseProps from '../utils/promise-props.js'; import { HttpError } from './errors.js'; function curateAlert(alert, context = {}) { return { id: alert.id, notify: alert.notify, email: alert.email, createdAt: alert.created_at, isFromPreset: alert.from_preset, and: { fields: alert.all, actors: alert.all_actors, tags: alert.all_tags, entities: alert.all_entities, matches: alert.all_tags, }, actors: context.actors?.map((actor) => ({ id: actor.actor_id, name: actor.actor_name, slug: actor.actor_slug, })) || [], tags: context.tags?.map((tag) => ({ id: tag.tag_id, name: tag.tag_name, slug: tag.tag_slug, })) || [], entities: context.entities?.map((entity) => ({ id: entity.entity_id, name: entity.entity_name, slug: entity.entity_slug, type: entity.entity_type, })) || [], matches: context.matches?.map((match) => ({ id: match.id, property: match.property, expression: match.expression, })) || [], stashes: context.stashes?.map((stash) => ({ id: stash.stash_id, name: stash.stash_name, slug: stash.stash_slug, isPrimary: stash.stash_primary, })) || [], comment: alert.comment, meta: alert.meta, }; } export async function fetchAlerts(user, alertIds) { const { alerts, actors, tags, entities, matches, stashes, } = await promiseProps({ alerts: knex('alerts') .where('user_id', user.id) .where((builder) => { if (alertIds) { builder.whereIn('id', alertIds); } }) .orderBy('created_at', 'desc'), actors: knex('alerts_actors') .select('alerts_actors.*', 'actors.name as actor_name', 'actors.slug as actor_slug') .leftJoin('alerts', 'alerts.id', 'alerts_actors.alert_id') .leftJoin('actors', 'actors.id', 'alerts_actors.actor_id') .where('alerts.user_id', user.id) .where((builder) => { if (alertIds) { builder.whereIn('alerts.id', alertIds); } }), tags: knex('alerts_tags') .select('alerts_tags.*', 'tags.name as tag_name', 'tags.slug as tag_slug') .leftJoin('alerts', 'alerts.id', 'alerts_tags.alert_id') .leftJoin('tags', 'tags.id', 'alerts_tags.tag_id') .where('alerts.user_id', user.id) .where((builder) => { if (alertIds) { builder.whereIn('alerts.id', alertIds); } }), entities: knex('alerts_entities') .select('alerts_entities.*', 'entities.name as entity_name', 'entities.slug as entity_slug', 'entities.type as entity_type') .leftJoin('alerts', 'alerts.id', 'alerts_entities.alert_id') .leftJoin('entities', 'entities.id', 'alerts_entities.entity_id') .where('alerts.user_id', user.id) .where((builder) => { if (alertIds) { builder.whereIn('alerts.id', alertIds); } }), matches: knex('alerts_matches') .select('alerts_matches.*') .leftJoin('alerts', 'alerts.id', 'alerts_matches.alert_id') .where('alerts.user_id', user.id) .where((builder) => { if (alertIds) { builder.whereIn('alerts.id', alertIds); } }), stashes: knex('alerts_stashes') .select('alerts_stashes.*', 'stashes.id as stash_id', 'stashes.name as stash_name', 'stashes.slug as stash_slug', 'stashes.primary as stash_primary') .leftJoin('alerts', 'alerts.id', 'alerts_stashes.alert_id') .leftJoin('stashes', 'stashes.id', 'alerts_stashes.stash_id') .where('alerts.user_id', user.id) .where((builder) => { if (alertIds) { builder.whereIn('alerts.id', alertIds); } }), }); const curatedAlerts = alerts.map((alert) => curateAlert(alert, { actors: actors.filter((actor) => actor.alert_id === alert.id), tags: tags.filter((tag) => tag.alert_id === alert.id), entities: entities.filter((entity) => entity.alert_id === alert.id), matches: matches.filter((match) => match.alert_id === alert.id), stashes: stashes.filter((stash) => stash.alert_id === alert.id), })); return curatedAlerts; } export async function createAlert(alert, reqUser) { if (!reqUser) { throw new HttpError('You are not authenthicated', 401); } const tagIds = (await getIdsBySlug(alert.tags, 'tags') || []).concat(alert.tagIds || []); const entityIds = (await getIdsBySlug(alert.entities || [], 'entities')).concat(alert.entityIds || []); const actorIds = [...(alert.actors || []), ...(alert.actorIds || [])]; // for consistency with tagIds and entityIds const stashIds = [...(alert.stashes || []), ...(alert.stashIds || [])]; // for consistency with tagIds and entityIds if (actorIds.length === 0 && tagIds.length === 0 && entityIds.length === 0 && (!alert.matches || alert.matches.length === 0)) { throw new HttpError('Alert must contain at least one actor, tag or entity', 400); } if (alert.matches?.some((match) => !match.property || !match.expression)) { throw new HttpError('Match must define a property and an expression', 400); } if (tagIds.length < (alert.tags?.length || 0) + (alert.tagIds?.length || 0)) { throw new HttpError('Failed to resolve all tags'); } if (entityIds.length < (alert.entities?.length || 0) + (alert.entityIds?.length || 0)) { throw new HttpError('Failed to resolve all entities'); } const [{ id: alertId }] = await knex('alerts') .insert({ user_id: reqUser.id, notify: alert.notify, email: alert.email, all: alert.all, all_actors: alert.allActors, all_entities: alert.allEntities, all_tags: alert.allTags, all_matches: alert.allMatches, from_preset: alert.preset, comment: alert.comment, meta: alert.meta, }) .returning('id'); await Promise.all([ actorIds?.length > 0 && knex('alerts_actors').insert(actorIds.map((actorId) => ({ alert_id: alertId, actor_id: actorId, }))), tagIds?.length > 0 && knex('alerts_tags').insert(tagIds.map((tagId) => ({ alert_id: alertId, tag_id: tagId, }))), alert.matches?.length > 0 && knex('alerts_matches').insert(alert.matches.map((match) => ({ alert_id: alertId, property: match.property, expression: /\/.*\//.test(match.expression) ? match.expression.slice(1, -1) : escapeRegexp(match.expression), }))), stashIds?.length > 0 && knex('alerts_stashes').insert(stashIds.map((stashId) => ({ alert_id: alertId, stash_id: stashId, }))), entityIds?.length > 0 && knex('alerts_entities').insert(entityIds.map((entityId) => ({ alert_id: alertId, entity_id: entityId, })).slice(0, alert.allEntities ? 1 : Infinity)), // one scene can never match multiple entities in AND mode ]); await Promise.all([ alert.actors?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_actors'), alert.tags?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_tags'), alert.entities?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_entities'), ]); const [newAlert] = await fetchAlerts(reqUser, [alertId]); return newAlert; } export async function removeAlert(alertId, reqUser) { const alert = await knex('alerts') .where('alerts.id', alertId) .where('alerts.user_id', reqUser.id) .select( 'alerts.id', knex.raw('coalesce(array_agg(distinct alerts_actors.actor_id) filter (where alerts_actors.actor_id is not null), \'{}\') as actor_ids'), knex.raw('coalesce(array_agg(distinct alerts_entities.entity_id) filter (where alerts_entities.entity_id is not null), \'{}\') as entity_ids'), knex.raw('coalesce(array_agg(distinct alerts_tags.tag_id) filter (where alerts_tags.tag_id is not null), \'{}\') as tag_ids'), ) .leftJoin('alerts_actors', 'alerts_actors.alert_id', 'alerts.id') .leftJoin('alerts_entities', 'alerts_entities.alert_id', 'alerts.id') .leftJoin('alerts_tags', 'alerts_tags.alert_id', 'alerts.id') .leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id') .groupBy('alerts.id') .first(); if (!alert) { throw new HttpError(`Could not find alert ${alertId}`, 404); } const [removed] = await knex('alerts') .where('id', alertId) .where('user_id', reqUser.id) .delete() .returning('*'); if (!removed) { throw new HttpError(`Could not remove alert ${alertId}`, 404); } // slow not critical for response, don't await Promise.all([ alert.actor_ids.length > 0 && knex.schema.refreshMaterializedView('alerts_users_actors'), alert.tag_ids.length > 0 && knex.schema.refreshMaterializedView('alerts_users_tags'), alert.entity_ids?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_entities'), ]); return curateAlert(removed); } function curateNotification(notification, scenes) { const scene = scenes.find((sceneX) => sceneX.id === notification.scene_id); return { id: notification.id, sceneId: notification.scene_id, scene, alertId: notification.alert_id, matchedActors: scene.actors.filter((actor) => notification.alert_actors.includes(actor.id)), matchedTags: scene.tags.filter((tag) => notification.alert_tags.includes(tag.id)), matchedEntity: [scene.channel, scene.network].find((entity) => notification.alert_entities.includes(entity?.id)) || null, matchedExpressions: notification.alert_matches .filter((match) => new RegExp(match.expression, 'ui').test(scene[match.property])) .map((match) => ({ id: match.id, property: match.property, expression: match.expression, })), isSeen: notification.seen, createdAt: notification.created_at, }; } export async function fetchUnseenNotificationsCount(reqUser) { if (!reqUser) { return null; } const rawUnseen = await knex('notifications') .select(knex.raw('count(id)')) .where('user_id', reqUser.id) .where('seen', false) .first(); return Number(rawUnseen.count); } export async function fetchNotifications(reqUser, options = {}) { if (!reqUser) { return []; } const [notifications, unseen] = await Promise.all([ knex('notifications') .select( 'notifications.*', 'alerts.id as alert_id', knex.raw('coalesce(array_agg(alerts_actors.actor_id) filter (where alerts_actors.id is not null), \'{}\') as alert_actors'), knex.raw('coalesce(array_agg(alerts_tags.tag_id) filter (where alerts_tags.id is not null), \'{}\') as alert_tags'), knex.raw('coalesce(array_agg(alerts_entities.entity_id) filter (where alerts_entities.id is not null), \'{}\') as alert_entities'), knex.raw('coalesce(json_agg(alerts_matches) filter (where alerts_matches.id is not null), \'[]\') as alert_matches'), ) .leftJoin('alerts', 'alerts.id', 'notifications.alert_id') .leftJoin('alerts_actors', 'alerts_actors.alert_id', 'alerts.id') .leftJoin('alerts_tags', 'alerts_tags.alert_id', 'alerts.id') .leftJoin('alerts_entities', 'alerts_entities.alert_id', 'alerts.id') .leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id') .where('notifications.user_id', reqUser.id) .limit(options.limit) .groupBy('notifications.id', 'alerts.id') .orderBy('created_at', 'desc'), fetchUnseenNotificationsCount(reqUser), ]); const scenes = await fetchScenesById(notifications.map((notification) => notification.scene_id)); const curatedNotifications = notifications.map((notification) => curateNotification(notification, scenes)); return { notifications: curatedNotifications, unseen, }; } export async function updateNotification(notificationId, updatedNotification, reqUser) { const [updated] = await knex('notifications') .where('id', notificationId) .where('user_id', reqUser.id) .update({ seen: updatedNotification.seen, }) .returning('*'); if (!updated) { throw new HttpError(`No notification ${notificationId} found to update`, 404); } return updated.id; } export async function updateNotifications(updatedNotification, reqUser) { const updatedCount = await knex('notifications') .where('user_id', reqUser.id) .update({ seen: updatedNotification.seen, }); return updatedCount; }