feat: live class sessions, CE workflow, and docs (#683)
Adds live-class session scheduling on lessons (join link, date/time, timezone) with automatic 24h and 1h reminder emails plus a calendar invite (.ics), a time-change notification with an updated invite, a teacher-authored welcome email and session invite on enrollment, and a "Notify students" quiz nudge. LMS gets an upcoming live-sessions card and Join buttons, plus a reusable live-session card component (3 states) in @cio/ui with a Storybook story. The sidebar content tree now reflects sequential progression locking like the content list, and the course-completion confetti regression is fixed. Org-based emails no longer reference ClassroomIO by name. Docs add live-class, welcome-email, and course-progression guides, expand the certificates guide (design, signatures, corrected labels), move plan-required callouts to the top, flatten how-to-guides to the docs root with redirects, and add screenshots. Includes an add-docs-image skill.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { listUpcomingSessionsForReminderScan } from '@cio/db/queries/course';
|
||||
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;
|
||||
|
||||
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 {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user