Files
classroomio/apps/jobs/src/processors/notifications/session-reminder-scan.ts
T
Rotimi BestandRotimi Best fa060f5f6d feat(notifications): email notification preferences (#747)
* feat(notifications): add email notification preferences

Implement the email notification preferences PRD with org-level defaults
and personal opt-outs enforced at the email enqueue chokepoint.

- Add toggle map, profile.settings jsonb, and shouldSendEmail resolver
- Extend profile and organization update APIs with emailNotifications
- Pass preference context through all toggleable email send call sites
- Add Settings > Notifications page with personal and org admin sections

Co-authored-by: Rotimi Best <[email protected]>

* feat(notifications): group email toggles into course, cohort, and enrollment sections

Organize personal and org notification settings into scannable sections
so users can find course- or cohort-related emails quickly.

Co-authored-by: Rotimi Best <[email protected]>

* fix(notifications): use Exercise instead of Quiz in toggle copy

Co-authored-by: Rotimi Best <[email protected]>

* fix(notifications): only mark certification email sent when enqueued

Skip setMemberCertificationEmailSent when notification preferences or
missing Redis prevent the completion email from being queued, so the
email can still send if preferences change later.

Co-authored-by: Rotimi Best <[email protected]>

* fix(notifications): address PR review feedback

- Add EmailPreferenceLookupCache to avoid N+1 settings lookups in batch
  processors and multi-recipient email enqueue paths
- Replace $effect store hydration in notifications settings with onMount
  and explicit sync after save
- Remove thin api email-preferences re-export; import from db queries
- Hide tutor-only personal toggles (newStudent, newSubmission) for students

Co-authored-by: Rotimi Best <[email protected]>

* fix(notifications): hydrate toggles after store load and add profile settings migration

- One-time store hydration via onMount plus guarded effect when profile/org
  load after mount, without clobbering in-progress edits
- Add drizzle migration for profile.settings jsonb column

Co-authored-by: Rotimi Best <[email protected]>

---------
2026-07-13 10:33:19 +01:00

116 lines
3.6 KiB
TypeScript

import { listUpcomingSessionsForReminderScan } from '@cio/db/queries/course';
import { EmailPreferenceLookupCache } from '@cio/db/queries/notifications';
import { buildEmailBranding, buildEmailFromName, buildSessionIcs } from '@cio/email';
import { enqueueEmailSend } from '@cio/jobs';
import { log } from '../../utils/logger';
interface ScanResult {
scanned: number;
remindersEnqueued: number;
}
const OFFSETS_MINUTES = [1440, 60] as const;
function formatSessionTime(lessonAt: string, timezone: string | null): string {
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: timezone ?? 'UTC',
dateStyle: 'medium',
timeStyle: 'short',
timeZoneName: 'short'
}).format(new Date(lessonAt));
} catch {
return new Date(lessonAt).toUTCString();
}
}
/**
* Scan upcoming live sessions and enqueue 24h / 1h reminder emails to enrolled
* students. Idempotency key per (lesson, student, offset) so each reminder
* fires once (BullMQ dedupes on jobId). The 24h reminder also carries an .ics.
*/
export async function processSessionReminderScan(): Promise<ScanResult> {
const rows = await listUpcomingSessionsForReminderScan();
const now = Date.now();
let remindersEnqueued = 0;
const preferenceCache = new EmailPreferenceLookupCache();
for (const row of rows) {
if (!row.email) continue;
const startMs = new Date(row.lessonAt).getTime();
if (Number.isNaN(startMs)) continue;
const minutesUntil = Math.round((startMs - now) / 60_000);
if (minutesUntil <= 0) continue;
const branding = buildEmailBranding({
name: row.organizationName,
avatarUrl: row.organizationAvatarUrl,
theme: row.organizationTheme
});
const sessionTimeLabel = formatSessionTime(row.lessonAt, row.sessionTimezone);
for (const offset of OFFSETS_MINUTES) {
if (minutesUntil > offset) continue;
const whenLabel = offset === 1440 ? 'in about 24 hours' : 'in about 1 hour';
const ics =
offset === 1440
? buildSessionIcs({
uid: `session-${row.lessonId}@classroomio`,
sequence: 0,
method: 'PUBLISH',
start: row.lessonAt,
title: row.lessonTitle,
description: `Join your live session: ${row.callUrl}`,
url: row.callUrl,
alarmsBeforeMinutes: [1440, 60]
})
: undefined;
try {
const allowed = await preferenceCache.shouldSend({
emailId: 'sessionReminder',
organizationId: row.organizationId,
recipientEmail: row.email,
recipientProfileId: row.profileId
});
if (!allowed) {
continue;
}
await enqueueEmailSend(
{
kind: 'template',
template: 'sessionReminder',
to: row.email,
fields: {
orgName: row.organizationName,
courseName: row.courseName,
sessionTitle: row.lessonTitle,
sessionTimeLabel,
whenLabel,
joinUrl: row.callUrl,
branding
},
from: buildEmailFromName(`${row.organizationName} (via ClassroomIO.com)`),
...(ics ? { ics } : {})
},
{ idempotencyKey: `session-reminder:${row.lessonId}:${row.profileId}:${offset}` }
);
remindersEnqueued += 1;
} catch (error) {
log.error('session-reminder-enqueue-failed', { lessonId: row.lessonId, offset });
}
}
}
log.info('session-reminder-scan-done', { scanned: rows.length, remindersEnqueued });
return { scanned: rows.length, remindersEnqueued };
}