Files
classroomio/apps/api/src/routes/course/exercise.ts
T
Rotimi BestandRotimi Best 27229cf8fe fix: canonical exercise completion, teacher course completion & upsert member fixes (#767)
* fix(db): preserve not-found error and dedupe member lookup in upsert (#763)

Re-throw Organization member not found so the service layer can return
404. Fetch existing notification prefs by resolved member id instead of
calling getOrganizationMemberEmailNotifications, which repeated the lookup.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(course): single source of truth for exercise completion; let teachers complete courses

Exercise completion was computed four different ways (course progress,
enrolled courses, content items, progression locks), and two of them
counted any submission as complete, ignoring the exercise's 'passed'
completion policy and pass threshold. My Learning and certificate
eligibility could therefore report 100% while the course itself showed
an unpassed exercise.

- Add isExerciseCompletedSql, a policy-aware SQL predicate in the db
  query layer, and route every completion computation through it
  (getCourseProgress, getEnrolledCourses, getCourseContentItems,
  getCompletedExerciseIdsForMember, isExerciseCompletedForMember).
- Remove the per-exercise N+1 pass checks in progression and the
  duplicated policy interpretation in the submission service.
- Fix infinite "Checking eligibility" modal on the certificates page:
  open the completion modal in its final state instead of 'checking'
  with no follow-up.
- Align teacher experience with students: certificate eligibility no
  longer requires the student role, completion modal triggers for any
  enrolled member, teachers can view/download their earned certificate,
  and video-watch progress tracks in the student experience regardless
  of org role.

* chore: run api:dev without prebuild; keep full build as api:dev:full

* fix(dashboard): gate video watch tracking on learner view

* feat: pr reviews

---------

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
2026-07-16 22:10:13 +01:00

408 lines
14 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,
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 {
getNotifyCourseExerciseStatusService,
notifyCourseExerciseService
} from '@api/services/course/notify-exercise';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
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');
}
})
.post('/:exerciseId/notify', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const courseId = c.req.param('courseId')!;
const exerciseId = c.req.param('exerciseId')!;
const result = await notifyCourseExerciseService(courseId, exerciseId);
return c.json({ success: true, data: result }, 202);
} catch (error) {
return handleError(c, error, 'Failed to notify students');
}
})
.get('/:exerciseId/notify/:jobId', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const jobId = c.req.param('jobId')!;
const pollCount = Number.parseInt(c.req.query('pollCount') ?? '0', 10) || 0;
const envelope = await getNotifyCourseExerciseStatusService(jobId, pollCount);
return c.json({ success: true, data: envelope }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch notification status');
}
})
.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');
}
});