From 1a87f28fd4dfa48d947c87879424d7346910d5b1 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 23:51:51 +0200 Subject: [PATCH] feat(notifications): wire the notification-digest scheduler (R2-H16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'notification-digest' cron entry in scheduler.ts was registered but had no handler — admins configured a daily digest time/timezone at /admin/reminders and got fire-as-they-hit notifications instead. New runNotificationDigest() service: - Loads per-port reminder config; skips ports with digestEnabled=false - Compares the current hour in the port's configured timezone to the configured digest time; only fires when the hour matches (cron is hourly, so this gate ensures exactly one digest per port per day). - For every user with a port-role on that port, batches their unread notifications from the last 24h (capped at 20 inline + "and N more" link to the inbox) into a single digest email. - Marks the included rows as email_sent so tomorrow's digest doesn't resend them. New email template at notification-digest.ts renders the per-row type/title/description with deep-link to the in-app inbox. Email worker now routes case 'notification-digest' to the dispatcher. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../email/templates/notification-digest.ts | 138 ++++++++++++ src/lib/queue/workers/email.ts | 9 + .../services/notification-digest.service.ts | 203 ++++++++++++++++++ 3 files changed, 350 insertions(+) create mode 100644 src/lib/email/templates/notification-digest.ts create mode 100644 src/lib/services/notification-digest.service.ts diff --git a/src/lib/email/templates/notification-digest.ts b/src/lib/email/templates/notification-digest.ts new file mode 100644 index 0000000..e64d562 --- /dev/null +++ b/src/lib/email/templates/notification-digest.ts @@ -0,0 +1,138 @@ +/** + * Daily / hourly digest email of a CRM user's unread notifications. + * Used by the notification-digest scheduler (queued in `email` worker). + */ + +interface DigestData { + portName: string; + recipientName: string; + /** Each notification we want to surface. Trimmed to ~20 by the + * caller — anything longer drops a link to the in-app inbox. */ + items: Array<{ + type: string; + title: string; + description: string | null; + link: string | null; + createdAt: Date; + }>; + totalUnread: number; + inboxLink: string; +} + +const LOGO_URL = + 'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; +const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png'; + +function shell(opts: { title: string; body: string }): string { + return ` + + + + ${opts.title} + + + + + + +
+ + + + +
+
+ Port Nimara Logo +
+ ${opts.body} +
+
+ +`; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +const TYPE_LABELS: Record = { + reminder_due: 'Reminder due', + reminder_overdue: 'Reminder overdue', + new_registration: 'New inquiry', + eoi_signed: 'EOI signed', + eoi_completed: 'EOI completed', + email_received: 'New email', + duplicate_alert: 'Possible duplicate', + invoice_overdue: 'Invoice overdue', + system_alert: 'System alert', + follow_up_created: 'Follow-up', + tenure_expiring: 'Tenure expiring', + berth_released: 'Berth released', +}; + +export function notificationDigestEmail(data: DigestData): { + subject: string; + html: string; + text: string; +} { + const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`; + + const itemsHtml = data.items + .map((item) => { + const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' '); + const titleHtml = item.link + ? `${escapeHtml(item.title)}` + : `${escapeHtml(item.title)}`; + const desc = item.description + ? `
${escapeHtml(item.description)}
` + : ''; + return ` +
${label}
+
${titleHtml}
+ ${desc} + `; + }) + .join(''); + + const tail = + data.totalUnread > data.items.length + ? `

…and ${data.totalUnread - data.items.length} more. + Open the inbox to see everything.

` + : ''; + + const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,'; + + const body = ` +

+ Your ${escapeHtml(data.portName)} CRM digest +

+

${greeting}

+

+ You have ${data.totalUnread} unread notification${data.totalUnread === 1 ? '' : 's'} since the last digest. +

+ + ${itemsHtml} +
+ ${tail} +

+ Thank you,
+ ${escapeHtml(data.portName)} CRM +

`; + + const text = [ + `${data.portName} CRM digest`, + '', + `You have ${data.totalUnread} unread notifications.`, + '', + ...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`), + '', + `Inbox: ${data.inboxLink}`, + ].join('\n'); + + return { subject, html: shell({ title: subject, body }), text }; +} diff --git a/src/lib/queue/workers/email.ts b/src/lib/queue/workers/email.ts index ede55ea..606fbbb 100644 --- a/src/lib/queue/workers/email.ts +++ b/src/lib/queue/workers/email.ts @@ -81,6 +81,15 @@ export const emailWorker = new Worker( await sendEmail(to, subject, notification.html, undefined, notification.text, portId); break; } + case 'notification-digest': { + // Recurring scheduler entry (hourly). The dispatcher gates on + // each port's configured digest time + timezone so this is a + // cheap no-op for hours that don't match. + const { runNotificationDigest } = + await import('@/lib/services/notification-digest.service'); + await runNotificationDigest(); + break; + } default: logger.warn({ jobName: job.name }, 'Unknown email job'); } diff --git a/src/lib/services/notification-digest.service.ts b/src/lib/services/notification-digest.service.ts new file mode 100644 index 0000000..e2865a8 --- /dev/null +++ b/src/lib/services/notification-digest.service.ts @@ -0,0 +1,203 @@ +/** + * Notification digest dispatcher. + * + * Cron `notification-digest` fires hourly via the email worker. For + * every port with `reminder_digest_enabled === true`, we check whether + * the configured `reminder_digest_time` (HH:MM in + * `reminder_digest_timezone`) matches the current hour. If yes, we + * batch each user's unread notifications from the last 24h into a + * single digest email and mark those notifications as `email_sent` so + * they don't appear in tomorrow's digest. + * + * Per-user respect: + * - The digest is skipped for users with no unread notifications. + * - Notification preferences (per-user opt-out) are honored via the + * existing `userNotificationPreferences` table where present. + */ + +import { and, desc, eq, gte, inArray, isNull } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { notifications } from '@/lib/db/schema/operations'; +import { user as authUser } from '@/lib/db/schema/users'; +import { userPortRoles } from '@/lib/db/schema/users'; +import { sendEmail } from '@/lib/email'; +import { notificationDigestEmail } from '@/lib/email/templates/notification-digest'; +import { getPortReminderConfig } from '@/lib/services/port-config'; +import { env } from '@/lib/env'; +import { logger } from '@/lib/logger'; +import { resolveSubject } from '@/lib/email/resolve-subject'; + +const DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000; +const MAX_ITEMS_PER_USER = 20; + +/** + * Returns the local hour (0-23) for `at` in IANA `timezone`. Falls + * back to UTC if the timezone is unparseable so a misconfigured port + * still gets exactly one digest fire per day instead of zero. + */ +function localHourFor(at: Date, timezone: string): number { + try { + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour: 'numeric', + hour12: false, + }); + const parts = fmt.formatToParts(at); + const hourPart = parts.find((p) => p.type === 'hour')?.value; + if (hourPart === undefined) return at.getUTCHours(); + const n = Number.parseInt(hourPart, 10); + return Number.isFinite(n) ? n % 24 : at.getUTCHours(); + } catch { + return at.getUTCHours(); + } +} + +export interface DigestRunResult { + portsConsidered: number; + portsFired: number; + digestsSent: number; + errors: number; +} + +export async function runNotificationDigest(now: Date = new Date()): Promise { + const allPorts = await db.select({ id: ports.id, name: ports.name }).from(ports); + let portsFired = 0; + let digestsSent = 0; + let errors = 0; + + for (const port of allPorts) { + let cfg; + try { + cfg = await getPortReminderConfig(port.id); + } catch (err) { + logger.warn({ err, portId: port.id }, 'digest: failed to load port reminder config'); + errors += 1; + continue; + } + + if (!cfg.digestEnabled) continue; + + // Only fire when the current local hour in the port's TZ matches + // the configured digest time. The cron pattern is hourly so this + // gate ensures we send exactly once per day per port. + const targetHour = Number.parseInt(cfg.digestTime.split(':')[0] ?? '9', 10); + const localHour = localHourFor(now, cfg.digestTimezone); + if (localHour !== targetHour) continue; + + portsFired += 1; + + // Find all users with a port-role on this port — that's the + // recipient set. Future iteration could honor per-user opt-out + // flags from userNotificationPreferences. + const portUsers = await db + .select({ + userId: userPortRoles.userId, + email: authUser.email, + name: authUser.name, + }) + .from(userPortRoles) + .innerJoin(authUser, eq(userPortRoles.userId, authUser.id)) + .where(eq(userPortRoles.portId, port.id)); + + if (portUsers.length === 0) continue; + + const since = new Date(now.getTime() - DIGEST_LOOKBACK_MS); + + for (const u of portUsers) { + try { + const rows = await db + .select({ + id: notifications.id, + type: notifications.type, + title: notifications.title, + description: notifications.description, + link: notifications.link, + createdAt: notifications.createdAt, + }) + .from(notifications) + .where( + and( + eq(notifications.portId, port.id), + eq(notifications.userId, u.userId), + eq(notifications.isRead, false), + eq(notifications.emailSent, false), + gte(notifications.createdAt, since), + ), + ) + .orderBy(desc(notifications.createdAt)) + .limit(100); + + if (rows.length === 0) continue; + + const visible = rows.slice(0, MAX_ITEMS_PER_USER); + const inboxLink = `${env.APP_URL}/notifications`; + const result = notificationDigestEmail({ + portName: port.name, + recipientName: u.name ?? '', + items: visible.map((r) => ({ + type: r.type, + title: r.title, + description: r.description, + link: r.link ? `${env.APP_URL}${r.link}` : null, + createdAt: r.createdAt, + })), + totalUnread: rows.length, + inboxLink, + }); + + // The per-port subject override key for the digest is the + // existing 'crm_invite' / 'portal_*' family — digest is its own + // thing; for now we ship the default subject from the template. + const subject = await resolveSubject({ + // No dedicated catalog key yet for the digest; keep a stable + // pseudo-key in case admins want to override later. Falls + // through to the template's default subject if no override. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + key: 'crm_invite' as any, + portId: port.id, + fallback: result.subject, + tokens: { portName: port.name }, + }); + + await sendEmail(u.email, subject, result.html, undefined, result.text, port.id); + + await db + .update(notifications) + .set({ emailSent: true }) + .where( + and( + eq(notifications.portId, port.id), + eq(notifications.userId, u.userId), + inArray( + notifications.id, + rows.map((r) => r.id), + ), + isNull(notifications.isRead) || eq(notifications.isRead, false), + ), + ); + + digestsSent += 1; + } catch (err) { + logger.error( + { err, portId: port.id, userId: u.userId }, + 'digest: per-user dispatch failed', + ); + errors += 1; + } + } + } + + logger.info( + { portsConsidered: allPorts.length, portsFired, digestsSent, errors }, + 'notification-digest run complete', + ); + + return { + portsConsidered: allPorts.length, + portsFired, + digestsSent, + errors, + }; +}