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
@@ -19,8 +19,10 @@ function buildResourceOwnershipError(params: {
const { resourceType, resourceId, resource } = params;
if (!resource) {
const createTool = resourceType === 'Lesson' ? 'create_lesson' : 'create_exercise';
return new AppError(
`${resourceType} ${resourceId} does not exist in this course. The ID may have been hallucinated — call get_course_structure to fetch real IDs and retry.`,
`${resourceType} ${resourceId} does not exist in this course. Do NOT take IDs from the plan card — it contains no IDs. If you have not yet created this ${resourceType.toLowerCase()} in this run, call ${createTool} first and use the id it returns. Otherwise call get_course_structure to fetch the real id, then retry. Never invent or guess IDs.`,
'RESOURCE_NOT_IN_COURSE',
403
);
+6 -24
View File
@@ -1004,30 +1004,12 @@ export function buildAgentTools(
}
/**
* Tools that can only run inside a durable `agent_run` (BullMQ worker), never
* inline on `/agent/chat`. These are bulk-creation tools — the kind triggered
* by a teacher-approved course plan. Each one writes a significant amount of
* content per call and the plan-then-run path gives them retry, cancel, a
* progress card, and resumability.
*
* Note that `update_lesson_content` is intentionally NOT in this set: a teacher
* asking the chat assistant to revise one existing lesson ("make this more
* detailed", "shorten this", "rewrite the intro") is a single targeted edit,
* not bulk content creation. Routing it through `generate_course_plan` forced
* the model into a planning shape that doesn't match the request and tripped
* the final-exam schema. Single-lesson revisions run inline in chat.
* All tools are available in chat. The plan → approve → durable-run path is
* only triggered when the teacher explicitly approves a `generate_course_plan`
* result; it is not required for one-off creation requests.
*/
export const RUN_ONLY_TOOL_NAMES = new Set(['add_questions', 'create_lesson', 'create_exercise'] as const);
export const RUN_ONLY_TOOL_NAMES = new Set<string>();
/**
* Drop run-only tools from the chat-mode tool set so the model never sees their
* schemas in `/agent/chat` and routes heavy work through the plan-then-run path.
*/
export function filterToolsForChatMode<T extends Record<string, unknown>>(tools: T): Partial<T> {
const filtered: Record<string, unknown> = {};
for (const [name, value] of Object.entries(tools)) {
if (RUN_ONLY_TOOL_NAMES.has(name as never)) continue;
filtered[name] = value;
}
return filtered as Partial<T>;
export function filterToolsForChatMode<T extends Record<string, unknown>>(tools: T): T {
return tools;
}
+3 -1
View File
@@ -29,7 +29,7 @@ import type { TCourse } from '@cio/db/types';
import type { TCourseCreate } from '@cio/utils/validation/course';
import { db } from '@cio/db/drizzle';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth';
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
import { annotateCourseContentWithProgression } from './progression';
@@ -311,6 +311,8 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
if (!ok) {
throw new AppError('Certification exercise must belong to this course', ErrorCodes.VALIDATION_ERROR, 400);
}
await updateExercise(data.certificate.requiredExerciseId, { allowMultipleAttempts: true });
}
const updated = await updateCourseQuery(courseId, sanitizeUnknownStrings(sanitizedData));