Skip to content

Exam Service API

Current Endpoints

  • GET /healthz
  • GET /readyz
  • GET /v1
  • POST /v1/exams
  • GET /v1/exams
  • GET /v1/exams/{id}
  • PATCH /v1/exams/{id}
  • DELETE /v1/exams/{id}
  • POST /v1/exams/{id}/publish
  • PUT /v1/exams/{examId}/question-snapshots
  • GET /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-145 maps GET /api/exams, GET /api/exams/:id, POST /api/exams, PATCH /api/exams/:id, and DELETE /api/exams/:id.
  • apps/api/prisma/schema.prisma:2294-2368 defines the legacy Exam authoring/scheduling/status fields and indexes.
  • apps/api/prisma/schema.prisma:3094-3114 defines ExamStatus, ShowResultMode, ExamDeliveryMode, and ExamAccessLinkMode.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • GET /v1/exams supports status, folderId, workflow, view=list, and tenant/owner scoping through X-Organization-Id, X-User-Id, and X-User-Role.
  • 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"
}

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-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.
  • 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.
  • 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.
  • apps/api/prisma/schema.prisma:2369-2386 defines ExamAccessLink fields, link mode/status enums, indexes, and unique code.
  • 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.
  • 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.
  • 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-2431 defines ExamQuestion with questionVersionId, orderIndex, section/global indexes, score, questionSnapshotJson, optionOrderJson, and @@unique([examId, questionId]).
  • apps/api/prisma/schema.prisma:2475-2499 defines ExamAttemptQuestion with its own questionSnapshotJson and optionOrderJson.
  • 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.
  • apps/api/src/modules/app-data/app-data.exams-authoring.ts:772-818 refreshes every exam question snapshot before publishing.
  • 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.
  • 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.
  • 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 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 returns the 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.

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/start routed 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

Go-platform documentation is generated from repository Markdown.