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:
138
src/lib/email/templates/notification-digest.ts
Normal file
138
src/lib/email/templates/notification-digest.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user