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
+8
View File
@@ -35,6 +35,7 @@ import { courseAiTutorRouter } from '@api/routes/course/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone';
import { sanitizeHtml } from '@cio/core/utils/sanitize-html';
import { complianceRouter } from '@api/routes/course/compliance';
import { contentRouter } from '@api/routes/course/content';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
@@ -329,6 +330,13 @@ export const courseRouter = new Hono()
const validatedData = c.req.valid('json');
const { tagIds, ...courseData } = validatedData;
if (courseData.metadata?.welcomeEmailMessage) {
courseData.metadata = {
...courseData.metadata,
welcomeEmailMessage: sanitizeHtml(courseData.metadata.welcomeEmailMessage)
};
}
const result = await updateCourse(courseId, courseData);
if (tagIds !== undefined) {
+28
View File
@@ -29,8 +29,14 @@ import {
initVideoRecordingUpload
} from '@api/services/exercise/video-recording';
import {
getNotifyCourseExerciseStatusService,
notifyCourseExerciseService
} from '@api/services/course/notify-exercise';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key';
@@ -82,6 +88,28 @@ export const exerciseRouter = new Hono()
return handleError(c, error, 'Failed to fetch exercise submissions overview');
}
})
.post('/:exerciseId/notify', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const courseId = c.req.param('courseId')!;
const exerciseId = c.req.param('exerciseId')!;
const result = await notifyCourseExerciseService(courseId, exerciseId);
return c.json({ success: true, data: result }, 202);
} catch (error) {
return handleError(c, error, 'Failed to notify students');
}
})
.get('/:exerciseId/notify/:jobId', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const jobId = c.req.param('jobId')!;
const pollCount = Number.parseInt(c.req.query('pollCount') ?? '0', 10) || 0;
const envelope = await getNotifyCourseExerciseStatusService(jobId, pollCount);
return c.json({ success: true, data: envelope }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch notification status');
}
})
.get(
'/:exerciseId',
authOrAutomationKeyMiddleware,
+19
View File
@@ -38,6 +38,8 @@ import { Hono } from '@api/utils/hono';
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
import { authMiddleware } from '@api/middlewares/auth';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { notifyCourseSessionUpdateService } from '@api/services/course/notify-session';
import { generateLessonPdf } from '@api/utils/lesson';
import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
import { handleError } from '@api/utils/errors';
@@ -121,6 +123,23 @@ export const lessonRouter = new Hono()
}
}
)
.post(
'/:lessonId/notify-session-update',
authMiddleware,
courseTeamMemberMiddleware,
zValidator('param', ZLessonGetParam),
async (c) => {
try {
const courseId = c.req.param('courseId')!;
const { lessonId } = c.req.valid('param');
const result = await notifyCourseSessionUpdateService(courseId, lessonId);
return c.json({ success: true, data: result }, 202);
} catch (error) {
return handleError(c, error, 'Failed to notify session update');
}
}
)
.delete('/:lessonId', authMiddleware, courseMemberMiddleware, zValidator('param', ZLessonGetParam), async (c) => {
try {
const { lessonId } = c.req.valid('param');
@@ -3,9 +3,19 @@ import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { enqueueSessionReminderScan } from '@cio/jobs';
import { handleError } from '@api/utils/errors';
export const internalComplianceRouter = new Hono()
.post('/send-session-reminders', apiKeyMiddleware, async (c) => {
try {
const jobId = await enqueueSessionReminderScan();
return c.json({ success: true, data: { jobId: jobId ?? null } }, 200);
} catch (error) {
return handleError(c, error, 'Failed to enqueue session reminder scan');
}
})
.post('/check-expiry', apiKeyMiddleware, async (c) => {
try {
const data = await runComplianceExpiryCheck();
+41 -10
View File
@@ -29,11 +29,12 @@ import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email';
import { buildEmailFromName, buildEmailBranding, type EmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { getWelcomeSessionIcs } from './session-invite';
import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
@@ -358,20 +359,26 @@ async function sendStudentJoinEmails(input: {
courseName: string;
orgName: string;
org: OrgUrlInfo;
branding: EmailBranding;
studentId: string;
studentEmail: string;
welcomeEmailMessage?: string | null;
}) {
try {
const loginUrl = getDashboardBaseUrl(input.org);
const ics = await getWelcomeSessionIcs(input.courseId);
await enqueueTransactionalEmail('studentCourseWelcome', {
to: input.studentEmail,
fields: {
orgName: input.orgName,
courseName: input.courseName,
loginUrl
loginUrl,
customMessage: input.welcomeEmailMessage ?? undefined,
branding: input.branding
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`,
ics
});
} catch (error) {
console.error('Failed to enqueue student welcome email:', error);
@@ -395,7 +402,8 @@ async function sendStudentJoinEmails(input: {
fields: {
courseName: input.courseName,
studentName,
studentEmail: input.studentEmail
studentEmail: input.studentEmail,
branding: input.branding
},
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
@@ -414,6 +422,7 @@ async function createEmailInviteAndSend(input: {
courseName: string;
courseSlug: string;
org: OrgUrlInfo;
branding: EmailBranding;
metadata?: Record<string, unknown>;
shouldSend: boolean;
}) {
@@ -450,7 +459,8 @@ async function createEmailInviteAndSend(input: {
orgName: input.orgName,
courseName: input.courseName,
inviteLink: createdInvite.inviteLink,
expiresAt: getExpiryLabel(createdInvite.expiresAt)
expiresAt: getExpiryLabel(createdInvite.expiresAt),
branding: input.branding
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-invite-email:${createdInvite.id}`
@@ -549,6 +559,11 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
const orgName = courseOrgData.orgName || 'ClassroomIO';
const courseName = courseOrgData.courseTitle || course[0].title || 'Course';
const orgBranding = buildEmailBranding({
name: courseOrgData.orgName,
avatarUrl: courseOrgData.orgAvatarUrl,
theme: courseOrgData.orgTheme
});
const inviteResults = await Promise.all(
recipients.valid.map((recipientEmail) =>
@@ -561,6 +576,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
courseName,
courseSlug,
org: orgUrlInfo,
branding: orgBranding,
metadata: data.metadata,
shouldSend: data.sendEmail
})
@@ -630,7 +646,8 @@ export async function enrollInCourse(
throw new AppError('This course is not available for enrollment', ErrorCodes.VALIDATION_ERROR, 400);
}
const courseMetadata = (courseWithRelations.metadata as { allowNewStudent?: boolean } | null) ?? null;
const courseMetadata =
(courseWithRelations.metadata as { allowNewStudent?: boolean; welcomeEmailMessage?: string | null } | null) ?? null;
const isInternalEnrollmentOnly = org.settings?.internalEnrollmentOnly ?? false;
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(org.id, user.id);
@@ -677,8 +694,10 @@ export async function enrollInCourse(
courseName: title,
orgName: org.name,
org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme }),
studentId: user.id,
studentEmail: normalizedEmail
studentEmail: normalizedEmail,
welcomeEmailMessage: courseMetadata?.welcomeEmailMessage
});
trackServerEvent({
@@ -944,7 +963,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
orgName: organization.name,
orgSiteName: organization.siteName,
orgCustomDomain: organization.customDomain,
orgIsCustomDomainVerified: organization.isCustomDomainVerified
orgIsCustomDomainVerified: organization.isCustomDomainVerified,
orgAvatarUrl: organization.avatarUrl,
orgTheme: organization.theme
};
}
@@ -1025,7 +1046,11 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
orgName: organization.name,
orgSiteName: organization.siteName,
orgCustomDomain: organization.customDomain,
orgIsCustomDomainVerified: organization.isCustomDomainVerified
orgIsCustomDomainVerified: organization.isCustomDomainVerified,
orgAvatarUrl: organization.avatarUrl,
orgTheme: organization.theme,
welcomeEmailMessage:
(course.metadata as { welcomeEmailMessage?: string | null } | null)?.welcomeEmailMessage ?? null
};
});
@@ -1041,8 +1066,14 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
customDomain: result.orgCustomDomain,
isCustomDomainVerified: result.orgIsCustomDomainVerified
},
branding: buildEmailBranding({
name: result.orgName,
avatarUrl: result.orgAvatarUrl,
theme: result.orgTheme
}),
studentId: user.id,
studentEmail: normalizedEmail
studentEmail: normalizedEmail,
welcomeEmailMessage: result.welcomeEmailMessage
});
trackServerEvent({
@@ -0,0 +1,90 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseById, getCourseWithOrgData } from '@cio/db/queries/course';
import { getExerciseById } from '@cio/db/queries/exercise';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { QUEUE_NAMES, enqueueNotifyCourseExercise, getQueueJobEnvelope, type JobEnvelope } from '@cio/jobs';
const NOTIFY_DOMAIN = 'notify-course-exercise';
/**
* Enqueue a background job that emails every course member a "quiz assigned"
* nudge with a deep link to take the exercise. Returns the job id the
* dashboard polls for completion.
*/
export async function notifyCourseExerciseService(courseId: string, exerciseId: string): Promise<{ jobId: string }> {
const [courseRows, orgData, exercise] = await Promise.all([
getCourseById(courseId),
getCourseWithOrgData(courseId),
getExerciseById(exerciseId)
]);
const course = courseRows?.[0];
if (!course) {
throw new AppError('Course not found', ErrorCodes.NOT_FOUND, 404);
}
if (!orgData) {
throw new AppError('Course organization not found', ErrorCodes.NOT_FOUND, 404);
}
if (!exercise || exercise.courseId !== courseId) {
throw new AppError('Exercise not found for this course', ErrorCodes.NOT_FOUND, 404);
}
const baseUrl = getDashboardBaseUrl({
siteName: orgData.orgSiteName,
customDomain: orgData.orgCustomDomain,
isCustomDomainVerified: orgData.orgIsCustomDomainVerified
});
// PUBLIC courses are taken on the anonymous slug-based org-site route;
// every other type uses the authenticated id-based LMS route.
let quizUrl: string;
if (course.type === 'PUBLIC') {
if (!course.slug || !exercise.slug) {
throw new AppError('Public course/exercise is missing a slug to link to', ErrorCodes.VALIDATION_ERROR, 400);
}
quizUrl = `${baseUrl}/course/${course.slug}/lesson/${exercise.slug}`;
} else {
quizUrl = `${baseUrl}/courses/${courseId}/exercises/${exerciseId}`;
}
const orgName = orgData.orgName || 'ClassroomIO';
const jobId = await enqueueNotifyCourseExercise({
courseId,
exerciseTitle: exercise.title,
courseName: course.title,
orgName,
fromName: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
quizUrl,
branding: buildEmailBranding({
name: orgData.orgName,
avatarUrl: orgData.orgAvatarUrl,
theme: orgData.orgTheme
})
});
if (!jobId) {
throw new AppError('Failed to enqueue notification job', ErrorCodes.INTERNAL_ERROR, 500);
}
return { jobId };
}
/**
* Read the status of a notify job (BullMQ-backed envelope). Throws 404 if the
* job is gone (retention swept it).
*/
export async function getNotifyCourseExerciseStatusService(jobId: string, pollCount = 0): Promise<JobEnvelope> {
const envelope = await getQueueJobEnvelope(QUEUE_NAMES.notifications, jobId, NOTIFY_DOMAIN, pollCount);
if (!envelope) {
throw new AppError('Notification job not found', ErrorCodes.NOT_FOUND, 404);
}
return envelope;
}
@@ -0,0 +1,16 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueNotifyCourseSessionUpdate } from '@cio/jobs';
/**
* Enqueue a background job that emails every course member an updated calendar
* invite after a teacher changes a live session. Returns the job id.
*/
export async function notifyCourseSessionUpdateService(courseId: string, lessonId: string): Promise<{ jobId: string }> {
const jobId = await enqueueNotifyCourseSessionUpdate({ courseId, lessonId });
if (!jobId) {
throw new AppError('Failed to enqueue session update notification', ErrorCodes.INTERNAL_ERROR, 500);
}
return { jobId };
}
@@ -2,7 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { buildEmailFromName } from '@cio/email';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
@@ -27,6 +27,11 @@ export async function createPaymentRequest(data: PaymentRequestData) {
const courseName = course.courseTitle || '';
const orgName = course.orgName || 'ClassroomIO';
const branding = buildEmailBranding({
name: course.orgName,
avatarUrl: course.orgAvatarUrl,
theme: course.orgTheme
});
const groupId = course.groupId;
if (!groupId) {
@@ -48,7 +53,8 @@ export async function createPaymentRequest(data: PaymentRequestData) {
fields: {
courseName,
studentEmail: data.studentEmail,
studentFullname: data.studentFullname
studentFullname: data.studentFullname,
branding
},
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
@@ -64,7 +70,8 @@ export async function createPaymentRequest(data: PaymentRequestData) {
courseName,
teacherEmail,
studentFullname: data.studentFullname,
orgName
orgName,
branding
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: teacherEmail,
+21 -5
View File
@@ -14,9 +14,10 @@ import type { TGroupmember } from '@cio/db/types';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { getWelcomeSessionIcs } from './session-invite';
/**
* Gets all course members (people) for a course
@@ -68,6 +69,11 @@ export async function addMember(
if (courseOrgData) {
const courseName = courseOrgData.courseTitle || '';
const orgName = courseOrgData.orgName || 'ClassroomIO';
const branding = buildEmailBranding({
name: courseOrgData.orgName,
avatarUrl: courseOrgData.orgAvatarUrl,
theme: courseOrgData.orgTheme
});
const loginUrl = getDashboardBaseUrl({
siteName: courseOrgData.orgSiteName,
customDomain: courseOrgData.orgCustomDomain,
@@ -97,10 +103,13 @@ export async function addMember(
fields: {
orgName,
courseName,
loginUrl
loginUrl,
customMessage: courseOrgData.welcomeEmailMessage ?? undefined,
branding
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`,
ics: await getWelcomeSessionIcs(courseId)
});
} catch (emailError) {
console.error('Failed to enqueue student welcome email:', emailError);
@@ -119,7 +128,8 @@ export async function addMember(
fields: {
courseName,
studentName,
studentEmail
studentEmail,
branding
},
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
@@ -182,6 +192,11 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
const courseName = courseOrgData.courseTitle || '';
const orgName = courseOrgData.orgName || 'ClassroomIO';
const orgSiteName = courseOrgData.orgSiteName || '';
const branding = buildEmailBranding({
name: courseOrgData.orgName,
avatarUrl: courseOrgData.orgAvatarUrl,
theme: courseOrgData.orgTheme
});
// Add all members
const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member)));
@@ -217,7 +232,8 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
name,
orgName,
courseName,
inviteLink
inviteLink,
branding
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
@@ -0,0 +1,37 @@
import { getCourseById, getUpcomingSessionsForCourseIds } from '@cio/db/queries/course';
import { buildSessionIcs } from '@cio/email';
/**
* Build the calendar invite (.ics) for a course's next upcoming live session,
* for attaching to the welcome email on enrollment. Returns undefined when the
* course is not a LIVE_CLASS or has no upcoming session. The invite reuses the
* same UID as the session reminders, so calendars treat them as one event.
*/
export async function getWelcomeSessionIcs(courseId: string): Promise<string | undefined> {
try {
const courseRows = await getCourseById(courseId);
const course = courseRows?.[0];
if (!course || course.type !== 'LIVE_CLASS') {
return undefined;
}
const upcoming = (await getUpcomingSessionsForCourseIds([courseId])).get(courseId);
if (!upcoming) {
return undefined;
}
return buildSessionIcs({
uid: `session-${upcoming.lessonId}@classroomio`,
sequence: 0,
method: 'PUBLISH',
start: upcoming.lessonAt,
title: upcoming.lessonTitle,
description: `Join your live session: ${upcoming.callUrl}`,
url: upcoming.callUrl,
alarmsBeforeMinutes: [1440, 60]
});
} catch (error) {
console.error('getWelcomeSessionIcs error:', error);
return undefined;
}
}
+4 -1
View File
@@ -20,6 +20,8 @@ export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOp
fields: z.infer<EmailSchemaFor<TId>>;
from?: string;
replyTo?: string;
/** Optional iCalendar (.ics) body attached as a text/calendar part. */
ics?: string;
}
export interface EnqueueRawEmailInput extends CommonOptions {
@@ -81,7 +83,8 @@ export async function enqueueTransactionalEmail<TId extends EmailId>(
to: recipient,
fields: validatedFields,
from: input.from,
replyTo: input.replyTo
replyTo: input.replyTo,
ics: input.ics
},
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
);
+15 -3
View File
@@ -19,7 +19,7 @@ import {
} from '@cio/db/queries/newsfeed';
import { env } from '@cio/core/config/env';
import { buildEmailFromName } from '@cio/email';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
/**
@@ -359,6 +359,11 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
const orgName = feedData.organization?.name || 'ClassroomIO';
const orgSiteName = feedData.organization?.siteName || 'app';
const branding = buildEmailBranding({
name: feedData.organization?.name,
avatarUrl: feedData.organization?.avatarUrl,
theme: feedData.organization?.theme
});
const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
const recipients = feedData.courseMembers
@@ -374,7 +379,8 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
teacherName: feedData.author?.fullname || 'A teacher',
content: feedData.content || '',
postLink,
orgName
orgName,
branding
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: feedData.author?.email || 'noreply@classroomio.com',
@@ -403,6 +409,11 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
const orgName = feedData.organization?.name || 'ClassroomIO';
const orgSiteName = feedData.organization?.siteName || 'app';
const branding = buildEmailBranding({
name: feedData.organization?.name,
avatarUrl: feedData.organization?.avatarUrl,
theme: feedData.organization?.theme
});
const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
await enqueueTransactionalEmail('newsfeedComment', {
@@ -411,7 +422,8 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
courseTitle: feedData.courseTitle,
comment: commentContent,
postLink,
orgName
orgName,
branding
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: 'noreply@classroomio.com'
+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}`
+9 -4
View File
@@ -32,6 +32,7 @@ import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { buildEmailBranding } from '@cio/email';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
@@ -60,8 +61,11 @@ function normalizeEmails(emails: string[]): string[] {
return [...new Set(emails.map((email) => email.toLowerCase().trim()).filter(Boolean))];
}
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 getRoleLabel(roleId: number): string {
@@ -245,7 +249,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
}
});
const inviteLink = buildInviteLink(token);
const inviteLink = buildInviteLink(token, organization);
try {
await enqueueTransactionalEmail('inviteTeacher', {
@@ -256,7 +260,8 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
orgSiteName: organization.siteName,
roleName,
expiresAt: getExpiryLabel(expiresAt),
inviteLink
inviteLink,
branding: buildEmailBranding(organization)
},
idempotencyKey: `org-invite-teacher:${invite.id}`
});
+7 -1
View File
@@ -1,6 +1,7 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { buildEmailBranding } from '@cio/email';
import {
countAssignmentsByStatus,
createProgramGoal,
@@ -425,7 +426,12 @@ export async function runProgramGoalReminderScan(): Promise<{
daysUntilDue,
completedCount: row.assignment.completedCount ?? 0,
requiredCount: row.assignment.requiredCount ?? 0,
loginUrl: `${loginUrl}/lms/programs/${row.programId}`
loginUrl: `${loginUrl}/lms/programs/${row.programId}`,
branding: buildEmailBranding({
name: row.organizationName,
avatarUrl: row.organizationAvatarUrl,
theme: row.organizationTheme
})
},
idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
});
+66 -55
View File
@@ -12,8 +12,8 @@ import type {
TSubmissionGradesUpdate,
TSubmissionUpdate
} from '@cio/utils/validation/submission';
import { buildEmailFromName } from '@cio/email';
import { enqueueRawEmail } from '@api/services/jobs';
import { buildEmailBranding, buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import {
createSubmission,
deleteSubmission,
@@ -34,7 +34,7 @@ import {
getQuestionTypeByTypename,
requiresManualGrading
} from '@cio/question-types';
import { getCourseById, getOrganizationByCourseId } from '@cio/db/queries/course';
import { getCourseById, getCourseWithOrgData } from '@cio/db/queries/course';
import { getCourseTeachers, getProfileByGroupMemberId } from '@cio/db/queries/course/people';
import { getExerciseById, getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
@@ -578,7 +578,8 @@ export async function createSubmissionService(
.map((question) => question.questionType?.typename ?? '')
.filter((typename) => typename.length > 0)
);
const gradingState: SubmissionGradingState = 'queued';
const gradingState: SubmissionGradingState =
overallStatus === 'manual_required' || overallStatus === 'hybrid' ? 'awaiting_manual' : 'queued';
const submissionData: TNewSubmission = {
courseId,
@@ -869,7 +870,6 @@ export async function updateSubmissionGradesBatch(
const currentGradingState = resolveSubmissionGradingState(submission);
const targetGradingState: SubmissionGradingState = 'completed';
const targetStatusId = projectLegacyStatusId(targetGradingState);
if (!isAllowedGradingTransition(currentGradingState, targetGradingState)) {
throw new AppError('Invalid submission workflow transition', ErrorCodes.VALIDATION_ERROR, 400);
}
@@ -965,13 +965,17 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
return;
}
// Get organization name
const orgResult = await getOrganizationByCourseId(fullSubmission.courseId || '');
// Get organization name and URL info
const orgResult = await getCourseWithOrgData(fullSubmission.courseId || '');
const orgName = orgResult?.orgName || 'ClassroomIO';
const statusText = LEGACY_BOARD_STATUS_LABELS[newStatusId] || 'Updated';
const baseUrl = getDashboardBaseUrl();
const baseUrl = getDashboardBaseUrl({
siteName: orgResult?.orgSiteName,
customDomain: orgResult?.orgCustomDomain,
isCustomDomainVerified: orgResult?.orgIsCustomDomainVerified
});
const exerciseLink = `${baseUrl}/courses/${fullSubmission.courseId}/exercises/${fullSubmission.exercise.id}`;
// Calculate total and max points
@@ -979,37 +983,33 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
const totalMark = answers.reduce((sum: number, a: any) => sum + (a.point || 0), 0);
const maxMark = (fullSubmission.exercise.questions || []).reduce((sum: number, q: any) => sum + (q.points || 0), 0);
let content = `
<p>Hello ${fullSubmission.groupmember.profile.fullname || 'Student'},</p>
<p>The status of your submitted exercise on <strong>${fullSubmission.exercise.title}</strong> has been updated to ${statusText}</p>
`;
const isGraded = newStatusId === 3;
const score = isGraded ? `${totalMark}/${maxMark}` : undefined;
if (newStatusId === 3) {
content += `
<p>Your score was ${totalMark}/${maxMark}</p>
<a class="button" href="${exerciseLink}">View your Result</a>
`;
} else {
content += `<a class="button" href="${exerciseLink}">Open Exercise</a>`;
try {
await enqueueTransactionalEmail('submissionGraded', {
to: fullSubmission.groupmember.profile.email,
fields: {
orgName,
studentName: fullSubmission.groupmember.profile.fullname || 'Student',
exerciseTitle: fullSubmission.exercise.title,
courseName: course.title,
statusText,
exerciseLink,
score,
lessonTitle: fullSubmission.lesson?.title,
branding: buildEmailBranding({
name: orgResult?.orgName,
avatarUrl: orgResult?.orgAvatarUrl,
theme: orgResult?.orgTheme
})
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
});
} catch (error) {
console.error('Failed to enqueue submission graded email:', error);
}
if (fullSubmission.lesson?.title) {
content += `
<p>This exercise is for <strong>${fullSubmission.lesson.title}</strong> in a course you are taking titled <strong>${course.title}</strong></p>
`;
} else {
content += `
<p>This exercise is in a course you are taking titled <strong>${course.title}</strong></p>
`;
}
await enqueueRawEmail({
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: fullSubmission.groupmember.profile.email,
subject: 'Submission Update',
content,
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
});
}
/**
@@ -1042,32 +1042,43 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
return;
}
// Get organization name
const orgResult = await getOrganizationByCourseId(courseId);
// Get organization name and URL info
const orgResult = await getCourseWithOrgData(courseId);
const orgName = orgResult?.orgName || 'ClassroomIO';
const baseUrl = getDashboardBaseUrl();
const baseUrl = getDashboardBaseUrl({
siteName: orgResult?.orgSiteName,
customDomain: orgResult?.orgCustomDomain,
isCustomDomainVerified: orgResult?.orgIsCustomDomainVerified
});
const exerciseLink = `${baseUrl}/courses/${courseId}/exercises/${exerciseId}`;
const submissionLink = `${baseUrl}/courses/${courseId}/submissions`;
const content = `
<p>Hello,</p>
<p>A student ${student.fullname || student.username} just submitted an exercise <a href="${exerciseLink}">${exercise.title}</a></p>
<p>You can get started grading by clicking "Open Submissions"</p>
<div>
<a class="button" href="${submissionLink}">Open Submissions</a>
</div>
`;
const studentName = student.fullname || student.username || 'A student';
const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email));
if (tutorEmails.length === 0) return;
await enqueueRawEmail({
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: tutorEmails,
subject: `[Submitted]: ${exercise.title}`,
content,
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
});
try {
await enqueueTransactionalEmail('submissionReceived', {
to: tutorEmails,
fields: {
orgName,
studentName,
exerciseTitle: exercise.title,
exerciseLink,
submissionLink,
branding: buildEmailBranding({
name: orgResult?.orgName,
avatarUrl: orgResult?.orgAvatarUrl,
theme: orgResult?.orgTheme
})
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
});
} catch (error) {
console.error('Failed to enqueue submission received email:', error);
}
}
+7 -2
View File
@@ -5,7 +5,7 @@ import {
} from '@cio/db/queries/course/course';
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
export function calcCourseProgressPercent(params: {
@@ -81,7 +81,12 @@ export function scheduleCertificationCompletionWork(params: {
courseName: courseRow.title,
studentName: profile?.fullname || studentEmail,
certificateUrl,
customMessage: courseRow.certificate?.emailMessage ?? null
customMessage: courseRow.certificate?.emailMessage ?? null,
branding: buildEmailBranding({
name: courseRow.orgName,
avatarUrl: courseRow.orgAvatarUrl,
theme: courseRow.orgTheme
})
},
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-completion:${groupMemberId}`