Files
classroomio/apps/dashboard/src/lib/features/course/api/exercise.svelte.ts
T
Rotimi Best 81294a47f9 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.
2026-06-22 05:43:48 +01:00

402 lines
12 KiB
TypeScript

import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CreateExerciseFromTemplateRequest,
CreateExerciseRequest,
DeleteExerciseRequest,
Exercise,
GetExerciseRequest,
SubmitExerciseRequest,
UpdateExerciseRequest,
VideoRecordingPlaybackRequest,
VideoRecordingUploadCompleteRequest,
VideoRecordingUploadInitRequest
} from '../utils/types';
import type {
TExerciseCreate,
TExerciseSubmissionCreate,
TExerciseUpdate,
TExerciseVideoRecordingUploadComplete,
TExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
import {
ZExerciseCreate,
ZExerciseSubmissionCreate,
ZExerciseUpdate,
ZExerciseVideoRecordingUploadComplete,
ZExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
import { toast } from '@cio/ui/base/sonner';
import { t } from '$lib/utils/functions/translations';
/**
* API class for exercise operations
*/
export class ExerciseApi extends BaseApiWithErrors {
exercise = $state<Exercise | null>(null);
/**
* Gets an exercise by ID
*/
async get(courseId: string, exerciseId: string) {
await this.execute<GetExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].$get({
param: { courseId, exerciseId }
}),
logContext: 'fetching exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as unknown as Exercise;
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch exercise');
}
}
});
}
/**
* Notifies all course members that a quiz has been assigned. Runs as a
* background job; shows a sonner spinner that polls until the job is done.
*/
async notifyStudents(courseId: string, exerciseId: string) {
const toastId = toast.loading(t.get('course.navItem.lessons.exercises.all_exercises.notify.sending'), {
position: 'bottom-right'
});
try {
const res = await classroomio.course[':courseId'].exercise[':exerciseId'].notify.$post({
param: { courseId, exerciseId }
});
const body = await res.json();
if (!body.success) {
throw new Error('Failed to start notification');
}
const jobId = body.data.jobId;
const maxPolls = 60;
for (let pollCount = 0; pollCount < maxPolls; pollCount++) {
const statusRes = await classroomio.course[':courseId'].exercise[':exerciseId'].notify[':jobId'].$get({
param: { courseId, exerciseId, jobId },
query: { pollCount: String(pollCount) }
});
const statusBody = await statusRes.json();
if (!statusBody.success) {
throw new Error('Failed to read notification status');
}
const { job, nextPollMs } = statusBody.data;
if (job.status === 'completed') {
const notified = (job.result as { notified?: number } | null)?.notified ?? 0;
toast.success(t.get('course.navItem.lessons.exercises.all_exercises.notify.sent', { count: notified }), {
id: toastId,
position: 'bottom-right'
});
return;
}
if (job.status === 'failed' || job.status === 'canceled') {
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
return;
}
await new Promise((resolve) => setTimeout(resolve, nextPollMs || 2000));
}
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
} catch (error) {
console.error('notifyStudents error:', error);
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
}
}
/**
* Creates a new exercise
*/
async create(courseId: string, fields: Omit<TExerciseCreate, 'courseId'>) {
const result = ZExerciseCreate.safeParse({ ...fields, courseId });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return;
}
await this.execute<CreateExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise.$post({
param: { courseId },
json: result.data
}),
logContext: 'creating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as unknown as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to create exercise');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates an exercise
*/
async update(courseId: string, exerciseId: string, fields: TExerciseUpdate) {
this.success = false;
const result = ZExerciseUpdate.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return;
}
this.errors = {};
await this.execute<UpdateExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].$put({
param: { courseId, exerciseId },
json: result.data
}),
logContext: 'updating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as unknown as Exercise;
this.success = true;
this.errors = {};
}
},
onError: (result) => {
this.success = false;
if (typeof result === 'string') {
snackbar.error('Failed to update exercise');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Deletes an exercise
*/
async delete(courseId: string, exerciseId: string) {
await this.execute<DeleteExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].$delete({
param: { courseId, exerciseId }
}),
logContext: 'deleting exercise',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Exercise deleted successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete exercise');
}
}
});
}
/**
* Submits an exercise
*/
async submit(courseId: string, exerciseId: string, answers: TExerciseSubmissionCreate['answers']) {
const result = ZExerciseSubmissionCreate.safeParse({ exerciseId, answers });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return;
}
return await this.execute<SubmitExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].submission.$post({
param: { courseId, exerciseId },
json: result.data
}),
logContext: 'submitting exercise',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Exercise submitted successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to submit exercise');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
async initVideoRecordingUpload(
courseId: string,
exerciseId: string,
questionId: number | string,
fields: TExerciseVideoRecordingUploadInit
) {
const result = ZExerciseVideoRecordingUploadInit.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return null;
}
return await this.execute<VideoRecordingUploadInitRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].question[':questionId'][
'video-recording'
].upload.init.$post({
param: { courseId, exerciseId, questionId: String(questionId) },
json: result.data
}),
logContext: 'initializing video recording upload'
});
}
async completeVideoRecordingUpload(
courseId: string,
exerciseId: string,
questionId: number | string,
fields: TExerciseVideoRecordingUploadComplete
) {
const result = ZExerciseVideoRecordingUploadComplete.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return null;
}
return await this.execute<VideoRecordingUploadCompleteRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].question[':questionId'][
'video-recording'
].upload.complete.$post({
param: { courseId, exerciseId, questionId: String(questionId) },
json: result.data
}),
logContext: 'completing video recording upload'
});
}
async getVideoRecordingPlaybackUrl(
courseId: string,
exerciseId: string,
submissionId: string,
questionId: number | string
) {
return await this.execute<VideoRecordingPlaybackRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].submission[':submissionId'].question[':questionId'][
'video-recording'
].playback.$get({
param: { courseId, exerciseId, submissionId, questionId: String(questionId) }
}),
logContext: 'getting video recording playback URL'
});
}
/**
* Creates an exercise from a template
* @param courseId Course ID
* @param lessonId Lesson ID
* @param templateId Template ID
* @returns The created exercise data or null on error
*/
async createFromTemplate(
courseId: string,
templateId: number | string,
options: { lessonId?: string; sectionId?: string; order?: number } = {}
) {
const templateIdValue = Number(templateId);
await this.execute<CreateExerciseFromTemplateRequest>({
requestFn: () =>
classroomio.course[':courseId']['exercise']['from-template'].$post({
param: { courseId },
json: {
lessonId: options.lessonId,
sectionId: options.sectionId,
order: options.order,
templateId: templateIdValue
}
}),
logContext: 'creating exercise from template',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as unknown as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to create exercise from template');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Checks if exercise is completed
* NOTE: This route does not exist in the API. Use the submission list endpoint instead.
*/
}
export const exerciseApi = /* @__PURE__ */ new ExerciseApi();