Get Hedhog updates in your inbox
New releases, fresh recipes, and breaking changes — no spam.
LMS Submodule
exams
Exam bank with a global question subject catalog, five question types (multiple choice, true/false, essay, fill-in-the-blank, matching), a standalone question bank, and the full learner attempt lifecycle.
Source path: libraries/lms/src/exam
Module file: exam.module.ts
Introduction
The Exams submodule is split across three controllers that together cover authoring and taking exams. `ExamController` (`/lms/exams`) owns exam definitions — title, code, scoring threshold, time limit, shuffle/answer-key-visibility flags, level, and status — plus a nested question-management surface for adding, editing, removing, and reordering each exam's questions. Five question types are supported: multiple_choice and true_false are auto-graded against marked-correct alternatives (true_false strictly requires exactly two), essay is stored ungraded for manual review, fill_blank is graded by case-insensitive exact match against one or more accepted answers, and matching is graded by exact left/right pair-set comparison. A small but easy-to-miss design choice: `GET /lms/exams/:id` returns `null` rather than a 404 when the exam doesn't exist, and several question-management endpoints (delete, reorder) are no-ops rather than errors when the target doesn't exist or the submitted id set doesn't match — callers should check response shape rather than relying purely on HTTP status.
`QuestionController` (`/lms/questions`) is a separate, flatter standalone question bank — questions created here are not linked to any exam at creation time, useful for building a reusable item bank before assembling exams, or for other parts of the platform that need ad-hoc graded questions outside the exam-attempt flow.
`ExamAttemptController` (also under `/lms/exams`) is the learner-facing half: starting an attempt, saving in-progress answers, and submitting for grading. `GET /:id/attempt` auto-finalizes an attempt if its time limit has silently elapsed, and resolves which `person` record the attempt belongs to via a fallback chain — explicit `studentId` override, then email-matched person, then (as a last resort) auto-creating a new person record for the calling user, which is a notable side effect worth knowing about before scripting against this endpoint with a fresh service account. Starting a new attempt is idempotent against an existing unexpired in-progress attempt, and submission publishes an `lms.exam.attempt.submitted` integration event carrying the final score and pass/fail outcome for downstream consumers (e.g. achievement-granting or enterprise license reporting) to react to.
HTTP Endpoints
19 endpoints
Paginated list of question subjects/categories.
- Query
- page, pageSize, search
Creates a question subject; rejects empty or case-insensitive duplicate names.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
| name | string | yes | — |
| description | string | no | — |
Paginated exam list with per-exam average score.
- Query
- page, pageSize, search, status
Aggregate stats: total/published exams, total questions, overall average score.
Gets an exam by ID; returns null (not 404) if not found.
- Params
- id (int)
Lists the exam's questions in order, including type-specific alternatives/answers/pairs.
- Params
- id (int)
Adds a question to the exam; per-type validation enforced (objective needs ≥2 alternatives with ≥1 correct, true_false exactly 2, fill_blank ≥1 answer, matching ≥2 unique pairs).
- Params
- id (int)
Body
| Field | Type | Required | Notes |
|---|---|---|---|
| subjectId | number | yes | — |
| statement | string | yes | — |
| points | number | yes | Min 1 |
| questionType | 'multiple_choice' | 'true_false' | 'essay' | 'fill_blank' | 'matching' | no | Default multiple_choice |
| explanation | string | no | — |
| alternatives | { text, isCorrect }[] | no | Max 2000 chars per text |
| fillBlankAnswers | { answer, alternatives? }[] | no | Min size 1 if present |
| matchingPairs | { id, leftText, rightText }[] | no | Min size 2 if present |
Updates a question (same validators as create, applied when fields/type change).
- Params
- id, questionId (int)
Removes a question and re-sequences remaining order (no-op if not found).
- Params
- id, questionId (int)
Reorders questions; returns {success:false} if the submitted ID set does not exactly match the exam's questions.
- Params
- id (int)
Body
| Field | Type | Required | Notes |
|---|---|---|---|
| questionIds | number[] | yes | ArrayMinSize(1) |
Creates an exam (exam_type fixed to "test").
Body
| Field | Type | Required | Notes |
|---|---|---|---|
| title | string | yes | Max 255 chars |
| code | string | no | Max 32 chars |
| minScore | number | no | Passing threshold on a base-10 scale, default 7 |
| timeLimit | number | no | Minutes, default 60 |
| shuffle | boolean | no | — |
| showAnswerKeyAfterFinish | boolean | no | — |
| level | 'beginner' | 'intermediate' | 'advanced' | no | Default beginner |
| status | 'draft' | 'published' | 'closed' | 'archived' | no | Default draft |
| primaryColor | string | no | Max 32 chars |
| secondaryColor | string | no | Max 32 chars |
Updates exam fields (all optional, same shape as create).
- Params
- id (int)
Hard-deletes the exam.
- Params
- id (int)
Lists the global standalone question bank (substring match on statement).
- Query
- search?, page?, limit? (default 50)
Creates a standalone question, not linked to any exam. Same per-type validation as exam questions.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
| subjectId | number | no | — |
| statement | string | yes | — |
| explanation | string | no | — |
| questionType | 'multiple_choice' | 'true_false' | 'essay' | 'fill_blank' | 'matching' | no | Default multiple_choice |
| points | number | no | Default 1 |
| alternatives | { text, isCorrect }[] | no | — |
| fillBlankAnswers | { answer, alternatives? }[] | no | — |
| matchingPairs | { id, leftText, rightText }[] | no | — |
Current attempt state for the user; auto-finalizes an expired in-progress attempt; resolves/creates a person record if none exists.
- Params
- id (int)
- Query
- studentId?
Starts a new attempt, or returns an existing unexpired in-progress one. Rejects if the attempt quota is exhausted or the exam has no questions.
- Params
- id (int)
Body
| Field | Type | Required | Notes |
|---|---|---|---|
| studentId | number | no | Admin-initiated attempt for another student |
Saves answers mid-attempt with per-type auto-grading where applicable.
- Params
- id, attemptId (int)
Body
| Field | Type | Required | Notes |
|---|---|---|---|
| answers | SaveExamAttemptAnswerDto[] | yes | { questionId, examOptionId?, answerText?, matchingPairs? }[] |
Finalizes the attempt, computes the score, and publishes lms.exam.attempt.submitted. Idempotent if already completed.
- Params
- id, attemptId (int)
Body
| Field | Type | Required | Notes |
|---|---|---|---|
| answers | SaveExamAttemptAnswerDto[] | yes | — |
| force | boolean | no | Bypass the require-all-answered check |
MCP Tools
Named tools callable by AI models via the Model Context Protocol (MCP).
lms.exams.listLists exams with optional pagination and filters
lms.exams.statsReturns aggregate statistics for exams
lms.exams.getReturns a single exam by ID
lms.exams.createCreates a new exam
lms.exams.updateUpdates an existing exam
lms.exams.deleteRemoves an exam by ID
lms.exams.question-subjects.listLists question subjects (topics) available for exam questions
lms.exams.question-subjects.createCreates a new question subject
lms.exams.questions.listLists all questions for an exam
lms.exams.questions.createCreates a new question for an exam
lms.exams.questions.updateUpdates a question in an exam
lms.exams.questions.deleteRemoves a question from an exam
lms.exams.questions.reorderReorders questions within an exam
lms.exams.attempt.getReturns the current attempt state for an exam for the authenticated user
lms.exams.attempt.startStarts a new exam attempt for the authenticated user
lms.exams.attempt.save-answersSaves answers for an in-progress exam attempt
lms.exams.attempt.submitSubmits an exam attempt for grading