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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user