traxxx-web/src/alerts.js

352 lines
12 KiB
JavaScript
Executable File

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;
}