Appearance
Question Bank Service API
Current Endpoints
GET /healthzGET /readyzGET /v1GET /v1/question-typesPOST /v1/question-typesPATCH /v1/question-types/bulk-statusPOST /v1/question-types/bulk-deletePATCH /v1/question-types/{id}DELETE /v1/question-types/{id}GET /v1/taxonomy/refsPUT /v1/taxonomy/refsGET /v1/questionsGET /v1/questions/{id}PUT /v1/questions/usage-countsPUT /v1/questions/relation-refsPOST /v1/questions/ai-classify/contextPATCH /v1/questions/ai-classify/applyPOST /v1/questions/import-docx-output
Question Type Compatibility
/v1/question-types mirrors legacy /api/question-types list and write semantics for the native foundation:
- Query
summary=trueorsummary=1defaults status toACTIVEand omitsusageCount. - Query
status=ACTIVE|ARCHIVEDfilters by definition status. - Query
q=filters case-insensitively overcodeandname. - When
X-User-Idis present, custom definitions are scoped to that actor. - Response uses the legacy success envelope:
json
{
"success": true,
"data": [],
"message": "OK"
}Native write endpoints:
POST /v1/question-typescreates actor-owned custom definitions, uppercasescode, defaultsstatustoACTIVE, and rejects duplicate customcodefor the same organization and actor.PATCH /v1/question-types/{id}updates custom definitions owned by the actor. System definitions can be updated only byADMINactors and ignorecode,baseType, andisSystemchanges.DELETE /v1/question-types/{id}archives system definitions or definitions withusageCount > 0; unused custom definitions are hard-deleted and return{ "id": "...", "deleted": true }.PATCH /v1/question-types/bulk-statusandPOST /v1/question-types/bulk-deletededuplicate non-empty IDs, then apply the same single-item authorization and validation.
Writes require X-Organization-Id, X-User-Id, and X-User-Role (ADMIN or TEACHER). The default public gateway route remains legacy; native writes are available only through the non-default route-table examples until browser parity and route promotion are approved.
The native read model is service-owned. On startup/request it uses the question_type_definitions table when DATABASE_URL is reachable, otherwise it falls back to the in-memory store used by local go run tests. The store seeds active core definitions from legacy systemQuestionTypeDefinitions for the current X-Organization-Id scope:
SINGLE_CHOICEMULTIPLE_CHOICETRUE_FALSETRUE_FALSE_GROUPSHORT_ANSWERSHORT_NUMERIC_ANSWERESSAYPASSAGE_READING
Legacy THPT/DGNL/template preset codes stay out of summary results. They are normalized by legacy import/review code to active core definitions and should not be reintroduced as active system definitions.
X-Organization-Id scopes system definition rows. Requests without the header keep global deterministic IDs equal to the core code; requests with the header get deterministic org-scoped IDs so service-owned rows do not collide between tenants. X-User-Id is recorded as createdById when a scoped seed is created.
Database
services/question-bank-service/migrations/000002_question_type_definitions.sql creates:
question_type_definitions- unique expression index for system definitions on
(COALESCE(organization_id, ''), code) - unique expression index for custom definitions on
(COALESCE(organization_id, ''), COALESCE(created_by_id, ''), code) - indexes for
(organization_id, status),(is_system, name), andcreated_by_id
services/question-bank-service/migrations/000008_question_type_definition_actor_scope.sql drops the earlier broad scope/code unique index for already-migrated databases and installs the actor-scoped indexes above.
usage_count on question_type_definitions is a definition-level read model initialized to 0. Per-question legacy _count fields are handled separately by the question_usage_counts projection below.
Taxonomy Reference Read Model
GET /v1/taxonomy/refs and PUT /v1/taxonomy/refs are internal migration/backfill endpoints for service-owned question-bank taxonomy refs. They cover the legacy taxonomy family assigned to question-bank-service:
SubjectChapterTopicEducationLevelGradeCurriculumCurriculumNodeExamTrackQuestionSourceDifficultyLevel
PUT /v1/taxonomy/refs accepts:
json
{
"items": [
{
"id": "subject_math",
"kind": "SUBJECT",
"code": "MATH",
"name": "Toán"
}
]
}If an item omits organizationId, the endpoint uses X-Organization-Id as the default tenant scope. GET /v1/taxonomy/refs supports kind, status, q, and includeGlobal=1.
services/question-bank-service/migrations/000005_taxonomy_refs.sql creates question_taxonomy_refs, a denormalized read model for the shared legacy taxonomy fields. Real legacy-to-native backfill remains a later migration slice.
Question Usage Count Read Model
PUT /v1/questions/usage-counts is an internal migration/backfill endpoint for service-owned per-question usage counts. It accepts already-computed counters from a migrator, projection worker, or later exam/attempt event consumer:
json
{
"items": [
{
"questionId": "q_123",
"examItems": 2,
"attemptItems": 7,
"answers": 7
}
]
}If an item omits organizationId, the endpoint uses X-Organization-Id as the default tenant scope. Counts must be non-negative. The question-bank-service stores these values in its own question_usage_counts table and never joins exam/attempt databases while serving question reads.
Native GET /v1/questions and GET /v1/questions/{id} hydrate _count from this projection when a matching row exists. Missing rows keep the legacy-compatible zero placeholder from Question.ApplyListCompatibilityDefaults.
services/question-bank-service/migrations/000006_question_usage_counts.sql creates question_usage_counts, scoped by (organization_id, question_id), with non-negative counters and updated_at.
Question Relation Read Models
PUT /v1/questions/relation-refs is an internal migration/backfill endpoint for denormalized user and organization refs needed by native question reads:
json
{
"users": [
{
"id": "teacher_1",
"fullName": "Nguyễn Văn A",
"email": "teacher@example.test"
}
],
"organizations": [
{
"id": "org_1",
"name": "Trường THPT A",
"slug": "truong-thpt-a"
}
]
}If a user item omits organizationId, the endpoint uses X-Organization-Id as the default tenant scope. If an organization item omits organizationId, the endpoint uses the item id as the natural tenant scope before falling back to X-Organization-Id. The question-bank-service stores these projections in its own question_user_refs and question_organization_refs tables and never joins user-service, school-service, or legacy databases while serving question reads.
Native GET /v1/questions and GET /v1/questions/{id} hydrate createdBy and organization from these projections when matching rows exist. Missing rows keep the legacy-compatible placeholder behavior from Question.ApplyListCompatibilityDefaults.
services/question-bank-service/migrations/000007_question_relation_refs.sql creates question_user_refs and question_organization_refs, each scoped by (organization_id, id).
services/question-bank-service/migrations/000003_question_core.sql creates the Phase 6 core native question tables:
question_groupsquestionsquestion_versionsquestion_optionsquestion_media_refsquestion_formula_refstagsquestion_tags
services/question-bank-service/migrations/000004_question_list_compat.sql adds nullable legacy list/read reference fields to questions for taxonomy/source filters:
education_level_id,grade_id,curriculum_id,curriculum_node_idexam_track_id,question_source_id,difficulty_level_idchapter_id,topic_idsource_url,source_teacher_id
Native DOCX Output Ingestion
Phase 6 native endpoint:
POST /v1/questions/import-docx-output
This endpoint is an internal service-to-service contract from docx-import-service output into question-bank-service; it is not a cutover for legacy /api/questions yet. The write path preserves the legacy invariant from AppDataQuestionWriteService.createQuestion and createApprovalQuestion: create the Question, QuestionVersion, and QuestionOption rows in one transaction, then set currentVersionId.
Compatibility rules for the first native ingestion cut:
- Keep
group_idfrom DOCX output as grouping metadata and/orquestion_groupsrelation; do not infer semantic question type from grouping metadata alone. - Normalize known legacy template/preset codes to active core question types.
- Preserve media references as
media_asset_id/URL/object metadata owned bydocument-service; do not copy binary media into question-bank. - Preserve formula
htmlandlatexin question-bank formula references and version JSON. - Store source document/job/file metadata in
sourceMetadataJson. - Rollback remains route-level: keep default
/api/questionsand/api/exam-import/jobs/:id/approvetraffic on legacy until native approval cutover is explicitly verified. P4-007 provides a non-default gateway route example for the approval path only.
Required headers:
X-Organization-IdX-User-Id
The request body accepts the docx-import-service QAS output shape (document_id, latency_ms, questions, warnings, stats) plus optional question-bank metadata such as subjectId, gradeLevel, sourceFileName, sourceImportJobId, sourceMetadataJson, and tags.
Native Question List
GET /v1/questions returns hydrated native rows in the legacy-style success envelope:
json
{
"success": true,
"data": {
"items": [],
"meta": {
"page": 1,
"limit": 20,
"total": 0,
"totalPages": 1
}
},
"message": "OK"
}Supported filters in this Phase 6 slice: q, page, limit, subjectId, gradeLevel, difficulty, educationLevelId, gradeId, curriculumId, curriculumNodeId, examTrackId, questionSourceId, difficultyLevelId, type, customTypeId, sourceType, status, includeArchived, allOrgs, unclassifiedOnly, missingExplanationOnly, fidelityStatus, sourceMetadataSource, createdById, and sourceImportJobId. q searches question content/text, source name/file name, and tag name. Rows are hydrated with currentVersion, options, mediaRefs, formulaRefs, tags, customType, and taxonomy named refs when present.
Scoping follows the legacy list guardrails where the actor is available: non-admin reads are owner-scoped when X-User-Id is present, and allOrgs=1 bypasses organization scope only when X-User-Role is ADMIN. Native rows hydrate customType from active native question_type_definitions by customTypeId, definition code, or core type. Native rows also hydrate subject, chapter, topic, educationLevel, grade, curriculum, curriculumNode, examTrack, questionSource, and difficultyLevel from active question_taxonomy_refs by ID when that read model has data. Native rows hydrate _count from question_usage_counts and createdBy/organization from question_user_refs/question_organization_refs when projection rows exist; missing projections stay placeholders.
This is still not a full /api/questions cutover. Real legacy taxonomy, usage-count, and relation backfill, browser parity, route-table cutover proof, and rich create/update/delete parity remain pending.
Legacy Question Read Adapter
GET /v1/legacy/questions and GET /v1/legacy/questions/{id} are internal compatibility endpoints for a later gateway cutover of GET /api/questions and GET /api/questions/:id.
They reuse the same native filters and scope headers as /v1/questions, but project the response into the legacy read surfaces:
- list items include scalar question fields,
subject/chapter/topicname refs,customType.name/baseType,createdBy.fullName/email,organization.id/name/slug, and_count.examItems/attemptItems/answers - list items intentionally omit full native internals such as
currentVersion,options,mediaRefs, andformulaRefs - list scope preserves native question read policy:
allOrgs=1is honored only whenX-User-Role=ADMIN; non-admin actors remain owner/org-scoped - normal detail includes legacy-style scalar fields,
group,customType,createdBy,currentVersion, orderedoptions, and tag wrappers GET /v1/legacy/questions/{id}?view=editorreuses the legacy editor-selected projection from/v1/questions/{id}?view=editor
Default gateway routes still point /api/questions at legacy. The non-default shadow-read and native-read route examples now target /v1/legacy/questions; rerun shadow/native/browser parity with real backfilled data before any public cutover.
Native Question Detail And Editor Read
GET /v1/questions/{id} returns one hydrated native row in the same success envelope:
json
{
"success": true,
"data": {
"id": "q_...",
"currentVersion": {},
"options": [],
"mediaRefs": [],
"formulaRefs": [],
"tags": []
},
"message": "OK"
}Legacy evidence:
QuestionsController.detailservesGET /api/questions/:idand switches to the editor projection whenview=editor.AppDataQuestionReadService.getQuestionincludessubject,chapter,topic,group,customType,createdBy,currentVersion, orderedoptions, andtags.AppDataQuestionReadService.getQuestionForEditorselects rich content/explanation JSON, taxonomy/source IDs, source metadata, group passage fields,currentVersion.answerKeysJson,currentVersion.subItemsJson, and ordered option content.- Frontend editor code calls
/questions/{id}?view=editor; preview/generation flows call/questions/{id}.
Native Phase 6 behavior:
view=editoris accepted onGET /v1/questions/{id}and returns the legacy editor-selected shape: rich content/explanation JSON, taxonomy/source IDs, group passage fields,currentVersion.answerKeysJson,currentVersion.subItemsJson, and ordered option editor fields.- The same scope headers and query fallbacks as list reads are honored:
X-Organization-Id, optionalorganizationId,X-User-Id,X-User-Role, and admin-onlyallOrgs=1. - Non-admin reads are owner-scoped when
X-User-Idis present, matching the legacyquestionOwnerScopeguardrail. - Detail reads include archived rows by default when the actor/tenant scope matches, matching legacy
getQuestionandgetQuestionForEditor. - Detail reads hydrate
customTypewith the same native definition lookup as list reads. - Detail reads hydrate
subject,chapter,topic,educationLevel,grade,curriculum,curriculumNode,examTrack,questionSource, anddifficultyLevelfrom active taxonomy refs when present. - Missing or out-of-scope IDs return
404 QUESTION_NOT_FOUND.
This endpoint is native-read parity groundwork, not a write cutover. Legacy /api/questions/:id update/delete and editor save remain on the old API until rich create/update/version semantics are ported.
Native AI Classification Context
POST /v1/questions/ai-classify/context returns internal prompt input for native AI classification providers. It accepts:
questionIdswith at most 25 ids.fieldsusing the supported classification fields.modeasmissingorall;allforcesoverwrite=true.overwrite,onlyUnclassified, andfullText.
The endpoint returns scoped question rows in request order with bounded content, explanation, options, current classification, candidate grades, and question-specific candidate curriculum nodes. Shared taxonomy context comes from active service-owned question type definitions and taxonomy refs with global fallback. Curriculum node candidates mirror the legacy prompt guardrails: subject, grade, and curriculum matching first, then subject/grade fallback, capped at 160 nodes.
onlyUnclassified uses the legacy AI job predicate, not the general question-list unclassifiedOnly filter: a row qualifies when required classification refs are missing or the current difficulty came from the legacy deterministic difficulty sync reason. In that deterministic difficulty case, the response sets needsDifficultyReclassification=true and returns an empty current difficultyLevelId.
This endpoint is internal prompt-context groundwork only. Public /api/questions/ai-classify/suggestions, /api/questions/ai-classify/jobs*, job SSE, job errors, and cancel routes remain legacy-proxied until ai-classifier-service consumes this context through an explicit service client and browser parity is proven.
Native AI Classification Apply
PATCH /v1/questions/ai-classify/apply accepts the legacy-shaped AI classification apply payload:
items[].questionIditems[].acceptedFieldsitems[].fieldsthreshold- optional
taxonomy
The native foundation updates service-owned question classification fields and stores audit/history data under sourceMetadataJson.aiClassification. Supported direct fields are type, customTypeId, subjectId, educationLevelId, gradeId, curriculumId, curriculumNodeId, examTrackId, questionSourceId, and difficultyLevelId.
Native apply builds classification context from active service-owned question_type_definitions and question_taxonomy_refs with global fallback. The request taxonomy payload remains optional and can contribute candidates, but taxonomy-backed fields no longer require client-supplied context. Native apply mirrors the legacy derived field behavior:
customTypeIdis normalized through native activequestion_type_definitionsand applies the selected definitionbaseTypetotype.subjectId,educationLevelId,curriculumId,curriculumNodeId,examTrackId, andquestionSourceIdaccept taxonomyid,code, orname.gradeIdaccepts taxonomyid,code, ornameand appliesnumericLeveltogradeLevel.difficultyLevelIdaccepts taxonomyid,code, ornameand appliesengineDifficultytodifficulty.
This is internal groundwork only. Public /api/questions/ai-classify/apply remains legacy-proxied until real legacy taxonomy backfill, gateway auth/RBAC, and browser parity are proven.
make test-question-classification-apply-live is the opt-in write smoke for the non-default gateway adapter. It requires explicit confirmation, a real native question id, auth/org context, and a gateway running with routes.question-classification-apply-native-localhost-example.json.
Gateway Rehearsal
Use deploy/gateway/routes.question-types-shadow-localhost-example.json to compare native exact GET /api/question-types reads while still returning the legacy response during local go run:
bash
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.question-types-shadow-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/serverRun question-bank-service on :8089, or in Docker Compose use the container-facing deploy/gateway/routes.question-types-shadow-example.json route table. For native read/write rehearsal, use deploy/gateway/routes.question-types-native-localhost-example.json or deploy/gateway/routes.question-types-native-example.json.
Static route-table coverage:
bash
make test-question-types-routesOpt-in live gateway write smoke:
bash
QUESTION_TYPES_LIVE_CONFIRM=write-native \
QUESTION_TYPES_AUTHORIZATION='Bearer <token>' \
QUESTION_TYPES_ORGANIZATION_ID=<org-id> \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-question-types-liveThe live smoke requires the non-default native question-type route table. It checks /v1/routes, confirms a nested GET manager guard remains legacy, creates a custom type, updates it, bulk-archives it, and hard-deletes it. Hermetic validation uses QUESTION_TYPES_SELF_TEST=1 make test-question-types-live and does not contact a running gateway.
Rollback is changing GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json or deleting the question-types-read and question-types-write routes. The broad question-types route remains legacy_proxy in every non-default example so unlisted methods and nested GET question-type manager routes continue to hit legacy.
Full native cutover is not approved yet. Keep default public routing on legacy until /teacher/questions/types, import-editor consumers, auth/RBAC, browser parity, and rollback evidence are ready.