From 33355a4b2345c8102c9fb403a23863884539c19f Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 4 Jun 2026 11:07:02 -0700 Subject: [PATCH] feat: PDF_ATTACHMENT stimulus, dynamic question_text rules, admin builder docs Add PDF_ATTACHMENT stimulus kind and MinIO pdf upload (media_type=pdf) for question-side PDFs. Reject top-level question_text on DYNAMIC create/update; omit it from API responses and derive stored text from stimulus only. Expand DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md with full API request/response reference and workflows. Co-authored-by: Cursor --- ...QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md | 1287 +++++++++++++---- docs/PRACTICE_CREATION_API_GUIDE.md | 4 +- internal/domain/question_type_builder.go | 50 +- internal/domain/question_type_builder_test.go | 71 +- internal/web_server/handlers/file_handler.go | 24 +- .../handlers/file_handler_media_test.go | 20 + .../web_server/handlers/initial_assessment.go | 13 +- internal/web_server/handlers/questions.go | 61 +- 8 files changed, 1204 insertions(+), 326 deletions(-) create mode 100644 internal/web_server/handlers/file_handler_media_test.go diff --git a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md index 261002d..8a51748 100644 --- a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md +++ b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md @@ -1,105 +1,295 @@ -# Dynamic Question Type Builder — Admin Panel Integration Guide +# Dynamic Question Type Builder — Admin Panel Integration Guide (Complete) -## Overview +This document is the **canonical integration reference** for wiring the Yimaru admin panel to the **Dynamic Question Type Builder** backend. It covers architecture, every API used in the feature, full request/response shapes, media upload (MinIO), validation rules, and step-by-step admin workflows. -This guide explains how to integrate the backend **Dynamic Question Type Builder** into your admin panel app so admins can: +**Base URL:** `{API_HOST}/api/v1` +**Auth:** `Authorization: Bearer ` on all endpoints below unless noted. +**Content-Type:** `application/json` for JSON bodies; `multipart/form-data` for file upload. -1. Create reusable dynamic question type definitions -2. Configure schema-driven stimulus/response fields -3. Create `DYNAMIC` questions using those definitions -4. Add dynamic questions into question sets/practices - -The backend already supports: -- Component catalog discovery -- Definition validation + CRUD -- Dynamic question create/update/list/get with payload validation -- Persistence of `question_type_definition_id` and `dynamic_payload` +**Related artifacts:** +- Postman: `postman/Dynamic-Question-Type-Builder.postman_collection.json` +- Practice flow (sets + lessons): `docs/PRACTICE_CREATION_API_GUIDE.md` --- -## Feature Architecture (Admin Perspective) +## Table of contents -Treat the integration as two linked modules: - -1. **Definition Builder Module** - - Creates reusable templates (question type definitions) - - Managed by admins before creating actual questions - -2. **Dynamic Question Authoring Module** - - Creates question instances using a selected definition - - Stores content in `dynamic_payload` - -Recommended admin menu structure: -- `Questions > Type Builder > Definitions` -- `Questions > Create Question` (includes `DYNAMIC` path) -- `Question Sets > Manage Set Items` +1. [Concepts and architecture](#1-concepts-and-architecture) +2. [Authentication and RBAC](#2-authentication-and-rbac) +3. [Standard response envelopes](#3-standard-response-envelopes) +4. [Component catalog (stimulus and response kinds)](#4-component-catalog-stimulus-and-response-kinds) +5. [API reference — type builder](#5-api-reference--type-builder) +6. [API reference — media upload (MinIO)](#6-api-reference--media-upload-minio) +7. [API reference — dynamic questions](#7-api-reference--dynamic-questions) +8. [API reference — question sets](#8-api-reference--question-sets) +9. [Payload value cookbook](#9-payload-value-cookbook) +10. [End-to-end admin workflows](#10-end-to-end-admin-workflows) +11. [Admin UI implementation guide](#11-admin-ui-implementation-guide) +12. [Validation and error handling](#12-validation-and-error-handling) +13. [Runtime `question_type` mapping](#13-runtime-question_type-mapping) +14. [System-seeded definitions](#14-system-seeded-definitions) +15. [QA checklist](#15-qa-checklist) --- -## Backend Endpoints Used +## 1. Concepts and architecture -All endpoints are under `/api/v1` and require bearer auth. +The feature has **two layers**. Do not conflate them in the UI. -### Builder + Definition Endpoints +| Layer | What it is | Stored in | Admin screen | +|--------|------------|-----------|--------------| +| **Question type definition** | Reusable template: which stimulus/response components exist and their schema slots | `question_type_definitions` | Type Builder → Definitions | +| **Dynamic question (instance)** | A real question authored from a definition | `questions` + `dynamic_payload` JSONB | Questions → Create/Edit (mode `DYNAMIC`) | -| Method | Endpoint | Purpose | -|---|---|---| -| `GET` | `/questions/component-catalog` | Fetch valid component kind codes | -| `POST` | `/questions/validate-question-type-definition` | Validate component-kind selection | -| `POST` | `/questions/type-definitions` | Create reusable definition | -| `GET` | `/questions/type-definitions` | List definitions | -| `GET` | `/questions/type-definitions/:id` | Get one definition | -| `PUT` | `/questions/type-definitions/:id` | Update definition | -| `DELETE` | `/questions/type-definitions/:id` | Delete non-system definition | +### Definition fields (template) -### Dynamic Question Endpoints +- **`stimulus_component_kinds` / `response_component_kinds`:** Allowed component type codes (from catalog). +- **`stimulus_schema` / `response_schema`:** Form blueprint — each item has `id`, `kind`, `label`, `required`, optional `config` (UI hints; server stores but does not enforce `config` limits on save today). -| Method | Endpoint | Purpose | -|---|---|---| -| `POST` | `/questions` | Create a dynamic question | -| `PUT` | `/questions/:id` | Update dynamic question | -| `GET` | `/questions/:id` | Fetch question details | -| `GET` | `/questions?question_type=DYNAMIC` | List dynamic questions | +### Instance fields (question) -### Question Set Linking Endpoints +- **`question_type`:** Must be `"DYNAMIC"` when using the builder. +- **`question_type_definition_id`:** FK to the definition used to author this question. +- **`dynamic_payload`:** Actual content: + - `stimulus[]` — material shown **with** the question (prompt, image, table, PDF, etc.). + - `response[]` — how the learner answers (options, text input, PDF upload, etc.). -| Method | Endpoint | Purpose | -|---|---|---| -| `POST` | `/question-sets` | Create set (if needed) | -| `POST` | `/question-sets/:setId/questions` | Link question to set | -| `GET` | `/question-sets/:setId/questions` | View set questions | +### `question_text` column vs builder prompt + +- **DYNAMIC questions must not send top-level `question_text`** in create/update JSON. Prompt text belongs in `dynamic_payload.stimulus` (`QUESTION_TEXT`, `INSTRUCTION`, or `TEXT_PASSAGE`). +- The server still stores an internal `question_text` (derived from stimulus) for search/logs. +- **API responses omit `question_text`** for `question_type: "DYNAMIC"`; clients read prompts from `dynamic_payload`. + +### Legacy vs dynamic + +| Mode | `question_type` | Definition ID | Payload | +|------|-----------------|---------------|---------| +| Legacy MCQ / T-F / short answer / audio | `MCQ`, `TRUE_FALSE`, `SHORT_ANSWER`, `AUDIO` | Optional / absent | `options`, `short_answers`, etc. | +| Dynamic builder | `DYNAMIC` | **Required** | **`dynamic_payload` required** | --- -## Required RBAC Permissions +## 2. Authentication and RBAC -Ensure the admin role includes at least: +### Headers (every request) -- `questions.create` -- `questions.list` -- `questions.get` -- `questions.update` -- `questions.delete` -- `question_sets.create` -- `question_set_items.add` -- `question_set_items.list` -- `question_set_items.remove` -- `question_set_items.update_order` +```http +Authorization: Bearer +Content-Type: application/json +``` -If your UI supports definition delete/update/list, include corresponding question permissions already used by those routes (`questions.update`, `questions.delete`, etc.). +### Permissions by endpoint + +| Permission | Used for | +|------------|----------| +| `questions.list` | Component catalog, list definitions, list questions | +| `questions.get` | Get definition by id, get question by id | +| `questions.create` | Validate definition, create definition, create question | +| `questions.update` | Update definition, update question | +| `questions.delete` | Delete definition, delete question | +| `questions.search` | Search questions by text | +| `question_sets.create` | Create question set | +| `question_sets.get` | Get question set | +| `question_set_items.add` | Add question to set | +| `question_set_items.list` | List questions in set, question-types summary | +| `question_set_items.remove` | Remove question from set | +| `question_set_items.update_order` | Reorder questions in set | + +**File upload** (`POST /files/upload`, `GET /files/url`) requires authentication only (no separate RBAC permission in routes). + +Admin roles should include the `questions.*` and `question_set_*` permissions above. --- -## Data Contracts +## 3. Standard response envelopes -### 1) Definition Create Payload +### Success: `domain.Response` + +```json +{ + "message": "Human-readable summary", + "data": { }, + "success": true, + "status_code": 200, + "metadata": null +} +``` + +`data` shape varies per endpoint (documented below). `metadata` is often omitted/null. + +### Error: `domain.ErrorResponse` + +```json +{ + "message": "Short error title", + "error": "Detailed validation or system message" +} +``` + +Common HTTP status codes: `400` validation, `401` unauthorized, `403` forbidden (RBAC), `404` not found, `500` server error. + +--- + +## 4. Component catalog (stimulus and response kinds) + +Load dynamically via **`GET /questions/component-catalog`**. Do not hardcode kinds in the admin app. + +### Stimulus kinds (question content — Section A) + +| Kind | Purpose | Typical `value` shape | +|------|---------|------------------------| +| `QUESTION_TEXT` | Main prompt text | `string` | +| `INSTRUCTION` | Instruction / directions | `string` | +| `PREP_TIME` | Preparation time (seconds) | `number` or `{ "seconds": N }` | +| `AUDIO_PROMPT` | Audio URL to play | `string` (HTTPS or MinIO presigned URL) | +| `AUDIO_CLIP` | Embedded audio clip | `string` URL | +| `TEXT_PASSAGE` | Reading passage | `string` | +| `IMAGE` | Image URL | `string` URL | +| `CHART` | Chart data | `object` (app-defined) | +| `TABLE` | Reference table | `{ "columns": string[], "rows": string[][] }` | +| `FLOW_CHART` | Flow chart | `object` (app-defined) | +| `MATCHING_INPUTS` | Matching exercise inputs | `object` (app-defined) | +| `SELECT_MISSING_WORDS` | Passage with blanks | `object` (app-defined) | +| `PDF_ATTACHMENT` | **Question-side PDF** (read-only for learner) | `string` URL (from `POST /files/upload` with `media_type=pdf`) | + +### Response kinds (learner answer — Section B) + +| Kind | Purpose | Typical `value` shape | +|------|---------|------------------------| +| `OPTION` | MCQ-style options | `{ "options": [{ "id", "text", "is_correct" }] }` | +| `MULTIPLE_CHOICE` | Legacy mapping to MCQ runtime | (definition-level; instances often use `OPTION`) | +| `SHORT_ANSWER` | Short text answer config | `object` / acceptable answers in legacy tables | +| `TEXT_INPUT` | Free text input | `string` or config object | +| `AUDIO_RESPONSE` | Learner records audio | URL or reference after upload | +| `PDF_UPLOAD` | **Learner uploads PDF** as answer | URL after learner upload | +| `SELECT_MISSING_WORDS` | Fill blanks answer | `object` | +| `MATCHING_ANSWER` | Matching answer | `object` | +| `LABEL_SELECTION` | Label selection | `object` | +| `SEQUENCE_ORDER` | Ordering answer | `object` | +| `ANSWER_TIMER` | Timer (auxiliary) | `{ "seconds": N }` — cannot be the **only** response kind | + +**Important:** `PDF_ATTACHMENT` (stimulus) ≠ `PDF_UPLOAD` (response). + +--- + +## 5. API reference — type builder + +### 5.1 Get component catalog + +| | | +|--|--| +| **Method / path** | `GET /questions/component-catalog` | +| **Permission** | `questions.list` | + +**Request:** No body. No required query params. + +**Success `200` — `data`:** + +```json +{ + "stimulus_component_kinds": [ + "QUESTION_TEXT", + "PREP_TIME", + "INSTRUCTION", + "AUDIO_PROMPT", + "AUDIO_CLIP", + "TEXT_PASSAGE", + "IMAGE", + "CHART", + "MATCHING_INPUTS", + "SELECT_MISSING_WORDS", + "TABLE", + "FLOW_CHART", + "PDF_ATTACHMENT" + ], + "response_component_kinds": [ + "AUDIO_RESPONSE", + "TEXT_INPUT", + "SHORT_ANSWER", + "MULTIPLE_CHOICE", + "OPTION", + "ANSWER_TIMER", + "SELECT_MISSING_WORDS", + "PDF_UPLOAD", + "MATCHING_ANSWER", + "LABEL_SELECTION", + "SEQUENCE_ORDER" + ] +} +``` + +**Example response wrapper:** + +```json +{ + "message": "Component catalog", + "data": { "stimulus_component_kinds": ["..."], "response_component_kinds": ["..."] }, + "success": true, + "status_code": 200 +} +``` + +--- + +### 5.2 Validate definition (pre-flight) + +| | | +|--|--| +| **Method / path** | `POST /questions/validate-question-type-definition` | +| **Permission** | `questions.create` | + +Validates **component kind lists only** (not full schema). Use before “Save definition” for instant feedback. + +**Request body:** ```json { - "key": "dynamic_visual_mcq_001", - "display_name": "Dynamic Visual MCQ", - "description": "Reusable dynamic MCQ-style type", "stimulus_component_kinds": ["QUESTION_TEXT", "IMAGE", "TABLE"], + "response_component_kinds": ["OPTION", "ANSWER_TIMER"] +} +``` + +| Field | Type | Required | +|-------|------|----------| +| `stimulus_component_kinds` | `string[]` | Yes (≥1 valid kind) | +| `response_component_kinds` | `string[]` | Yes (≥1 valid kind; not timer-only) | + +**Success `200` — `data`:** + +```json +{ + "valid": true +} +``` + +**Error `400` example:** + +```json +{ + "message": "Invalid question type definition", + "error": "response: at least one non-timer answer component is required (ANSWER_TIMER alone is not sufficient)" +} +``` + +--- + +### 5.3 Create question type definition + +| | | +|--|--| +| **Method / path** | `POST /questions/type-definitions` | +| **Permission** | `questions.create` | + +Persists a custom (non-system) definition. Enforces: valid kinds, schema IDs, and **runtime mappability** (see [§13](#13-runtime-question_type-mapping)). + +**Request body:** + +```json +{ + "key": "dynamic_visual_mcq_v1", + "display_name": "Dynamic Visual MCQ", + "description": "Optional description", + "stimulus_component_kinds": ["QUESTION_TEXT", "IMAGE", "TABLE", "PDF_ATTACHMENT"], "response_component_kinds": ["OPTION", "ANSWER_TIMER"], "stimulus_schema": [ { @@ -108,49 +298,365 @@ If your UI supports definition delete/update/list, include corresponding questio "label": "Prompt", "required": true, "config": { "max_length": 1000 } + }, + { + "id": "illustration", + "kind": "IMAGE", + "label": "Supporting image", + "required": false, + "config": { "allowed_formats": ["png", "jpg", "webp"] } + }, + { + "id": "data_table", + "kind": "TABLE", + "label": "Reference table", + "required": false, + "config": { "max_rows": 20, "max_columns": 8 } + }, + { + "id": "reference_pdf", + "kind": "PDF_ATTACHMENT", + "label": "Reading PDF", + "required": false, + "config": {} } ], "response_schema": [ { "id": "choices", "kind": "OPTION", - "label": "Answer Choices", + "label": "Answer choices", "required": true, - "config": { "min_options": 2, "max_options": 6, "allow_multiple": false } + "config": { "min_options": 2, "max_options": 6 } }, { "id": "timer", "kind": "ANSWER_TIMER", "label": "Timer", "required": false, - "config": { "min_seconds": 5, "max_seconds": 180 } + "config": { "max_seconds": 180 } } ], "status": "ACTIVE" } ``` -### 2) Dynamic Question Create Payload +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `key` | `string` | Yes | Unique slug; normalized server-side | +| `display_name` | `string` | Yes | Shown in admin UI | +| `description` | `string` | No | | +| `stimulus_component_kinds` | `string[]` | Yes* | *Can be inferred from `stimulus_schema` if omitted | +| `response_component_kinds` | `string[]` | Yes* | *Can be inferred from `response_schema` if omitted | +| `stimulus_schema` | `DynamicElementDefinition[]` | Recommended | | +| `response_schema` | `DynamicElementDefinition[]` | Recommended | | +| `status` | `string` | No | `ACTIVE` (default) or `INACTIVE` | + +**`DynamicElementDefinition`:** + +```json +{ + "id": "unique_slot_id", + "kind": "QUESTION_TEXT", + "label": "Optional label", + "required": true, + "config": {} +} +``` + +**Success `201` — `data`:** full `QuestionTypeDefinition` object: + +```json +{ + "id": 42, + "key": "dynamic_visual_mcq_v1", + "display_name": "Dynamic Visual MCQ", + "description": "Optional description", + "stimulus_component_kinds": ["QUESTION_TEXT", "IMAGE", "TABLE", "PDF_ATTACHMENT"], + "response_component_kinds": ["OPTION", "ANSWER_TIMER"], + "stimulus_schema": [ ], + "response_schema": [ ], + "is_system": false, + "status": "ACTIVE", + "created_at": "2026-06-04T10:00:00Z", + "updated_at": null +} +``` + +**Error `400` examples:** + +```json +{ + "message": "Unable to create question type definition", + "error": "unable to map definition to runtime question_type" +} +``` + +```json +{ + "message": "Validation failed", + "error": "display_name is required" +} +``` + +**Admin action:** Store `data.id` as `question_type_definition_id` for question authoring. + +--- + +### 5.4 List question type definitions + +| | | +|--|--| +| **Method / path** | `GET /questions/type-definitions` | +| **Permission** | `questions.list` | + +**Query parameters:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `status` | `string` | (all) | `ACTIVE` or `INACTIVE` | +| `include_system` | `boolean` | `true` | Include seeded system definitions | + +**Example:** `GET /questions/type-definitions?include_system=true&status=ACTIVE` + +**Success `200` — `data`:** array of `QuestionTypeDefinition` (same shape as create response). + +--- + +### 5.5 Get question type definition by ID + +| | | +|--|--| +| **Method / path** | `GET /questions/type-definitions/:id` | +| **Permission** | `questions.get` | + +**Path:** `id` — positive integer. + +**Success `200` — `data`:** single `QuestionTypeDefinition`. + +**Error `404`:** + +```json +{ + "message": "Question type definition not found", + "error": "..." +} +``` + +--- + +### 5.6 Update question type definition + +| | | +|--|--| +| **Method / path** | `PUT /questions/type-definitions/:id` | +| **Permission** | `questions.update` | + +**Request body** (all fields optional; send full schema when replacing): + +```json +{ + "display_name": "Updated name", + "description": "Updated description", + "stimulus_component_kinds": ["QUESTION_TEXT", "OPTION"], + "response_component_kinds": ["OPTION"], + "stimulus_schema": [ ], + "response_schema": [ ], + "status": "ACTIVE" +} +``` + +Note: `key` is **not** updatable via this handler. + +**Success `200` — `data`:** + +```json +{ + "id": 42 +} +``` + +**Error `400`:** Same classes as create (invalid kinds, unmappable runtime type, schema errors). + +--- + +### 5.7 Delete question type definition + +| | | +|--|--| +| **Method / path** | `DELETE /questions/type-definitions/:id` | +| **Permission** | `questions.delete` | + +**Success `200` — `data`:** + +```json +{ + "id": 42 +} +``` + +**Error `400` (system definition):** + +```json +{ + "message": "Unable to delete question type definition", + "error": "system question type definitions cannot be deleted" +} +``` + +**UI:** Hide delete for `is_system === true`. + +--- + +## 6. API reference — media upload (MinIO) + +Use before filling `IMAGE`, `AUDIO_*`, or `PDF_ATTACHMENT` stimulus values. + +### 6.1 Upload media file + +| | | +|--|--| +| **Method / path** | `POST /files/upload` | +| **Auth** | Bearer token | +| **Content-Type** | `multipart/form-data` | + +**Form fields:** + +| Field | Required | Values | +|-------|----------|--------| +| `file` | Yes* | Binary file | +| `media_type` | Yes | `image`, `audio`, `video`, **`pdf`** | +| `source_url` | No* | Alternative to `file` — fetch from URL (JSON body also supported) | +| `title` | No | Used for video Vimeo upload | +| `description` | No | Video metadata | + +\* Provide either `file` or `source_url`. + +**Size limits:** + +| `media_type` | Max size | +|--------------|----------| +| `image` | 10 MB | +| `audio` | 50 MB | +| `pdf` | 25 MB | +| `video` | 500 MB (Vimeo) | + +**Success `200` — `data` (MinIO image/audio/pdf):** + +```json +{ + "object_key": "pdf/a1b2c3d4-....pdf", + "url": "https://minio.example.com/bucket/pdf/....pdf?X-Amz-...", + "content_type": "application/pdf", + "media_type": "pdf", + "provider": "MINIO" +} +``` + +**Success `200` — `data` (video / Vimeo):** + +```json +{ + "url": "https://vimeo.com/...", + "content_type": "video/mp4", + "media_type": "video", + "provider": "VIMEO", + "vimeo_id": "123456", + "embed_url": "https://player.vimeo.com/video/123456" +} +``` + +**Error `400`:** + +```json +{ + "message": "Invalid media_type", + "error": "media_type must be one of: image, audio, video, pdf" +} +``` + +```json +{ + "message": "Invalid file type", + "error": "only PDF files are allowed" +} +``` + +**Admin usage:** Put `data.url` into `dynamic_payload.stimulus[].value` for `IMAGE` / `AUDIO_CLIP` / `PDF_ATTACHMENT`. Optionally store `minio://{object_key}` and resolve later. + +--- + +### 6.2 Get presigned file URL + +| | | +|--|--| +| **Method / path** | `GET /files/url?key={object_key}` | +| **Auth** | Bearer token | + +**Query:** `key` — MinIO object key (e.g. `pdf/uuid.pdf`). + +**Success `200` — `data`:** + +```json +{ + "url": "https://minio.example.com/...presigned..." +} +``` + +Use when the UI stored `minio://` references instead of long presigned URLs. + +--- + +## 7. API reference — dynamic questions + +### 7.1 Create question (dynamic) + +| | | +|--|--| +| **Method / path** | `POST /questions` | +| **Permission** | `questions.create` | + +**Request body (dynamic):** ```json { - "question_text": "Choose the grammatically correct sentence.", "question_type": "DYNAMIC", - "question_type_definition_id": 123, + "question_type_definition_id": 42, "difficulty_level": "MEDIUM", "points": 2, + "explanation": "Optional explanation shown after answer", + "tips": "Optional tip", + "voice_prompt": null, + "sample_answer_voice_prompt": null, + "image_url": null, "status": "DRAFT", "dynamic_payload": { "stimulus": [ { "id": "prompt", "kind": "QUESTION_TEXT", - "value": "Pick the best sentence." + "value": "Select the best completion using the table and PDF." }, { "id": "illustration", "kind": "IMAGE", - "value": "https://cdn.example.com/image.png" + "value": "https://minio.example.com/.../image.jpg" + }, + { + "id": "data_table", + "kind": "TABLE", + "value": { + "columns": ["Verb", "Past Form"], + "rows": [ + ["go", "went"], + ["write", "wrote"] + ] + } + }, + { + "id": "reference_pdf", + "kind": "PDF_ATTACHMENT", + "value": "https://minio.example.com/.../pdf/uuid.pdf" } ], "response": [ @@ -174,124 +680,355 @@ If your UI supports definition delete/update/list, include corresponding questio } ``` ---- +| Field | Dynamic rules | +|-------|----------------| +| `question_text` | **Must not be sent** (400 if provided) | +| `question_type` | Must be `DYNAMIC` | +| `question_type_definition_id` | **Required** | +| `dynamic_payload` | **Required**; must satisfy definition schema | +| `options` / `short_answers` | Not used for pure dynamic MCQ (use `OPTION` in payload) | -## End-to-End Admin Workflow - -## Step 1: Load Component Catalog - -Call: -- `GET /questions/component-catalog` - -Use response values to populate: -- Stimulus component type selector -- Response component type selector - -Do not hardcode component kinds in UI. - -## Step 2: Build "Create Definition" Form - -Form fields: -- `key` (slug-like identifier, unique) -- `display_name` -- `description` (optional) -- `stimulus_component_kinds` (multi-select) -- `response_component_kinds` (multi-select) -- `stimulus_schema` (repeater builder) -- `response_schema` (repeater builder) -- `status` (`ACTIVE` or `INACTIVE`) - -Schema item editor fields: -- `id` (string, unique per side) -- `kind` (from catalog) -- `label` (optional) -- `required` (boolean) -- `config` (JSON object, optional) - -## Step 3: Validate Component Kind Selection (Optional Pre-check) - -Call: -- `POST /questions/validate-question-type-definition` - -Use this before submit for immediate feedback. -Important: this endpoint validates component selection, while create endpoint enforces full persistence checks. - -## Step 4: Create Definition - -Call: -- `POST /questions/type-definitions` - -On success: -- store returned definition `id` -- navigate to "Definition Details" view -- show copyable key + id - -## Step 5: Build Definitions List Screen - -Call: -- `GET /questions/type-definitions?include_system=true&status=ACTIVE` - -Recommended columns: -- `id` -- `key` -- `display_name` -- `is_system` -- `status` -- stimulus/response kind counts -- created date - -Actions: -- View details -- Edit -- Delete (disable for `is_system=true`) - -## Step 6: Create Dynamic Question Using Definition - -In question create UI: - -1. Admin chooses question mode: `DYNAMIC` -2. Admin selects definition from searchable list (`GET /questions/type-definitions`) -3. UI generates form sections from selected definition schema -4. UI builds `dynamic_payload.stimulus[]` and `dynamic_payload.response[]` -5. Submit `POST /questions` - -Rules to enforce in UI: -- `question_type` must be `DYNAMIC` -- `question_type_definition_id` is required -- `dynamic_payload` is required -- include all required schema item IDs -- each payload item `kind` must match allowed kinds for selected definition - -## Step 7: Edit Dynamic Question - -Call: -- `PUT /questions/:id` - -When loading edit page: -- `GET /questions/:id` -- if `question_type = DYNAMIC`, hydrate schema-based editors from `dynamic_payload` - -## Step 8: Add Dynamic Question to Set - -Call: -- `POST /question-sets/:setId/questions` - -Body: +**Success `201` — `data`:** `questionRes` (no `question_text` when dynamic): ```json { - "question_id": 456, + "id": 1001, + "question_type": "DYNAMIC", + "question_type_definition_id": 42, + "dynamic_payload": { "stimulus": [ ], "response": [ ] }, + "difficulty_level": "MEDIUM", + "points": 2, + "status": "DRAFT", + "created_at": "2026-06-04T12:00:00Z" +} +``` + +**Error `400` examples:** + +```json +{ + "message": "Invalid question_text", + "error": "question_text is not used for DYNAMIC questions; set prompt text in dynamic_payload stimulus (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)" +} +``` + +```json +{ + "message": "Invalid dynamic_payload", + "error": "dynamic_payload.stimulus: required element id \"prompt\" is missing" +} +``` + +```json +{ + "message": "Invalid dynamic_payload", + "error": "dynamic_payload.stimulus[1]: kind \"AUDIO_CLIP\" is not allowed by selected definition" +} +``` + +--- + +### 7.2 Get question by ID + +| | | +|--|--| +| **Method / path** | `GET /questions/:id` | +| **Permission** | `questions.get` | + +**Success `200` — `data`:** `questionRes` with `options`, `short_answers`, `audio_correct_answer_text` when applicable. Hydrate dynamic editors from `dynamic_payload`. + +--- + +### 7.3 Update question (dynamic) + +| | | +|--|--| +| **Method / path** | `PUT /questions/:id` | +| **Permission** | `questions.update` | + +**Request body:** Same fields as create, all optional except dynamic rules still apply when the question remains `DYNAMIC`: + +- If `dynamic_payload` is sent, it is validated against the effective definition. +- Do not send `question_text` for dynamic questions. + +**Success `200`:** + +```json +{ + "message": "Question updated successfully", + "success": true, + "status_code": 200 +} +``` + +--- + +### 7.4 List questions + +| | | +|--|--| +| **Method / path** | `GET /questions` | +| **Permission** | `questions.list` | + +**Query:** + +| Param | Description | +|-------|-------------| +| `question_type` | e.g. `DYNAMIC` | +| `difficulty` | `EASY`, `MEDIUM`, `HARD` | +| `status` | `DRAFT`, `PUBLISHED`, `INACTIVE` | +| `limit` | Default `10` | +| `offset` | Default `0` | + +**Success `200` — `data`:** + +```json +{ + "questions": [ { "id": 1, "question_type": "DYNAMIC", "dynamic_payload": { } } ], + "total_count": 42 +} +``` + +--- + +### 7.5 Search questions + +| | | +|--|--| +| **Method / path** | `GET /questions/search?q={text}&limit=10&offset=0` | +| **Permission** | `questions.search` | + +Searches stored `question_text` (including text derived from dynamic stimulus). + +--- + +### 7.6 Delete question + +| | | +|--|--| +| **Method / path** | `DELETE /questions/:id` | +| **Permission** | `questions.delete` | + +--- + +## 8. API reference — question sets + +### 8.1 Add question to set + +| | | +|--|--| +| **Method / path** | `POST /question-sets/:setId/questions` | +| **Permission** | `question_set_items.add` | + +**Request:** + +```json +{ + "question_id": 1001, + "display_order": 1 +} +``` + +| Field | Type | Required | +|-------|------|----------| +| `question_id` | `int64` | Yes | +| `display_order` | `int32` | No | + +**Success `201` — `data`:** + +```json +{ + "id": 500, + "set_id": 10, + "question_id": 1001, "display_order": 1 } ``` --- -## Frontend UI/State Design +### 8.2 List questions in set -Recommended frontend state model: +| | | +|--|--| +| **Method / path** | `GET /question-sets/:setId/questions` | +| **Permission** | `question_set_items.list` | -```ts +**Success `200` — `data`:** Array of full `questionRes` objects (with options/short answers when loaded). + +--- + +### 8.3 Question types summary in set + +| | | +|--|--| +| **Method / path** | `GET /question-sets/:setId/question-types` | +| **Permission** | `question_set_items.list` | + +**Success `200` — `data`:** + +```json +{ + "question_set_id": 10, + "total_questions": 5, + "question_types": [ + { + "question_type_definition_id": 42, + "key": "dynamic_visual_mcq_v1", + "display_name": "Dynamic Visual MCQ", + "count": 3 + } + ] +} +``` + +Use for practice/set dashboards (“this set contains 3× Dynamic Visual MCQ”). + +--- + +### 8.4 Remove question from set + +| | | +|--|--| +| **Method / path** | `DELETE /question-sets/:setId/questions/:questionId` | +| **Permission** | `question_set_items.remove` | + +--- + +### 8.5 Update question order in set + +| | | +|--|--| +| **Method / path** | `PUT /question-sets/:setId/questions/:questionId/order` | +| **Permission** | `question_set_items.update_order` | + +(See swagger for body — typically `{ "display_order": N }`.) + +--- + +## 9. Payload value cookbook + +### TABLE (stimulus) — dynamic rows/columns per question + +Definition declares slot `data_table`. Each question instance fills: + +```json +{ + "id": "data_table", + "kind": "TABLE", + "value": { + "columns": ["Col A", "Col B"], + "rows": [ + ["r1c1", "r1c2"], + ["r2c1", "r2c2"] + ] + } +} +``` + +Server does not yet enforce `config.max_rows` / `max_columns`; mirror in UI. + +### OPTION (response) — dynamic MCQ + +```json +{ + "id": "choices", + "kind": "OPTION", + "value": { + "options": [ + { "id": "a", "text": "Answer A", "is_correct": false }, + { "id": "b", "text": "Answer B", "is_correct": true } + ] + } +} +``` + +`id` on each option should be stable strings (uuid/slug). + +### PDF_ATTACHMENT (stimulus) vs PDF_UPLOAD (response) + +| Kind | Side | Who uploads | Upload API | +|------|------|-------------|------------| +| `PDF_ATTACHMENT` | Stimulus | **Admin** when authoring question | `POST /files/upload` (`media_type=pdf`) | +| `PDF_UPLOAD` | Response | **Learner** at answer time | Learner app flow (separate) | + +### IMAGE / AUDIO_CLIP (stimulus) + +```json +{ "id": "illustration", "kind": "IMAGE", "value": "https://..." } +``` + +--- + +## 10. End-to-end admin workflows + +### Workflow A — Create a new question type (template) + +| Step | Action | API | +|------|--------|-----| +| A1 | Open Type Builder → Create | — | +| A2 | Load kind pickers | `GET /questions/component-catalog` | +| A3 | User selects stimulus/response kinds | — | +| A4 | (Optional) Pre-validate kinds | `POST /questions/validate-question-type-definition` | +| A5 | User builds `stimulus_schema` + `response_schema` | — | +| A6 | Submit definition | `POST /questions/type-definitions` | +| A7 | Show `id`, `key`, `display_name`; navigate to list | — | + +### Workflow B — Author a dynamic question + +| Step | Action | API | +|------|--------|-----| +| B1 | Choose mode **DYNAMIC** | — | +| B2 | Pick definition | `GET /questions/type-definitions?status=ACTIVE` | +| B3 | Render form from `stimulus_schema` + `response_schema` | `GET /questions/type-definitions/:id` (if needed) | +| B4 | Upload assets (image/pdf/audio) | `POST /files/upload` | +| B5 | Build table/options in UI → `dynamic_payload` | — | +| B6 | Submit question | `POST /questions` | +| B7 | Link to practice set | `POST /question-sets/:setId/questions` | + +### Workflow C — Edit definition or question + +| Target | API | +|--------|-----| +| Definition | `GET` → `PUT /questions/type-definitions/:id` | +| Question | `GET /questions/:id` → `PUT /questions/:id` | + +### Workflow D — Attach to practice + +See `docs/PRACTICE_CREATION_API_GUIDE.md` for question set + lesson practice linking. + +--- + +## 11. Admin UI implementation guide + +### Recommended routes (frontend) + +``` +/admin/questions/type-definitions → list +/admin/questions/type-definitions/new → create (Workflow A) +/admin/questions/type-definitions/:id → view/edit +/admin/questions/new?mode=dynamic → create question (Workflow B) +/admin/questions/:id/edit → edit question +``` + +### Suggested components + +| Component | Responsibility | +|-----------|----------------| +| `ComponentCatalogLoader` | Fetches catalog once per session | +| `DefinitionKindPicker` | Multi-select from catalog | +| `SchemaSlotEditor` | Repeater for schema entries (`id`, `kind`, `label`, `required`, `config`) | +| `DefinitionForm` | Create/update definition | +| `DefinitionPicker` | Searchable list of ACTIVE definitions | +| `DynamicQuestionComposer` | Renders inputs per schema slot | +| `TableEditor` | Emits TABLE `value` `{ columns, rows }` | +| `OptionListEditor` | Emits OPTION `value` | +| `MediaUploadField` | Wraps `POST /files/upload`, inserts URL into stimulus | +| `PayloadPreview` | Debug JSON view of `dynamic_payload` | + +### TypeScript models + +```typescript type DynamicElementDefinition = { id: string; kind: string; @@ -311,102 +1048,144 @@ type DynamicQuestionPayload = { stimulus: DynamicElementInstance[]; response: DynamicElementInstance[]; }; + +type QuestionTypeDefinition = { + id: number; + key: string; + display_name: string; + description?: string | null; + stimulus_component_kinds: string[]; + response_component_kinds: string[]; + stimulus_schema: DynamicElementDefinition[]; + response_schema: DynamicElementDefinition[]; + is_system: boolean; + status: "ACTIVE" | "INACTIVE"; + created_at: string; + updated_at?: string | null; +}; ``` -Suggested screen components: -- `DefinitionForm` -- `SchemaBuilderTable` -- `DynamicQuestionComposer` -- `DynamicPayloadPreviewJson` -- `DefinitionPickerModal` +### Building `dynamic_payload` from schema + +For each `stimulus_schema` / `response_schema` entry: + +1. Find UI state keyed by `schema.id`. +2. Emit `{ id: schema.id, kind: schema.kind, value: }`. +3. Include every `required: true` slot. +4. Do not add extra ids not in schema (allowed by server if kind matches, but confuses UI). +5. Never send top-level `question_text` when `question_type === "DYNAMIC"`. --- -## Validation Rules You Should Mirror Client-Side +## 12. Validation and error handling -To reduce failed submits, mirror these checks: +### Server-side (definition create/update) -1. At least one stimulus component kind -2. At least one response component kind -3. No duplicate component kinds -4. `ANSWER_TIMER` cannot be the only response kind -5. Schema item IDs are required and unique per side -6. Schema kinds must be valid catalog kinds -7. Dynamic question must include definition ID + payload together -8. For dynamic questions, `question_type` must be `DYNAMIC` -9. Payload must include required schema IDs +1. ≥1 stimulus kind and ≥1 response kind +2. All kinds exist in catalog +3. No duplicate kinds per side +4. At most one `PREP_TIME` +5. At most one `ANSWER_TIMER` +6. Response cannot be timer-only +7. Schema: unique non-empty `id` per side; valid kinds +8. **Mappable** to runtime `question_type` (§13) + +### Server-side (dynamic question) + +1. `question_type_definition_id` required when `DYNAMIC` +2. `dynamic_payload` required +3. `ValidateDynamicPayloadAgainstDefinition` +4. `question_text` rejected in request for `DYNAMIC` +5. Prompt derived from stimulus for DB search column + +### Client-side (mirror to reduce 400s) + +Same rules as above; validate TABLE has ≥1 column and rectangular rows before submit. + +### Error UX + +- Toast `message` +- Inline `error` near schema section (parse `dynamic_payload.stimulus[0]` prefixes when possible) +- Keep form state on 400 +- Offer “Reset from definition” to rebuild empty slots --- -## Error Handling Strategy +## 13. Runtime `question_type` mapping -Backend commonly returns: +Definitions must map to a stored `questions.question_type`: -```json -{ - "message": "Invalid dynamic_payload", - "error": "dynamic_payload.response: required element id \"choices\" is missing" -} -``` +| Condition | Stored `question_type` | +|-----------|-------------------------| +| Key is `true_false` | `TRUE_FALSE` | +| Response includes `AUDIO_RESPONSE` | `AUDIO` | +| Response includes `MULTIPLE_CHOICE` | `MCQ` | +| Response includes `SHORT_ANSWER`, `TEXT_INPUT`, `SELECT_MISSING_WORDS`, `MATCHING_ANSWER`, `LABEL_SELECTION`, `PDF_UPLOAD` | `SHORT_ANSWER` | +| Other non-auxiliary response kinds (e.g. `OPTION` only) | `DYNAMIC` | -UI recommendations: -- Show `message` as toast title -- Show `error` inline near relevant section -- Keep submitted form state (do not reset on 400) -- If schema mismatch occurs, provide "Rebuild from Definition" action +If mapping returns empty → create definition fails with `unable to map definition to runtime question_type`. + +**Implication:** A definition with only `OPTION` + `ANSWER_TIMER` persists questions as `DYNAMIC`, not legacy `MCQ`, even though UX is MCQ-like. --- -## Practical Example: Dynamic MCQ Type +## 14. System-seeded definitions -Use this pattern to represent multiple-choice dynamically: +Seeded in migration `000056` (`is_system: true`, cannot delete): -- `response_component_kinds` includes `OPTION` -- `response_schema` has required element with `kind: "OPTION"` and id `choices` -- `dynamic_payload.response` contains an element with same id (`choices`) and options array in `value` +| key | display_name | Stimulus kinds | Response kinds | +|-----|--------------|----------------|----------------| +| `multiple_choice` | Multiple Choice | `INSTRUCTION` | `MULTIPLE_CHOICE` | +| `true_false` | True / False | `INSTRUCTION` | `MULTIPLE_CHOICE` | +| `fill_in_the_blank` | Fill In The Blank | `TEXT_PASSAGE`, `SELECT_MISSING_WORDS` | `TEXT_INPUT`, `SELECT_MISSING_WORDS` | +| `short_answer` | Short Answer | `INSTRUCTION` | `SHORT_ANSWER` | -This creates an MCQ-like authoring experience through the dynamic builder. +Custom admin-created definitions have `is_system: false`. --- -## Important Product Note +## 15. QA checklist -This backend fully supports authoring/storage/serving dynamic questions. -If your admin panel expects automatic learner scoring for all dynamic payload shapes, verify assessment runtime behavior separately before release. +- [ ] Catalog loads; kinds match §4 +- [ ] Validate endpoint catches timer-only response +- [ ] Create definition returns `id`; list/get round-trip +- [ ] System definitions cannot be deleted +- [ ] PDF upload (`media_type=pdf`) returns `url` + `object_key` +- [ ] Dynamic question create without `question_text` succeeds +- [ ] Sending `question_text` on dynamic create returns 400 +- [ ] Missing required schema id in payload returns 400 with clear `error` +- [ ] TABLE payload round-trips on get/update +- [ ] `PDF_ATTACHMENT` in stimulus displays URL in admin preview +- [ ] Question appears in `GET /questions?question_type=DYNAMIC` +- [ ] Add to question set succeeds +- [ ] `GET /question-sets/:setId/question-types` shows definition summary +- [ ] 403 shows permission message --- -## QA Checklist (Admin Panel) +## Quick reference — all endpoints -- [ ] Catalog loads and renders kind options correctly -- [ ] Definition create succeeds with valid schema -- [ ] Invalid schema shows clear inline errors -- [ ] System definitions cannot be deleted in UI -- [ ] Dynamic question cannot submit without definition ID -- [ ] Dynamic question cannot submit without payload -- [ ] Required schema fields enforce data entry -- [ ] Created question appears in `question_type=DYNAMIC` list -- [ ] Dynamic question can be edited and re-saved -- [ ] Dynamic question can be linked to question set -- [ ] Permission errors (`403`) are surfaced with actionable text +| # | Method | Path | Permission | +|---|--------|------|------------| +| 1 | GET | `/questions/component-catalog` | `questions.list` | +| 2 | POST | `/questions/validate-question-type-definition` | `questions.create` | +| 3 | POST | `/questions/type-definitions` | `questions.create` | +| 4 | GET | `/questions/type-definitions` | `questions.list` | +| 5 | GET | `/questions/type-definitions/:id` | `questions.get` | +| 6 | PUT | `/questions/type-definitions/:id` | `questions.update` | +| 7 | DELETE | `/questions/type-definitions/:id` | `questions.delete` | +| 8 | POST | `/files/upload` | auth | +| 9 | GET | `/files/url` | auth | +| 10 | POST | `/questions` | `questions.create` | +| 11 | GET | `/questions/:id` | `questions.get` | +| 12 | PUT | `/questions/:id` | `questions.update` | +| 13 | GET | `/questions` | `questions.list` | +| 14 | GET | `/questions/search` | `questions.search` | +| 15 | DELETE | `/questions/:id` | `questions.delete` | +| 16 | POST | `/question-sets/:setId/questions` | `question_set_items.add` | +| 17 | GET | `/question-sets/:setId/questions` | `question_set_items.list` | +| 18 | GET | `/question-sets/:setId/question-types` | `question_set_items.list` | --- -## Suggested Delivery Plan - -1. Implement definition list/create/edit screens -2. Implement dynamic question composer tied to selected definition -3. Add question set linking support -4. Add role/permission guard handling in UI -5. Add integration tests for create/edit flows -6. Run UAT with at least one MCQ-like dynamic definition and one non-MCQ definition - ---- - -## Reference Artifact - -There is a Postman collection in this repository that covers runtime flow: - -- `postman/Dynamic-Question-Type-Builder.postman_collection.json` - -Use it as the source of truth for request examples while building admin API services. +*Last aligned with backend: dynamic builder, `PDF_ATTACHMENT` stimulus, `pdf` upload, DYNAMIC `question_text` omission in API.* diff --git a/docs/PRACTICE_CREATION_API_GUIDE.md b/docs/PRACTICE_CREATION_API_GUIDE.md index 0c65428..b4a9283 100644 --- a/docs/PRACTICE_CREATION_API_GUIDE.md +++ b/docs/PRACTICE_CREATION_API_GUIDE.md @@ -65,7 +65,7 @@ If you create/update dynamic definitions: ## Step 0 (Optional): Upload Media -Use this when question content references audio/image URLs. +Use this when question content references audio/image/PDF URLs (e.g. dynamic `IMAGE`, `AUDIO_CLIP`, or `PDF_ATTACHMENT` stimulus). ### Endpoint @@ -74,7 +74,7 @@ Use this when question content references audio/image URLs. ### Form fields - `file`: binary -- `media_type`: `image` or `audio` or `video` +- `media_type`: `image`, `audio`, `video`, or `pdf` (PDF is stored in MinIO; response includes presigned `url` and `object_key`) ### Example success response (shape) diff --git a/internal/domain/question_type_builder.go b/internal/domain/question_type_builder.go index 862557e..7b2b071 100644 --- a/internal/domain/question_type_builder.go +++ b/internal/domain/question_type_builder.go @@ -22,6 +22,8 @@ const ( StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS" StimulusTable StimulusComponentKind = "TABLE" StimulusFlowChart StimulusComponentKind = "FLOW_CHART" + // StimulusPDFAttachment is question-side PDF content (URL from MinIO upload or HTTPS). + StimulusPDFAttachment StimulusComponentKind = "PDF_ATTACHMENT" ) // Response-side components for the question-type builder (Section B — answer types). @@ -75,6 +77,7 @@ var ( StimulusSelectMissingWords, StimulusTable, StimulusFlowChart, + StimulusPDFAttachment, } stimulusSet map[string]struct{} @@ -564,27 +567,48 @@ var stimulusTextKindsForQuestionText = []string{ string(StimulusTextPassage), } -// ResolveQuestionTextForWrite returns the questions.question_text column value for create/update. -// explicit is the optional request field; existing is the current DB value on update (empty on create). -// For DYNAMIC questions, text may come from stimulus components (QUESTION_TEXT, INSTRUCTION, TEXT_PASSAGE). -func ResolveQuestionTextForWrite(explicit string, questionType string, payload *DynamicQuestionPayload, existing string) (string, error) { - explicit = strings.TrimSpace(explicit) - if explicit != "" { - return explicit, nil +// UsesDynamicQuestionPayload reports whether the runtime type stores prompt content in dynamic_payload only. +func UsesDynamicQuestionPayload(questionType string) bool { + return strings.ToUpper(strings.TrimSpace(questionType)) == "DYNAMIC" +} + +// ValidateQuestionTextNotAllowedForDynamic rejects top-level question_text on DYNAMIC create/update requests. +func ValidateQuestionTextNotAllowedForDynamic(questionType string, explicit string) error { + if !UsesDynamicQuestionPayload(questionType) { + return nil } - if derived := stimulusTextFromPayload(payload); derived != "" { + if strings.TrimSpace(explicit) != "" { + return fmt.Errorf("question_text is not used for DYNAMIC questions; set prompt text in dynamic_payload stimulus (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)") + } + return nil +} + +// ResolveDynamicStoredQuestionText returns the questions.question_text column value for DYNAMIC rows +// (search/activity logs only; clients read prompt text from dynamic_payload). +func ResolveDynamicStoredQuestionText(payload *DynamicQuestionPayload, existing string) (string, error) { + if derived := StimulusTextFromPayload(payload); derived != "" { return derived, nil } if strings.TrimSpace(existing) != "" { return strings.TrimSpace(existing), nil } - if strings.ToUpper(strings.TrimSpace(questionType)) == "DYNAMIC" { - return "", fmt.Errorf("provide question_text or stimulus text (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE) in dynamic_payload") - } - return "", fmt.Errorf("question_text is required") + return "", fmt.Errorf("dynamic_payload must include prompt text (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)") } -func stimulusTextFromPayload(payload *DynamicQuestionPayload) string { +// QuestionTextJSONField returns question_text for API responses. Omitted (nil) for DYNAMIC questions. +func QuestionTextJSONField(questionType string, stored string) *string { + if UsesDynamicQuestionPayload(questionType) { + return nil + } + stored = strings.TrimSpace(stored) + if stored == "" { + return nil + } + return &stored +} + +// StimulusTextFromPayload extracts the first non-empty prompt string from allowed stimulus kinds. +func StimulusTextFromPayload(payload *DynamicQuestionPayload) string { if payload == nil { return "" } diff --git a/internal/domain/question_type_builder_test.go b/internal/domain/question_type_builder_test.go index dab763c..18340ca 100644 --- a/internal/domain/question_type_builder_test.go +++ b/internal/domain/question_type_builder_test.go @@ -5,6 +5,20 @@ import ( "testing" ) +func TestStimulusComponentCatalog_includesPDFAttachment(t *testing.T) { + catalog := StimulusComponentCatalog() + found := false + for _, k := range catalog { + if k == string(StimulusPDFAttachment) { + found = true + break + } + } + if !found { + t.Fatalf("expected PDF_ATTACHMENT in stimulus catalog, got %v", catalog) + } +} + func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) { err := ValidateDynamicQuestionTypeDefinition( []string{"INSTRUCTION", "IMAGE"}, @@ -15,6 +29,16 @@ func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) { } } +func TestValidateDynamicQuestionTypeDefinition_pdfAttachmentStimulus(t *testing.T) { + err := ValidateDynamicQuestionTypeDefinition( + []string{"QUESTION_TEXT", "PDF_ATTACHMENT"}, + []string{"SHORT_ANSWER"}, + ) + if err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + func TestValidateDynamicQuestionTypeDefinition_unknownStimulus(t *testing.T) { err := ValidateDynamicQuestionTypeDefinition( []string{"NOT_A_KIND"}, @@ -177,58 +201,65 @@ func TestResolveQuestionTypeDefinitionForQuestion_linkedDefinition(t *testing.T) } } -func TestResolveQuestionTextForWrite_explicit(t *testing.T) { - got, err := ResolveQuestionTextForWrite("Hello", "DYNAMIC", nil, "") - if err != nil || got != "Hello" { - t.Fatalf("expected Hello, got %q err=%v", got, err) +func TestValidateQuestionTextNotAllowedForDynamic(t *testing.T) { + if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", "nope"); err == nil { + t.Fatal("expected error when question_text sent for DYNAMIC") + } + if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", ""); err != nil { + t.Fatalf("expected nil for empty explicit, got %v", err) + } + if err := ValidateQuestionTextNotAllowedForDynamic("MCQ", "ok"); err != nil { + t.Fatalf("expected nil for legacy, got %v", err) } } -func TestResolveQuestionTextForWrite_fromQUESTION_TEXTStimulus(t *testing.T) { +func TestResolveDynamicStoredQuestionText_fromQUESTION_TEXTStimulus(t *testing.T) { payload := &DynamicQuestionPayload{ Stimulus: []DynamicElementInstance{ {ID: "prompt", Kind: "QUESTION_TEXT", Value: "Pick the correct answer."}, }, } - got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "") + got, err := ResolveDynamicStoredQuestionText(payload, "") if err != nil || got != "Pick the correct answer." { t.Fatalf("expected derived text, got %q err=%v", got, err) } } -func TestResolveQuestionTextForWrite_fromINSTRUCTIONStimulus(t *testing.T) { +func TestResolveDynamicStoredQuestionText_fromINSTRUCTIONStimulus(t *testing.T) { payload := &DynamicQuestionPayload{ Stimulus: []DynamicElementInstance{ {ID: "prompt", Kind: "INSTRUCTION", Value: "Choose true or false."}, }, } - got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "") + got, err := ResolveDynamicStoredQuestionText(payload, "") if err != nil || got != "Choose true or false." { t.Fatalf("expected derived text, got %q err=%v", got, err) } } -func TestResolveQuestionTextForWrite_dynamicMissingText(t *testing.T) { - _, err := ResolveQuestionTextForWrite("", "DYNAMIC", &DynamicQuestionPayload{}, "") +func TestResolveDynamicStoredQuestionText_missingText(t *testing.T) { + _, err := ResolveDynamicStoredQuestionText(&DynamicQuestionPayload{}, "") if err == nil { - t.Fatal("expected error when no question text source") + t.Fatal("expected error when no prompt in payload") } } -func TestResolveQuestionTextForWrite_legacyRequiresText(t *testing.T) { - _, err := ResolveQuestionTextForWrite("", "MCQ", nil, "") - if err == nil || !strings.Contains(err.Error(), "question_text is required") { - t.Fatalf("expected legacy required error, got %v", err) - } -} - -func TestResolveQuestionTextForWrite_updateKeepsExisting(t *testing.T) { - got, err := ResolveQuestionTextForWrite("", "DYNAMIC", nil, "Previous title") +func TestResolveDynamicStoredQuestionText_updateKeepsExisting(t *testing.T) { + got, err := ResolveDynamicStoredQuestionText(nil, "Previous title") if err != nil || got != "Previous title" { t.Fatalf("expected existing text, got %q err=%v", got, err) } } +func TestQuestionTextJSONField_omitsDynamic(t *testing.T) { + if got := QuestionTextJSONField("DYNAMIC", "stored"); got != nil { + t.Fatalf("expected nil for DYNAMIC, got %v", got) + } + if got := QuestionTextJSONField("MCQ", "Pick one"); got == nil || *got != "Pick one" { + t.Fatalf("expected MCQ text, got %v", got) + } +} + func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) { id := int64(10) summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{ diff --git a/internal/web_server/handlers/file_handler.go b/internal/web_server/handlers/file_handler.go index fa9e7bf..583121a 100644 --- a/internal/web_server/handlers/file_handler.go +++ b/internal/web_server/handlers/file_handler.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "path" "strconv" "strings" "time" @@ -174,11 +175,11 @@ func (h *Handler) RefreshFileURL(c *fiber.Ctx) error { }) } -// UploadMedia uploads an image/audio/video file and returns its URL and key. +// UploadMedia uploads an image/audio/video/pdf file and returns its URL and key. // @Summary Upload media file // @Tags files // @Accept multipart/form-data -// @Param media_type formData string true "Media type: image|audio|video" +// @Param media_type formData string true "Media type: image|audio|video|pdf" // @Param file formData file true "Media file" // @Success 200 {object} domain.Response // @Router /api/v1/files/upload [post] @@ -205,10 +206,10 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error { if mediaType == "" { mediaType = "file" } - if mediaType != "image" && mediaType != "audio" && mediaType != "video" { + if mediaType != "image" && mediaType != "audio" && mediaType != "video" && mediaType != "pdf" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid media_type", - Error: "media_type must be one of: image, audio, video", + Error: "media_type must be one of: image, audio, video, pdf", }) } @@ -218,6 +219,8 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error { maxSize = 10 * 1024 * 1024 case "audio": maxSize = 50 * 1024 * 1024 + case "pdf": + maxSize = 25 * 1024 * 1024 case "video": maxSize = 500 * 1024 * 1024 } @@ -226,9 +229,9 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error { Message: "Vimeo service is not available for video uploads", }) } - if (mediaType == "image" || mediaType == "audio") && h.minioSvc == nil { + if (mediaType == "image" || mediaType == "audio" || mediaType == "pdf") && h.minioSvc == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ - Message: "MinIO service is not available for image/audio uploads", + Message: "MinIO service is not available for image/audio/pdf uploads", }) } @@ -396,6 +399,15 @@ func normalizeAndValidateMediaContentType(mediaType, contentType, fileName strin if !strings.HasPrefix(contentType, "video/") { return "", fmt.Errorf("only video files are allowed") } + case "pdf": + if contentType == "application/octet-stream" { + if ext := strings.ToLower(path.Ext(fileName)); ext == ".pdf" { + contentType = "application/pdf" + } + } + if contentType != "application/pdf" { + return "", fmt.Errorf("only PDF files are allowed") + } } return contentType, nil diff --git a/internal/web_server/handlers/file_handler_media_test.go b/internal/web_server/handlers/file_handler_media_test.go new file mode 100644 index 0000000..9400a46 --- /dev/null +++ b/internal/web_server/handlers/file_handler_media_test.go @@ -0,0 +1,20 @@ +package handlers + +import "testing" + +func TestNormalizeAndValidateMediaContentType_pdf(t *testing.T) { + got, err := normalizeAndValidateMediaContentType("pdf", "application/pdf", "reading-passage.pdf") + if err != nil || got != "application/pdf" { + t.Fatalf("expected application/pdf, got %q err=%v", got, err) + } + + got, err = normalizeAndValidateMediaContentType("pdf", "application/octet-stream", "notes.pdf") + if err != nil || got != "application/pdf" { + t.Fatalf("expected pdf from extension, got %q err=%v", got, err) + } + + _, err = normalizeAndValidateMediaContentType("pdf", "image/png", "file.png") + if err == nil { + t.Fatal("expected error for non-pdf content") + } +} diff --git a/internal/web_server/handlers/initial_assessment.go b/internal/web_server/handlers/initial_assessment.go index d61d674..e791bf7 100644 --- a/internal/web_server/handlers/initial_assessment.go +++ b/internal/web_server/handlers/initial_assessment.go @@ -28,12 +28,7 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error { } questionType := normalizeRuntimeQuestionType(req.QuestionType) - questionText, err := domain.ResolveQuestionTextForWrite( - optionalTrimmedString(req.QuestionText), - questionType, - req.DynamicPayload, - "", - ) + questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, req.DynamicPayload, "") if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid question_text", @@ -88,7 +83,7 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error { Success: true, Data: questionRes{ ID: question.ID, - QuestionText: question.QuestionText, + QuestionText: questionTextField(question.QuestionType, question.QuestionText), QuestionType: question.QuestionType, Status: question.Status, CreatedAt: question.CreatedAt.String(), @@ -136,7 +131,7 @@ func (h *Handler) ListAssessmentQuestions(c *fiber.Ctx) error { questionResponses = append(questionResponses, questionRes{ ID: q.ID, - QuestionText: q.QuestionText, + QuestionText: questionTextField(q.QuestionType, q.QuestionText), QuestionType: q.QuestionType, DifficultyLevel: q.DifficultyLevel, Points: q.Points, @@ -207,7 +202,7 @@ func (h *Handler) GetAssessmentQuestionByID(c *fiber.Ctx) error { Message: "Question fetched successfully", Data: questionRes{ ID: question.ID, - QuestionText: question.QuestionText, + QuestionText: questionTextField(question.QuestionType, question.QuestionText), QuestionType: question.QuestionType, DifficultyLevel: question.DifficultyLevel, Points: question.Points, diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index dea101d..2684ed4 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -58,7 +58,7 @@ type shortAnswerRes struct { type questionRes struct { ID int64 `json:"id"` - QuestionText string `json:"question_text"` + QuestionText *string `json:"question_text,omitempty"` QuestionType string `json:"question_type"` QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"` @@ -96,9 +96,36 @@ func optionalTrimmedString(s *string) string { return strings.TrimSpace(*s) } +func resolveStoredQuestionText(questionType string, explicit *string, payload *domain.DynamicQuestionPayload, existing string) (string, error) { + exp := optionalTrimmedString(explicit) + if err := domain.ValidateQuestionTextNotAllowedForDynamic(questionType, exp); err != nil { + return "", err + } + if domain.UsesDynamicQuestionPayload(questionType) { + return domain.ResolveDynamicStoredQuestionText(payload, existing) + } + if exp == "" { + return "", fmt.Errorf("question_text is required") + } + return exp, nil +} + +func questionTextField(questionType string, stored string) *string { + return domain.QuestionTextJSONField(questionType, stored) +} + +func activityLogQuestionSummary(questionType string, stored string, payload *domain.DynamicQuestionPayload) string { + if domain.UsesDynamicQuestionPayload(questionType) { + if s := domain.StimulusTextFromPayload(payload); s != "" { + return s + } + } + return stored +} + // CreateQuestion godoc // @Summary Create a new question -// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). question_text is optional for DYNAMIC questions when stimulus includes QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE; it is required for legacy types (MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO). +// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). DYNAMIC questions must not send question_text; use dynamic_payload stimulus instead. Legacy types require question_text. // @Tags questions // @Accept json // @Produce json @@ -193,12 +220,7 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { }) } - questionText, err := domain.ResolveQuestionTextForWrite( - optionalTrimmedString(req.QuestionText), - questionType, - req.DynamicPayload, - "", - ) + questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, req.DynamicPayload, "") if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid question_text", @@ -240,13 +262,13 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { "question_type": question.QuestionType, "question_type_definition_id": req.QuestionTypeDefinitionID, }) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+question.QuestionText, meta, &ip, &ua) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+activityLogQuestionSummary(question.QuestionType, question.QuestionText, question.DynamicPayload), meta, &ip, &ua) return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Question created successfully", Data: questionRes{ ID: question.ID, - QuestionText: question.QuestionText, + QuestionText: questionTextField(question.QuestionType, question.QuestionText), QuestionType: question.QuestionType, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, DynamicPayload: question.DynamicPayload, @@ -319,7 +341,7 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error { Message: "Question retrieved successfully", Data: questionRes{ ID: question.ID, - QuestionText: question.QuestionText, + QuestionText: questionTextField(question.QuestionType, question.QuestionText), QuestionType: question.QuestionType, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, DynamicPayload: question.DynamicPayload, @@ -386,7 +408,7 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error { for _, q := range questions { questionResponses = append(questionResponses, questionRes{ ID: q.ID, - QuestionText: q.QuestionText, + QuestionText: questionTextField(q.QuestionType, q.QuestionText), QuestionType: q.QuestionType, QuestionTypeDefinitionID: q.QuestionTypeDefinitionID, DynamicPayload: q.DynamicPayload, @@ -446,7 +468,7 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error { for _, q := range questions { questionResponses = append(questionResponses, questionRes{ ID: q.ID, - QuestionText: q.QuestionText, + QuestionText: questionTextField(q.QuestionType, q.QuestionText), QuestionType: q.QuestionType, QuestionTypeDefinitionID: q.QuestionTypeDefinitionID, DynamicPayload: q.DynamicPayload, @@ -604,12 +626,7 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error { }) } - questionText, err := domain.ResolveQuestionTextForWrite( - optionalTrimmedString(req.QuestionText), - questionType, - effectiveDynamicPayload, - existingQuestion.QuestionText, - ) + questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, effectiveDynamicPayload, existingQuestion.QuestionText) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid question_text", @@ -1245,7 +1262,7 @@ type questionSetItemRes struct { SetID int64 `json:"set_id"` QuestionID int64 `json:"question_id"` DisplayOrder int32 `json:"display_order"` - QuestionText string `json:"question_text"` + QuestionText *string `json:"question_text,omitempty"` QuestionType string `json:"question_type"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"` DifficultyLevel *string `json:"difficulty_level,omitempty"` @@ -1274,7 +1291,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio SetID: item.SetID, QuestionID: item.QuestionID, DisplayOrder: item.DisplayOrder, - QuestionText: item.QuestionText, + QuestionText: questionTextField(item.QuestionType, item.QuestionText), QuestionType: item.QuestionType, DynamicPayload: item.DynamicPayload, DifficultyLevel: item.DifficultyLevel, @@ -1477,7 +1494,7 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { questionResponses = append(questionResponses, questionRes{ ID: question.ID, - QuestionText: question.QuestionText, + QuestionText: questionTextField(question.QuestionType, question.QuestionText), QuestionType: question.QuestionType, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, DynamicPayload: question.DynamicPayload,