Appearance
Attempt Service API
Current Endpoints
GET /healthzGET /readyzGET /v1POST /v1/exams/{examId}/attemptsPOST /v1/exams/{examId}/startGET /v1/attempts/{attemptId}POST /v1/attempts/{attemptId}/answersPOST /v1/attempts/{attemptId}/submitPOST /v1/attempts/{attemptId}/eventsGET /v1/attempts/{attemptId}/eventsGET /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-321mapsPOST /api/exams/:examId/start.apps/api/src/modules/attempts/attempts.controller.ts:14-70maps attempt detail, save answer, submit, events, and result routes.apps/api/src/modules/app-data/app-data.exams-attempts.ts:81-314validates 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, andSTARTevent creation.apps/api/src/modules/app-data/app-data.exams-attempts.ts:395-628later saves/submits againstExamAttemptQuestionsnapshots.apps/api/prisma/schema.prisma:2448-2533definesExamAttempt,ExamAttemptQuestion,ExamAnswer, andExamEvent.
Native contract:
POST /v1/exams/{examId}/attemptsstarts or reuses a native attempt for one student.POST /v1/exams/{examId}/startis a gateway compatibility alias for legacyPOST /api/exams/:examId/startand delegates to the same native start behavior.- The authenticated actor is provided by headers:
X-User-IdandX-User-Role. OnlyX-User-Role: STUDENTcan start attempts. - The request body carries an exam runtime snapshot supplied by an upstream gateway or
exam-serviceAPI call.attempt-servicedoes not query theexam-servicedatabase or thequestion-bank-servicedatabase. - The request body includes:
exam: published exam settings needed for runtime validation and deadline calculationquestions: exam-owned question snapshots copied into attempt-owned rowsaccess: assignment/link decision already resolved through service APIs- optional
studentInfocopied 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_PROGRESSattempt exists without a new access-link start request, the existing attempt is returned withcreated=false. - Successful start writes:
attemptsattempt_questions, one row per copied question snapshotattempt_eventswithtype=START
- Shuffle rules are applied from the exam snapshot:
shuffleQuestionsorrandomizePerAttemptshuffles attempt question ordershuffleOptionsshufflesoptionOrder
- 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:
attemptsattempt_questionsattempt_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/startand/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-43mapsPOST /api/attempts/:attemptId/answersandPOST /api/attempts/:attemptId/submit.packages/shared/src/index.ts:1802-1816definessaveAnswerSchemawithquestionId, nested or top-level answer fields,clientVersion, andsourceTabId.apps/api/src/modules/app-data/app-data.exams-attempts.ts:395-487validates the attempt owner, rejects locked attempts, checks the question belongs to the attempt snapshot set, enforces deadline timeout, detectsANSWER_VERSION_CONFLICT, upserts answers, and recordsSAVE_ANSWER.apps/api/src/modules/app-data/app-data.exams-attempts.ts:490-628submits and grades fromExamAttemptQuestion.questionSnapshotJson, updatesExamAnswer.isCorrect/score, updates attempt totals, and recordsSUBMITorTIMEOUT.apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:639-777defines grading logic for exact option, partial multiple choice, true/false ladder, and short answer/numeric answers.apps/api/prisma/schema.prisma:2501-2519definesExamAnswerwith optimisticclientVersionandserverVersion.
Native contract:
POST /v1/attempts/{attemptId}/answerssaves or updates one answer.- Only
X-User-Role: STUDENTwith matchingX-User-Idcan save answers. - Attempt must be
IN_PROGRESS; otherwise the service returnsAttempt is locked. questionIdmust exist inattempt_questions; otherwise the service returnsCâu hỏi không thuộc lượt làm bài này.- Request body accepts legacy-compatible fields:
answer.selectedOptionIdsor top-levelselectedOptionIdsanswer.statementAnswersor top-levelstatementAnswersanswer.textAnsweror top-leveltextAnswerclientVersion, default0sourceTabId
- If an existing answer has a higher
serverVersionthan a positive incomingclientVersion, the service returns HTTP409with codeANSWER_VERSION_CONFLICTand the current answer payload. - Successful save writes
attempt_answers, incrementsserverVersionon update, recordsSAVE_ANSWER, and returns attempt detail. - If the attempt deadline is expired, the service submits with
TIMEOUT, recordsTIMEOUT, and then returnsExam duration expired.
Save answer example:
json
{
"questionId": "q_1",
"answer": {
"selectedOptionIds": ["qo_a"]
},
"clientVersion": 1,
"sourceTabId": "tab_1"
}POST /v1/attempts/{attemptId}/submitsubmits and grades an attempt.- Only the attempt owner can submit with
source=STUDENT. - Re-submitting a non-
IN_PROGRESSattempt is idempotent and returns the current graded detail. - Grading uses only the copied
attempt_questionssnapshots. 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
incorrectPenaltyandminScore - THPT true/false ladder via
THPT_TRUE_FALSE_LADDERorTRUE_FALSE_GROUP - short text/numeric answer matching with optional tolerance
- Successful submit updates:
attempt_answers.isCorrectandattempt_answers.scoreattempts.status = GRADEDsubmittedAt,submittedBy,totalScore,correctCount,wrongCount,durationSecondsattempt_eventswithSUBMITorTIMEOUT
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-70mapsPOST /api/attempts/:attemptId/events,GET /api/attempts/:attemptId/events, andGET /api/attempts/:attemptId/result.apps/api/src/modules/app-data/app-data.exams-attempts.ts:315-394returns 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-644computes resultscorePercentfrom total possible attempt snapshot score.apps/api/src/modules/app-data/app-data.exams-attempts.ts:647-681records attempt events, special-casesTIMEOUTby submitting an in-progress attempt, and returns the latest event otherwise.apps/api/src/modules/app-data/app-data.exams-attempts.ts:683-696lists attempt events ordered bycreatedAt ASC.apps/api/src/modules/app-data/app-data.exam-runtime-core.ts:312-351defines student result visibility and hidden-result messages.apps/web/components/exam/exam-taking-client.tsx:213-245posts question-view, focus, network, and timeout events from the exam room.apps/web/components/exam/exam-score-client.tsx:27-123andapps/web/app/teacher/results/result-center-model.ts:83-103consume/attempts/{id}/result.
Native contract:
POST /v1/attempts/{attemptId}/eventsrecords one attempt event.- Allowed event types are
QUESTION_VIEW,TAB_HIDDEN,FOCUS_RETURNED,NETWORK_RETRY, andTIMEOUT. - 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.
TIMEOUTon anIN_PROGRESSattempt delegates to native submit withsource=TIMEOUTand returns the graded attempt detail; otherwise the route records an event and returns it.GET /v1/attempts/{attemptId}/eventsreturns attempt events ordered ascending by creation time.GET /v1/attempts/{attemptId}/resultreturns{ attempt, scorePercent }.- Result read uses attempt-owned snapshots only.
scorePercentis(totalScore / sum(attempt_questions.score)) * 100and is not rounded. - Result redaction follows the supplied exam runtime result policy snapshot:
- students see grading only when
showResultMode=IMMEDIATE, orAFTER_CLOSEafter close/results release, orMANUALafter results release - teacher/admin internal actors can see grading for non-
IN_PROGRESSattempts - hidden results return
totalScore,correctCount,wrongCount, answerisCorrect, answerscore, andscorePercentasnull/omitted and include a legacy hidden reason
- students see grading only when
This slice adds 000004_attempt_result_events.sql to persist the exam result policy snapshot on attempts:
exam_titleexam_duration_minutesexam_show_result_modeexam_close_timeexam_results_released_atexam_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/eventsand/api/attempts/:attemptId/resultrouted to legacy - stop callers from invoking native event/result endpoints
- run migration down locally if native result-policy test data must be removed