2026-06-22 05:43:48 +01:00
|
|
|
import { listUpcomingSessionsForReminderScan } from '@cio/db/queries/course';
|
2026-07-13 10:33:19 +01:00
|
|
|
import { EmailPreferenceLookupCache } from '@cio/db/queries/notifications';
|
2026-06-22 05:43:48 +01:00
|
|
|
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;
|
2026-07-13 10:33:19 +01:00
|
|
|
const preferenceCache = new EmailPreferenceLookupCache();
|
2026-06-22 05:43:48 +01:00
|
|
|
|
|
|
|
|
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 {
|
2026-07-13 10:33:19 +01:00
|
|
|
const allowed = await preferenceCache.shouldSend({
|
|
|
|
|
emailId: 'sessionReminder',
|
|
|
|
|
organizationId: row.organizationId,
|
|
|
|
|
recipientEmail: row.email,
|
|
|
|
|
recipientProfileId: row.profileId
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!allowed) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 05:43:48 +01:00
|
|
|
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 };
|
|
|
|
|
}
|