Appearance
Exam Service API
Current Endpoints
GET /healthzGET /readyzGET /v1POST /v1/examsGET /v1/examsGET /v1/exams/{id}PATCH /v1/exams/{id}DELETE /v1/exams/{id}POST /v1/exams/{id}/publishPUT /v1/exams/{examId}/question-snapshotsGET /v1/exams/{examId}/question-snapshots
Native Exam Authoring Foundation
Phase 7 authoring starts with native exam CRUD before publish/attempt cutover.
Legacy evidence:
apps/api/src/modules/exams/exams.controller.ts:34-145mapsGET /api/exams,GET /api/exams/:id,POST /api/exams,PATCH /api/exams/:id, andDELETE /api/exams/:id.apps/api/prisma/schema.prisma:2294-2368defines the legacyExamauthoring/scheduling/status fields and indexes.apps/api/prisma/schema.prisma:3094-3114definesExamStatus,ShowResultMode,ExamDeliveryMode, andExamAccessLinkMode.packages/shared/src/index.ts:1664-1697definesexamSchemavalidation for title, subject, grade, duration, max attempts, delivery/access/result modes, and optional access password.apps/api/src/modules/app-data/app-data.exams-authoring.ts:143-206creates exams with resolved organization, creator, public metadata, schedule settings, access password hash, and defaultDRAFTstatus.apps/api/src/modules/app-data/app-data.exams-authoring.ts:209-353rejects directPUBLISHEDupdates, rejects moving back toDRAFT, restricts published exam edits, and keepsaccessPasswordHashas stored secret state.apps/api/src/modules/app-data/app-data.exams-authoring.ts:365-389deletes only draft exams and refuses delete when assignment/attempt history exists.apps/api/src/modules/app-data/app-data.exams-read.ts:56-346scopes list/detail by organization and owner, filters bystatus,folderId, andworkflow, and redactsaccessPasswordHashintorequiresAccessPassword.apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:937-1012expands exams withquestionIds,totalScore,assignmentCount,requiresAccessPassword, and sanitized access links.
Native contract:
POST /v1/examscreates a draft exam.X-User-Idis required ascreatedById;X-Organization-Idwins over bodyorganizationIdwhen present.GET /v1/examssupportsstatus,folderId,workflow,view=list, and tenant/owner scoping throughX-Organization-Id,X-User-Id, andX-User-Role.GET /v1/exams/{id}returns one sanitized native exam with snapshot-derivedquestionIdsandtotalScore.PATCH /v1/exams/{id}supports partial authoring updates. Directstatus=PUBLISHEDis rejected; publish must use the later publish endpoint so snapshots can refresh first.DELETE /v1/exams/{id}deletes only native draft exams. Published/closed deletes are rejected to preserve assignment/attempt history.- Response envelopes use
{ "success": true, "data": ..., "message": "OK" }. accessPasswordHashis never returned; clients receive onlyrequiresAccessPassword.- This is not a public
/api/examscutover. Gateway rollback remains keeping/api/exams*on legacy.
Create example:
json
{
"title": "Đề kiểm tra học kỳ",
"subjectId": "math",
"gradeLevel": 10,
"durationMinutes": 90,
"maxAttempts": 1,
"deliveryMode": "ONLINE",
"defaultAccessMode": "GUEST_ALLOWED",
"showResultMode": "IMMEDIATE",
"accessPassword": "1234"
}Native Publish Workflow
Phase 7 publish turns a native draft exam into a published exam only after the question snapshot set is present in the exam-service database. This slice is still an internal /v1 foundation, not a public /api/exams/:id/publish cutover.
Legacy evidence:
apps/api/src/modules/app-data/app-data.exams-authoring.ts:772-870publishes only draft exams, rejects empty question sets, refreshes every exam-question snapshot, changes status toPUBLISHED, creates the default access link, and returns the expanded exam.apps/api/src/modules/app-data/app-data.exams-authoring.ts:826-852creates the default access link withmode = exam.defaultAccessMode, uses a guest limit forGUEST_ALLOWED, and retries generated link-code collisions.apps/api/src/modules/app-data/app-data.exams-access.ts:102-180enforces published-only share links and applies the same guest/login attempt limits.apps/api/prisma/schema.prisma:2369-2386definesExamAccessLinkfields, link mode/status enums, indexes, and uniquecode.apps/api/src/modules/app-data/app-data.shared.ts:165-166setsGUEST_ACCESS_LINK_STUDENT_LIMIT = 10000andSTUDENT_ACCESS_LINK_ATTEMPT_LIMIT = 10.apps/api/src/modules/app-data/app-data.shared.ts:801-806generates uppercase alphanumeric access-link codes from base64url random bytes.
Native contract:
POST /v1/exams/{id}/publishpublishes one draft exam in actor/tenant scope.- Request body is optional. When present,
snapshotsuses the same payload asPUT /v1/exams/{examId}/question-snapshots; the service replaces the exam snapshot set before publishing. - When
snapshotsis omitted, the service publishes from the already stored native snapshot set. - Publishing requires at least one snapshot. Empty snapshot state returns the legacy-compatible message
Cannot publish an exam without questions. - Publishing a non-draft exam returns the legacy-compatible message
Chỉ đề nháp mới có thể xuất bản. - Successful publish changes
statustoPUBLISHED, attaches snapshot-derivedquestionIdsandtotalScore, and returns the default access link. - Default access link rules:
mode = exam.defaultAccessModestatus = ACTIVEmaxAttempts = 10000forGUEST_ALLOWEDmaxAttempts = exam.maxAttemptsforLOGIN_REQUIREDcreatedById = X-User-Idwhen present, otherwise the exam creator
- The service records an
exam.publishedrow inexam_outbox_events. This is the durable service-local outbox foundation; live NATS dispatch is intentionally not part of this slice. - Response envelope uses
{ "success": true, "data": { "exam": ..., "defaultAccessLink": ..., "event": ... }, "message": "OK" }.
Example request with snapshot refresh:
json
{
"snapshots": {
"items": [
{
"questionId": "q_123",
"questionVersionId": "qv_123",
"orderIndex": 0,
"score": 1,
"type": "SINGLE_CHOICE",
"content": "<p>Question?</p>",
"options": [
{ "id": "qo_a", "label": "A", "content": "A", "isCorrect": true, "orderIndex": 0 }
],
"optionOrder": ["qo_a"],
"scoringRule": { "mode": "EXACT", "maxScore": 1 }
}
]
}
}Example response:
json
{
"success": true,
"data": {
"exam": {
"id": "exam_123",
"status": "PUBLISHED",
"questionIds": ["q_123"],
"totalScore": 1,
"accessLinks": [
{
"id": "eal_123",
"examId": "exam_123",
"code": "ABCDEFGH1234",
"mode": "GUEST_ALLOWED",
"status": "ACTIVE",
"maxAttempts": 10000
}
]
},
"defaultAccessLink": {
"id": "eal_123",
"examId": "exam_123",
"code": "ABCDEFGH1234",
"mode": "GUEST_ALLOWED",
"status": "ACTIVE",
"maxAttempts": 10000
},
"event": {
"type": "exam.published",
"source": "exam-service",
"aggregateId": "exam_123"
}
},
"message": "OK"
}Native Question Snapshot Foundation
Phase 7 starts with the exam-owned question snapshot contract because publish and attempt flows must not depend on live question rows after a question is edited.
Legacy evidence:
apps/api/prisma/schema.prisma:2388-2431definesExamQuestionwithquestionVersionId,orderIndex, section/global indexes,score,questionSnapshotJson,optionOrderJson, and@@unique([examId, questionId]).apps/api/prisma/schema.prisma:2475-2499definesExamAttemptQuestionwith its ownquestionSnapshotJsonandoptionOrderJson.apps/api/src/modules/app-data/app-data.exams-authoring.ts:516-641snapshots source question content when a draft exam question is added or refreshed.apps/api/src/modules/app-data/app-data.exams-authoring.ts:772-818refreshes every exam question snapshot before publishing.apps/api/src/modules/app-data/app-data.exams-attempts.ts:81-314copies exam question snapshots into attempt-owned rows when a student starts.apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:445-504builds the snapshot payload from question content, current version, options, sub-items, answer keys, and scoring rule.apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:640-780grades from the saved snapshot, not from current question rows.
Native contract:
PUT /v1/exams/{examId}/question-snapshotsreplaces the service-owned snapshot set for one exam. This is an internal service-to-service endpoint for draft authoring, import approval, and publish refresh. It is not a public/api/exams/*cutover.GET /v1/exams/{examId}/question-snapshotsreturns the stored snapshot rows ordered byorderIndex.- The caller supplies the hydrated question payload.
exam-servicestores it as exam-owned state and does not join or query thequestion-bank-servicedatabase. - The payload preserves:
questionId,questionVersionId, order/section metadata, display number, and scoretype,content,contentText,contentJsonexplanation,explanationText,explanationJsonoptions,subItems,answerKeys,scoringRulemediaRefs,formulaRefs,optionOrder, andsourceSnapshotJson
- Attempt snapshots remain a separate
attempt-serviceresponsibility. Later attempt start work must copy these exam snapshots into attempt-owned rows before shuffle/answer/grading logic runs.
Example request:
json
{
"items": [
{
"questionId": "q_123",
"questionVersionId": "qv_123",
"orderIndex": 0,
"globalIndex": 1,
"displayNumber": "1",
"score": 1,
"type": "SINGLE_CHOICE",
"content": "<p>Question?</p>",
"contentText": "Question?",
"contentJson": { "type": "docx_import_question", "text": "Question?" },
"options": [
{ "id": "qo_a", "label": "A", "content": "A", "isCorrect": true, "orderIndex": 0 }
],
"optionOrder": ["qo_a"],
"subItems": [],
"answerKeys": [],
"scoringRule": { "mode": "EXACT", "maxScore": 1 },
"mediaRefs": [],
"formulaRefs": [],
"sourceSnapshotJson": { "source": "question-bank-service" }
}
]
}Response envelope:
json
{
"success": true,
"data": {
"examId": "exam_123",
"count": 1,
"items": []
},
"message": "OK"
}Database
services/exam-service/migrations/000002_exam_question_snapshots.sql creates exam_question_snapshots in the exam-service database.
services/exam-service/migrations/000003_exam_authoring_core.sql creates the native authoring exams table.
services/exam-service/migrations/000004_exam_publish_workflow.sql creates the native exam_access_links and exam_outbox_events tables.
Validation queries:
sql
SELECT id, organization_id, title, status, workflow, created_by_id
FROM exams
ORDER BY created_at DESC
LIMIT 20;
SELECT status, count(*)
FROM exams
GROUP BY status;
SELECT exam_id, count(*)
FROM exam_question_snapshots
GROUP BY exam_id;
SELECT exam_id, question_id, question_version_id, order_index, question_type
FROM exam_question_snapshots
WHERE exam_id = '<exam-id>'
ORDER BY order_index;
SELECT exam_id, code, mode, status, max_attempts, created_by_id
FROM exam_access_links
WHERE exam_id = '<exam-id>';
SELECT type, source, aggregate_id, schema_version, payload_json
FROM exam_outbox_events
WHERE aggregate_id = '<exam-id>'
ORDER BY occurred_at DESC;Rollback for this native slice is local because no gateway route is cut over yet:
- keep
/api/exams,/api/exams/:id,/api/exams/:id/questions,/api/exams/:id/publish, and/api/exams/:examId/startrouted to legacy - stop callers from invoking native
/v1/exams* - stop callers from invoking
POST /v1/exams/{id}/publish - stop callers from invoking
PUT /v1/exams/{examId}/question-snapshots - drop the service-local table with the migration down step if local test data must be reset