Integrated e-mail alerts, added dialog option for release alerts.

This commit is contained in:
2026-07-21 04:31:12 +02:00
parent 6444cf21eb
commit 827566d57d
11 changed files with 487 additions and 308 deletions

View File

@@ -1,5 +1,6 @@
import config from 'config';
import { CronJob } from 'cron';
import { add } from 'date-fns';
import escapeRegexp from 'escape-string-regexp';
import markdownIt from 'markdown-it';
import { Resend } from 'resend';
@@ -374,6 +375,7 @@ export async function fetchNotifications(reqUser, options = {}) {
.leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id')
.leftJoin('alerts_scenes', 'alerts_scenes.alert_id', 'alerts.id')
.where('notifications.user_id', reqUser.id)
.where('notifications.notify', true) // notification table is also used to defer e-mail alerts
.limit(options.limit)
.groupBy('notifications.id', 'alerts.id')
.orderBy('created_at', 'desc'),
@@ -428,68 +430,155 @@ function groupByKey(data, byKey, itemKey) {
}, {});
}
async function sendEmailAlerts(triggers, scenes) {
const frequencies = {
daily: { days: 1 },
weekly: { weeks: 1 },
monthly: { months: 1 },
};
async function sendEmailAlerts() {
if (!config.email.enabled) {
return;
}
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
const qualifyingUsers = await knex('notifications')
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
.where('notifications.email', true)
.innerJoin('users', 'users.id', 'notifications.user_id')
.groupBy('users.id')
.having(knex.raw('bool_or(emailed_at is null)'));
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: [],
};
const dueUserIds = qualifyingUsers
.filter(({ settings, last_email }) => {
const frequency = frequencies[settings?.alertEmailFrequency];
if (!frequency || !last_email) {
// email setting set to immediate or invalid, or no recent emails sent
return true;
}
acc[trigger.alert.userId].alerts.push({
...trigger,
scene: scenesBySceneId[trigger.sceneId] || null,
// is this last e-mail sent before the frequency setting timeframe?
return add(last_email, frequency) <= new Date();
})
.map((row) => row.user_id);
// immediately mark as e-mailed to prevent double e-mails if cronjob coincides with direct trigger
const markedNotifications = await knex('notifications')
.where('email', true)
.whereNull('emailed_at')
.whereIn('user_id', dueUserIds)
.update('emailed_at', knex.fn.now())
.returning('*');
const userIds = Array.from(new Set(markedNotifications.map((notification) => notification.user_id)));
const sceneIds = Array.from(new Set(markedNotifications.map((notification) => notification.scene_id)));
const alertIds = Array.from(new Set(markedNotifications.map((notification) => notification.alert_id)));
const scenes = await fetchScenesById(sceneIds);
const [
users,
alertsActors,
alertsTags,
alertsEntities,
alertsScenes,
alertsMatches,
] = await Promise.all([
knex('users')
.select('users.id', 'users.username', 'users.email', 'users.settings')
.whereIn('users.id', userIds),
knex('alerts_actors')
.select('alerts_actors.alert_id', 'actors.id', 'actors.name', 'actors.slug')
.whereIn('alerts_actors.alert_id', alertIds)
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'),
knex('alerts_tags')
.select('alerts_tags.alert_id', 'tags.id', 'tags.name', 'tags.slug')
.whereIn('alerts_tags.alert_id', alertIds)
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'),
knex('alerts_entities')
.select('alerts_entities.alert_id', 'entities.id', 'entities.type', 'entities.name', 'entities.slug')
.whereIn('alerts_entities.alert_id', alertIds)
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'),
knex('alerts_scenes')
.whereIn('alerts_scenes.alert_id', alertIds),
knex('alerts_matches')
.whereIn('alerts_matches.alert_id', alertIds),
]);
const usersByUserId = Object.fromEntries(users.map((user) => [user.id, user]));
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
const alertActorsByAlertId = groupByKey(alertsActors, 'alert_id');
const alertTagsByAlertId = groupByKey(alertsTags, 'alert_id');
const alertEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id');
const alertMatchesByAlertId = groupByKey(alertsMatches, 'alert_id');
const alertSceneIdsByAlertId = groupByKey(alertsScenes, 'alert_id', 'scene_id');
const triggers = markedNotifications
.map((notification) => ({
scene: scenesBySceneId[notification.scene_id] || null,
posterUrl: getImagePath(scenesBySceneId[notification.scene_id]?.poster),
user: usersByUserId[notification.user_id] || null,
alertId: notification.alert_id,
alertTags: alertTagsByAlertId[notification.alert_id] || [],
alertActors: alertActorsByAlertId[notification.alert_id] || [],
alertEntities: alertEntitiesByAlertId[notification.alert_id] || [],
alertMatches: alertMatchesByAlertId[notification.alert_id] || [],
alertSceneIds: alertSceneIdsByAlertId[notification.alert_id] || [],
}))
.filter((notification) => !!notification.user
&& !!notification.scene
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
const emails = await Promise.all(Object.values(usersByUserId).map(async (user) => {
const userTriggers = Object.values(Object.fromEntries(triggers
.filter((trigger) => trigger.user.id === user.id)
.map((trigger) => [trigger.scene.id, trigger])))
.toSorted((triggerA, triggerB) => triggerA.scene.effectiveDate - triggerB.scene.effectiveDate);
if (userTriggers.length === 0) {
return null;
}
try {
const { html, text } = await emailTemplates.render('alert.vue', {
props: {
user,
triggers: userTriggers,
},
});
return acc;
}, {}));
return {
from: config.email.from,
to: user.email,
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
html,
text,
};
}
catch (error) {
return null;
}
})).then((allEmails) => allEmails.filter(Boolean));
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,
};
}));
if (emails.length === 0) {
logger.info(`No alert e-mails sent`);
return;
}
const { data, error } = await resend.batch.send(emails);
if (error) {
logger.error(`Failed to send ${emails.length} alert e-mails: ${error}`);
logger.error(`Failed to send ${emails.length} alert e-mails (${error.statusCode} ${error.name}): ${error.message}`);
}
else {
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
}
}
const genderOrder = ['female', 'transsexual', 'male'];
export async function notify(sceneIds) {
export async function notify(sceneIds, options = {}) {
const scenes = await fetchScenesById(sceneIds);
const [
releasesActors,
releasesTags,
rawAlerts,
alertsActors,
alertsTags,
@@ -497,36 +586,19 @@ export async function notify(sceneIds) {
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')
.select('alerts.*', 'users.username', 'users.email as user_email', 'users.settings as user_settings')
.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_actors').select('alert_id', 'actor_id'),
knex('alerts_tags').select('alert_id', 'tag_id'),
knex('alerts_entities').select('alert_id', '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 alertActorIdsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
const alertTagIdsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
const alertEntityIdsByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
@@ -547,6 +619,7 @@ export async function notify(sceneIds) {
userId: alert.user_id,
username: alert.username,
userEmail: alert.user_email,
userSettings: alert.user_settings,
notify: alert.notify,
email: alert.email,
all: alert.all,
@@ -554,54 +627,47 @@ export async function notify(sceneIds) {
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 })) || [],
actorIds: alertActorIdsByAlertId[alert.id] || [],
tagIds: alertTagIdsByAlertId[alert.id] || [],
entityIds: alertEntityIdsByAlertId[alert.id] || [],
matches: alertsMatchesByAlertId[alert.id] || [],
stashIds: alertsStashesByAlertId[alert.id] || [],
}));
const curatedScenes = scenes
.filter((scene) => scene.isNew)
.filter((scene) => scene.isNew || options.spoofNew)
.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 })),
actorIds: scene.actors.map((actor) => actor.id),
tagIds: scene.tags.map((tag) => tag.id),
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
if (alert.actorIds.length === 0
&& alert.tagIds.length === 0
&& alert.entityIds.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))) {
if (alert.actorIds.length > 0 && !alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
return false;
}
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tag) => scene.tagIds.includes(tag.id))) {
if (alert.tagIds.length > 0 && !alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
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)) {
if (alert.entityIds.length > 0 && !alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
return false;
}
@@ -612,16 +678,16 @@ export async function notify(sceneIds) {
return true;
}
if (alert.matches.length > 0 && alert.actors[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
if (alert.matches.length > 0 && alert.actorIds[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))) {
if (alert.tagIds.length > 0 && alert.tagIds[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)) {
if (alert.entityIds.length > 0 && alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
return true;
}
@@ -638,15 +704,39 @@ export async function notify(sceneIds) {
}));
});
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])))
// e-mails are stored as notifications to facilitate the e-mail frequency buffer
const notifications = Object.values(triggers // prevent multiple notifications for the same scene
.map((trigger) => ({
...trigger,
notify: trigger.alert.notify && trigger.alert.userSettings?.alertNotificationsEnabled !== false,
email: trigger.alert.email && trigger.alert.userEmail && trigger.alert.userSettings?.alertEmailsEnabled !== false,
}))
.filter((trigger) => trigger.notify || trigger.email)
.reduce((acc, trigger) => {
const key = `${trigger.alert.userId}:${trigger.sceneId}`;
acc[key] = {
...trigger,
notify: acc[key]?.notify || trigger.notify,
email: acc[key]?.email || trigger.email,
};
return acc;
}, {}))
.map((trigger) => ({
user_id: trigger.alert.userId,
alert_id: trigger.alert.id,
scene_id: trigger.sceneId,
// we assign the alert outputs to this specific notification, instead of joining them from the alerts table
// this ensures changing the alert outputs later doesn't trigger a barrage of e-mails from old notifications
notify: trigger.notify,
email: trigger.email,
}));
if (notifications.length === 0) {
logger.verbose(`No alert notifications triggered`);
}
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({
stashId,
sceneId: trigger.sceneId,
@@ -696,7 +786,7 @@ export async function notify(sceneIds) {
}
}
await sendEmailAlerts(triggers, curatedScenes);
await sendEmailAlerts();
return triggers;
}
@@ -704,8 +794,7 @@ export async function notify(sceneIds) {
async function alertSceneReleases() {
const alerts = await knex('alerts_scenes')
.select(
'alerts.id',
'alerts.user_id',
'alerts.*',
'releases.id as scene_id',
'releases.title as scene_title',
'releases.slug as scene_slug',
@@ -728,6 +817,8 @@ async function alertSceneReleases() {
user_id: alert.user_id,
scene_id: alert.scene_id,
alert_id: alert.id,
notify: alert.notify,
email: alert.email,
})));
await trx('alerts')
@@ -778,6 +869,9 @@ export function initAlertCron() {
alertSceneReleases(),
purgeExpiredAlerts(),
]);
// allow release alerts to set e-mail notifications
await sendEmailAlerts();
},
start: config.alerts.enabled,
runOnInit: true,