feat(notifications): alert oversight review queues
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
export const OVERSIGHT_NOTIFICATION_CATEGORIES = [
|
||||
{ id: 'data_changes', label: 'DATA CHANGES', permission: 'data.approve', link: '/isac/data-moderation' },
|
||||
{ id: 'avatars', label: 'AVATAR REVIEW', permission: 'avatars.review', link: '/isac/avatar-oversight' }
|
||||
] as const;
|
||||
|
||||
export const OVERSIGHT_NOTIFICATION_FREQUENCIES = ['immediate', '15_minutes', '1_hour', 'daily'] as const;
|
||||
export type OversightNotificationCategory = (typeof OVERSIGHT_NOTIFICATION_CATEGORIES)[number]['id'];
|
||||
export type OversightNotificationFrequency = (typeof OVERSIGHT_NOTIFICATION_FREQUENCIES)[number];
|
||||
|
||||
function easternParts(date: Date) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).formatToParts(date);
|
||||
return Object.fromEntries(parts.map((part) => [part.type, Number(part.value)])) as Record<string, number>;
|
||||
}
|
||||
|
||||
function easternInstant(year: number, month: number, day: number, hour: number) {
|
||||
for (const offset of [4, 5]) {
|
||||
const candidate = new Date(Date.UTC(year, month - 1, day, hour + offset));
|
||||
const parts = easternParts(candidate);
|
||||
if (parts.year === year && parts.month === month && parts.day === day && parts.hour === hour) return candidate;
|
||||
}
|
||||
throw new Error('Unable to resolve Eastern delivery time.');
|
||||
}
|
||||
|
||||
function deliveryTime(frequency: Exclude<OversightNotificationFrequency, 'immediate'>, now: Date) {
|
||||
if (frequency !== 'daily') {
|
||||
const window = frequency === '15_minutes' ? 15 * 60_000 : 60 * 60_000;
|
||||
return new Date(Math.floor(now.getTime() / window) * window + window);
|
||||
}
|
||||
const parts = easternParts(now);
|
||||
const localDate = new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
|
||||
if (parts.hour >= 10) localDate.setUTCDate(localDate.getUTCDate() + 1);
|
||||
return easternInstant(localDate.getUTCFullYear(), localDate.getUTCMonth() + 1, localDate.getUTCDate(), 10);
|
||||
}
|
||||
|
||||
async function eligibleReviewers(permission: string) {
|
||||
const database = await getDatabase();
|
||||
return (await database.prepare(`
|
||||
SELECT DISTINCT users.id
|
||||
FROM users
|
||||
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
|
||||
LEFT JOIN user_status status ON status.user_id=users.id
|
||||
LEFT JOIN user_roles ON user_roles.user_id=users.id
|
||||
LEFT JOIN role_permissions ON role_permissions.role_id=user_roles.role_id
|
||||
LEFT JOIN permissions role_permission ON role_permission.id=role_permissions.permission_id
|
||||
LEFT JOIN user_permissions ON user_permissions.user_id=users.id
|
||||
LEFT JOIN permissions direct_permission ON direct_permission.id=user_permissions.permission_id
|
||||
WHERE deleted.user_id IS NULL AND status.blocked_at IS NULL
|
||||
AND (status.banned_until IS NULL OR status.banned_until<=?)
|
||||
AND (users.role='admin' OR role_permission.name IN (?, 'system.admin') OR direct_permission.name IN (?, 'system.admin'))
|
||||
`)).all(new Date().toISOString(), permission, permission);
|
||||
}
|
||||
|
||||
export async function notifyOversightReview(input: {
|
||||
category: OversightNotificationCategory;
|
||||
itemId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
link?: string;
|
||||
}) {
|
||||
const definition = OVERSIGHT_NOTIFICATION_CATEGORIES.find((item) => item.id === input.category);
|
||||
if (!definition) throw new Error('Unknown Oversight notification category.');
|
||||
const database = await getDatabase();
|
||||
const now = new Date();
|
||||
for (const reviewer of await eligibleReviewers(definition.permission)) {
|
||||
const userId = String(reviewer.id);
|
||||
const preference = await (await database.prepare(
|
||||
'SELECT frequency FROM oversight_notification_preferences WHERE user_id=? AND oversight_category=?'
|
||||
)).get(userId, input.category);
|
||||
const frequency = String(preference?.frequency ?? 'immediate') as OversightNotificationFrequency;
|
||||
if (frequency === 'immediate') {
|
||||
await createNotification({
|
||||
userId,
|
||||
category: 'isac',
|
||||
source: 'ISAC OVERSIGHT NETWORK',
|
||||
title: input.title,
|
||||
body: input.summary,
|
||||
link: input.link ?? definition.link,
|
||||
mandatory: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await (await database.prepare(`INSERT INTO oversight_notification_queue
|
||||
(id,user_id,oversight_category,review_item_id,title,summary,link,frequency,created_at,deliver_after)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
randomUUID(), userId, input.category, input.itemId, input.title.slice(0, 140),
|
||||
input.summary.slice(0, 2000), input.link ?? definition.link, frequency,
|
||||
now.toISOString(), deliveryTime(frequency, now).toISOString()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user