Skip to content

Question Bank Service API

Current Endpoints

  • GET /healthz
  • GET /readyz
  • GET /v1
  • GET /v1/question-types
  • POST /v1/question-types
  • PATCH /v1/question-types/bulk-status
  • POST /v1/question-types/bulk-delete
  • PATCH /v1/question-types/{id}
  • DELETE /v1/question-types/{id}
  • GET /v1/taxonomy/refs
  • PUT /v1/taxonomy/refs
  • GET /v1/questions
  • GET /v1/questions/{id}
  • PUT /v1/questions/usage-counts
  • PUT /v1/questions/relation-refs
  • POST /v1/questions/ai-classify/context
  • PATCH /v1/questions/ai-classify/apply
  • POST /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=true or summary=1 defaults status to ACTIVE and omits usageCount.
  • Query status=ACTIVE|ARCHIVED filters by definition status.
  • Query q= filters case-insensitively over code and name.
  • When X-User-Id is 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-types creates actor-owned custom definitions, uppercases code, defaults status to ACTIVE, and rejects duplicate custom code for the same organization and actor.
  • PATCH /v1/question-types/{id} updates custom definitions owned by the actor. System definitions can be updated only by ADMIN actors and ignore code, baseType, and isSystem changes.
  • DELETE /v1/question-types/{id} archives system definitions or definitions with usageCount > 0; unused custom definitions are hard-deleted and return { "id": "...", "deleted": true }.
  • PATCH /v1/question-types/bulk-status and POST /v1/question-types/bulk-delete deduplicate 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_CHOICE
  • MULTIPLE_CHOICE
  • TRUE_FALSE
  • TRUE_FALSE_GROUP
  • SHORT_ANSWER
  • SHORT_NUMERIC_ANSWER
  • ESSAY
  • PASSAGE_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), and created_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:

  • Subject
  • Chapter
  • Topic
  • EducationLevel
  • Grade
  • Curriculum
  • CurriculumNode
  • ExamTrack
  • QuestionSource
  • DifficultyLevel

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_groups
  • questions
  • question_versions
  • question_options
  • question_media_refs
  • question_formula_refs
  • tags
  • question_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_id
  • exam_track_id, question_source_id, difficulty_level_id
  • chapter_id, topic_id
  • source_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_id from DOCX output as grouping metadata and/or question_groups relation; 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 by document-service; do not copy binary media into question-bank.
  • Preserve formula html and latex in question-bank formula references and version JSON.
  • Store source document/job/file metadata in sourceMetadataJson.
  • Rollback remains route-level: keep default /api/questions and /api/exam-import/jobs/:id/approve traffic 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-Id
  • X-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/topic name 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, and formulaRefs
  • list scope preserves native question read policy: allOrgs=1 is honored only when X-User-Role=ADMIN; non-admin actors remain owner/org-scoped
  • normal detail includes legacy-style scalar fields, group, customType, createdBy, currentVersion, ordered options, and tag wrappers
  • GET /v1/legacy/questions/{id}?view=editor reuses 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.detail serves GET /api/questions/:id and switches to the editor projection when view=editor.
  • AppDataQuestionReadService.getQuestion includes subject, chapter, topic, group, customType, createdBy, currentVersion, ordered options, and tags.
  • AppDataQuestionReadService.getQuestionForEditor selects 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=editor is accepted on GET /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, optional organizationId, X-User-Id, X-User-Role, and admin-only allOrgs=1.
  • Non-admin reads are owner-scoped when X-User-Id is present, matching the legacy questionOwnerScope guardrail.
  • Detail reads include archived rows by default when the actor/tenant scope matches, matching legacy getQuestion and getQuestionForEditor.
  • Detail reads hydrate customType with the same native definition lookup as list reads.
  • Detail reads hydrate subject, chapter, topic, educationLevel, grade, curriculum, curriculumNode, examTrack, questionSource, and difficultyLevel from 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:

  • questionIds with at most 25 ids.
  • fields using the supported classification fields.
  • mode as missing or all; all forces overwrite=true.
  • overwrite, onlyUnclassified, and fullText.

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[].questionId
  • items[].acceptedFields
  • items[].fields
  • threshold
  • 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:

  • customTypeId is normalized through native active question_type_definitions and applies the selected definition baseType to type.
  • subjectId, educationLevelId, curriculumId, curriculumNodeId, examTrackId, and questionSourceId accept taxonomy id, code, or name.
  • gradeId accepts taxonomy id, code, or name and applies numericLevel to gradeLevel.
  • difficultyLevelId accepts taxonomy id, code, or name and applies engineDifficulty to difficulty.

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/server

Run 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-routes

Opt-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-live

The 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.

Go-platform documentation is generated from repository Markdown.