'use strict'; const escapeRegexp = require('escape-string-regexp'); const knex = require('./knex'); const { indexApi } = require('./manticore'); const bulkInsert = require('./utils/bulk-insert'); const { HttpError } = require('./errors'); async function addAlert(alert, sessionUser) { if (!sessionUser) { throw new HttpError('You are not authenthicated', 401); } if ((!alert.actors || alert.actors.length === 0) && (!alert.tags || alert.tags.length === 0) && (!alert.entities || alert.entities.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); } const [{ id: alertId }] = await knex('alerts') .insert({ user_id: sessionUser.id, notify: alert.notify, email: alert.email, all: alert.all, }) .returning('id'); await Promise.all([ alert.actors?.length > 0 && bulkInsert('alerts_actors', alert.actors.map((actorId) => ({ alert_id: alertId, actor_id: actorId, })), false), alert.tags?.length > 0 && bulkInsert('alerts_tags', alert.tags.map((tagId) => ({ alert_id: alertId, tag_id: tagId, })), false), alert.matches?.length > 0 && bulkInsert('alerts_matches', alert.matches.map((match) => ({ alert_id: alertId, property: match.property, expression: match.expression, })), false), alert.stashes?.length > 0 && bulkInsert('alerts_stashes', alert.stashes.map((stashId) => ({ alert_id: alertId, stash_id: stashId, })), false), alert.entities && bulkInsert('alerts_entities', alert.entities.map((entityId) => ({ alert_id: alertId, entity_id: entityId, })).slice(0, alert.all ? 1 : Infinity), false), // one scene can never match multiple entities in AND mode ]); return alertId; } async function removeAlert(alertId) { await knex('alerts').where('id', alertId).delete(); } async function notify(scenes) { const sceneIds = scenes.map((scene) => scene.id); const [ releasesActors, releasesTags, rawAlerts, alertsActors, alertsTags, alertsEntities, alertsMatches, alertsStashes, ] = await Promise.all([ knex('releases_actors').whereIn('release_id', sceneIds), knex('releases_tags').whereIn('release_id', sceneIds), knex('alerts'), knex('alerts_actors'), knex('alerts_tags'), knex('alerts_entities'), knex('alerts_matches'), knex('alerts_stashes'), ]); const actorIdsByReleaseId = releasesActors.reduce((acc, releaseActor) => { if (!acc[releaseActor.release_id]) { acc[releaseActor.release_id] = []; } acc[releaseActor.release_id].push(releaseActor.actor_id); return acc; }, {}); const tagIdsByReleaseId = releasesTags.reduce((acc, releaseTag) => { if (!acc[releaseTag.release_id]) { acc[releaseTag.release_id] = []; } acc[releaseTag.release_id].push(releaseTag.tag_id); return acc; }, {}); const alertsActorsByAlertId = alertsActors.reduce((acc, alertActor) => { if (!acc[alertActor.alert_id]) { acc[alertActor.alert_id] = []; } acc[alertActor.alert_id].push(alertActor.actor_id); return acc; }, {}); const alertsTagsByAlertId = alertsTags.reduce((acc, alertTag) => { if (!acc[alertTag.alert_id]) { acc[alertTag.alert_id] = []; } acc[alertTag.alert_id].push(alertTag.tag_id); return acc; }, {}); const alertsEntitiesByAlertId = alertsEntities.reduce((acc, alertEntity) => { if (!acc[alertEntity.alert_id]) { acc[alertEntity.alert_id] = []; } acc[alertEntity.alert_id].push(alertEntity.entity_id); return acc; }, {}); const alertsStashesByAlertId = alertsStashes.reduce((acc, alertStash) => { if (!acc[alertStash.alert_id]) { acc[alertStash.alert_id] = []; } acc[alertStash.alert_id].push(alertStash.stash_id); return acc; }, {}); const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => { if (!acc[alertMatch.alert_id]) { acc[alertMatch.alert_id] = []; } acc[alertMatch.alert_id].push({ property: alertMatch.property, expression: /\/.*\//.test(alertMatch.expression) ? new RegExp(alertMatch.expression.slice(1, -1), 'ui') : new RegExp(escapeRegexp(alertMatch.expression), 'ui'), }); return acc; }, {}); const alerts = rawAlerts.map((alert) => ({ id: alert.id, userId: alert.user_id, notify: alert.notify, email: alert.email, all: alert.all, actors: alertsActorsByAlertId[alert.id] || [], tags: alertsTagsByAlertId[alert.id] || [], entities: alertsEntitiesByAlertId[alert.id] || [], matches: alertsMatchesByAlertId[alert.id] || [], stashes: alertsStashesByAlertId[alert.id] || [], })); const curatedScenes = scenes.map((scene) => ({ id: scene.id, title: scene.title, description: scene.description, actorIds: actorIdsByReleaseId[scene.id] || [], tagIds: tagIdsByReleaseId[scene.id] || [], entityId: scene.entity.id, parentEntityId: scene.entity.parent?.id, })); const triggers = alerts.flatMap((alert) => { const alertScenes = curatedScenes.filter((scene) => { if (alert.all) { if (alert.actors.length > 0 && !alert.actors.every((actorId) => scene.actorIds.includes(actorId))) { return false; } if (alert.tags.length > 0 && !alert.tags.every((tagId) => scene.tagIds.includes(tagId))) { return false; } // multiple entities can only be matched in OR mode if (alert.entities.length > 0 && !alert.entities.some((alertEntityId) => alertEntityId === scene.entityId || alertEntityId === scene.parentEntityId)) { return false; } if (alert.matches.length > 0 && !alert.matches.every((match) => match.expression.test(scene[match.property]))) { return false; } return true; } if (alert.actors.some((actorId) => scene.actorIds.includes(actorId))) { return true; } if (alert.tags.some((tagId) => scene.tagIds.includes(tagId))) { return true; } // multiple entities can only be matched in OR mode if (alert.entities.some((alertEntityId) => alertEntityId === scene.entityId || alertEntityId === scene.parentEntityId)) { return true; } if (alert.matches.some((match) => match.expression.test(scene[match.property]))) { return true; } return false; }); return alertScenes.map((scene) => ({ sceneId: scene.id, alert, })); }); const notifications = Object.values(Object.fromEntries(triggers // prevent multiple notifications for the same scene .filter((trigger) => trigger.alert.notify) .map((trigger) => [`${trigger.alert.userId}:${trigger.sceneId}`, trigger]))) .map((trigger) => ({ user_id: trigger.alert.userId, alert_id: trigger.alert.id, scene_id: trigger.sceneId, })); const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashes.map((stashId) => ({ stashId, sceneId: trigger.sceneId, userId: trigger.alert.userId, }))).map((stash) => [`${stash.stashId}:${stash.sceneId}`, stash]))); const stashEntries = uniqueStashes.map((stash) => ({ scene_id: stash.sceneId, stash_id: stash.stashId, })); const [stashed] = await Promise.all([ bulkInsert('stashes_scenes', stashEntries, false), bulkInsert('notifications', notifications, false), ]); // we need created_at from the databased, but user_id is not returned. it's easier to query it than to try and merge it with the input data const stashedEntries = await knex('stashes_scenes') .select('stashes_scenes.*', 'stashes.user_id') .leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id') .whereIn('stashes_scenes.id', stashed.map((stash) => stash.id)); const docs = stashedEntries.map((stash) => ({ replace: { index: 'scenes_stashed', id: stash.id, doc: { scene_id: stash.scene_id, user_id: stash.user_id, stash_id: stash.stash_id, created_at: Math.round(stash.created_at.getTime() / 1000), }, }, })); if (docs.length > 0) { await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n')); } return triggers; } async function updateNotification(notificationId, notification, sessionUser) { await knex('notifications') .where('user_id', sessionUser.id) .where('id', notificationId) .update({ seen: notification.seen, }); } async function updateNotifications(notification, sessionUser) { await knex('notifications') .where('user_id', sessionUser.id) .update({ seen: notification.seen, }); } module.exports = { addAlert, removeAlert, notify, updateNotification, updateNotifications, };