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

@@ -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');
}