Appearance
Question Service API (question-bank-service alias)
SVC-008 Save/Reload Route Proof
See Question Save/Reload Route Proof for the accepted bounded update/reload evidence, native route guards, and the remaining live/browser/default-route gate.
Current Endpoints
GET /healthzGET /readyzGET /v1GET /v1/question-groupsPOST /v1/question-groupsGET /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/questionsPOST /v1/questionsPOST /v1/questions/ai-preview-draftsPOST /v1/questions/ai-preview-solutionsPATCH /v1/questions/bulk-statusPOST /v1/questions/permanent-delete-impactPOST /v1/questions/permanent-deletePOST /v1/questions/permanent-delete/jobsGET /v1/questions/permanent-delete/jobs/{id}GET /v1/questions/permanent-delete/jobs/{id}/eventsPOST /v1/questions/permanent-delete/jobs/{id}/cancelGET /v1/questions/search-projectionsGET /v1/questions/{id}PATCH /v1/questions/{id}DELETE /v1/questions/{id}PUT /v1/questions/usage-countsPUT /v1/questions/relation-refsPUT /v1/questions/exam-refsPOST /v1/internal/questions/select-for-exam-blueprintPOST /v1/questions/ai-classify/contextPATCH /v1/questions/ai-classify/applyPOST /v1/questions/import-docx-output
Exam Blueprint Selection Contract
POST /v1/internal/questions/select-for-exam-blueprint is a trusted service-to-service contract for exam-service, not a frontend or public Gateway route.
- Question Bank applies normal tenant/actor scope, published-only status, curriculum/source/type/difficulty filters,
chapterId,topicId, optional grouped-question requirement, and duplicate exclusion. - Rule selection is ordered. A
randomSeedproduces a deterministic shuffled result for oneRANDOM_PER_EXAMgeneration; without it the normal newest first ordering remains stable. - The response is hydrated with current version, options, answer keys, media, and formula refs.
exam-servicesnapshots those values by value and never reads Question Bank storage directly. - Selection keeps its ordered, duplicate-safe rule evaluation, then batches custom-type, taxonomy, usage, and relation read-model hydration across the completed result. This avoids repeating those lookups once per matrix rule without changing the returned snapshots.
Teacher-Approved AI Preview Draft
POST /v1/questions/ai-preview-drafts is the owner-service write boundary for a teacher-reviewed generation preview. The teacher UI reaches it only through the exact opt-in Gateway route.
- Question Bank forces
status=DRAFT,source=AI preview, andsourceType=AI_GENERATED; provider/model/generation mode and the reviewed source metadata are stored as provenance. - The idempotency scope is
(organizationId, actorId, previewId). The same reviewed payload returns the original draft with200; a new draft returns201; reuse ofpreviewIdwith different reviewed content is rejected with400. - A hard delete removes the service-owned idempotency record with the question, allowing the teacher to create a new draft from that preview. An archive keeps the accepted draft intact.
- The endpoint does not publish a question, debit usage, or apply an AI solution to another canonical question.
Teacher-Approved AI Preview Solution
POST /v1/questions/ai-preview-solutions is the owner-service write boundary for a teacher-approved AI explanation on an existing canonical question. The teacher UI can reach it only through the exact opt-in Gateway route.
- The request requires a canonical
questionId, a client-stablepreviewId, and reviewedsolution. Question Bank appends a replacementquestion_versionsrow and movescurrentVersionId; it never changes the original question source/import provenance and never publishes the question. - Explanation JSON records
source=ai-preview,previewId, provider/model when supplied, and normalized solution steps. The durable idempotency scope is(organizationId, actorId, previewId); a replay returns200, while a different solution for the same preview is rejected with400. - Tenant and owner scope are enforced before the write. A teacher cannot apply a preview to another teacher's private Question Bank row. Hard deletion removes the companion idempotency record with the question.
- This endpoint does not call an AI provider, debit usage, or replace broad
/api/questionslegacy traffic. AI only provides the preview; Question Bank is the canonical solution/version owner.
Question Group Compatibility
GET /v1/question-groups and POST /v1/question-groups are the first native foundation for legacy /api/question-groups.
GET /v1/question-groupsapplies tenant and owner scope fromX-Organization-Id,X-User-Id, andX-User-Role;allOrgs=1is honored only forADMINactors.GET /v1/question-groups?q=filters over title and content text.- Responses include legacy
_count.questionsso grouped passage/context lists can show usage without joining exam or attempt databases. POST /v1/question-groupsrequires organization and actor scope, plustitleandcontentText; it stores richcontentJson,mediaJson, and source metadata in question-bank-service ownership.
Default gateway routes for /api/question-groups remain legacy until the broader question write-parity slice is promoted.
Question Folder Compatibility
GET /v1/question-folders, POST /v1/question-folders, PATCH /v1/question-folders/{id}, and DELETE /v1/question-folders/{id} are the native foundation for legacy /api/question-folders.
- Folder routes apply tenant and owner scope from
X-Organization-Id,X-User-Id, andX-User-Role;allOrgs=1is honored only forADMINactors. POST /v1/question-foldersrequiresname, stores optionalparentId, and acceptsvisibility=PRIVATE|ORGANIZATION|SHARED_LIBRARY|PUBLIC.PATCH /v1/question-folders/{id}updatesnameandparentId; an emptyparentIdclears the parent folder.DELETE /v1/question-folders/{id}hard-deletes only empty folders. Folders with child folders or questions are rejected to avoid implicit question ownership changes.- Manual
POST /v1/questionsnow acceptsfolderIdafter validating the folder is visible in the same question-bank-service tenant/actor scope.
Default gateway routes for /api/question-folders remain legacy until browser save/reload proof and rollback notes are complete.
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": "[email protected]"
}
],
"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 profile-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).
Question Exam Reference Read Model
PUT /v1/questions/exam-refs is an internal migration/projection endpoint for denormalized exam metadata needed by permanent-delete impact previews:
json
{
"exams": [
{
"questionId": "q_123",
"examId": "exam_1",
"title": "Đề kiểm tra giữa kỳ",
"status": "PUBLISHED",
"createdAt": "2026-07-08T10:00:00Z"
}
]
}If an item omits organizationId, the endpoint uses X-Organization-Id as the default tenant scope. The question-bank-service stores these projections in its own question_exam_refs table and never joins exam-service or legacy exam databases while serving impact previews.
POST /v1/questions/permanent-delete-impact groups matching exam refs into affectedExams, including id, title, status, createdAt, and affectedQuestions. Missing projection rows leave affectedExams empty while affectedExamCount can still come from question_usage_counts.
services/question-bank-service/migrations/000010_question_exam_refs.sql creates question_exam_refs, scoped by (organization_id, question_id, exam_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 Manual Question Create
Phase 6 native endpoint:
POST /v1/questions
This endpoint creates a manual/native question inside question-bank-service. It preserves the legacy AppDataQuestionWriteService.createQuestion invariant: create the Question, initial QuestionVersion, QuestionOption rows, media/formula refs, and tags in the question-bank transaction, then set currentVersionId. It is service-native write parity only; default public /api/questions traffic stays gateway-controlled until browser editor save/reload parity is proven.
Required headers:
X-Organization-IdX-User-IdX-User-Role: ADMIN|TEACHER
Request compatibility:
- Required body field:
content. - Defaults match the legacy shared schema:
type=SINGLE_CHOICE,subjectId=math,gradeLevel=12,difficulty=MEDIUM,sourceType=MANUAL,visibility=PRIVATE, andstatus=DRAFT. - Preserves rich
contentJson,explanationJson, taxonomy/source IDs,customTypeId,answerKeys,scoringRule,subItems, orderedoptions,mediaRefs,formulaRefs,tags, andsourceMetadataJson. answerKeysJson,scoringRuleJson, andsubItemsJsonare accepted as internal compatibility aliases.- If
status=PUBLISHED, native validation enforces the legacy base-type constraints for choice, short-answer, true/false group, and essay questions. folderIdis accepted only after native question-service validates the folder in the same tenant/actor scope.
Native Question Update, Archive And Bulk Status
Phase 6 native endpoints:
PATCH /v1/questions/{id}DELETE /v1/questions/{id}PATCH /v1/questions/bulk-statusPOST /v1/questions/permanent-delete-impactPOST /v1/questions/permanent-deletePOST /v1/questions/permanent-delete/jobsGET /v1/questions/permanent-delete/jobs/{id}GET /v1/questions/permanent-delete/jobs/{id}/eventsPOST /v1/questions/permanent-delete/jobs/{id}/cancel
These endpoints provide the conservative native foundation for legacy update, archive, and bulk status actions. They require X-Organization-Id, X-User-Id, and X-User-Role: ADMIN|TEACHER, use the same tenant/actor scope as native manual create, and preserve question-service ownership boundaries.
PATCH /v1/questions/{id} accepts a partial manual-question payload. Omitted fields preserve current values; supplied options, tags, mediaRefs, and formulaRefs are full replacements. Each successful update appends a new question_versions row, moves currentVersionId, and returns the hydrated question with message Question updated. Updating plain content or explanation without rich JSON regenerates matching rich text JSON for the new current version.
DELETE /v1/questions/{id} mirrors the legacy archive-vs-hard-delete branch without crossing service ownership. If projected _count.examItems, _count.attemptItems, _count.answers, or same-organization clone refs exist, the question is archived. Otherwise the service clears currentVersionId and hard-deletes the question-bank-owned row; local cascades remove versions, options, media/formula refs, and question-tag links. It never deletes exam-service, attempt-service, course-service, or other owner-service data.
Archive branch response:
json
{
"success": true,
"data": {
"id": "question_id",
"deleted": false,
"archived": true
},
"message": "Question deleted"
}Hard-delete branch response:
json
{
"success": true,
"data": {
"id": "question_id",
"deleted": true,
"archived": false
},
"message": "Question deleted"
}PATCH /v1/questions/bulk-status accepts up to 100 non-empty IDs and status=DRAFT|PUBLISHED|ARCHIVED. IDs are deduplicated before update. Rows outside the actor/tenant scope are skipped; the response includes the deduplicated request count and updated count.
POST /v1/questions/permanent-delete accepts up to 100 IDs, deduplicates them, includes archived rows, skips rows outside the actor/tenant scope, and hard-deletes only question-bank-service-owned rows/projections. It intentionally does not emulate the legacy forced cleanup that removes ExamQuestion, ExamAttemptQuestion, ExamAnswer, or CourseLessonQuestion rows from other owners. Response data is { "requested": 2, "deleted": 2 }.
POST /v1/questions/permanent-delete-impact accepts either selected ids or { "mode": "all" } for the first scoped archived batch. It returns the legacy preview shape with questions, affectedExams, and affectedAttemptCount; affectedExams is hydrated from service-owned question_exam_refs when exam-service or migration tooling has published detailed metadata projections, while affectedExamCount and affectedAttemptCount are computed from service-owned question_usage_counts.
POST /v1/questions/permanent-delete/jobs creates a service-local job for the same permanent-delete behavior. It accepts selected ids or { "mode": "all" }, deduplicates selected IDs, scopes by X-Organization-Id, X-User-Id, and X-User-Role, then processes archived question-bank-owned rows in native batches. The response exposes legacy-compatible progress fields including status, phase, totalQuestions, processedQuestions, deletedCount, failedCount, currentBatch, progressPercent, timestamps, warnings, and an optional error.
GET /v1/questions/permanent-delete/jobs/{id} returns the current service-local job snapshot. GET /v1/questions/permanent-delete/jobs/{id}/events emits an SSE snapshot event and short-lived job.updated events for live progress polling. POST /v1/questions/permanent-delete/jobs/{id}/cancel cancels pending or running native jobs; completed jobs are returned unchanged.
These endpoints never delete exam snapshots or attempt data. Service-local job routes now exist, but durable broker/BullMQ parity, public route promotion, browser save/reload proof, and rollback notes remain pending.
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-IdIdempotency-Keyis optional but sent by the current Import approval path. Without it,sourceImportJobIdis the compatibility replay identity.
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.
Question Bank owns a durable ingest ledger keyed by organization, source import job, and canonical request digest. The ledger and all groups, questions, versions, options, media, and formula references commit in one owner transaction. An identical retry returns the original documentId and questionIds; an Idempotency-Key rebound to another payload returns 409. For TRUE_FALSE_GROUP, the owner persists ordered A through D subitems with content and correctBoolean, accepting both parser boolean maps and the real DOCX compact value form such as a:ĐÚNG;b:SAI;c:ĐÚNG;d:SAI.
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. Manual native create, update, archive-vs-hard-delete, permanent-delete impact/batch, and bulk-status foundations exist at POST /v1/questions, PATCH /v1/questions/{id}, DELETE /v1/questions/{id}, POST /v1/questions/permanent-delete-impact, POST /v1/questions/permanent-delete, POST /v1/questions/permanent-delete/jobs, GET /v1/questions/permanent-delete/jobs/{id}, GET /v1/questions/permanent-delete/jobs/{id}/events, POST /v1/questions/permanent-delete/jobs/{id}/cancel, and PATCH /v1/questions/bulk-status, but real legacy taxonomy, usage-count, and relation backfill, browser parity, route-table cutover proof, and durable permanent-delete broker/worker semantics remain pending.
Question Search Projection Backfill
GET /v1/questions/search-projections is the owner-service backfill contract for search-service question rebuilds. It applies the same scope and filters as GET /v1/questions, then returns copied QUESTION search documents:
json
{
"success": true,
"data": {
"sourceService": "question-bank-service",
"entityType": "QUESTION",
"documents": [],
"nextCursor": null,
"hasMore": false,
"meta": {
"page": 1,
"limit": 20,
"total": 0,
"totalPages": 1
}
},
"message": "OK"
}When status is omitted, the route includes archived questions so full search rebuilds do not silently drop delete/archive state. status=PUBLISHED and the other native list filters can still narrow a targeted projection check.
The route preserves the service boundary: search-service consumes copied documents over HTTP or events and stores its own index rows. It must not read question-bank-service tables directly. This endpoint is internal backfill groundwork only; no public gateway search route is promoted by its existence.
Durable Search Projection Outbox
services/question-bank-service/migrations/000015_question_search_projection_outbox.sql adds a service-owned revision table and durable outbox for search.projection.changed.v1 delivery. A Postgres trigger covers canonical question INSERT, UPDATE, and DELETE inside the same transaction as the owner write. It assigns a strictly increasing per-question sourceVersion, stores a stable event id, and writes a copied QUESTION envelope without answer keys, access credentials, or raw source metadata.
Repeated UPSERT mutations for the same question in one database transaction are coalesced into one outbox row whose payload reflects the final question row. DELETE creates a higher-version tombstone. A rolled-back owner transaction leaves neither canonical data nor an outbox row. With QUESTION_SEARCH_PROJECTION_EVENT_TRANSPORT=nats, Question Bank leases due outbox rows and publishes the exact persisted envelope to JetStream stream SEARCH_PROJECTIONS on search.projection.changed.v1. Leases, retries, and dead rows are delivery state only; they do not change the canonical write. That publisher is disabled by default, currently covers Question Bank only, and does not add a Gateway route. GET /v1/questions/search-projections remains the backfill and reconciliation path.
bash
QUESTION_SEARCH_PROJECTION_OUTBOX_POSTGRES_CONFIRM=disposable-postgres \
make test-question-search-projection-outbox-postgresbash
SEARCH_PROJECTION_BROKER_SMOKE_CONFIRM=disposable-runtime \
make test-search-projection-broker-liveLegacy 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 public write cutover. Native PATCH /v1/questions/{id} now covers rich update/version replacement internally, but legacy /api/questions/:id update/delete and editor save remain on the old API until browser save/reload, route-table, and rollback evidence are complete.
The named non-default question write proof gate is docs/qa/question-write-route-rehearsals.md. Agents must run make test-question-write-routes, QUESTION_WRITE_SELF_TEST=1 make test-question-write-live, and QUESTION_WRITE_BROWSER_SELF_TEST=1 make test-question-write-browser before requesting public editor save/reload promotion. The real live/browser modes still require a running gateway/frontend, auth/org context, seeded editable question data, artifacts, and rollback notes.
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.