Appearance
Exam Service API
SVC-009 Exam Attempt Contract Proof
See Exam Attempt Public Proof for the accepted bounded runtime-access contract, ownership boundary, and remaining live/browser/default-route gate.
Current Endpoints
GET /healthzGET /readyzGET /v1GET /v1/admin/exams/summaryPOST /v1/examsGET /v1/examsPOST /v1/exam-blueprintsGET /v1/exam-blueprintsGET /v1/exam-blueprints/{id}PATCH /v1/exam-blueprints/{id}DELETE /v1/exam-blueprints/{id}POST /v1/exam-blueprints/{id}/generatePOST /v1/exam-print-templatesGET /v1/exam-print-templatesGET /v1/exam-print-templates/{id}PATCH /v1/exam-print-templates/{id}DELETE /v1/exam-print-templates/{id}GET /v1/exams/search-projectionsGET /v1/exams/{id}PATCH /v1/exams/{id}DELETE /v1/exams/{id}POST /v1/exams/{id}/publishPOST /v1/exams/{id}/assignmentsPOST /v1/exams/{id}/release-resultsPOST /v1/exams/{id}/runtime-accessGET /v1/exams/{id}/print-docx?templateId={templateId}POST /v1/exams/{id}/print-exportsGET /v1/exams/{id}/print-exportsPUT /v1/exams/{examId}/question-snapshotsGET /v1/exams/{examId}/question-snapshots
Admin Exam Summary
GET /v1/admin/exams/summary is the service-owned read API for admin dashboard/source-map adapters. It reports counts from exam-service local tables only:
examsbyDRAFT,PUBLISHED,CLOSED, visibility, and featured state.exam_assignmentsrow count.exam_access_linksby stored status and access mode.
The endpoint does not read attempt-service, analytics-service, question-bank, classroom-service, profile-service, or legacy Prisma storage. Attempt progress, scores, answers, and result analytics stay in attempt-service or analytics-service.
Native Exam Authoring Foundation
Phase 7 authoring starts with native exam CRUD before publish/attempt cutover.
Legacy evidence:
node-platform/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.node-platform/apps/api/prisma/schema.prisma:2294-2368defines the legacyExamauthoring/scheduling/status fields and indexes.node-platform/apps/api/prisma/schema.prisma:3094-3114definesExamStatus,ShowResultMode,ExamDeliveryMode, andExamAccessLinkMode.node-platform/packages/shared/src/index.ts:1664-1697definesexamSchemavalidation for title, subject, grade, duration, max attempts, delivery/access/result modes, and optional access password.node-platform/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.node-platform/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.node-platform/apps/api/src/modules/app-data/app-data.exams-authoring.ts:365-389deletes only draft exams and refuses delete when assignment/attempt history exists.node-platform/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.node-platform/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. An optionalIdempotency-Keyis ledgered with the organization, actor, and canonical create payload in the same Exam transaction. A matching replay returns the original draft with201; a changed payload returns409 EXAM_IDEMPOTENCY_CONFLICTwithout another draft.GET /v1/examssupportsstatus,folderId,workflow,view=list, and tenant/owner scoping throughX-Organization-Id,X-User-Id, andX-User-Role. Its list summaries batch access links, assignment counts, and snapshot score/question projections by the already-scoped exam IDs; it does not perform per-exam reads or join another service database.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"
}The Exam owner ledger is local to exam-service (exam_create_idempotency_keys) and cascades when a draft is deliberately removed by the existing compensation path. It improves retry recovery for draft creation; it is not a distributed transaction with Question Bank or Import.
Exam Blueprint Matrix Foundation
exam-service owns persistent exam matrices in exam_blueprints and their ordered rules in exam_blueprint_rules. A rule is an opaque Question Bank or curriculum filter plus a required count and score; this service never reads Question Bank storage directly.
POST /v1/exam-blueprintsandPATCH /v1/exam-blueprints/{id}validate a title, subject, grade, duration, mode, and 1-100 rules. A rule replacement is atomic in PostgreSQL.- Teachers list their own matrices plus organization system matrices.
ADMINlists all matrices in the organization and is the only role that can create or changeisSystem. - Teachers can update/delete only their own non-system matrices. Deletes reject a blueprint referenced by an existing local
exams.blueprint_idrow. POST /v1/exam-blueprints/{id}/generateforwards the scoped matrix to the Question Bank internal selection API, validates snapshots before creating a draft, then persists immutable local snapshots. Insufficient questions return400without creating a draft.deliveryMode=ONLINEcreates an online draft;deliveryMode=OFFLINEcreates an offline/Word-ready draft from the same immutable snapshots.FIXEDkeeps stable source selection.RANDOM_PER_EXAMsupplies a fresh seed to Question Bank and enables shuffled questions.RANDOM_PER_ATTEMPTuses the existing runtime per-attempt randomization flag.
The non-default BFF/Gateway rehearsal and current runtime evidence are in Teacher Exam Blueprint BFF Route Rehearsal and Exam Blueprint Generation Runtime Proof. The default /api/teacher/* routes remain legacy until authenticated browser and rollback evidence exists.
Word Print Template Foundation
exam-service now owns tenant and author scoped print-template configurations for a later DOCX renderer:
POST /v1/exam-print-templatesGET /v1/exam-print-templatesGET /v1/exam-print-templates/{id}PATCH /v1/exam-print-templates/{id}DELETE /v1/exam-print-templates/{id}
The service stores each template in exam_print_templates with an independent config_json object and config_version. It deliberately does not reuse exams.paper_template_id, which is legacy exam metadata and not a native Word template relationship.
Native contract:
X-Organization-Id,X-User-Id, andX-User-Roleare required. OnlyADMINandTEACHERmay manage templates.- Every list, read, update, and delete is exact
(organizationId, createdById)scope. An existing template outside that scope returnsEXAM_NOT_FOUNDso metadata is not disclosed. - A caller may create at most 20 templates inside one organization. The title is capped at 200 characters, description at 4,000 characters, and the JSON configuration at 128 KiB.
configmust be a JSON object. Replacing it incrementsconfigVersion; a title or description update preserves the version.- Responses use
{ "success": true, "data": ..., "message": "OK" }.
Example request:
json
{
"title": "Mẫu đề A4 có mã đề",
"description": "Dùng cho kiểm tra giữa kỳ",
"config": {
"headerTitle": "TRƯỜNG THPT HỌC TẬP",
"footerText": "Nội bộ",
"includeAnswerSheet": true,
"answerSheetMode": "separate-page"
}
}includeAnswerSheet=true adds the native generic answer sheet from the service-owned snapshots. answerSheetMode=after-questions continues directly after the last question; separate-page inserts a Word page break first. Single/multiple choice questions list their option labels, true/false sub-items receive true/false boxes, and free-response questions receive a write-in line. Unknown modes fall back to after-questions.
Native DOCX Print Export
GET /v1/exams/{id}/print-docx?templateId={templateId} renders an ephemeral DOCX download from the native, service-owned snapshot set and the caller-owned template configuration. The caller must provide the same organization/actor headers as template CRUD; ADMIN and TEACHER are the only accepted roles.
templateIdis required and is read in exact(organizationId, createdById)scope. A template outside the caller scope returnsEXAM_NOT_FOUND.includeAnswers=trueappends the current snapshot answer labels and explanations after a page break. Its default isfalse.- The DOCX uses OpenXML parts for the document, styles, header, and footer. It applies the template's common legacy-compatible configuration keys: header/footer text, watermark toggle/text, font family/size, margins, official header labels, candidate label, and exam-code/page-count switches.
includeAnswerSheet=truerenders the generic native answer sheet using the immutable snapshot types and option labels.answerSheetModeacceptsafter-questionsorseparate-page; it does not imply a legacy optical answer-sheet layout.- The response is
application/vnd.openxmlformats-officedocument.wordprocessingml.document, has an attachment filename,Cache-Control: private, no-store, and reports the exact template id/version inX-Exam-Print-Template-IdandX-Exam-Print-Template-Version. - Question text and explanations are normalized from either
contentTextor rich HTML snapshot fields before XML escaping, preventing HTML tags from appearing as Word content. - When snapshots contain
mediaAssetIdreferences, the renderer fetches only tenant-scoped bytes from document-service's token-gated internal media route and packages PNG, JPEG, or GIF data underword/media. Missing assets, tenant mismatches, unsupported image types, or invalid image bytes fail the export rather than silently dropping a diagram. The renderer never accepts an object key or browser content URL. - Snapshot
formulaRefs[].latexare emitted as native OMML for the supported fraction, root, script, common symbol, and elementary function subset. The renderer reads DOCX provenance from either top-level fields or the canonical Question BankformulaRefs[].sourceJsonshape. A reference markedreviewRequired, an unsupported command, or a reference without LaTeX remains visible as aCông thức cần rà soáttext line; this is content preservation, not full source-DOCX equation-layout parity.
This remains an internal /v1 download. It deliberately does not create a document-service artifact, pin an export record, produce the legacy answer-sheet variants, support multi-paper shuffle, or expose /api/exams/:id/paper-export. Those additions require a separate artifact/version contract and gateway/BFF/browser proof before promotion.
Durable DOCX Print Exports
POST /v1/exams/{id}/print-exports is the durable alternative. It accepts a required templateId and optional includeAnswers, applies the same ADMIN/TEACHER organization and owner scope, then pins the exact template configuration/version with the service-owned snapshot render. exam-service sends only the resulting DOCX bytes to document-service over the trusted artifact-token boundary (X-Document-Artifact-Token from DOCUMENT_ARTIFACT_SERVICE_TOKEN); it never writes object storage directly.
The durable path uses the same organization-scoped media resolver and OMML subset as the ephemeral download. It therefore fails before creating an export record when a referenced image cannot be resolved or packaged; it cannot create a completed artifact that silently omits snapshot media.
The returned record starts as PENDING, becomes COMPLETED with documentAssetId, storageKey, checksum, and byte size when the artifact has been saved, or persists bounded failure detail as FAILED if document storage rejects the upload. GET /v1/exams/{id}/print-exports lists only records created by the current owner in the current organization. These internal endpoints are not gateway/BFF or browser contracts; artifact reads need a separate authenticated frontend route before promotion.
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:
node-platform/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.node-platform/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.node-platform/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.node-platform/apps/api/prisma/schema.prisma:2369-2386definesExamAccessLinkfields, link mode/status enums, indexes, and uniquecode.node-platform/apps/api/src/modules/app-data/app-data.shared.ts:165-166setsGUEST_ACCESS_LINK_STUDENT_LIMIT = 10000andSTUDENT_ACCESS_LINK_ATTEMPT_LIMIT = 10.node-platform/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. - When
ANALYTICS_SERVICE_URLorANALYTICS_SERVICE_BASE_URLis configured, publish also best-effort emitsexam.published.v1to analytics-servicePOST /v1/analytics/eventswithsourceService=exam-serviceandsourceEventId={examId}:published. Analytics outages do not fail publish. The payload includes exam/question summary and default access-link metadata but excludes access-link codes and password hashes. - 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 Assignment Workflow
POST /v1/exams/{id}/assignments upserts one service-owned classroom assignment for a published native exam. This is still an internal /v1 foundation, not a public /api/exams/:id/assign cutover.
Legacy evidence:
node-platform/apps/api/src/modules/exams/exams.controller.ts:211-221mapsPOST /api/exams/:id/assign.node-platform/apps/api/src/modules/app-data/app-data.exams-access.ts:41-99enforces published-only assignment, classroom manager checks,(examId,classroomId)upsert behavior, due-date updates, and notification fanout.node-platform/apps/api/src/modules/exams/exam-core.service.ts:331-353emitsexam.assignedafter assignment.node-platform/apps/api/prisma/schema.prisma:2428-2446definesExamAssignmentwith classroom, assigned-by, and due-date fields.
Native contract:
- Request body requires
classroomId;dueAtis optional RFC3339. - Request body may include optional
notificationhints withclassroomName,link, and already-resolvedrecipients. Each recipient requiresuserId;roledefaults toSTUDENT, andPARENTrecipients must includestudentNamebecause exam-service does not query profile-service for display names. - The exam must exist in actor/tenant scope and must already be
PUBLISHED. - Assignment is upserted by native
(examId,classroomId). Reassigning the same classroom updatesdueAtandassignedByIdwithout creating a second row. assignedByIdcomes fromX-User-Id, falling back to the exam creator only for internal backfill-style callers.- Classroom existence, classroom-manager validation, and recipient resolution remain gateway/classroom/profile adapter work before any public route promotion. Exam-service stores the
classroomIdreference and does not read classroom-service or profile-service databases. - When
ANALYTICS_SERVICE_URLorANALYTICS_SERVICE_BASE_URLis configured, assignment also best-effort emitsexam.assigned.v1to analytics-servicePOST /v1/analytics/eventswithsourceService=exam-serviceandsourceEventId={assignmentId}:assigned. The payload contains assignment refs, schedule metadata, and an exam summary only; it excludes classroom member/profile PII. - Assignment analytics delivery is best-effort and does not roll back the assignment write.
- When
NOTIFICATION_SERVICE_URLorNOTIFICATION_SERVICE_BASE_URLis configured andnotification.recipientsis supplied, assignment best-effort emits notification-servicePOST /v1/events/notificationevents withsourceService=exam-service,sourceEventId={assignmentId}:notification:exam_assignedfor student recipients andsourceEventId={assignmentId}:notification:exam_assigned_parentfor parent recipients. The events setpreferenceType=EXAM_ASSIGNEDorEXAM_ASSIGNED_PARENT, carry bounded exam/assignment metadata only, and rely on notification-service replay/preference handling. Notification delivery failure does not roll back the assignment write.
Native Result Release Workflow
POST /v1/exams/{id}/release-results sets resultsReleasedAt for a native published or closed exam. This is still an internal /v1 foundation, not a public /api/exams/:id/release-results cutover.
Legacy evidence:
node-platform/apps/api/src/modules/exams/exams.controller.ts:237-242mapsPOST /api/exams/:id/release-results.node-platform/apps/api/src/modules/app-data/app-data.exams-access.ts:385-407looks up the exam in tenant scope, rejects draft exams, and updatesresultsReleasedAt = new Date().node-platform/apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:314-334usesresultsReleasedAtto makeMANUALresults visible and to releaseAFTER_CLOSEresults before the scheduled close time when a teacher/admin manually releases them.node-platform/apps/api/src/modules/app-data/app-data.exams-analytics.ts:87-91includesshowResultMode,closeTime, andresultsReleasedAtin analytics result visibility reads.
Native contract:
- The exam must exist in actor/tenant scope and must not be
DRAFT. X-User-Idis required andX-User-Rolemust beADMINorTEACHER. Missing actors or unsupported roles returnEXAM_FORBIDDEN.- The service sets
resultsReleasedAtandupdatedAtto the service clock and returns{ "exam": ... }inside the normal success envelope. - Releasing an already released exam is allowed and preserves the first
resultsReleasedAttimestamp so duplicate release requests remain replay-safe. This is intentionally narrower than the legacy blind timestamp refresh and is not a public route parity claim. - Current Go actor scoping still owner-scopes non-admin teachers. Exact legacy org-member teacher release parity requires a later gateway/IAM verifier adapter before any public route promotion.
- Exam-service does not read attempt-service, analytics-service, classroom, profile, or score databases in this mutation. Result visibility consumers use the timestamp as copied exam metadata.
- attempt-service exposes internal
POST /v1/events/exam-results-releasedto copy this timestamp into already-started attempt policy snapshots by exam id. - When
ATTEMPT_SERVICE_URLorATTEMPT_SERVICE_BASE_URLis configured, exam-service sends that event directly withX-Internal-Service: exam-serviceandX-Internal-TokenfromEXAM_INTERNAL_SERVICE_TOKENorINTERNAL_SERVICE_TOKEN. The event has stablesourceEventId=exam:{examId}:results-released, carries only the release policy metadata, and runs in parallel with the analytics publication so it does not add a second upstream wait to this mutation. Delivery is best-effort and does not roll back the persisted exam release; the attempt consumer is replay-safe. - When
ANALYTICS_SERVICE_URLorANALYTICS_SERVICE_BASE_URLis configured, release also best-effort emitsexam.results_released.v1to analytics-servicePOST /v1/analytics/eventswithsourceService=exam-serviceandsourceEventId=exam:{examId}:results-released. The payload contains exam visibility metadata only and excludes per-student attempts, scores, answers, parent links, and profile data. - Analytics delivery is best-effort and does not roll back the exam update.
Native Runtime Access Resolver
POST /v1/exams/{id}/runtime-access is the internal resolver that must run before any native attempt start. It returns a startInput payload shaped for attempt-service and is not a public /api/exams/:id/start cutover.
Only api-gateway may call it, using X-Internal-Service: api-gateway and the shared X-Internal-Token. startInput contains grading material required by attempt-service, including answer keys and correct-option state; Gateway must forward it only to the trusted attempt start endpoint and must never return it to the student browser.
Native checks in this slice:
- requires
X-User-IdandX-User-Role=STUDENT; bodystudentIdandstudentInfoare rejected because the actor comes from gateway-auth headers and profile data remains profile-service-owned - scopes the exam by
X-Organization-Idand rejects missing org scope for tenant-owned exams - requires
status=PUBLISHED,deliveryMode=ONLINE, and the current time insideopenTime/closeTime - verifies
accessPasswordagainst the stored bcrypt hash and never returnsaccessPasswordHash - resolves
accessLinkIdoraccessLinkCodeagainst nativeexam_access_links, requiringACTIVEandexpiresAtstrictly after the evaluation instant, then returnsaccessLinkId, mode, expiry, and attempt limit - resolves no-link starts through
exam_assignments; classroom membership is delegated to a verifier interface so exam-service does not read classroom or IAM databases directly - when
IAM_SERVICE_URLorIAM_SERVICE_BASE_URLandCLASSROOM_SERVICE_URLorCLASSROOM_SERVICE_BASE_URLare configured together, the production verifier calls IAMGET /v1/internal/organizations/{organizationId}/members/{accountId}/checkfor active organization membership and classroom-serviceGET /v1/classrooms/{classroomId}withX-Actor-Role=STUDENTfor assigned classroom membership - if assignment rows exist but the verifier is not wired, denies with
ASSIGNMENT_DECISION_UNAVAILABLEand includesclassroomMembershipinpendingPolicyChecks - requires non-empty
exam_question_snapshotsand maps each snapshot by value into attempt-service question fields
Known blockers before public promotion:
- attempt-service owns retake/open-attempt/access-link count enforcement after the trusted runtime decision and rejects direct start bodies without
X-Internal-Service: api-gateway, the sharedX-Internal-Token,X-Exam-Runtime-Decision-Source: exam-service, and idempotency/correlation context - non-default gateway route tables use
exam_start_adapterto extract onlydata.startInputand post it to attempt-service; direct public routing to attempt-service remains unsafe - default public promotion still requires browser/runtime smoke and rollback evidence
Example request:
json
{
"accessPassword": "1234",
"accessLinkCode": "ABCDEFGH1234"
}Allowed response shape:
json
{
"success": true,
"data": {
"allowed": true,
"startInput": {
"exam": {
"id": "exam_123",
"organizationId": "org_1",
"title": "Đề kiểm tra học kỳ",
"subject": "math",
"gradeLevel": 10,
"status": "PUBLISHED",
"deliveryMode": "ONLINE",
"durationMinutes": 90,
"maxAttempts": 1,
"showResultMode": "IMMEDIATE",
"requiresAccessPassword": true,
"accessPasswordVerified": true
},
"access": {
"accessLinkId": "eal_123",
"accessLinkMode": "GUEST_ALLOWED",
"accessLinkActive": true,
"attemptLimit": 10000
},
"questions": [
{
"id": "eqs_123",
"examQuestionId": "eqs_123",
"questionId": "q_123",
"questionVersionId": "qv_123",
"orderIndex": 0,
"score": 1,
"type": "SINGLE_CHOICE",
"content": "<p>Question?</p>",
"optionOrder": ["qo_a"]
}
]
},
"pendingPolicyChecks": ["organizationMembership"],
"evaluatedAt": "2026-07-06T09:00:00Z",
"decisionSource": "exam-service"
},
"message": "OK"
}Denied response shape keeps the envelope 200 but omits startInput:
json
{
"success": true,
"data": {
"allowed": false,
"denialCode": "ACCESS_PASSWORD_INVALID",
"denialReason": "Mật khẩu đề thi không đúng.",
"evaluatedAt": "2026-07-06T09:00:00Z",
"decisionSource": "exam-service"
},
"message": "OK"
}Runtime denial codes are stable contract values for the gateway/BFF adapter: EXAM_NOT_PUBLISHED, EXAM_OFFLINE, EXAM_NOT_OPEN, EXAM_CLOSED, ACCESS_PASSWORD_INVALID, ACCESS_LINK_NOT_FOUND, ACCESS_LINK_INACTIVE, EXAM_NOT_ASSIGNED, ASSIGNMENT_DECISION_UNAVAILABLE, STUDENT_NOT_ASSIGNED, ASSIGNMENT_OVERDUE, and EXAM_SNAPSHOTS_MISSING.
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:
node-platform/apps/api/prisma/schema.prisma:2388-2431definesExamQuestionwithquestionVersionId,orderIndex, section/global indexes,score,questionSnapshotJson,optionOrderJson, and@@unique([examId, questionId]).node-platform/apps/api/prisma/schema.prisma:2475-2499definesExamAttemptQuestionwith its ownquestionSnapshotJsonandoptionOrderJson.node-platform/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.node-platform/apps/api/src/modules/app-data/app-data.exams-authoring.ts:772-818refreshes every exam question snapshot before publishing.node-platform/apps/api/src/modules/app-data/app-data.exams-attempts.ts:81-314copies exam question snapshots into attempt-owned rows when a student starts.node-platform/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.node-platform/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 existing, tenant/actor-scoped 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-snapshotsresolves the same scoped exam before returning 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.
services/exam-service/migrations/000005_exam_assignments.sql creates the native assignment table used by assignment upserts and the runtime-access resolver.
Validation queries:
sql
SELECT id, organization_id, title, status, workflow, show_result_mode,
results_released_at, 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 exam_id, classroom_id, due_at, assigned_by_id
FROM exam_assignments
WHERE exam_id = '<exam-id>'
ORDER BY created_at;
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,/api/exams/:id/assign,/api/exams/:id/release-results, 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
POST /v1/exams/{id}/assignments - stop callers from invoking
POST /v1/exams/{id}/release-results - stop callers from invoking
POST /v1/exams/{id}/runtime-access - 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
Native Search Projection
GET /v1/exams/search-projections is the owner-service backfill contract for search-service. It returns copied EXAM search documents from exam-service data; search-service may index those documents but must not treat them as canonical exam state.
Supported query fields:
pageandlimitfor page-based rebuilds;limitis capped at 100.organizationIdfor an explicit tenant scope.allOrgs=1orallOrgs=truefor ADMIN rebuilds whenorganizationIdis not supplied.status,folderId,workflow, andviewwith the same meaning as the native exam list endpoint.
Headers X-Organization-Id, X-User-Id, and X-User-Role carry actor scope. ADMIN can bypass owner scope; non-admin actors remain scoped through the same list rules as GET /v1/exams.
Each returned document has entityType=EXAM, sourceService=exam-service, title, summary/content fields, status, visibility, tags, taxonomy, source update time, and metadata needed for search filters. Sensitive runtime fields stay redacted: the projection includes requiresAccessPassword, question/access-link counts, and active-link count, but never returns accessPasswordHash or access-link codes.
Opt-in Durable Search Projection Transport
Migration 000011_exam_search_projection_outbox.sql keeps the copied EXAM projection durable without changing this pull endpoint. It records a monotonic revision and a redacted search.projection.changed.v1 envelope in the same transaction as each canonical exam or copied child-count mutation. Snapshot content, answer keys, access-link codes/passwords, student data, and storage references are not placed in the event.
The publisher starts only when EXAM_SEARCH_PROJECTION_EVENT_TRANSPORT=nats. It accepts EXAM_SEARCH_PROJECTION_EVENTS_NATS_URL (then SEARCH_PROJECTION_EVENTS_NATS_URL or NATS_URL), EXAM_SEARCH_PROJECTION_EVENT_NATS_SUBJECT, and SEARCH_PROJECTION_EVENTS_NATS_STREAM. The default remains disabled. CLOSED produces an UPSERT, while physical row deletion produces a DELETE tombstone. Existing publish/snapshot flows can use multiple canonical transactions, so this is an atomic per-mutation outbox guarantee, not a whole-workflow transaction claim.