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.
380 lines
13 KiB
TypeScript
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');
|
|
}
|
|
});
|