Files
classroomio/apps/api/src/routes/course/exercise.ts
T
Rotimi Best 52d9bf18c3 feat: add sequential progression and video watch lesson completion
Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
2026-06-11 14:48:34 +01:00

380 lines
13 KiB
TypeScript

import { ErrorCodes, handleError } from '@api/utils/errors';
import {
ZExerciseCreate,
ZExerciseFromTemplate,
ZExerciseGetParam,
ZExerciseListQuery,
ZExerciseSubmissionCreate,
ZExerciseUpdate,
ZExerciseVideoRecordingParam,
ZExerciseVideoRecordingPlaybackParam,
ZExerciseVideoRecordingUploadComplete,
ZExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
import { ZTemplateById, ZTemplateByTag } from '@cio/utils/validation/mocks';
import {
createExercise,
createExerciseFromTemplate,
deleteExerciseForCourseService,
deleteExerciseService,
getExercise,
listExercises,
updateExerciseService
} from '@cio/core/services/exercise/exercise';
import { fetchAllTemplatesMetadata, fetchTemplateById, fetchTemplatesByTag } from '@api/services/exercise/template';
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import {
completeVideoRecordingUpload,
getVideoRecordingPlaybackUrl,
initVideoRecordingUpload
} from '@api/services/exercise/video-recording';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
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';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission';
import { assertEnrolledStudentContentAccess } from '@api/services/course/access';
import { ContentType } from '@cio/utils/constants';
import { zValidator } from '@hono/zod-validator';
export const exerciseRouter = new Hono()
// Exercise CRUD routes
.get(
'/',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
zValidator('query', ZExerciseListQuery),
async (c) => {
try {
const courseId = c.req.param('courseId')!;
const user = c.get('user');
const automationKey = c.get('automationKey');
const { lessonId, sectionId } = c.req.valid('query');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'list_course_exercises');
}
const exercises = await listExercises(courseId, { lessonId, sectionId }, user?.id);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'list_course_exercises', { courseId, lessonId, sectionId });
}
return c.json({ success: true, data: exercises }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list exercises');
}
}
)
.get('/:exerciseId/submissions', authMiddleware, courseMemberMiddleware, async (c) => {
try {
const courseId = c.req.param('courseId')!;
const exerciseId = c.req.param('exerciseId')!;
const user = c.get('user')!;
const data = await listExerciseSubmissionsOverview(courseId, exerciseId, user.id);
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch exercise submissions overview');
}
})
.get(
'/:exerciseId',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
zValidator('param', ZExerciseGetParam),
async (c) => {
try {
const { exerciseId } = c.req.valid('param');
const user = c.get('user');
const automationKey = c.get('automationKey');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_exercise');
}
if (user?.id && !automationKey) {
await assertEnrolledStudentContentAccess({
courseId: c.req.param('courseId')!,
profileId: user.id,
contentId: exerciseId,
type: ContentType.Exercise
});
}
const exercise = await getExercise(exerciseId, undefined, user?.id);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'get_course_exercise', {
courseId: c.req.param('courseId')!,
exerciseId
});
}
return c.json({ success: true, data: exercise }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch exercise');
}
}
)
.post(
'/',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('json', ZExerciseCreate),
async (c) => {
try {
const data = c.req.valid('json');
const automationKey = c.get('automationKey');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise');
}
const exercise = await createExercise(data);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_exercise', {
courseId: c.req.param('courseId')!,
exerciseId: exercise.id
});
}
return c.json({ success: true, data: exercise }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create exercise');
}
}
)
/**
* POST /course/:courseId/exercise/from-template
* Creates an exercise from a template
* Requires authentication and course membership (admin/tutor role)
*/
.post(
'/from-template',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('json', ZExerciseFromTemplate),
async (c) => {
try {
const courseId = c.req.param('courseId')!;
const automationKey = c.get('automationKey');
const { lessonId, sectionId, order, templateId } = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise_from_template');
}
// Fetch template from database
const template = await fetchTemplateById(templateId);
if (!template) {
return c.json({ success: false, error: 'Template not found' }, 404);
}
if (!template.questionnaire) {
return c.json({ success: false, error: 'Template is missing questionnaire data' }, 400);
}
const exercise = await createExerciseFromTemplate(courseId, lessonId, sectionId, order, template);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_exercise_from_template', {
courseId,
exerciseId: exercise.id,
templateId
});
}
return c.json({ success: true, data: exercise }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create exercise from template');
}
}
)
.put(
'/:exerciseId',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('param', ZExerciseGetParam),
zValidator('json', ZExerciseUpdate),
async (c) => {
try {
const { exerciseId } = c.req.valid('param');
const data = c.req.valid('json');
const courseId = c.req.param('courseId')!;
const user = c.get('user');
const automationKey = c.get('automationKey');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_exercise');
}
if (!automationKey && user && data.isUnlocked !== undefined) {
const isAuthorized = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isAuthorized) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: ErrorCodes.UNAUTHORIZED
},
403
);
}
}
const exercise = await updateExerciseService(exerciseId, data);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'update_course_exercise', { courseId, exerciseId });
}
return c.json({ success: true, data: exercise }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update exercise');
}
}
)
.delete('/:exerciseId', authMiddleware, courseMemberMiddleware, zValidator('param', ZExerciseGetParam), async (c) => {
try {
const courseId = c.req.param('courseId')!;
const { exerciseId } = c.req.valid('param');
const exercise = await deleteExerciseForCourseService(courseId, exerciseId);
return c.json({ success: true, data: exercise }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete exercise');
}
})
// Exercise Submission routes
.post(
'/:exerciseId/submission',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZExerciseGetParam),
zValidator('json', ZExerciseSubmissionCreate),
async (c) => {
try {
const user = c.get('user')!;
const courseId = c.req.param('courseId')!;
const { exerciseId } = c.req.valid('param');
const { answers } = c.req.valid('json');
const groupMemberId = await getGroupMemberIdByCourseAndProfile(courseId, user.id);
if (!groupMemberId) {
return c.json({ success: false, error: 'User is not a member of this course' }, 403);
}
await assertEnrolledStudentContentAccess({
courseId,
profileId: user.id,
contentId: exerciseId,
type: ContentType.Exercise
});
const submission = await createSubmissionService(courseId, exerciseId, groupMemberId, answers);
return c.json({ success: true, data: submission }, 201);
} catch (error) {
return handleError(c, error, 'Failed to submit exercise');
}
}
)
.post(
'/:exerciseId/question/:questionId/video-recording/upload/init',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZExerciseVideoRecordingParam),
zValidator('json', ZExerciseVideoRecordingUploadInit),
async (c) => {
try {
const user = c.get('user')!;
const courseId = c.req.param('courseId')!;
const { exerciseId, questionId } = c.req.valid('param');
const data = c.req.valid('json');
const upload = await initVideoRecordingUpload(courseId, exerciseId, questionId, user.id, data);
return c.json({ success: true, data: upload }, 200);
} catch (error) {
return handleError(c, error, 'Failed to initialize video recording upload');
}
}
)
.post(
'/:exerciseId/question/:questionId/video-recording/upload/complete',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZExerciseVideoRecordingParam),
zValidator('json', ZExerciseVideoRecordingUploadComplete),
async (c) => {
try {
const user = c.get('user')!;
const courseId = c.req.param('courseId')!;
const { exerciseId, questionId } = c.req.valid('param');
const data = c.req.valid('json');
const answer = await completeVideoRecordingUpload(courseId, exerciseId, questionId, user.id, data);
return c.json({ success: true, data: answer }, 200);
} catch (error) {
return handleError(c, error, 'Failed to complete video recording upload');
}
}
)
.get(
'/:exerciseId/submission/:submissionId/question/:questionId/video-recording/playback',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZExerciseVideoRecordingPlaybackParam),
async (c) => {
try {
const user = c.get('user')!;
const courseId = c.req.param('courseId')!;
const { exerciseId, submissionId, questionId } = c.req.valid('param');
const playback = await getVideoRecordingPlaybackUrl(courseId, exerciseId, submissionId, questionId, user.id);
return c.json({ success: true, data: playback }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get video recording playback URL');
}
}
)
// Template routes
.get('/template', authMiddleware, courseMemberMiddleware, async (c) => {
try {
const result = await fetchAllTemplatesMetadata();
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load template metadata');
}
})
.get('/template/:id', authMiddleware, courseMemberMiddleware, zValidator('param', ZTemplateById), async (c) => {
try {
const { id } = c.req.valid('param');
const result = await fetchTemplateById(id);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load template');
}
})
.get('/template/tag/:tag', authMiddleware, courseMemberMiddleware, zValidator('param', ZTemplateByTag), async (c) => {
try {
const { tag } = c.req.valid('param');
const result = await fetchTemplatesByTag(tag);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load template');
}
});