feat(notifications): wire the notification-digest scheduler (R2-H16)

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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 23:51:51 +02:00
parent f3143d7561
commit 1a87f28fd4
3 changed files with 350 additions and 0 deletions

View File

@@ -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 `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${opts.title}</title>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:24px; font-family: Arial, sans-serif; color:#333333;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const TYPE_LABELS: Record<string, string> = {
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
? `<a href="${item.link}" style="color:#007bff; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
: `<strong>${escapeHtml(item.title)}</strong>`;
const desc = item.description
? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>`
: '';
return `<tr><td style="padding:10px 0; border-bottom:1px solid #eee;">
<div style="font-size:11px; text-transform:uppercase; color:#999; letter-spacing:0.04em;">${label}</div>
<div style="font-size:14px; margin-top:2px;">${titleHtml}</div>
${desc}
</td></tr>`;
})
.join('');
const tail =
data.totalUnread > data.items.length
? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more.
<a href="${data.inboxLink}" style="color:#007bff;">Open the inbox</a> to see everything.</p>`
: '';
const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';
const body = `
<p style="font-size:18px; font-weight:bold; color:#0F4C81; margin:0 0 6px;">
Your ${escapeHtml(data.portName)} CRM digest
</p>
<p style="font-size:14px; line-height:1.5; margin:0 0 14px;">${greeting}</p>
<p style="font-size:14px; line-height:1.5; margin:0 0 16px;">
You have <strong>${data.totalUnread}</strong> unread notification${data.totalUnread === 1 ? '' : 's'} since the last digest.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
${itemsHtml}
</table>
${tail}
<p style="margin-top:24px; font-size:13px; color:#666;">
Thank you,<br />
<strong>${escapeHtml(data.portName)} CRM</strong>
</p>`;
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 };
}