import config from 'config'; import { CronJob } from 'cron'; import escapeRegexp from 'escape-string-regexp'; import markdownIt from 'markdown-it'; import { Resend } from 'resend'; import promiseProps from '../utils/promise-props.js'; import { getIdsBySlug } from './cache.js'; import { emailTemplates } from './email-templates.js'; import { HttpError } from './errors.js'; import { knexOwner as knex } from './knex.js'; import initLogger from './logger.js'; import { indexApi } from './manticore.js'; import { fetchScenesById } from './scenes.js'; const logger = initLogger(); const resend = new Resend(config.email.apiKey); function getImagePath(media) { if (!media) { return null; } if (media.isS3) { return new URL(media.lazy, config.media.s3Path).href; } return new URL(`${config.media.mediaPath}/${media.lazy}`, config.web.address).href; } const md = markdownIt({ html: true, }); md.disable('code'); 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, })) || [], scenes: context.scenes?.map((scene) => ({ id: scene.scene_id, title: scene.scene_title, slug: scene.scene_slug, date: scene.scene_date, })) || [], 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, scenes, matches, stashes, } = await promiseProps({ alerts: knex('alerts') .where('user_id', user.id) .where('is_expired', false) .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('alerts.is_expired', false) .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('alerts.is_expired', false) .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('alerts.is_expired', false) .where((builder) => { if (alertIds) { builder.whereIn('alerts.id', alertIds); } }), scenes: knex('alerts_scenes') .select('alerts_scenes.*', 'releases.id as scene_id', 'releases.slug as scene_slug', 'releases.title as scene_title', 'releases.date as scene_date') .leftJoin('alerts', 'alerts.id', 'alerts_scenes.alert_id') .leftJoin('releases', 'releases.id', 'alerts_scenes.scene_id') .where('alerts.user_id', user.id) .where('alerts.is_expired', false) .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('alerts.is_expired', false) .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('alerts.is_expired', false) .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), scenes: scenes.filter((scene) => scene.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 const sceneIds = [...(alert.scenes || []), ...(alert.sceneIds || [])]; // for consistency with tagIds and entityIds if (actorIds.length === 0 && tagIds.length === 0 && entityIds.length === 0 && (!alert.matches || alert.matches.length === 0) && sceneIds.length === 0) { throw new HttpError('Alert must contain at least one actor, tag, entity or scene', 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 sceneIds?.length > 0 && knex('alerts_scenes').insert(sceneIds.map((sceneId) => ({ alert_id: alertId, scene_id: sceneId, }))), ]); await Promise.all([ actorIds?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_actors'), tagIds?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_tags'), entityIds?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_entities'), sceneIds?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_scenes'), ]); 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'), knex.raw('coalesce(array_agg(distinct alerts_scenes.scene_id) filter (where alerts_scenes.scene_id is not null), \'{}\') as scene_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') .leftJoin('alerts_scenes', 'alerts_scenes.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'), alert?.scene_ids?.length > 0 && knex.schema.refreshMaterializedView('alerts_users_scenes'), ]); 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, matchedSceneIds: notification.alert_scenes, 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, 'iu').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(array_agg(alerts_scenes.scene_id) filter (where alerts_scenes.id is not null), \'{}\') as alert_scenes'), 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') .leftJoin('alerts_scenes', 'alerts_scenes.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, seen_at: updatedNotification.seen ? knex.fn.now() : null, }); return updatedCount; } function groupByKey(data, byKey, itemKey) { return data.reduce((acc, item) => { if (!acc[item[byKey]]) { acc[item[byKey]] = []; } acc[item[byKey]].push(itemKey ? item[itemKey] : item); return acc; }, {}); } async function sendEmailAlerts(triggers, scenes) { if (!config.email.enabled) { return; } const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene])); const triggersByUserId = Object.values(triggers // prevent multiple emails for the same scene .filter((trigger) => trigger.alert.email && trigger.alert.userEmail) .reduce((acc, trigger) => { if (!acc[trigger.alert.userId]) { acc[trigger.alert.userId] = { userId: trigger.alert.userId, username: trigger.alert.username, userEmail: trigger.alert.userEmail, alerts: [], }; } acc[trigger.alert.userId].alerts.push({ ...trigger, scene: scenesBySceneId[trigger.sceneId] || null, }); return acc; }, {})); const emails = await Promise.all(triggersByUserId.map(async (trigger) => { const { html, text } = await emailTemplates.render('alert.vue', { props: { trigger, address: config.web.address, }, }); return { from: config.email.from, to: [trigger.userEmail], subject: `Notification for ${trigger.alerts.length} scenes on traxxx`, html, text, }; })); const { data, error } = await resend.batch.send(emails); if (error) { logger.error(`Failed to send ${emails.length} alert e-mails: ${error}`); } else { logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`); } } const genderOrder = ['female', 'transsexual', 'male']; export async function notify(sceneIds) { const scenes = await fetchScenesById(sceneIds); const [ releasesActors, releasesTags, rawAlerts, alertsActors, alertsTags, alertsEntities, alertsMatches, alertsStashes, ] = await Promise.all([ knex('releases_actors') .select('releases_actors.*', 'actors.name as actor_name', 'actors.gender as actor_gender') .whereIn('release_id', sceneIds) .leftJoin('actors', 'actors.id', 'releases_actors.actor_id'), knex('releases_tags') .select('releases_tags.*', 'tags.name as tag_name', 'tags.slug as tag_slug') .leftJoin('tags', 'tags.id', 'releases_tags.tag_id') .whereIn('release_id', sceneIds) .orderBy('tags.priority', 'desc'), knex('alerts') .select('alerts.*', 'users.username', 'users.email as user_email') .leftJoin('users', 'users.id', 'alerts.user_id'), knex('alerts_actors') .select('alerts_actors.*', 'actors.name as actor_name', 'actors.slug as actor_slug') .leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'), knex('alerts_tags') .select('alerts_tags.*', 'tags.name as tag_name') .leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'), knex('alerts_entities') .select('alerts_entities.*', 'entities.name as entity_name') .leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'), knex('alerts_matches'), knex('alerts_stashes'), ]); const actorsByReleaseId = groupByKey(releasesActors, 'release_id', null); const tagsByReleaseId = groupByKey(releasesTags, 'release_id', null); const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', null); const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', null); const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', null); const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id'); const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => { if (!acc[alertMatch.alert_id]) { acc[alertMatch.alert_id] = []; } acc[alertMatch.alert_id].push({ property: alertMatch.property, expression: new RegExp(alertMatch.expression, 'iu'), }); return acc; }, {}); const alerts = rawAlerts.map((alert) => ({ id: alert.id, userId: alert.user_id, username: alert.username, userEmail: alert.user_email, notify: alert.notify, email: alert.email, all: alert.all, allActors: alert.all_actors, allEntities: alert.all_entities, allTags: alert.all_tags, allMatches: alert.all_matches, actors: alertsActorsByAlertId[alert.id] ?.map((actor) => ({ id: actor.actor_id, name: actor.actor_name, gender: actor.actor_gender })) .toSorted((actorA, actorB) => genderOrder.indexOf(actorA.gender) - genderOrder.indexOf(actorB.gender)) || [], tags: alertsTagsByAlertId[alert.id]?.map((tag) => ({ id: tag.tag_id, name: tag.tag_name })) || [], entities: alertsEntitiesByAlertId[alert.id]?.map((entity) => ({ id: entity.entity_id, name: entity.entity_name })) || [], matches: alertsMatchesByAlertId[alert.id] || [], stashIds: alertsStashesByAlertId[alert.id] || [], })); const curatedScenes = scenes .filter((scene) => scene.isNew) .toSorted((sceneA, sceneB) => sceneB.date - sceneA.date) .map((scene) => ({ id: scene.id, title: scene.title, slug: scene.slug, date: scene.date, description: scene.description, actors: (actorsByReleaseId[scene.id] || []).filter((actor) => !!actor.actor_name).map((actor) => ({ id: actor.actor_id, name: actor.actor_name, slug: actor.slug })), actorIds: (actorsByReleaseId[scene.id] || []).map((actor) => actor.actor_id), tagIds: (tagsByReleaseId[scene.id] || []).map((tag) => tag.id), tags: (tagsByReleaseId[scene.id] || []).filter((tag) => !!tag.tag_name).map((tag) => ({ slug: tag.tag_slug, name: tag.tag_name })), entityId: scene.channel?.id || scene.network?.id || null, parentEntityId: scene.network?.id, posterUrl: getImagePath(scene.poster), })); const triggers = alerts.flatMap((alert) => { const alertScenes = curatedScenes.filter((scene) => { if (alert.all) { if (alert.actors.length === 0 && alert.tags.length === 0 && alert.entities.length === 0 && alert.matches.length === 0) { // we must match on *something*, this is likely a release date alert handled separately return false; } if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actor) => scene.actorIds.includes(actor.id))) { return false; } if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tag) => scene.tagIds.includes(tag.id))) { return false; } // multiple entities can only be matched in OR mode if (alert.entities.length > 0 && !alert.entities.some((alertEntity) => alertEntity.id === scene.entityId || alertEntity.id === scene.parentEntityId)) { return false; } if (alert.matches.length > 0 && !alert.matches[alert.allMatches ? 'every' : 'some']((match) => match.expression.test(scene[match.property]))) { return false; } return true; } if (alert.matches.length > 0 && alert.actors[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) { return true; } if (alert.tags.length > 0 && alert.tags[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) { return true; } // 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 true; } if (alert.matches.length > 0 && alert.matches[alert.allMatches ? 'every' : '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.stashIds.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([ stashEntries.length > 0 ? knex('stashes_scenes') .insert(stashEntries) .onConflict(['stash_id', 'scene_id']) .ignore() : [], notifications.length > 0 ? knex('notifications') .insert(notifications) : [], ]); if (stashed.length > 0) { // 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')); } } await sendEmailAlerts(triggers, curatedScenes); return triggers; } async function alertSceneReleases() { const alerts = await knex('alerts_scenes') .select( 'alerts.id', 'alerts.user_id', 'releases.id as scene_id', 'releases.title as scene_title', 'releases.slug as scene_slug', 'releases.date as scene_date', ) .innerJoin('alerts', 'alerts.id', 'alerts_scenes.alert_id') .innerJoin('releases', 'releases.id', 'alerts_scenes.scene_id') .where('alerts.is_expired', false) .where('releases.date', '<=', knex.fn.now()); if (alerts.length === 0) { return; } const trx = await knex.transaction(); try { await trx('notifications') .insert(alerts.map((alert) => ({ user_id: alert.user_id, scene_id: alert.scene_id, alert_id: alert.id, }))); await trx('alerts') .whereIn('id', alerts.map((alert) => alert.id)) .update('is_expired', true); await trx.commit(); await knex.schema.refreshMaterializedView('alerts_users_scenes'); } catch (error) { await trx.rollback(); logger.error(`Failed to notify alerts: ${error.message}`); } } async function purgeExpiredAlerts() { const purgedNotifications = await knex('notifications') .where((builder) => { builder .where('seen', true) .where((seenBuilder) => { seenBuilder .whereNull('seen_at') // marked seen before seen_at was added .orWhereRaw('seen_at < now() - (?::int * interval \'1 day\')', [config.alerts.seenExpiry]); // '? day' would be interpreted literally }); }) .orWhereRaw('created_at < now() - (?::int * interval \'1 day\')', [config.alerts.finalExpiry]) // don't accumulate notifications indefinitely .delete(); const purgedAlerts = await knex('alerts') .where('is_expired', true) .whereNotExists( knex('notifications') .whereRaw('notifications.alert_id = alerts.id'), ) .delete(); await knex.schema.refreshMaterializedView('alerts_users_scenes'); logger.info(`Purged ${purgedNotifications} notifications and ${purgedAlerts} alerts`); } export function initAlertCron() { CronJob.from({ cronTime: config.alerts.crontab, async onTick() { await Promise.all([ alertSceneReleases(), purgeExpiredAlerts(), ]); }, start: config.alerts.enabled, runOnInit: true, }); }