Skip to content

Attempt Service API

Current Endpoints

  • GET /healthz
  • GET /readyz
  • GET /v1
  • GET /v1/admin/attempts/summary
  • GET /v1/student/attempts
  • 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
  • POST /v1/events/exam-results-released

SVC-010 Attempt Public Idempotency Proof

The bounded SVC-010-attempt-public-idempotency-proof contract is recorded in Attempt Public Idempotency Proof. The native route tests cover trusted start, answer save, event ordering, submit/grading, result visibility, timeout/retake policy, exact replay after a graded attempt, and result redaction. Native attempt routes remain non-default; real gateway/browser and rollback-after-candidate evidence is still required before public promotion.

Admin Attempt Metrics Summary

GET /v1/admin/attempts/summary is the service-owned read API for admin dashboard/source-map adapters. It reports counts from attempt-service local tables only:

  • attempts by IN_PROGRESS, SUBMITTED, and GRADED.
  • attempts.submitted_by by stored submit source.
  • Attempts with copied scores, access-link starts, start idempotency keys, and copied result-release timestamps.
  • attempt_questions, attempt_answers, graded answer rows, and attempt_events row counts.

The endpoint does not read exam-service, analytics-service, question-bank, classroom-service, profile-service, or legacy Prisma storage. Weak-topic, result, progress, mastery, and dashboard rollups stay in analytics-service.

Native Student Attempt List

Phase 7 now includes the lightweight student attempt list surface used by legacy GET /api/student/attempts. This is still a non-default route rehearsal until public exam-room cutover evidence is complete.

Legacy evidence:

  • node-platform/apps/api/src/modules/exams/exams.controller.ts:560-566 maps GET /api/student/attempts under JwtAuthGuard and @Roles("STUDENT").
  • node-platform/apps/api/src/modules/app-data/app-data.exams-access.ts:527-557 filters by studentId = actor.id, orders by startedAt DESC, caps at 100, and returns a lightweight item with nested exam metadata.
  • node-platform/apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:312-351 defines result visibility and Vietnamese hidden-result messages.

Native contract:

  • GET /v1/student/attempts requires X-User-Id plus X-User-Role: STUDENT.
  • The response is { "success": true, "data": [...], "message": "OK" }.
  • Items are ordered newest first and capped at 100.
  • Items include id, examId, status, startedAt, submittedAt, deadlineAt, durationSeconds, totalScore, correctCount, wrongCount, resultVisible, hiddenReason, and nested exam.
  • The nested exam object includes id, title, subject, gradeLevel, durationMinutes, showResultMode, and closeTime.
  • List items intentionally omit attempt internals such as studentId, accessLinkId, questions, answers, events, and submittedBy.
  • Scores and counts are null until the student can see results. Hidden messages follow the same legacy Vietnamese strings used by result reads.

This slice adds 000005_attempt_student_list.sql to persist exam list snapshot fields and support list ordering:

  • exam_subject
  • exam_grade_level
  • attempts_student_started_idx

Grade Notification Outbox

Attempt-service durably queues visible grade-release notifications. When NOTIFICATION_SERVICE_URL or NOTIFICATION_SERVICE_BASE_URL is configured, the first successful native submit/grade writes attempt.grade_released.notification.v1 rows to its own attempt_notification_outbox in the same transaction as grading. A background worker posts due rows to notification-service /v1/events/notification; no notification or profile HTTP request is on the core submit/release write path.

  • Student notification source id: {attemptId}:notification:grade_released.
  • Parent notification source id: {attemptId}:notification:grade_released_parent.
  • Student preference key: GRADE_RELEASED.
  • Parent preference key: GRADE_RELEASED_PARENT.
  • Parent recipients are resolved only by the dispatcher through profile-service /v1/internal/profiles/parent-recipients/resolve when PROFILE_SERVICE_URL or PROFILE_SERVICE_BASE_URL is configured.
  • Notification-service remains the replay/preference owner; attempt-service does not write notification rows directly.
  • Payloads intentionally omit answers, answer keys, access-link ids, email, phone, and profile data.

When exam-service later sends exam.results_released.v1, attempt-service updates only attempts that do not already have a copied release timestamp. For each newly changed submitted, graded attempt that was hidden under MANUAL or pre-close AFTER_CLOSE, the same transaction queues the student and parent rows. The release event id is retained as payload provenance, while stable per-attempt source ids keep notification-service idempotency intact. Failed delivery increments the row attempt count and schedules retry; after the configured maximum it is marked DEAD. A notification failure never rolls back result visibility, and a replay with no newly changed attempts creates no additional notification row.

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:

  • node-platform/apps/api/src/modules/exams/exams.controller.ts:316-321 maps POST /api/exams/:examId/start.
  • node-platform/apps/api/src/modules/attempts/attempts.controller.ts:14-70 maps attempt detail, save answer, submit, events, and result routes.
  • node-platform/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.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-attempts.ts:395-628 later saves/submits against ExamAttemptQuestion snapshots.
  • node-platform/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 from an exam-service runtime-access startInput payload.
  • POST /v1/exams/{examId}/start is a gateway compatibility alias for legacy POST /api/exams/:examId/start.
  • Both start endpoints reject direct caller-supplied runtime bodies unless the trusted Gateway adapter supplies X-Internal-Service: api-gateway, the shared X-Internal-Token, X-Exam-Runtime-Decision-Source: exam-service, and one of Idempotency-Key, X-Idempotency-Key, or X-Correlation-Id.
  • The selected idempotency/correlation value is persisted on the attempt per exam/student. Exact replays return the original attempt with created=false even if that attempt has already been submitted or graded.
  • 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
    • exam.createdById, exam.assignedClassroomIds, and exam.resultViewerTeacherIds: copied creator/classroom scope facts from exam-service/classroom adapters. Attempt-service uses these values to allow only the exam creator or managed-classroom teachers to read teacher detail/events/results.
    • 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
    • optional courseAttemptContext, inserted only by the authenticated Gateway after course-service has resolved active enrollment, local quiz placement, published topic-bound lesson, and the matching examId. When present the start hop must also include X-Course-Attempt-Context-Source: course-service; Attempt rejects a missing source marker, mismatched student/organization/exam, incomplete context, or any validator other than course-service.
  • 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
    • access links with expiresAt equal to or before the current instant are expired
    • max attempts and guest-link student limit
    • assignment due time copied from the trusted resolver decision
    • 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.
  • The attempt database has a partial unique index for one IN_PROGRESS attempt per exam/student, so concurrent start requests resolve to the stored open attempt instead of creating duplicate active rows.
  • After exam-service grants runtime access, attempt-service still owns attempt-count enforcement. Submitted attempts count toward retake limits, LOGIN_REQUIRED access links are counted per student/link, and GUEST_ALLOWED access-link capacity is shared across all students for that link.
  • Successful start writes:
    • attempts
    • attempt_questions, one row per copied question snapshot
    • attempt_events with type=START
    • optional immutable Course context columns from 000010_attempt_course_context.sql; no enrollment, lesson content, question content, or direct Course database read is persisted.
  • 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

services/attempt-service/migrations/000006_attempt_start_idempotency.sql adds:

  • attempts.start_idempotency_key
  • attempts_start_idempotency_unique_idx, scoped by exam/student/key
  • attempts_open_exam_student_unique_idx, scoped to status='IN_PROGRESS'

services/attempt-service/migrations/000010_attempt_course_context.sql adds an all-or-none, immutable Course context snapshot to attempts. It duplicates only the Course owner decision identifiers and validation metadata so a later analytics replay does not join Course tables.

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
  • if either native start endpoint returns ATTEMPT_START_UNTRUSTED, route traffic through legacy or the adapter flow that calls exam-service runtime access first
  • 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:

  • node-platform/apps/api/src/modules/attempts/attempts.controller.ts:21-43 maps POST /api/attempts/:attemptId/answers and POST /api/attempts/:attemptId/submit.
  • node-platform/packages/shared/src/index.ts:1802-1816 defines saveAnswerSchema with questionId, nested or top-level answer fields, clientVersion, and sourceTabId.
  • node-platform/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.
  • node-platform/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.
  • node-platform/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.
  • node-platform/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

Internal essay grade handoff:

  • POST /v1/attempts/{attemptId}/essay-grade-handoffs accepts a versioned ai-classifier-service handoff after submit: sourceService=ai-classifier-service, type=ai.essay_grade.handoff.v1, sourceEventId, questionId, score, maxScore, optional feedback, criteria, provider, model, and occurredAt.
  • The route is internal /v1 only and is not a public /api/attempts/* promotion. ai-classifier-service still returns preview/handoff payloads; the stored grade write happens in attempt-service.
  • Attempt-service validates that the attempt is already submitted, the question belongs to the attempt snapshot, the question is ESSAY or MANUAL scored, a stored answer exists, and the handoff score does not exceed the attempt question score.
  • Successful handoff updates the stored answer score, recomputes attempt totals, records ESSAY_GRADE_HANDOFF with the source event metadata, and returns { attempt, applied, sourceEventId }. Replaying the same sourceEventId returns applied=false without adding another event.
  • This slice does not call usage-service or wallet-service. Future quota checks and credit movement must remain separate owner-service APIs.

Optional analytics handoff:

  • Set ANALYTICS_SERVICE_URL or ANALYTICS_SERVICE_BASE_URL to enable best-effort event publishing to analytics-service POST /v1/analytics/events.
  • The attempt write path does not require analytics-service. Publish failures are ignored after the attempt submit transaction succeeds.
  • First successful submit emits replay-safe owner-service events: attempt.submitted.v1 with sourceEventId={attemptId}:submitted, and attempt.graded.v1 with sourceEventId={attemptId}:graded.
  • When the start snapshot has a valid Course owner decision, the same first successful submit additionally emits attempt.graded.v2 with sourceEventId={attemptId}:graded:v2 and schemaVersion=2. Its courseContext contains only courseId, courseQuizReferenceId, lessonId, studentId, organizationId, validatedBy=course-service, and validatedAt; the adapter-only examId is deliberately absent. Generic v1 events remain unchanged and do not imply Course mastery.
  • Both events set sourceService=attempt-service, entity=ExamAttempt, entityId={attemptId}, organizationId, the submit actor/student refs, occurredAt, and a bounded payload with exam snapshot, result totals, answer count, and per-question grading metadata copied from attempt-owned snapshots. The producer does not read analytics-service or question/exam databases.

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:

  • node-platform/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.
  • node-platform/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.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-attempts.ts:630-644 computes result scorePercent from total possible attempt snapshot score.
  • node-platform/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.
  • node-platform/apps/api/src/modules/app-data/app-data.exams-attempts.ts:683-696 lists attempt events ordered by createdAt ASC.
  • node-platform/apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:312-351 defines student result visibility and hidden-result messages.
  • node-platform/apps/web/components/exam/exam-taking-client.tsx:213-245 posts question-view, focus, network, and timeout events from the exam room.
  • node-platform/apps/web/components/exam/exam-score-client.tsx:27-123 and node-platform/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.
  • Event writes are accepted only from the owner student. Missing actor identity, other students, parents, teachers, and admins are rejected before the event is persisted. This matches the non-default gateway route table, which exposes POST /api/attempts/:attemptId/events only to STUDENT actors.
  • 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 }.
  • Event and result reads allow admins, owner students, and teachers listed in the copied teacher-scope snapshot (createdById or resultViewerTeacherIds). Parent linkage remains a later profile-service adapter. Public teacher result routes still need adapter/browser proof before default promotion.
  • 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
    • admin and scoped teacher 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
  • POST /v1/events/exam-results-released consumes the internal exam.results_released.v1 handoff from exam-service. The request must carry sourceService=exam-service, stable sourceEventId, and type=exam.results_released.v1, X-Internal-Service: exam-service, and an X-Internal-Token matching ATTEMPT_INTERNAL_SERVICE_TOKEN or INTERNAL_SERVICE_TOKEN. Missing or wrong transport credentials return 401 before JSON decoding or attempt mutation; a missing receiver token returns 503. It accepts either flat organizationId/examId/releasedAt fields or the analytics-style entity=Exam, entityId, occurredAt, and payload shape emitted by exam-service. When payload is present, payload.schemaVersion must be 1 and payload.status must not be DRAFT.
  • The release consumer updates copied attempts.exam_results_released_at for matching existing attempts by examId and optional organizationId only when the copied value is still NULL. Replays are idempotent and return updatedAttempts=0 after the first update.
  • attempt-service still does not read exam-service databases. Future attempts should receive resultsReleasedAt from the exam-service runtime start snapshot; this event repairs attempts that started before release.

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

  • exam_title
  • exam_subject
  • exam_grade_level
  • 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.