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:
Rotimi Best
2026-06-22 05:43:48 +01:00
committed by GitHub
parent 3547901de6
commit 81294a47f9
193 changed files with 5477 additions and 805 deletions
+25 -10
View File
@@ -5,7 +5,7 @@ import type {
TImportAudienceMembers
} from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName } from '@cio/email';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import {
@@ -30,6 +30,7 @@ import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getProfilesByEmails } from '@cio/db/queries/auth';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
import { getWelcomeSessionIcs } from '../course/session-invite';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
@@ -62,8 +63,11 @@ function generateToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
function buildInviteLink(token: string): string {
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
function buildInviteLink(
token: string,
org?: { siteName?: string | null; customDomain?: string | null; isCustomDomainVerified?: boolean | null }
): string {
return `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`;
}
function getExpiryLabel(expiresAtIso: string): string {
@@ -170,6 +174,7 @@ async function enrollAudienceStudentProfilesInCourses(
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
const welcomeMessageByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.welcomeEmailMessage]));
const validProfiles = uniqueProfileIds.filter((id) => validProfileIds.has(id));
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
@@ -198,6 +203,10 @@ async function enrollAudienceStudentProfilesInCourses(
const loginUrl = getDashboardBaseUrl(organization);
if (shouldSendEmail && toInsert.length > 0) {
const icsByGroupId = new Map(
await Promise.all(courseGroups.map(async (cg) => [cg.groupId, await getWelcomeSessionIcs(cg.courseId)] as const))
);
const emailPromises = toInsert
.filter((p) => profileEmailMap.get(p.profileId))
.map(async (p) => {
@@ -208,10 +217,13 @@ async function enrollAudienceStudentProfilesInCourses(
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
loginUrl
loginUrl,
customMessage: welcomeMessageByGroupId.get(p.groupId) ?? undefined,
branding: buildEmailBranding(organization)
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`,
ics: icsByGroupId.get(p.groupId)
});
emailsSent++;
} catch (emailError) {
@@ -290,7 +302,8 @@ async function enrollAudienceStudentProfilesInPrograms(
fields: {
orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program',
loginUrl
loginUrl,
branding: buildEmailBranding(organization)
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
@@ -387,7 +400,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
const invite = inviteByEmail.get(email)!;
const token = tokenByEmail.get(email)!;
try {
const inviteLink = buildInviteLink(token);
const inviteLink = buildInviteLink(token, organization);
await enqueueTransactionalEmail('studentOrgInvite', {
to: email,
fields: {
@@ -395,7 +408,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel
courseNames: accessNamesLabel,
branding: buildEmailBranding(organization)
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `student-org-invite:${invite.id}`
@@ -677,7 +691,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
let emailSent = false;
try {
const inviteLink = buildInviteLink(token);
const inviteLink = buildInviteLink(token, organization);
await enqueueTransactionalEmail('studentOrgInvite', {
to: emailToUse,
fields: {
@@ -685,7 +699,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel
courseNames: accessNamesLabel,
branding: buildEmailBranding(organization)
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `student-org-invite:${invite.id}`