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
+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);
}
}