Skip to content

Attempt Service API

Current Endpoints

  • GET /healthz
  • GET /readyz
  • GET /v1
  • POST /v1/exams/{examId}/attempts
  • POST /v1/exams/{examId}/start
  • GET /v1/attempts/{attemptId}
  • POST /v1/attempts/{attemptId}/answers
  • POST /v1/attempts/{attemptId}/submit
  • POST /v1/attempts/{attemptId}/events
  • GET /v1/attempts/{attemptId}/events
  • GET /v1/attempts/{attemptId}/result

Native Attempt Start Snapshot Foundation

Phase 7 attempt start creates attempt-owned question snapshots. This is an internal /v1 foundation, not a public /api/exams/:examId/start cutover.

Legacy evidence:

  • apps/api/src/modules/exams/exams.controller.ts:316-321 maps POST /api/exams/:examId/start.
  • apps/api/src/modules/attempts/attempts.controller.ts:14-70 maps attempt detail, save answer, submit, events, and result routes.
  • apps/api/src/modules/app-data/app-data.exams-attempts.ts:81-314 validates student-only start, published/online/open exam state, organization/class assignment or access link, max attempts, deadline, question shuffle, option shuffle, attempt row creation, attempt question snapshot copy, and START event creation.
  • apps/api/src/modules/app-data/app-data.exams-attempts.ts:395-628 later saves/submits against ExamAttemptQuestion snapshots.
  • apps/api/prisma/schema.prisma:2448-2533 defines ExamAttempt, ExamAttemptQuestion, ExamAnswer, and ExamEvent.

Native contract:

  • POST /v1/exams/{examId}/attempts starts or reuses a native attempt for one student.
  • POST /v1/exams/{examId}/start is a gateway compatibility alias for legacy POST /api/exams/:examId/start and delegates to the same native start behavior.
  • The authenticated actor is provided by headers: X-User-Id and X-User-Role. Only X-User-Role: STUDENT can start attempts.
  • The request body carries an exam runtime snapshot supplied by an upstream gateway or exam-service API call. attempt-service does not query the exam-service database or the question-bank-service database.
  • The request body includes:
    • exam: published exam settings needed for runtime validation and deadline calculation
    • questions: exam-owned question snapshots copied into attempt-owned rows
    • access: assignment/link decision already resolved through service APIs
    • optional studentInfo copied to the attempt row
  • The service validates:
    • exam id in path matches body
    • actor is a student
    • exam status is PUBLISHED
    • delivery mode is not OFFLINE
    • current time is within openTime/closeTime
    • password access has been verified when the exam requires a password
    • either assignment is valid or an active access link is present
    • max attempts and guest-link student limit
    • at least one question snapshot exists
  • If a matching IN_PROGRESS attempt exists without a new access-link start request, the existing attempt is returned with created=false.
  • Successful start writes:
    • attempts
    • attempt_questions, one row per copied question snapshot
    • attempt_events with type=START
  • Shuffle rules are applied from the exam snapshot:
    • shuffleQuestions or randomizePerAttempt shuffles attempt question order
    • shuffleOptions shuffles optionOrder
  • Response envelope uses { "success": true, "data": { "attempt": ..., "created": true }, "message": "OK" }.

Example request:

json
{
  "exam": {
    "id": "exam_123",
    "organizationId": "org_1",
    "title": "Đề kiểm tra",
    "status": "PUBLISHED",
    "deliveryMode": "ONLINE",
    "durationMinutes": 45,
    "maxAttempts": 1,
    "requiresAccessPassword": false,
    "shuffleQuestions": false,
    "shuffleOptions": false,
    "randomizePerAttempt": false
  },
  "access": {
    "assigned": true,
    "attemptLimit": 1
  },
  "questions": [
    {
      "id": "eqs_1",
      "examQuestionId": "eqs_1",
      "questionId": "q_1",
      "questionVersionId": "qv_1",
      "orderIndex": 0,
      "score": 1,
      "type": "SINGLE_CHOICE",
      "content": "<p>Question?</p>",
      "contentText": "Question?",
      "options": [
        { "id": "qo_a", "label": "A", "content": "A" }
      ],
      "optionOrder": ["qo_a"],
      "scoringRule": { "mode": "EXACT", "maxScore": 1 }
    }
  ]
}

Database

services/attempt-service/migrations/000002_attempt_start.sql creates:

  • attempts
  • attempt_questions
  • attempt_events

Validation queries:

sql
SELECT id, exam_id, student_id, status, started_at, deadline_at
FROM attempts
ORDER BY started_at DESC
LIMIT 20;

SELECT attempt_id, count(*)
FROM attempt_questions
GROUP BY attempt_id;

SELECT attempt_id, type, metadata_json, created_at
FROM attempt_events
ORDER BY created_at DESC
LIMIT 20;

Rollback for this native slice:

  • keep /api/exams/:examId/start and /api/attempts/* routed to legacy
  • stop callers from invoking native POST /v1/exams/{examId}/attempts
  • drop attempt-service local tables with the migration down step if local test data must be reset

Native Answer Save And Submit Grading

Phase 7 answer/save submit completes the first native attempt runtime loop. It is still an internal /v1 foundation, not a public /api/attempts/* cutover.

Legacy evidence:

  • apps/api/src/modules/attempts/attempts.controller.ts:21-43 maps POST /api/attempts/:attemptId/answers and POST /api/attempts/:attemptId/submit.
  • packages/shared/src/index.ts:1802-1816 defines saveAnswerSchema with questionId, nested or top-level answer fields, clientVersion, and sourceTabId.
  • apps/api/src/modules/app-data/app-data.exams-attempts.ts:395-487 validates the attempt owner, rejects locked attempts, checks the question belongs to the attempt snapshot set, enforces deadline timeout, detects ANSWER_VERSION_CONFLICT, upserts answers, and records SAVE_ANSWER.
  • apps/api/src/modules/app-data/app-data.exams-attempts.ts:490-628 submits and grades from ExamAttemptQuestion.questionSnapshotJson, updates ExamAnswer.isCorrect/score, updates attempt totals, and records SUBMIT or TIMEOUT.
  • apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:639-777 defines grading logic for exact option, partial multiple choice, true/false ladder, and short answer/numeric answers.
  • apps/api/prisma/schema.prisma:2501-2519 defines ExamAnswer with optimistic clientVersion and serverVersion.

Native contract:

  • POST /v1/attempts/{attemptId}/answers saves or updates one answer.
  • Only X-User-Role: STUDENT with matching X-User-Id can save answers.
  • Attempt must be IN_PROGRESS; otherwise the service returns Attempt is locked.
  • questionId must exist in attempt_questions; otherwise the service returns Câu hỏi không thuộc lượt làm bài này.
  • Request body accepts legacy-compatible fields:
    • answer.selectedOptionIds or top-level selectedOptionIds
    • answer.statementAnswers or top-level statementAnswers
    • answer.textAnswer or top-level textAnswer
    • clientVersion, default 0
    • sourceTabId
  • If an existing answer has a higher serverVersion than a positive incoming clientVersion, the service returns HTTP 409 with code ANSWER_VERSION_CONFLICT and the current answer payload.
  • Successful save writes attempt_answers, increments serverVersion on update, records SAVE_ANSWER, and returns attempt detail.
  • If the attempt deadline is expired, the service submits with TIMEOUT, records TIMEOUT, and then returns Exam duration expired.

Save answer example:

json
{
  "questionId": "q_1",
  "answer": {
    "selectedOptionIds": ["qo_a"]
  },
  "clientVersion": 1,
  "sourceTabId": "tab_1"
}
  • POST /v1/attempts/{attemptId}/submit submits and grades an attempt.
  • Only the attempt owner can submit with source=STUDENT.
  • Re-submitting a non-IN_PROGRESS attempt is idempotent and returns the current graded detail.
  • Grading uses only the copied attempt_questions snapshots. It must not read current question rows.
  • Supported native grading modes in this slice:
    • exact selected option matching
    • partial multiple-choice / true-false option scoring with incorrectPenalty and minScore
    • THPT true/false ladder via THPT_TRUE_FALSE_LADDER or TRUE_FALSE_GROUP
    • short text/numeric answer matching with optional tolerance
  • Successful submit updates:
    • attempt_answers.isCorrect and attempt_answers.score
    • attempts.status = GRADED
    • submittedAt, submittedBy, totalScore, correctCount, wrongCount, durationSeconds
    • attempt_events with SUBMIT or TIMEOUT

Submit example:

json
{
  "source": "STUDENT"
}

Additional validation queries after applying 000003_attempt_answers_grading.sql:

sql
SELECT attempt_id, question_id, answer_json, is_correct, score, server_version
FROM attempt_answers
WHERE attempt_id = '<attempt-id>'
ORDER BY question_id;

SELECT id, status, total_score, correct_count, wrong_count, submitted_by
FROM attempts
WHERE id = '<attempt-id>';

Native Attempt Events And Result Read

Phase 7 event/result read parity completes the attempt runtime history surface. It is still an internal /v1 foundation, not a public /api/attempts/* cutover.

Legacy evidence:

  • apps/api/src/modules/attempts/attempts.controller.ts:48-70 maps POST /api/attempts/:attemptId/events, GET /api/attempts/:attemptId/events, and GET /api/attempts/:attemptId/result.
  • apps/api/src/modules/app-data/app-data.exams-attempts.ts:315-394 returns attempt detail, redacts grading data while results are hidden, and builds result visibility/hidden-reason fields.
  • apps/api/src/modules/app-data/app-data.exams-attempts.ts:630-644 computes result scorePercent from total possible attempt snapshot score.
  • apps/api/src/modules/app-data/app-data.exams-attempts.ts:647-681 records attempt events, special-cases TIMEOUT by submitting an in-progress attempt, and returns the latest event otherwise.
  • apps/api/src/modules/app-data/app-data.exams-attempts.ts:683-696 lists attempt events ordered by createdAt ASC.
  • apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:312-351 defines student result visibility and hidden-result messages.
  • apps/web/components/exam/exam-taking-client.tsx:213-245 posts question-view, focus, network, and timeout events from the exam room.
  • apps/web/components/exam/exam-score-client.tsx:27-123 and apps/web/app/teacher/results/result-center-model.ts:83-103 consume /attempts/{id}/result.

Native contract:

  • POST /v1/attempts/{attemptId}/events records one attempt event.
  • Allowed event types are QUESTION_VIEW, TAB_HIDDEN, FOCUS_RETURNED, NETWORK_RETRY, and TIMEOUT.
  • The actor must pass attempt access rules. Student actors can only access their own attempts; admin/teacher actors are allowed for this internal foundation. Parent linkage remains a later user-service adapter.
  • TIMEOUT on an IN_PROGRESS attempt delegates to native submit with source=TIMEOUT and returns the graded attempt detail; otherwise the route records an event and returns it.
  • GET /v1/attempts/{attemptId}/events returns attempt events ordered ascending by creation time.
  • GET /v1/attempts/{attemptId}/result returns { attempt, scorePercent }.
  • Result read uses attempt-owned snapshots only. scorePercent is (totalScore / sum(attempt_questions.score)) * 100 and is not rounded.
  • Result redaction follows the supplied exam runtime result policy snapshot:
    • students see grading only when showResultMode=IMMEDIATE, or AFTER_CLOSE after close/results release, or MANUAL after results release
    • teacher/admin internal actors can see grading for non-IN_PROGRESS attempts
    • hidden results return totalScore, correctCount, wrongCount, answer isCorrect, answer score, and scorePercent as null/omitted and include a legacy hidden reason

This slice adds 000004_attempt_result_events.sql to persist the exam result policy snapshot on attempts:

  • exam_title
  • exam_duration_minutes
  • exam_show_result_mode
  • exam_close_time
  • exam_results_released_at
  • exam_status_snapshot

Validation queries after applying 000004_attempt_result_events.sql:

sql
SELECT id, exam_title, exam_show_result_mode, exam_close_time, exam_results_released_at
FROM attempts
WHERE id = '<attempt-id>';

SELECT attempt_id, type, metadata_json, created_at
FROM attempt_events
WHERE attempt_id = '<attempt-id>'
ORDER BY created_at ASC;

Rollback:

  • keep /api/attempts/:attemptId/events and /api/attempts/:attemptId/result routed to legacy
  • stop callers from invoking native event/result endpoints
  • run migration down locally if native result-policy test data must be removed

Go-platform documentation is generated from repository Markdown.