Skip to content

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 /healthz
  • GET /readyz
  • GET /v1
  • GET /v1/admin/exams/summary
  • POST /v1/exams
  • GET /v1/exams
  • POST /v1/exam-blueprints
  • GET /v1/exam-blueprints
  • GET /v1/exam-blueprints/{id}
  • PATCH /v1/exam-blueprints/{id}
  • DELETE /v1/exam-blueprints/{id}
  • POST /v1/exam-blueprints/{id}/generate
  • POST /v1/exam-print-templates
  • GET /v1/exam-print-templates
  • GET /v1/exam-print-templates/{id}
  • PATCH /v1/exam-print-templates/{id}
  • DELETE /v1/exam-print-templates/{id}
  • GET /v1/exams/search-projections
  • GET /v1/exams/{id}
  • PATCH /v1/exams/{id}
  • DELETE /v1/exams/{id}
  • POST /v1/exams/{id}/publish
  • POST /v1/exams/{id}/assignments
  • POST /v1/exams/{id}/release-results
  • POST /v1/exams/{id}/runtime-access
  • GET /v1/exams/{id}/print-docx?templateId={templateId}
  • POST /v1/exams/{id}/print-exports
  • GET /v1/exams/{id}/print-exports
  • PUT /v1/exams/{examId}/question-snapshots
  • GET /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:

  • exams by DRAFT, PUBLISHED, CLOSED, visibility, and featured state.
  • exam_assignments row count.
  • exam_access_links by 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-145 maps GET /api/exams, GET /api/exams/:id, POST /api/exams, PATCH /api/exams/:id, and DELETE /api/exams/:id.
  • node-platform/apps/api/prisma/schema.prisma:2294-2368 defines the legacy Exam authoring/scheduling/status fields and indexes.
  • node-platform/apps/api/prisma/schema.prisma:3094-3114 defines ExamStatus, ShowResultMode, ExamDeliveryMode, and ExamAccessLinkMode.
  • node-platform/packages/shared/src/index.ts:1664-1697 defines examSchema validation 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-206 creates exams with resolved organization, creator, public metadata, schedule settings, access password hash, and default DRAFT status.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-authoring.ts:209-353 rejects direct PUBLISHED updates, rejects moving back to DRAFT, restricts published exam edits, and keeps accessPasswordHash as stored secret state.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-authoring.ts:365-389 deletes 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-346 scopes list/detail by organization and owner, filters by status, folderId, and workflow, and redacts accessPasswordHash into requiresAccessPassword.
  • node-platform/apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:937-1012 expands exams with questionIds, totalScore, assignmentCount, requiresAccessPassword, and sanitized access links.

Native contract:

  • POST /v1/exams creates a draft exam. X-User-Id is required as createdById; X-Organization-Id wins over body organizationId when present. An optional Idempotency-Key is ledgered with the organization, actor, and canonical create payload in the same Exam transaction. A matching replay returns the original draft with 201; a changed payload returns 409 EXAM_IDEMPOTENCY_CONFLICT without another draft.
  • GET /v1/exams supports status, folderId, workflow, view=list, and tenant/owner scoping through X-Organization-Id, X-User-Id, and X-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-derived questionIds and totalScore.
  • PATCH /v1/exams/{id} supports partial authoring updates. Direct status=PUBLISHED is 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" }.
  • accessPasswordHash is never returned; clients receive only requiresAccessPassword.
  • This is not a public /api/exams cutover. 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-blueprints and PATCH /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. ADMIN lists all matrices in the organization and is the only role that can create or change isSystem.
  • Teachers can update/delete only their own non-system matrices. Deletes reject a blueprint referenced by an existing local exams.blueprint_id row.
  • POST /v1/exam-blueprints/{id}/generate forwards the scoped matrix to the Question Bank internal selection API, validates snapshots before creating a draft, then persists immutable local snapshots. Insufficient questions return 400 without creating a draft.
  • deliveryMode=ONLINE creates an online draft; deliveryMode=OFFLINE creates an offline/Word-ready draft from the same immutable snapshots.
  • FIXED keeps stable source selection. RANDOM_PER_EXAM supplies a fresh seed to Question Bank and enables shuffled questions. RANDOM_PER_ATTEMPT uses 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-templates
  • GET /v1/exam-print-templates
  • GET /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, and X-User-Role are required. Only ADMIN and TEACHER may manage templates.
  • Every list, read, update, and delete is exact (organizationId, createdById) scope. An existing template outside that scope returns EXAM_NOT_FOUND so 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.
  • config must be a JSON object. Replacing it increments configVersion; 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.

  • templateId is required and is read in exact (organizationId, createdById) scope. A template outside the caller scope returns EXAM_NOT_FOUND.
  • includeAnswers=true appends the current snapshot answer labels and explanations after a page break. Its default is false.
  • 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=true renders the generic native answer sheet using the immutable snapshot types and option labels. answerSheetMode accepts after-questions or separate-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 in X-Exam-Print-Template-Id and X-Exam-Print-Template-Version.
  • Question text and explanations are normalized from either contentText or rich HTML snapshot fields before XML escaping, preventing HTML tags from appearing as Word content.
  • When snapshots contain mediaAssetId references, the renderer fetches only tenant-scoped bytes from document-service's token-gated internal media route and packages PNG, JPEG, or GIF data under word/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[].latex are 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 Bank formulaRefs[].sourceJson shape. A reference marked reviewRequired, an unsupported command, or a reference without LaTeX remains visible as a Công thức cần rà soát text 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-870 publishes only draft exams, rejects empty question sets, refreshes every exam-question snapshot, changes status to PUBLISHED, creates the default access link, and returns the expanded exam.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-authoring.ts:826-852 creates the default access link with mode = exam.defaultAccessMode, uses a guest limit for GUEST_ALLOWED, and retries generated link-code collisions.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-access.ts:102-180 enforces published-only share links and applies the same guest/login attempt limits.
  • node-platform/apps/api/prisma/schema.prisma:2369-2386 defines ExamAccessLink fields, link mode/status enums, indexes, and unique code.
  • node-platform/apps/api/src/modules/app-data/app-data.shared.ts:165-166 sets GUEST_ACCESS_LINK_STUDENT_LIMIT = 10000 and STUDENT_ACCESS_LINK_ATTEMPT_LIMIT = 10.
  • node-platform/apps/api/src/modules/app-data/app-data.shared.ts:801-806 generates uppercase alphanumeric access-link codes from base64url random bytes.

Native contract:

  • POST /v1/exams/{id}/publish publishes one draft exam in actor/tenant scope.
  • Request body is optional. When present, snapshots uses the same payload as PUT /v1/exams/{examId}/question-snapshots; the service replaces the exam snapshot set before publishing.
  • When snapshots is 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 status to PUBLISHED, attaches snapshot-derived questionIds and totalScore, and returns the default access link.
  • Default access link rules:
    • mode = exam.defaultAccessMode
    • status = ACTIVE
    • maxAttempts = 10000 for GUEST_ALLOWED
    • maxAttempts = exam.maxAttempts for LOGIN_REQUIRED
    • createdById = X-User-Id when present, otherwise the exam creator
  • The service records an exam.published row in exam_outbox_events. This is the durable service-local outbox foundation; live NATS dispatch is intentionally not part of this slice.
  • When ANALYTICS_SERVICE_URL or ANALYTICS_SERVICE_BASE_URL is configured, publish also best-effort emits exam.published.v1 to analytics-service POST /v1/analytics/events with sourceService=exam-service and sourceEventId={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-221 maps POST /api/exams/:id/assign.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-access.ts:41-99 enforces 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-353 emits exam.assigned after assignment.
  • node-platform/apps/api/prisma/schema.prisma:2428-2446 defines ExamAssignment with classroom, assigned-by, and due-date fields.

Native contract:

  • Request body requires classroomId; dueAt is optional RFC3339.
  • Request body may include optional notification hints with classroomName, link, and already-resolved recipients. Each recipient requires userId; role defaults to STUDENT, and PARENT recipients must include studentName because 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 updates dueAt and assignedById without creating a second row.
  • assignedById comes from X-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 classroomId reference and does not read classroom-service or profile-service databases.
  • When ANALYTICS_SERVICE_URL or ANALYTICS_SERVICE_BASE_URL is configured, assignment also best-effort emits exam.assigned.v1 to analytics-service POST /v1/analytics/events with sourceService=exam-service and sourceEventId={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_URL or NOTIFICATION_SERVICE_BASE_URL is configured and notification.recipients is supplied, assignment best-effort emits notification-service POST /v1/events/notification events with sourceService=exam-service, sourceEventId={assignmentId}:notification:exam_assigned for student recipients and sourceEventId={assignmentId}:notification:exam_assigned_parent for parent recipients. The events set preferenceType=EXAM_ASSIGNED or EXAM_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-242 maps POST /api/exams/:id/release-results.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-access.ts:385-407 looks up the exam in tenant scope, rejects draft exams, and updates resultsReleasedAt = new Date().
  • node-platform/apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:314-334 uses resultsReleasedAt to make MANUAL results visible and to release AFTER_CLOSE results 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-91 includes showResultMode, closeTime, and resultsReleasedAt in analytics result visibility reads.

Native contract:

  • The exam must exist in actor/tenant scope and must not be DRAFT.
  • X-User-Id is required and X-User-Role must be ADMIN or TEACHER. Missing actors or unsupported roles return EXAM_FORBIDDEN.
  • The service sets resultsReleasedAt and updatedAt to the service clock and returns { "exam": ... } inside the normal success envelope.
  • Releasing an already released exam is allowed and preserves the first resultsReleasedAt timestamp 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-released to copy this timestamp into already-started attempt policy snapshots by exam id.
  • When ATTEMPT_SERVICE_URL or ATTEMPT_SERVICE_BASE_URL is configured, exam-service sends that event directly with X-Internal-Service: exam-service and X-Internal-Token from EXAM_INTERNAL_SERVICE_TOKEN or INTERNAL_SERVICE_TOKEN. The event has stable sourceEventId=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_URL or ANALYTICS_SERVICE_BASE_URL is configured, release also best-effort emits exam.results_released.v1 to analytics-service POST /v1/analytics/events with sourceService=exam-service and sourceEventId=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-Id and X-User-Role=STUDENT; body studentId and studentInfo are rejected because the actor comes from gateway-auth headers and profile data remains profile-service-owned
  • scopes the exam by X-Organization-Id and rejects missing org scope for tenant-owned exams
  • requires status=PUBLISHED, deliveryMode=ONLINE, and the current time inside openTime/closeTime
  • verifies accessPassword against the stored bcrypt hash and never returns accessPasswordHash
  • resolves accessLinkId or accessLinkCode against native exam_access_links, requiring ACTIVE and expiresAt strictly after the evaluation instant, then returns accessLinkId, 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_URL or IAM_SERVICE_BASE_URL and CLASSROOM_SERVICE_URL or CLASSROOM_SERVICE_BASE_URL are configured together, the production verifier calls IAM GET /v1/internal/organizations/{organizationId}/members/{accountId}/check for active organization membership and classroom-service GET /v1/classrooms/{classroomId} with X-Actor-Role=STUDENT for assigned classroom membership
  • if assignment rows exist but the verifier is not wired, denies with ASSIGNMENT_DECISION_UNAVAILABLE and includes classroomMembership in pendingPolicyChecks
  • requires non-empty exam_question_snapshots and 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 shared X-Internal-Token, X-Exam-Runtime-Decision-Source: exam-service, and idempotency/correlation context
  • non-default gateway route tables use exam_start_adapter to extract only data.startInput and 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-2431 defines ExamQuestion with questionVersionId, orderIndex, section/global indexes, score, questionSnapshotJson, optionOrderJson, and @@unique([examId, questionId]).
  • node-platform/apps/api/prisma/schema.prisma:2475-2499 defines ExamAttemptQuestion with its own questionSnapshotJson and optionOrderJson.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-authoring.ts:516-641 snapshots 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-818 refreshes every exam question snapshot before publishing.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-attempts.ts:81-314 copies 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-504 builds 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-780 grades from the saved snapshot, not from current question rows.

Native contract:

  • PUT /v1/exams/{examId}/question-snapshots replaces 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-snapshots resolves the same scoped exam before returning stored snapshot rows ordered by orderIndex.
  • The caller supplies the hydrated question payload. exam-service stores it as exam-owned state and does not join or query the question-bank-service database.
  • The payload preserves:
    • questionId, questionVersionId, order/section metadata, display number, and score
    • type, content, contentText, contentJson
    • explanation, explanationText, explanationJson
    • options, subItems, answerKeys, scoringRule
    • mediaRefs, formulaRefs, optionOrder, and sourceSnapshotJson
  • Attempt snapshots remain a separate attempt-service responsibility. 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/start routed 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:

  • page and limit for page-based rebuilds; limit is capped at 100.
  • organizationId for an explicit tenant scope.
  • allOrgs=1 or allOrgs=true for ADMIN rebuilds when organizationId is not supplied.
  • status, folderId, workflow, and view with 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.

Go-platform documentation is generated from repository Markdown.