From b780db53072d4548d6209bad8593803a866a7ccc Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 9 Jun 2026 04:10:17 -0700 Subject: [PATCH] feat: expire stale pending payments with background worker Add a configurable cron worker to bulk-mark PENDING payments past expires_at as EXPIRED. Also remove obsolete integration docs and refresh Swagger/go.mod artifacts. Co-authored-by: Cursor --- db/query/payments.sql | 9 + docs/ARIFPAY_INTEGRATION.md | 346 ---- docs/CHAPA_INTEGRATION.md | 112 -- docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md | 995 ------------ ...QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md | 1390 ----------------- docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md | 224 --- ...RNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md | 218 --- docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md | 300 ---- docs/PRACTICE_CREATION_API_GUIDE.md | 544 ------- docs/TEST_PLAN.md | 514 ------ docs/audio-practice-integration.md | 303 ---- docs/docs.go | 411 ++++- docs/swagger.json | 411 ++++- docs/swagger.yaml | 286 +++- gen/db/payments.sql.go | 18 + go.mod | 2 +- internal/config/config.go | 21 + internal/ports/payment.go | 1 + internal/repository/payments.go | 4 + internal/services/chapa/service.go | 5 + internal/web_server/app.go | 57 +- 21 files changed, 1215 insertions(+), 4956 deletions(-) delete mode 100644 docs/ARIFPAY_INTEGRATION.md delete mode 100644 docs/CHAPA_INTEGRATION.md delete mode 100644 docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md delete mode 100644 docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md delete mode 100644 docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md delete mode 100644 docs/LEARNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md delete mode 100644 docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md delete mode 100644 docs/PRACTICE_CREATION_API_GUIDE.md delete mode 100644 docs/TEST_PLAN.md delete mode 100644 docs/audio-practice-integration.md diff --git a/db/query/payments.sql b/db/query/payments.sql index a5b5b9a..edf5355 100644 --- a/db/query/payments.sql +++ b/db/query/payments.sql @@ -91,6 +91,15 @@ SET updated_at = CURRENT_TIMESTAMP WHERE id = $1; +-- name: ExpireStalePendingPayments :execrows +UPDATE payments +SET + status = 'EXPIRED', + updated_at = CURRENT_TIMESTAMP +WHERE status = 'PENDING' + AND expires_at IS NOT NULL + AND expires_at <= CURRENT_TIMESTAMP; + -- name: CountUserPayments :one SELECT COUNT(*) FROM payments WHERE user_id = $1; diff --git a/docs/ARIFPAY_INTEGRATION.md b/docs/ARIFPAY_INTEGRATION.md deleted file mode 100644 index 3e538ab..0000000 --- a/docs/ARIFPAY_INTEGRATION.md +++ /dev/null @@ -1,346 +0,0 @@ -# ArifPay Payment Gateway Integration - -This document describes the ArifPay payment gateway integration for subscription payments in the Yimaru LMS application. - -## Overview - -The integration **coordinates payment with subscriptions** - users cannot create subscriptions without completing payment. Only admins can bypass this restriction for special cases (e.g., promotional subscriptions). - -### Key Features: -- **Payment-first approach**: Subscriptions are only created after successful payment -- **Multiple payment flows**: Checkout redirect or direct OTP-based payment -- **Webhook handling**: Automatic subscription creation on payment success -- **Role-based access**: Regular users must pay; admins can grant free subscriptions - -The integration supports multiple Ethiopian payment methods including: -- Telebirr -- CBE (Commercial Bank of Ethiopia) -- Awash Bank -- Amole -- HelloCash -- M-Pesa -- And more - -## Environment Variables - -Add the following environment variables to your `.env` file: - -```env -# ArifPay Configuration -ARIFPAY_API_KEY=your_arifpay_api_key -ARIFPAY_BASE_URL=https://gateway.arifpay.net -ARIFPAY_CANCEL_URL=https://yourdomain.com/payment/cancelled -ARIFPAY_SUCCESS_URL=https://yourdomain.com/payment/success -ARIFPAY_ERROR_URL=https://yourdomain.com/payment/error -ARIFPAY_C2B_NOTIFY_URL=https://yourdomain.com/api/v1/payments/webhook -ARIFPAY_B2C_NOTIFY_URL=https://yourdomain.com/api/v1/payments/b2c-webhook -ARIFPAY_BANK=AWINETAA -ARIFPAY_BENEFICIARY_ACCOUNT_NUMBER=your_account_number -ARIFPAY_DESCRIPTION=Yimaru LMS Subscription -ARIFPAY_ITEM_NAME=Subscription -``` - -## Database Migration - -Run the migration to create the payments table: - -```bash -migrate -path db/migrations -database "postgres://..." up -``` - -Or manually run: -```sql --- See db/migrations/000009_payments.up.sql -``` - -## API Endpoints - -### Subscription Endpoints - -#### Subscribe with Payment (Recommended) - -**POST** `/api/v1/subscriptions/checkout` - -The primary endpoint for users to subscribe. Initiates payment and returns checkout URL. - -**Request Body:** -```json -{ - "plan_id": 1, - "phone": "0912345678", - "email": "user@example.com" -} -``` - -**Response:** -```json -{ - "message": "Payment initiated. Complete payment to activate subscription.", - "data": { - "payment_id": 123, - "session_id": "ABC123DEF456", - "payment_url": "https://checkout.arifpay.net/...", - "amount": 299.99, - "currency": "ETB", - "expires_at": "2024-01-15T18:30:00Z" - } -} -``` - -#### Direct Subscribe (Admin Only) - -**POST** `/api/v1/subscriptions` - -Creates subscription without payment. Only accessible by admin/super_admin roles. - ---- - -### Payment Endpoints - -#### Initiate Subscription Payment - -**POST** `/api/v1/payments/subscribe` - -Creates a payment session for a subscription plan. - -**Request Body:** -```json -{ - "plan_id": 1, - "phone": "0912345678", - "email": "user@example.com" -} -``` - -**Response:** -```json -{ - "message": "Payment initiated successfully", - "data": { - "payment_id": 123, - "session_id": "ABC123DEF456", - "payment_url": "https://checkout.arifpay.net/...", - "amount": 299.99, - "currency": "ETB", - "expires_at": "2024-01-15T18:30:00Z" - } -} -``` - -### Verify Payment Status - -**GET** `/api/v1/payments/verify/:session_id` - -Checks the payment status with ArifPay and updates local records. - -**Response:** -```json -{ - "message": "Payment status retrieved", - "data": { - "id": 123, - "status": "SUCCESS", - "subscription_id": 456, - ... - } -} -``` - -### Get Payment History - -**GET** `/api/v1/payments` - -Returns the authenticated user's payment history. - -**Query Parameters:** -- `limit` (default: 20) -- `offset` (default: 0) - -### Get Payment Details - -**GET** `/api/v1/payments/:id` - -Returns details of a specific payment. - -### Cancel Payment - -**POST** `/api/v1/payments/:id/cancel` - -Cancels a pending payment. - -### Payment Webhook - -**POST** `/api/v1/payments/webhook` - -Webhook endpoint called by ArifPay when payment status changes. - -**Note:** This endpoint does not require authentication as it's called by ArifPay servers. - -### Get Available Payment Methods - -**GET** `/api/v1/payments/methods` - -Returns list of supported payment methods. - ---- - -## Direct Payment Endpoints (OTP-based) - -Direct payments allow users to pay without being redirected to a payment page. Instead, the payment is processed via OTP verification. - -### Initiate Direct Payment - -**POST** `/api/v1/payments/direct` - -Initiates a direct payment with a specific payment method. - -**Request Body:** -```json -{ - "plan_id": 1, - "phone": "0912345678", - "email": "user@example.com", - "payment_method": "AMOLE" -} -``` - -**Supported Payment Methods:** -- `TELEBIRR` - Telebirr (push notification) -- `TELEBIRR_USSD` - Telebirr USSD -- `CBE` - Commercial Bank of Ethiopia -- `AMOLE` - Amole (requires OTP) -- `HELLOCASH` - HelloCash (requires OTP) -- `AWASH` - Awash Bank (requires OTP) -- `MPESA` - M-Pesa - -**Response:** -```json -{ - "message": "OTP sent to your phone. Please verify to complete payment.", - "data": { - "payment_id": 123, - "session_id": "ABC123DEF456", - "requires_otp": true, - "amount": 299.99, - "currency": "ETB" - } -} -``` - -### Verify OTP - -**POST** `/api/v1/payments/direct/verify-otp` - -Verifies the OTP for direct payment methods (Amole, HelloCash, Awash). - -**Request Body:** -```json -{ - "session_id": "ABC123DEF456", - "otp": "123456" -} -``` - -**Response (Success):** -```json -{ - "message": "Payment completed successfully", - "data": { - "success": true, - "transaction_id": "TXN123456", - "payment_id": 123 - } -} -``` - -**Response (Failed):** -```json -{ - "message": "Invalid OTP" -} -``` - -### Get Direct Payment Methods - -**GET** `/api/v1/payments/direct/methods` - -Returns list of payment methods that support direct payment. - ---- - -## Payment Flows - -### Flow 1: Checkout Session (Redirect-based) - -1. **User selects a subscription plan** and initiates payment via `/payments/subscribe` -2. **Backend creates a payment record** with status `PENDING` -3. **Backend calls ArifPay** to create a checkout session -4. **User is redirected** to ArifPay payment page (using `payment_url`) -5. **User completes payment** on ArifPay -6. **ArifPay sends webhook** to notify payment status -7. **Backend processes webhook:** - - Updates payment status - - If successful, creates subscription - - Links payment to subscription -8. **User can verify** payment status via `/payments/verify/:session_id` - -### Flow 2: Direct Payment (OTP-based) - -1. **User selects plan and payment method** via `/payments/direct` -2. **Backend creates payment record** and checkout session -3. **Backend initiates direct transfer** with selected payment method -4. **For OTP-required methods (Amole, HelloCash, Awash):** - - User receives OTP via SMS - - User submits OTP via `/payments/direct/verify-otp` - - Backend verifies OTP with ArifPay - - On success, creates subscription -5. **For push-based methods (Telebirr, CBE):** - - User receives push notification on their app - - User approves payment in their app - - ArifPay sends webhook notification - - Backend creates subscription - -## Statuses - -### Payment Statuses -- `PENDING` - Payment initiated, waiting for user action -- `PROCESSING` - Payment is being processed -- `SUCCESS` - Payment completed successfully -- `FAILED` - Payment failed -- `CANCELLED` - Payment cancelled by user -- `EXPIRED` - Payment session expired - -### Subscription Statuses -- `PENDING` - Subscription pending payment -- `ACTIVE` - Subscription is active -- `EXPIRED` - Subscription has expired -- `CANCELLED` - Subscription was cancelled - -## Error Handling - -The integration handles various error scenarios: -- User already has an active subscription -- Plan not found or inactive -- Payment verification failures -- Webhook processing errors - -## Security Considerations - -1. Webhook endpoint validates requests from ArifPay -2. Payment verification double-checks with ArifPay API -3. User can only access their own payment records -4. Sensitive data (API keys) stored in environment variables - -## Testing - -For sandbox testing, use: -- Base URL: `https://gateway.arifpay.net` (sandbox mode enabled via API key) -- Test phone numbers provided by ArifPay -- Sandbox credentials from ArifPay developer portal - -## Support - -For ArifPay-specific issues: -- Developer Portal: https://developer.arifpay.net -- Telegram: https://t.me/arifochet -- Support: info@arifpay.com diff --git a/docs/CHAPA_INTEGRATION.md b/docs/CHAPA_INTEGRATION.md deleted file mode 100644 index 2155d94..0000000 --- a/docs/CHAPA_INTEGRATION.md +++ /dev/null @@ -1,112 +0,0 @@ -# Chapa Payment Gateway Integration - -Subscription payments for learners use [Chapa](https://developer.chapa.co/docs) hosted checkout, following the same payment-first flow as the previous ArifPay integration. - -## Overview - -- Subscriptions are created only after Chapa confirms payment (webhook and/or verify). -- `tx_ref` is stored as the payment `nonce` and returned as `session_id` in API responses. -- ArifPay direct-payment routes remain available for legacy flows; subscription checkout uses Chapa. - -## Environment Variables - -```env -CHAPA_SECRET_KEY=CHASECK_TEST-xxxxxxxx -CHAPA_PUBLIC_KEY=CHAPUBK_TEST-xxxxxxxx -CHAPA_WEBHOOK_SECRET=your_webhook_secret_from_dashboard -CHAPA_BASE_URL=https://api.chapa.co/v1 -CHAPA_CALLBACK_URL=https://your-api.example.com/api/v1/payments/chapa/callback -CHAPA_RETURN_URL=https://your-api.example.com/payment/success -CHAPA_RECEIPT_URL= -``` - -Configure the same webhook URL in the Chapa dashboard: - -`https://your-api.example.com/api/v1/payments/webhook` - -## Payment Flow - -1. Learner calls `POST /api/v1/subscriptions/checkout` or `POST /api/v1/payments/subscribe`. -2. Backend creates a pending payment and calls Chapa `POST /transaction/initialize`. -3. Client redirects the user to `payment_url` (`checkout_url` from Chapa). -4. After payment, Chapa redirects the learner to `return_url` (`/payment/success`) and calls `callback_url`. -5. The success page and callback both verify via Chapa `GET /transaction/verify/{tx_ref}` and activate the subscription when successful. -6. Chapa also sends a webhook; client may poll `GET /api/v1/payments/verify/{tx_ref}` (`session_id` path param is the `tx_ref`). - -## API Endpoints - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| POST | `/api/v1/subscriptions/checkout` | Yes | Initiate subscription payment | -| POST | `/api/v1/payments/subscribe` | Yes | Same as checkout | -| GET | `/api/v1/payments/verify/:session_id` | Yes | Verify by `tx_ref` | -| POST | `/api/v1/payments/webhook` | No | Chapa webhook (HMAC signature required) | -| GET | `/api/v1/payments/chapa/callback` | No | Chapa server callback (JSON) | -| GET | `/api/v1/payments/chapa/success` | No | Chapa learner success page (HTML) | -| GET | `/payment/success` | No | Same HTML success page (`CHAPA_RETURN_URL`) | -| GET | `/api/v1/payments/methods` | No | Supported Chapa methods | -| GET | `/api/v1/admin/payments` | Yes (admin) | List/filter all gateway payments (Chapa + ArifPay) | -| GET | `/api/v1/admin/payments/:id` | Yes (admin) | Get any payment by ID | - -### Admin: list all gateway payments - -`GET /api/v1/admin/payments` requires permission `payments.list_all` (assigned to `ADMIN` by default). - -Query filters (all optional): - -| Parameter | Description | -|-----------|-------------| -| `user_id` | Learner user ID | -| `plan_id` | Subscription plan ID | -| `subscription_id` | Linked subscription ID | -| `status` | `PENDING`, `PROCESSING`, `SUCCESS`, `FAILED`, `CANCELLED`, `EXPIRED` | -| `provider` or `payment_method` | `CHAPA` or `ARIFPAY` | -| `currency` | e.g. `ETB` | -| `plan_category` | `LEARN_ENGLISH`, `IELTS`, `DUOLINGO` | -| `reference` | Partial match on `session_id`, `nonce`, or `transaction_id` | -| `created_from`, `created_to` | RFC3339 or `YYYY-MM-DD` | -| `paid_from`, `paid_to` | RFC3339 or `YYYY-MM-DD` | -| `min_amount`, `max_amount` | Amount range | -| `limit`, `offset` | Pagination (default limit 20, max 100) | - -Example: - -`GET /api/v1/admin/payments?provider=CHAPA&status=SUCCESS&limit=50&offset=0` - -### Initiate payment request - -```json -{ - "plan_id": 1, - "phone": "0912345678", - "email": "learner@example.com" -} -``` - -### Initiate payment response - -```json -{ - "message": "Payment initiated. Complete payment to activate subscription.", - "data": { - "payment_id": 42, - "session_id": "550e8400-e29b-41d4-a716-446655440000", - "payment_url": "https://checkout.chapa.co/checkout/payment/...", - "amount": 500, - "currency": "ETB", - "expires_at": "2026-05-21T18:00:00Z" - } -} -``` - -## Webhook Security - -Chapa signs the raw JSON body with HMAC-SHA256 using your webhook secret. The handler checks `x-chapa-signature` or `chapa-signature` before processing. - -## Testing - -Use Chapa test keys and [test credentials](https://developer.chapa.co/test/testing-mobile). After checkout, confirm the subscription via verify endpoint or webhook logs. - -### Postman - -Import `postman/Chapa-Subscription-Payments.postman_collection.json`. Set collection variables (`base_url`, learner credentials, `chapa_webhook_secret`), then run folders **00 → 02** in order. diff --git a/docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md b/docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md deleted file mode 100644 index 8254a35..0000000 --- a/docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md +++ /dev/null @@ -1,995 +0,0 @@ -# Dynamic Practice Creation — LMS Guide (Course / Module / Lesson) - -This guide explains **step by step** how to create **practices** in the Learn English LMS hierarchy using **dynamic question types** (`DYNAMIC` questions with `question_type_definition_id` + `dynamic_payload`). - -It is the companion to: - -- **Type builder (definitions + components):** `docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md` -- **Lesson-only quick path (legacy + dynamic):** `docs/PRACTICE_CREATION_API_GUIDE.md` - -**Base URL:** `{API_HOST}/api/v1` -**Auth:** `Authorization: Bearer ` -**Content-Type:** `application/json` (except file upload) - ---- - -## Table of contents - -1. [Architecture](#1-architecture) -2. [Prerequisites and permissions](#2-prerequisites-and-permissions) -3. [Standard response envelopes](#3-standard-response-envelopes) -4. [ID map (what to store after each step)](#4-id-map-what-to-store-after-each-step) -5. [Publishing model](#5-publishing-model) -6. [End-to-end flow overview](#6-end-to-end-flow-overview) -7. [Step 0 — Resolve LMS parent IDs](#7-step-0--resolve-lms-parent-ids) -8. [Step 1 — (Optional) Upload media](#8-step-1--optional-upload-media) -9. [Step 2 — Create or select a question type definition](#9-step-2--create-or-select-a-question-type-definition) -10. [Step 3 — Create dynamic question(s)](#10-step-3--create-dynamic-questions) -11. [Step 4 — Create PRACTICE question set](#11-step-4--create-practice-question-set) -12. [Step 5 — Add questions to the set](#12-step-5--add-questions-to-the-set) -13. [Step 6 — Create practice shell (course / module / lesson)](#13-step-6--create-practice-shell-course--module--lesson) -14. [Step 7 — Verify and inspect](#14-step-7--verify-and-inspect) -15. [Optional — Reorder, update, publish](#15-optional--reorder-update-publish) -16. [Worked example — Lesson practice with TABLE + OPTION](#16-worked-example--lesson-practice-with-table--option) -17. [Scope-specific quick reference](#17-scope-specific-quick-reference) -18. [API index](#18-api-index) -19. [QA checklist](#19-qa-checklist) - ---- - -## 1. Architecture - -### LMS hierarchy - -``` -Program - └── Course - └── Module - └── Lesson -``` - -A **practice** is a learner-facing activity (story, persona, tips) backed by a **question set** containing one or more **questions**. - -### Database rule (one parent only) - -Each row in `lms_practices` attaches to **exactly one** of: - -| Scope | `parent_kind` | `parent_id` refers to | -|-------|---------------|------------------------| -| Course-level practice | `COURSE` | `courses.id` | -| Module-level practice | `MODULE` | `modules.id` | -| Lesson-level practice | `LESSON` | `lessons.id` | - -You cannot attach one practice to multiple parents. Choose the scope that matches your curriculum design. - -### How dynamic questions fit in - -```mermaid -flowchart LR - subgraph definitions - DEF[Question type definition] - end - subgraph content - Q1[Dynamic question 1] - Q2[Dynamic question 2] - end - subgraph packaging - QS[Question set set_type=PRACTICE] - P[Practice shell] - end - DEF --> Q1 - DEF --> Q2 - Q1 --> QS - Q2 --> QS - QS --> P - P --> L[Lesson / Module / Course] -``` - -1. **Definition** — template (which stimulus/response slots exist). -2. **Questions** — instances with `dynamic_payload` (real TABLE rows, OPTION choices, PDF URLs, etc.). -3. **Question set** — ordered list of question IDs (`set_type: "PRACTICE"`). -4. **Practice** — links `question_set_id` to a course, module, or lesson. - ---- - -## 2. Prerequisites and permissions - -### Minimum permissions (admin authoring) - -| Permission | Used for | -|------------|----------| -| `questions.list` | Component catalog, list definitions | -| `questions.create` | Definitions, dynamic questions | -| `questions.get` | Load question / definition details | -| `questions.update` | Update questions, definitions, publish practice | -| `question_sets.create` | Create PRACTICE set | -| `question_set_items.add` | Link questions to set | -| `question_set_items.list` | List questions in set, type summary | -| `question_set_items.update_order` | Reorder questions | -| `practices.create` | Create practice shell | -| `practices.list` | List practices under course/module/lesson | -| `practices.get` | Get practice by id | -| `practices.update` | Publish practice (`publish_status`) | -| `lessons.get` / `modules.get` / `courses.get` | Resolve parent IDs (as needed) | - -File upload: authenticated user only (`POST /files/upload`). - -### Related docs - -- Full definition API reference: `DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md` -- Postman collection: `postman/Dynamic-Question-Type-Builder.postman_collection.json` - ---- - -## 3. Standard response envelopes - -### Success — `domain.Response` - -```json -{ - "message": "Human-readable summary", - "data": {}, - "success": true, - "status_code": 200, - "metadata": null -} -``` - -`status_code` in the body may be `200` or `201` depending on the endpoint. - -### Error — `domain.ErrorResponse` - -```json -{ - "message": "Short error title", - "error": "Detailed validation or system message" -} -``` - ---- - -## 4. ID map (what to store after each step) - -| Step | Capture | Used in | -|------|---------|---------| -| Upload media | `url`, `object_key` | `dynamic_payload` stimulus `value` | -| Create definition | `question_type_definition_id` | Create each dynamic question | -| Create question | `question_id` | Add to set (repeat per question) | -| Create question set | `question_set_id` (`set_id`) | Create practice | -| Create practice | `practice_id` | Admin UI, learner routes | -| Parent resolution | `course_id` / `module_id` / `lesson_id` | `parent_id` + `owner_id` | - ---- - -## 5. Publishing model - -Three layers can affect learner visibility: - -| Layer | Field | Values | Notes | -|-------|--------|--------|-------| -| Question | `status` | `DRAFT`, `PUBLISHED`, `INACTIVE`, `ARCHIVED` | Use `PUBLISHED` for live content | -| Question set | `status` | `DRAFT`, `PUBLISHED`, … | Use `PUBLISHED` for live sets | -| Practice shell | `publish_status` | `DRAFT`, `PUBLISHED` | Omit or `PUBLISHED` on create; use `DRAFT` to hide until ready | - -**Recommendation for go-live:** set question `status`, question set `status`, and practice `publish_status` to published when learners should see the practice immediately. - -**Draft practice:** create with `"publish_status": "DRAFT"`, then `PUT /practices/:id` with `"publish_status": "PUBLISHED"` when ready. - ---- - -## 6. End-to-end flow overview - -| Step | Action | APIs (typical) | -|------|--------|----------------| -| 0 | Resolve `parent_id` (course / module / lesson) | `GET /courses/:id`, `GET /modules/:id`, `GET /lessons/:id` | -| 1 | Upload images / PDF / audio (if needed) | `POST /files/upload` | -| 2 | Create or pick question type definition | `GET /questions/type-definitions`, `POST /questions/type-definitions` | -| 3 | Create one or more dynamic questions | `POST /questions` (repeat) | -| 4 | Create PRACTICE question set | `POST /question-sets` | -| 5 | Add each question to set (ordered) | `POST /question-sets/:setId/questions` (repeat) | -| 6 | Create practice at chosen scope | `POST /practices` | -| 7 | Verify | `GET /lessons/:id/practices` (or course/module), `GET /question-sets/:setId/questions` | - ---- - -## 7. Step 0 — Resolve LMS parent IDs - -Before creating a practice, know the target **`parent_id`** and matching **`owner_type`** for the question set. - -### List lessons in a module (example) - -| | | -|--|--| -| **GET** | `/modules/:moduleId/lessons` | -| **Permission** | `lessons.list_by_module` | - -**Query (optional):** `limit`, `offset` (see lesson list handler defaults). - -**Success `200` — `data`:** array of lessons; capture `id` for `parent_id` when scope is `LESSON`. - -### Get lesson / module / course - -| Entity | Method / path | Permission | -|--------|---------------|------------| -| Lesson | `GET /lessons/:id` | `lessons.get` | -| Module | `GET /modules/:id` | `modules.get` | -| Course | `GET /courses/:id` | `courses.get` | - -Use these to confirm the parent exists and to display titles in the admin UI. - ---- - -## 8. Step 1 — (Optional) Upload media - -Required when the definition uses `IMAGE`, `AUDIO_PROMPT`, or `PDF_ATTACHMENT` stimulus slots. - -### POST `/files/upload` - -**Content-Type:** `multipart/form-data` - -| Field | Value | -|-------|--------| -| `file` | Binary | -| `media_type` | `image`, `audio`, `video`, or `pdf` | - -**Success `200` — `data`:** - -```json -{ - "object_key": "pdf/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf", - "url": "https://minio.example.com/bucket/pdf/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf?X-Amz-Algorithm=...", - "content_type": "application/pdf", - "media_type": "pdf", - "provider": "MINIO" -} -``` - -**Use in `dynamic_payload`:** set stimulus `value` to `data.url` (or store `minio://{object_key}` and resolve with `GET /files/url?key=...`). - -**Errors `400`:** - -```json -{ - "message": "Invalid media_type", - "error": "media_type must be one of: image, audio, video, pdf" -} -``` - ---- - -## 9. Step 2 — Create or select a question type definition - -Skip creation if reusing an existing ACTIVE definition. - -### 9.1 List existing definitions - -| | | -|--|--| -| **GET** | `/questions/type-definitions?include_system=true&status=ACTIVE` | -| **Permission** | `questions.list` | - -**Success `200` — `data`:** array of definitions (see shape in type builder doc). - -### 9.2 (Optional) Validate kinds - -| | | -|--|--| -| **POST** | `/questions/validate-question-type-definition` | -| **Permission** | `questions.create` | - -**Request:** - -```json -{ - "stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"], - "response_component_kinds": ["OPTION", "ANSWER_TIMER"] -} -``` - -**Success `200` — `data`:** - -```json -{ - "valid": true -} -``` - -### 9.3 Create definition (example with TABLE) - -| | | -|--|--| -| **POST** | `/questions/type-definitions` | -| **Permission** | `questions.create` | - -**Request:** - -```json -{ - "key": "lesson_table_mcq_v1", - "display_name": "Lesson Table MCQ", - "description": "Prompt + optional table + image; MCQ response", - "stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"], - "response_component_kinds": ["OPTION"], - "stimulus_schema": [ - { - "id": "prompt", - "kind": "QUESTION_TEXT", - "label": "Question prompt", - "required": true, - "config": { "max_length": 2000 } - }, - { - "id": "data_table", - "kind": "TABLE", - "label": "Reference table", - "required": true, - "config": { "max_rows": 30, "max_columns": 10 } - }, - { - "id": "illustration", - "kind": "IMAGE", - "label": "Supporting image", - "required": false, - "config": {} - } - ], - "response_schema": [ - { - "id": "choices", - "kind": "OPTION", - "label": "Answer choices", - "required": true, - "config": { "min_options": 2, "max_options": 6 } - } - ], - "status": "ACTIVE" -} -``` - -**Success `201` — `data` (full `QuestionTypeDefinition`):** - -```json -{ - "id": 42, - "key": "lesson_table_mcq_v1", - "display_name": "Lesson Table MCQ", - "description": "Prompt + optional table + image; MCQ response", - "stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"], - "response_component_kinds": ["OPTION"], - "stimulus_schema": [ ], - "response_schema": [ ], - "is_system": false, - "status": "ACTIVE", - "created_at": "2026-06-04T10:00:00Z", - "updated_at": null -} -``` - -**Capture:** `data.id` → `question_type_definition_id` (e.g. `42`). - ---- - -## 10. Step 3 — Create dynamic question(s) - -Repeat this step for each question in the practice. - -### POST `/questions` - -| | | -|--|--| -| **Permission** | `questions.create` | - -**Rules:** - -- `question_type` must be `"DYNAMIC"`. -- `question_type_definition_id` is **required**. -- `dynamic_payload` is **required**. -- Do **not** send top-level `question_text` (prompt lives in stimulus). -- Do **not** send legacy `options` / `short_answers` for pure dynamic MCQ (use `OPTION` in payload). - -**Request (TABLE + OPTION example):** - -```json -{ - "question_type": "DYNAMIC", - "question_type_definition_id": 42, - "difficulty_level": "MEDIUM", - "points": 2, - "status": "PUBLISHED", - "dynamic_payload": { - "stimulus": [ - { - "id": "prompt", - "kind": "QUESTION_TEXT", - "value": "Using the table, choose the correct past tense." - }, - { - "id": "data_table", - "kind": "TABLE", - "value": { - "columns": ["Verb", "Past Form"], - "rows": [ - ["go", "went"], - ["write", "wrote"], - ["see", "saw"] - ] - } - }, - { - "id": "illustration", - "kind": "IMAGE", - "value": "https://minio.example.com/bucket/image/uuid.jpg" - } - ], - "response": [ - { - "id": "choices", - "kind": "OPTION", - "value": { - "options": [ - { "id": "a", "text": "He goed home.", "is_correct": false }, - { "id": "b", "text": "He went home.", "is_correct": true }, - { "id": "c", "text": "He go home.", "is_correct": false } - ] - } - } - ] - } -} -``` - -**TABLE `value` contract:** - -| Field | Type | Description | -|-------|------|-------------| -| `columns` | `string[]` | Header labels | -| `rows` | `string[][]` | Each row length should match `columns.length` | - -**Success `201` — `data`:** - -```json -{ - "id": 1001, - "question_type": "DYNAMIC", - "question_type_definition_id": 42, - "dynamic_payload": { - "stimulus": [ ], - "response": [ ] - }, - "difficulty_level": "MEDIUM", - "points": 2, - "status": "PUBLISHED", - "created_at": "2026-06-04T11:00:00Z" -} -``` - -Note: `question_text` is **omitted** from the JSON response for `DYNAMIC` questions. - -**Error `400` examples:** - -```json -{ - "message": "Invalid dynamic_payload", - "error": "dynamic_payload.stimulus: required element id \"data_table\" is missing" -} -``` - -```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)" -} -``` - -**Capture:** `data.id` → `question_id` (repeat list: `[1001, 1002, ...]`). - ---- - -## 11. Step 4 — Create PRACTICE question set - -The question set groups questions. Its `owner_type` / `owner_id` should match the practice scope (recommended for reporting and sequence gating). - -### POST `/question-sets` - -| | | -|--|--| -| **Permission** | `question_sets.create` | - -**Request — lesson scope:** - -```json -{ - "title": "Lesson 12 — Dynamic drill", - "description": "Practice question set for lesson 12", - "set_type": "PRACTICE", - "owner_type": "LESSON", - "owner_id": 12, - "status": "PUBLISHED", - "shuffle_questions": false -} -``` - -**Request — module scope:** - -```json -{ - "title": "Module 3 — Review set", - "set_type": "PRACTICE", - "owner_type": "MODULE", - "owner_id": 3, - "status": "PUBLISHED", - "shuffle_questions": false -} -``` - -**Request — course scope:** - -```json -{ - "title": "Course 1 — Capstone practice", - "set_type": "PRACTICE", - "owner_type": "COURSE", - "owner_id": 1, - "status": "PUBLISHED", - "shuffle_questions": false -} -``` - -| Field | Required | Notes | -|-------|----------|-------| -| `title` | Yes | Admin display | -| `set_type` | Yes | Must be `"PRACTICE"` for LMS practices | -| `owner_type` | Recommended | `LESSON`, `MODULE`, or `COURSE` (match practice parent) | -| `owner_id` | Recommended | ID of that entity | -| `description` | No | | -| `status` | No | Default `DRAFT`; use `PUBLISHED` for learners | -| `shuffle_questions` | No | Default `false` | -| `time_limit_minutes` | No | Optional | -| `passing_score` | No | Optional | -| `intro_video_url` | No | Optional | - -**Success `201` — `data`:** - -```json -{ - "id": 55, - "title": "Lesson 12 — Dynamic drill", - "description": "Practice question set for lesson 12", - "set_type": "PRACTICE", - "owner_type": "LESSON", - "owner_id": 12, - "shuffle_questions": false, - "status": "PUBLISHED", - "created_at": "2026-06-04T11:30:00Z" -} -``` - -**Capture:** `data.id` → `question_set_id` / `set_id` (e.g. `55`). - ---- - -## 12. Step 5 — Add questions to the set - -Run once per `question_id`. `display_order` controls sequence (important for `STUDENT` practice gating). - -### POST `/question-sets/:setId/questions` - -| | | -|--|--| -| **Permission** | `question_set_items.add` | - -**Path:** `setId` = question set id from Step 4. - -**Request (first question):** - -```json -{ - "question_id": 1001, - "display_order": 1 -} -``` - -**Request (second question):** - -```json -{ - "question_id": 1002, - "display_order": 2 -} -``` - -| Field | Type | Required | -|-------|------|----------| -| `question_id` | `int64` | Yes | -| `display_order` | `int32` | No | - -**Success `201` — `data`:** - -```json -{ - "id": 901, - "set_id": 55, - "question_id": 1001, - "display_order": 1 -} -``` - -**Errors:** `400` invalid ids; `500` link failure. - -### (Optional) Question type summary for set - -| | | -|--|--| -| **GET** | `/question-sets/:setId/question-types` | -| **Permission** | `question_set_items.list` | - -**Success `200` — `data`:** - -```json -{ - "question_set_id": 55, - "total_questions": 2, - "question_types": [ - { - "question_type_definition_id": 42, - "key": "lesson_table_mcq_v1", - "display_name": "Lesson Table MCQ", - "count": 2 - } - ] -} -``` - ---- - -## 13. Step 6 — Create practice shell (course / module / lesson) - -Links the question set to exactly one LMS parent. - -### POST `/practices` - -| | | -|--|--| -| **Permission** | `practices.create` | - -**Request — lesson:** - -```json -{ - "parent_kind": "LESSON", - "parent_id": 12, - "title": "Lesson 12 — Table MCQ practice", - "story_description": "Read the table and choose the best answer.", - "story_image": "https://minio.example.com/bucket/image/story.webp", - "question_set_id": 55, - "quick_tips": "Check every row in the table before selecting.", - "publish_status": "DRAFT" -} -``` - -**Request — module:** - -```json -{ - "parent_kind": "MODULE", - "parent_id": 3, - "title": "Module 3 review", - "question_set_id": 55, - "publish_status": "PUBLISHED" -} -``` - -**Request — course:** - -```json -{ - "parent_kind": "COURSE", - "parent_id": 1, - "title": "Course-wide practice", - "question_set_id": 55, - "publish_status": "PUBLISHED" -} -``` - -| Field | Type | Required | Notes | -|-------|------|----------|-------| -| `parent_kind` | string | Yes | `COURSE`, `MODULE`, or `LESSON` | -| `parent_id` | int64 | Yes | Target entity id | -| `question_set_id` | int64 | Yes | From Step 4 | -| `title` | string | No | Empty string allowed | -| `story_description` | string | No | | -| `story_image` | string | No | URL | -| `persona_id` | int64 | No | `lms_personas` catalog id | -| `quick_tips` | string | No | | -| `publish_status` | string | No | `DRAFT` or `PUBLISHED`; default `PUBLISHED` if omitted | - -**Success `201` — `data` (`domain.Practice`):** - -```json -{ - "id": 37, - "parent_kind": "LESSON", - "parent_id": 12, - "title": "Lesson 12 — Table MCQ practice", - "story_description": "Read the table and choose the best answer.", - "story_image": "https://minio.example.com/bucket/image/story.webp", - "persona_id": null, - "question_set_id": 55, - "publish_status": "DRAFT", - "quick_tips": "Check every row in the table before selecting.", - "created_at": "2026-06-04T12:00:00Z", - "updated_at": null -} -``` - -**Errors:** - -| Status | `message` | Typical `error` | -|--------|-----------|-----------------| -| `404` | Lesson not found | Parent id invalid | -| `404` | Question set not found | Bad `question_set_id` | -| `404` | Persona not found | Bad `persona_id` | -| `400` | Invalid parent | Bad `parent_kind` | - -**Capture:** `data.id` → `practice_id` (e.g. `37`). - ---- - -## 14. Step 7 — Verify and inspect - -### 14.1 List practices under parent - -| Scope | GET | -|-------|-----| -| Lesson | `/lessons/:id/practices?limit=20&offset=0` | -| Module | `/modules/:id/practices?limit=20&offset=0` | -| Course | `/courses/:id/practices?limit=20&offset=0` | - -**Permission:** `practices.list` - -**Success `200` — `data`:** - -```json -{ - "practices": [ - { - "id": 37, - "parent_kind": "LESSON", - "parent_id": 12, - "title": "Lesson 12 — Table MCQ practice", - "question_set_id": 55, - "publish_status": "DRAFT", - "created_at": "2026-06-04T12:00:00Z" - } - ], - "total_count": 1, - "limit": 20, - "offset": 0 -} -``` - -### 14.2 Get practice by id - -| | | -|--|--| -| **GET** | `/practices/:id` | -| **Permission** | `practices.get` | - -**Success `200` — `data`:** full `Practice` object (includes `question_set_id`). - -### 14.3 List questions in set (admin — full dynamic payload) - -Use **`question_set_id`** from the practice record (not `practice_id`). - -| | | -|--|--| -| **GET** | `/question-sets/:setId/questions` | -| **Permission** | `question_set_items.list` | - -**Success `200` — `data`:** array of full questions including `dynamic_payload` and `question_type_definition_id`. - -For paginated learner-style listing with filters: - -| | | -|--|--| -| **GET** | `/practices/:practiceId/questions?limit=10&offset=0&question_type=DYNAMIC` | - -**Note:** This route’s path parameter is named `practiceId` in OpenAPI but is implemented against **`question_sets.id`**. For admin, prefer **`GET /question-sets/:setId/questions`** using `practice.question_set_id` from Step 14.2. - -**Paginated response shape (`GET /practices/.../questions`):** - -```json -{ - "questions": [ - { - "id": 901, - "set_id": 55, - "question_id": 1001, - "display_order": 1, - "question_type": "DYNAMIC", - "dynamic_payload": { "stimulus": [ ], "response": [ ] }, - "points": 2, - "question_status": "PUBLISHED" - } - ], - "total_count": 1, - "limit": 10, - "offset": 0 -} -``` - ---- - -## 15. Optional — Reorder, update, publish - -### Reorder question in set - -| | | -|--|--| -| **PUT** | `/question-sets/:setId/questions/:questionId/order` | -| **Permission** | `question_set_items.update_order` | - -**Request:** - -```json -{ - "display_order": 2 -} -``` - -**Success `200`:** - -```json -{ - "message": "Question order updated successfully", - "success": true, - "status_code": 200 -} -``` - -### Publish practice shell - -| | | -|--|--| -| **PUT** | `/practices/:id` | -| **Permission** | `practices.update` | - -**Request:** - -```json -{ - "publish_status": "PUBLISHED" -} -``` - -**Success `200` — `data`:** updated `Practice` with `publish_status: "PUBLISHED"`. - -### Update dynamic question content - -| | | -|--|--| -| **PUT** | `/questions/:id` | -| **Permission** | `questions.update` | - -Send updated `dynamic_payload` (and optional metadata). Do not send `question_text` for `DYNAMIC`. - -### Remove question from set - -| | | -|--|--| -| **DELETE** | `/question-sets/:setId/questions/:questionId` | -| **Permission** | `question_set_items.remove` | - ---- - -## 16. Worked example — Lesson practice with TABLE + OPTION - -**Goal:** Lesson `12` gets one practice with one dynamic TABLE+MCQ question. - -| Step | API | Key ids | -|------|-----|---------| -| 1 | `POST /questions/type-definitions` | `definition_id = 42` | -| 2 | `POST /questions` | `question_id = 1001` | -| 3 | `POST /question-sets` (`owner_type: LESSON`, `owner_id: 12`) | `set_id = 55` | -| 4 | `POST /question-sets/55/questions` | links `1001` order `1` | -| 5 | `POST /practices` (`parent_kind: LESSON`, `parent_id: 12`, `question_set_id: 55`) | `practice_id = 37` | -| 6 | `GET /lessons/12/practices` | confirms practice listed | -| 7 | `GET /question-sets/55/questions` | confirms TABLE payload | -| 8 | `PUT /practices/37` `{ "publish_status": "PUBLISHED" }` | go live | - -**Admin UI table editor → API:** bind columns/rows UI to stimulus slot `data_table` / kind `TABLE` before Step 2 (`POST /questions`). - ---- - -## 17. Scope-specific quick reference - -### Lesson practice - -```json -// Question set -{ "owner_type": "LESSON", "owner_id": , "set_type": "PRACTICE" } - -// Practice -{ "parent_kind": "LESSON", "parent_id": , "question_set_id": } -``` - -**Verify:** `GET /lessons//practices` - -### Module practice - -```json -{ "owner_type": "MODULE", "owner_id": } -{ "parent_kind": "MODULE", "parent_id": } -``` - -**Verify:** `GET /modules//practices` - -### Course practice - -```json -{ "owner_type": "COURSE", "owner_id": } -{ "parent_kind": "COURSE", "parent_id": } -``` - -**Verify:** `GET /courses//practices` - ---- - -## 18. API index - -| # | Method | Path | Permission | -|---|--------|------|------------| -| 1 | GET | `/questions/component-catalog` | `questions.list` | -| 2 | GET | `/questions/type-definitions` | `questions.list` | -| 3 | POST | `/questions/type-definitions` | `questions.create` | -| 4 | POST | `/questions/validate-question-type-definition` | `questions.create` | -| 5 | POST | `/files/upload` | auth | -| 6 | GET | `/files/url` | auth | -| 7 | POST | `/questions` | `questions.create` | -| 8 | PUT | `/questions/:id` | `questions.update` | -| 9 | POST | `/question-sets` | `question_sets.create` | -| 10 | POST | `/question-sets/:setId/questions` | `question_set_items.add` | -| 11 | GET | `/question-sets/:setId/questions` | `question_set_items.list` | -| 12 | GET | `/question-sets/:setId/question-types` | `question_set_items.list` | -| 13 | PUT | `/question-sets/:setId/questions/:questionId/order` | `question_set_items.update_order` | -| 14 | DELETE | `/question-sets/:setId/questions/:questionId` | `question_set_items.remove` | -| 15 | POST | `/practices` | `practices.create` | -| 16 | GET | `/practices/:id` | `practices.get` | -| 17 | PUT | `/practices/:id` | `practices.update` | -| 18 | DELETE | `/practices/:id` | `practices.delete` | -| 19 | GET | `/lessons/:id/practices` | `practices.list` | -| 20 | GET | `/modules/:id/practices` | `practices.list` | -| 21 | GET | `/courses/:id/practices` | `practices.list` | -| 22 | GET | `/practices/:practiceId/questions` | `question_set_items.list` (see §14.3 note) | - ---- - -## 19. QA checklist - -- [ ] Parent course/module/lesson exists (`GET` returns 200) -- [ ] Definition includes `TABLE` (or other) slots used in payload -- [ ] Dynamic question created without `question_text` in request -- [ ] TABLE `value` has `columns` + `rows` aligned -- [ ] Question set `set_type` is `PRACTICE` and `owner_type` matches practice scope -- [ ] All questions added to set with correct `display_order` -- [ ] Practice `question_set_id` matches set id -- [ ] `parent_kind` / `parent_id` match intended scope -- [ ] `GET` list practices under parent shows new practice -- [ ] `GET /question-sets/:id/questions` shows `dynamic_payload` -- [ ] Publish: question `PUBLISHED`, set `PUBLISHED`, practice `publish_status: PUBLISHED` when going live -- [ ] `OPEN_LEARNER` sees unlocked content; `STUDENT` respects practice sequence on same owner scope - ---- - -## Pitfalls - -1. **Do not send `question_text`** on dynamic question create/update — use `QUESTION_TEXT` (or `INSTRUCTION`) in `dynamic_payload.stimulus`. -2. **`owner_type` on question set** should match **`parent_kind` on practice** for consistent gating and admin filters. -3. **One practice → one `question_set_id`** in normal authoring; add multiple questions to the **same set**, not multiple sets per practice. -4. **TABLE content is per question** — the definition only declares the slot; each `POST /questions` supplies its own `columns` / `rows`. -5. **`GET /practices/:practiceId/questions`** — use `question_set_id` from practice when calling set-based list endpoints (see §14.3). -6. **Dynamic scoring runtime** — verify learner app supports your definition’s response shapes before release. - ---- - -*Last aligned with backend: LMS practices (`COURSE`/`MODULE`/`LESSON`), dynamic questions, `PDF_ATTACHMENT`, `TABLE` stimulus, practice `publish_status`, DYNAMIC `question_text` API omission.* diff --git a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md deleted file mode 100644 index 46d05db..0000000 --- a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md +++ /dev/null @@ -1,1390 +0,0 @@ -# Dynamic Question Type Builder — Admin Panel Integration Guide (Complete) - -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. - -**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. - -**Related artifacts:** -- Postman: `postman/Dynamic-Question-Type-Builder.postman_collection.json` -- Practice flow (sets + lessons): `docs/PRACTICE_CREATION_API_GUIDE.md` - ---- - -## Table of contents - -1. [Concepts and architecture](#1-concepts-and-architecture) - - [Schema slot labels (`label`)](#schema-slot-labels-label) -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) - ---- - -## 1. Concepts and architecture - -The feature has **two layers**. Do not conflate them in the UI. - -| 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`) | - -### Definition fields (template) - -- **`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). - -### Schema slot labels (`label`) - -Each stimulus and response slot in a question type definition can carry a **per-field display name** via the `label` property on `DynamicElementDefinition`. This is separate from the definition’s top-level **`display_name`** (the name of the whole question type in admin pickers). - -| Property | Scope | Purpose | Set when | Used when | -|----------|-------|---------|----------|-----------| -| `display_name` | Whole definition | “Lesson Table MCQ” in type lists | `POST` / `PUT /questions/type-definitions` | Picking a question type | -| `label` | Each schema slot | “Reference table”, “Question prompt” on form fields | Same APIs, inside `stimulus_schema[]` / `response_schema[]` | Authoring a question from that type | - -**Assigning `label` at definition create/update** - -Set `label` on every entry you add to `stimulus_schema` and `response_schema` when calling: - -- `POST /questions/type-definitions` (create) -- `PUT /questions/type-definitions/:id` (update — send the full schema when replacing slots) - -Example — two stimulus slots with labels: - -```json -"stimulus_schema": [ - { - "id": "prompt", - "kind": "QUESTION_TEXT", - "label": "Question prompt", - "required": true, - "config": { "max_length": 2000 } - }, - { - "id": "data_table", - "kind": "TABLE", - "label": "Reference table", - "required": true, - "config": { "max_rows": 30, "max_columns": 10 } - } -] -``` - -| `label` rule | Detail | -|--------------|--------| -| **Optional in API** | Server validates `id` and `kind` only; `label` may be omitted or `null`. | -| **Recommended in UI** | Always collect a label in the type builder so question authoring screens have human-readable field titles. | -| **Not in component catalog** | `GET /questions/component-catalog` returns kind codes only (`QUESTION_TEXT`, `TABLE`, …). There are no server-default labels per kind — your admin app supplies them. | -| **Persisted and returned** | Labels are stored in `question_type_definitions` JSONB and echoed on `GET` / `POST` / `PUT` responses inside `stimulus_schema` / `response_schema`. | -| **Not on question instances** | `dynamic_payload` slots use `id`, `kind`, `value` only. Labels are **never** sent on `POST /questions`; always read them from the linked definition. | - -**Using `label` when authoring questions (Workflow B)** - -1. User selects a definition (`question_type_definition_id`). -2. Load full schema: `GET /questions/type-definitions/:id`. -3. For each `stimulus_schema` entry, render a form section: - - **Field title:** `schema.label` if present, else fallback (see below). - - **Field key / payload `id`:** `schema.id` (e.g. `"data_table"`). - - **Widget:** driven by `schema.kind` (table editor, image upload, rich text, etc.). - - **Required indicator:** `schema.required`. - - **Hints / limits:** `schema.config` (client-enforced today). -4. Repeat for `response_schema` (e.g. “Answer choices” for `OPTION`). -5. On submit, map UI state keyed by `schema.id` → `dynamic_payload` instances **without** `label`: - -```json -{ "id": "data_table", "kind": "TABLE", "value": { "columns": ["A", "B"], "rows": [["1", "2"]] } } -``` - -**Recommended UI fallback when `label` is missing** - -```typescript -function slotLabel(schema: DynamicElementDefinition): string { - if (schema.label?.trim()) return schema.label.trim(); - return humanizeKind(schema.kind); // e.g. "QUESTION_TEXT" → "Question text" -} -``` - -Optionally append `schema.id` in debug/preview mode only (e.g. `Reference table (data_table)`). - -**Updating labels later** - -Changing a slot’s `label` on `PUT /questions/type-definitions/:id` updates what authors see on **new edits**; existing `dynamic_payload` content is unchanged. Do not rename `schema.id` on live definitions unless you migrate existing questions — `id` is the stable join key between schema and payload. - -### Instance fields (question) - -- **`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.). - -### `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** | - ---- - -## 2. Authentication and RBAC - -### Headers (every request) - -```http -Authorization: Bearer -Content-Type: application/json -``` - -### 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. - ---- - -## 3. Standard response envelopes - -### 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` | Stimulus audio (prompt, clip, or listening passage) | `string` URL (HTTPS or MinIO presigned URL from `POST /files/upload` with `media_type=audio`) | -| `TEXT_PASSAGE` | Reading passage | `string` | -| `IMAGE` | Image URL | `string` URL | -| `TABLE` | Reference table | `{ "columns": string[], "rows": string[][] }` | -| `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", - "TEXT_PASSAGE", - "IMAGE", - "MATCHING_INPUTS", - "SELECT_MISSING_WORDS", - "TABLE", - "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 -{ - "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": [ - { - "id": "prompt", - "kind": "QUESTION_TEXT", - "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", - "required": true, - "config": { "min_options": 2, "max_options": 6 } - }, - { - "id": "timer", - "kind": "ANSWER_TIMER", - "label": "Timer", - "required": false, - "config": { "max_seconds": 180 } - } - ], - "status": "ACTIVE" -} -``` - -| 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` (each `stimulus_schema` / `response_schema` item):** - -```json -{ - "id": "unique_slot_id", - "kind": "QUESTION_TEXT", - "label": "Question prompt", - "required": true, - "config": {} -} -``` - -| Field | Type | Required | Notes | -|-------|------|----------|-------| -| `id` | `string` | Yes | Stable slot key; must match `dynamic_payload[].id` when authoring questions. Use lowercase snake_case (e.g. `data_table`, `prompt`). | -| `kind` | `string` | Yes | Component kind from catalog (e.g. `QUESTION_TEXT`, `TABLE`, `OPTION`). | -| `label` | `string` | No | **Human-readable field title** for the question authoring UI. Assign when creating the type (see [Schema slot labels](#schema-slot-labels-label)). Not sent on `POST /questions`. | -| `required` | `boolean` | Yes | If `true`, authors must provide a `value` for this slot in `dynamic_payload`. | -| `config` | `object` | No | UI hints (max length, max rows/columns, etc.); stored server-side; not enforced on save today. | - -**Success `201` — `data`:** full `QuestionTypeDefinition` object (includes `stimulus_schema` / `response_schema` with `label` values echoed back): - -```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 | -| `limit` | `int` | `20` | Page size (max `200`) | -| `offset` | `int` | `0` | Page offset | - -**Example:** `GET /questions/type-definitions?include_system=true&status=ACTIVE&limit=20&offset=0` - -**Success `200` — `data`:** - -```json -{ - "question_type_definitions": [ - { - "id": 42, - "key": "dynamic_visual_mcq_v1", - "display_name": "Dynamic Visual MCQ", - "stimulus_component_kinds": ["QUESTION_TEXT", "OPTION"], - "response_component_kinds": ["OPTION"], - "stimulus_schema": [], - "response_schema": [], - "is_system": false, - "status": "ACTIVE", - "created_at": "2026-06-04T10:00:00Z" - } - ], - "total_count": 1, - "limit": 20, - "offset": 0 -} -``` - -Each item in `question_type_definitions` is a full `QuestionTypeDefinition` (same shape as create response). - -**Error `400` (invalid pagination):** - -```json -{ - "message": "Invalid limit", - "error": "strconv.Atoi: parsing \"x\": invalid syntax" -} -``` - ---- - -### 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. When updating schema slots, you may change **`label`** on any `stimulus_schema` / `response_schema` entry without changing `id` (see [Schema slot labels](#schema-slot-labels-label)). - -**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_PROMPT` / `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_type": "DYNAMIC", - "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": "Select the best completion using the table and PDF." - }, - { - "id": "illustration", - "kind": "IMAGE", - "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": [ - { - "id": "choices", - "kind": "OPTION", - "value": { - "options": [ - { "id": "a", "text": "He go yesterday", "is_correct": false }, - { "id": "b", "text": "He went yesterday", "is_correct": true } - ] - } - }, - { - "id": "timer", - "kind": "ANSWER_TIMER", - "value": { "seconds": 30 } - } - ] - } -} -``` - -| 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) | -| Slot `label` | **Not sent** on create/update — field titles come from `GET /questions/type-definitions/:id` → `stimulus_schema[].label` / `response_schema[].label` | - -**Success `201` — `data`:** `questionRes` (no `question_text` when dynamic): - -```json -{ - "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 \"TABLE\" 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 -} -``` - ---- - -### 8.2 List questions in set - -| | | -|--|--| -| **Method / path** | `GET /question-sets/:setId/questions` | -| **Permission** | `question_set_items.list` | - -**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_PROMPT (stimulus) - -```json -{ "id": "illustration", "kind": "IMAGE", "value": "https://..." } -``` - -```json -{ "id": "prompt_audio", "kind": "AUDIO_PROMPT", "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`; for each slot set `id`, `kind`, **`label`**, `required`, `config` | — | -| A6 | Submit definition (labels stored per slot) | `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 (paginated list) | `GET /questions/type-definitions?status=ACTIVE&limit=20&offset=0` | -| B3 | Load schema; render form using each slot’s **`label`** as field title and **`id`** as state key | `GET /questions/type-definitions/:id` | -| 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; includes **Label** text input per row (`id`, `kind`, `label`, `required`, `config`) | -| `DefinitionForm` | Create/update definition; maps slot editor rows → `stimulus_schema` / `response_schema` with `label` | -| `DefinitionPicker` | Searchable list of ACTIVE definitions (uses top-level `display_name`, not slot labels) | -| `DynamicQuestionComposer` | Renders inputs per schema slot; field captions from `schema.label` (fallback: humanized `kind`) | -| `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; - label?: string; - required: boolean; - config?: Record; -}; - -type DynamicElementInstance = { - id: string; - kind: string; - value?: unknown; - meta?: Record; -}; - -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; -}; -``` - -### Assigning `label` in the type builder UI - -When the admin adds a stimulus or response component to a new definition: - -1. **Kind** comes from `ComponentCatalogLoader` / `DefinitionKindPicker` (e.g. `TABLE`). -2. **Id** — auto-generate from kind (`table` → `data_table`) or let admin edit; must stay unique per side. -3. **Label** — show a required (client-side) text input; pre-fill with a humanized default: - -```typescript -const KIND_DEFAULT_LABELS: Record = { - QUESTION_TEXT: "Question prompt", - INSTRUCTION: "Instructions", - TEXT_PASSAGE: "Reading passage", - IMAGE: "Image", - TABLE: "Reference table", - PDF_ATTACHMENT: "PDF document", - AUDIO_PROMPT: "Audio", - OPTION: "Answer choices", - SHORT_ANSWER: "Short answer", - TEXT_INPUT: "Text input", - ANSWER_TIMER: "Time limit (seconds)", - // …extend for other kinds used in your product -}; - -function defaultLabelForKind(kind: string): string { - return KIND_DEFAULT_LABELS[kind] ?? kind.replace(/_/g, " ").toLowerCase(); -} -``` - -4. On **Create definition** submit, include each slot’s admin-edited label in the API body: - -```typescript -stimulus_schema: stimulusSlots.map((slot) => ({ - id: slot.id, - kind: slot.kind, - label: slot.label.trim() || defaultLabelForKind(slot.kind), - required: slot.required, - config: slot.config ?? {}, -})), -``` - -5. On **Update definition**, send the same shape; changing `label` does not require re-authoring existing questions. - -### Using `label` in the question composer UI - -```typescript -function renderSlotField( - schema: DynamicElementDefinition, - value: unknown, - onChange: (v: unknown) => void, -) { - const title = schema.label?.trim() || defaultLabelForKind(schema.kind); - return ( - - - - ); -} -``` - -- **Stimulus slots** — render in `stimulus_schema` order (or a stable sort); use `label` as the visible section heading. -- **Response slots** — same pattern for `response_schema` (e.g. label “Answer choices” above the option list editor). -- **Payload build** — `label` is display-only; never include it in `dynamic_payload`. - -### Building `dynamic_payload` from schema - -For each `stimulus_schema` / `response_schema` entry: - -1. Find UI state keyed by `schema.id` (not by `label` — labels can change on the definition). -2. Emit `{ id: schema.id, kind: schema.kind, value: }` — **no `label` property**. -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"`. - -**Stimulus `id` ↔ `label` ↔ payload mapping example:** - -| `stimulus_schema` slot | `label` (UI title) | `dynamic_payload` instance | -|------------------------|-------------------|----------------------------| -| `{ "id": "prompt", "kind": "QUESTION_TEXT", "label": "Question prompt" }` | “Question prompt” | `{ "id": "prompt", "kind": "QUESTION_TEXT", "value": "Choose the correct answer." }` | -| `{ "id": "data_table", "kind": "TABLE", "label": "Reference table" }` | “Reference table” | `{ "id": "data_table", "kind": "TABLE", "value": { "columns": [...], "rows": [...] } }` | - ---- - -## 12. Validation and error handling - -### Server-side (definition create/update) - -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 - ---- - -## 13. Runtime `question_type` mapping - -Definitions must map to a stored `questions.question_type`: - -| 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` | - -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. - ---- - -## 14. System-seeded definitions - -Seeded in migration `000056` (`is_system: true`, cannot delete): - -| 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` | - -Custom admin-created definitions have `is_system: false`. - ---- - -## 15. QA checklist - -- [ ] Catalog loads; kinds match §4 -- [ ] Validate endpoint catches timer-only response -- [ ] Create definition returns `id`; list/get round-trip -- [ ] `stimulus_schema[].label` / `response_schema[].label` persist on create and echo on GET -- [ ] Question composer shows slot `label` as field title (fallback when label omitted) -- [ ] `dynamic_payload` submit does not include `label` on instances -- [ ] Updating definition slot `label` via PUT updates composer captions without breaking existing questions -- [ ] 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 - ---- - -## Quick reference — all endpoints - -| # | 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` | - ---- - -*Last aligned with backend: dynamic builder, `PDF_ATTACHMENT` stimulus, `pdf` upload, DYNAMIC `question_text` omission in API.* diff --git a/docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md b/docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md deleted file mode 100644 index d841dbb..0000000 --- a/docs/JSON_MEDIA_UPLOAD_INTEGRATION_GUIDE.md +++ /dev/null @@ -1,224 +0,0 @@ -# JSON Media Integration Guide (Admin Panel) - -This guide documents the new media integration pattern introduced in the backend: - -- Upload binary file once through `POST /api/v1/files/upload` -- Use the returned URL/key in JSON request bodies for business endpoints - -This replaces direct form-data usage in common admin flows (while legacy multipart compatibility still exists). - ---- - -## 1) New General Media Upload Endpoint - -### `POST /api/v1/files/upload` - -**Auth:** Bearer token required -**Content-Type:** `multipart/form-data` -**Purpose:** Upload media and return reference data for subsequent JSON requests. - -### Request fields - -- `media_type` (required): `image` | `audio` | `video` -- `file` (required): binary file -- `title` (optional, video only): Vimeo video title -- `description` (optional, video only): Vimeo video description - -### Storage behavior - -- `media_type=image` -> MinIO -- `media_type=audio` -> MinIO -- `media_type=video` -> Vimeo - -### Success response shape - -```json -{ - "success": true, - "status_code": 200, - "message": "File uploaded successfully", - "data": { - "object_key": "image/abc123.webp", - "url": "https://...", - "content_type": "image/webp", - "media_type": "image", - "provider": "MINIO" - } -} -``` - -For videos, response includes Vimeo references: - -```json -{ - "success": true, - "status_code": 200, - "message": "Video uploaded successfully", - "data": { - "url": "https://vimeo.com/123456789", - "content_type": "video/mp4", - "media_type": "video", - "provider": "VIMEO", - "vimeo_id": "123456789", - "embed_url": "https://player.vimeo.com/video/123456789" - } -} -``` - ---- - -## 2) Endpoints Updated to JSON Media Reference Flow - -These endpoints now support JSON request bodies for media references. - -## A) Profile Picture - -### `POST /api/v1/user/:id/profile-picture` - -**Old style:** multipart with `file` -**New style:** JSON with uploaded URL - -#### JSON request body - -```json -{ - "profile_picture_url": "https://your-media-url-or-minio-presigned-url" -} -``` - -#### Success response - -```json -{ - "success": true, - "status_code": 200, - "message": "Profile picture URL updated successfully", - "data": { - "profile_picture_url": "https://your-media-url-or-minio-presigned-url" - } -} -``` - ---- - -## B) Course Thumbnail - -### `POST /api/v1/course-management/courses/:id/thumbnail` - -**Old style:** multipart with `file` -**New style:** JSON with uploaded URL - -#### JSON request body - -```json -{ - "thumbnail_url": "https://your-media-url-or-minio-presigned-url" -} -``` - -#### Success response - -```json -{ - "success": true, - "message": "Course thumbnail URL updated successfully", - "data": { - "thumbnail_url": "https://your-media-url-or-minio-presigned-url" - } -} -``` - ---- - -## C) Sub-course Thumbnail - -### `POST /api/v1/course-management/sub-courses/:id/thumbnail` - -**Old style:** multipart with `file` -**New style:** JSON with uploaded URL - -#### JSON request body - -```json -{ - "thumbnail_url": "https://your-media-url-or-minio-presigned-url" -} -``` - -#### Success response - -```json -{ - "success": true, - "message": "Sub-course thumbnail URL updated successfully", - "data": { - "thumbnail_url": "https://your-media-url-or-minio-presigned-url" - } -} -``` - ---- - -## D) Audio Answer Submission - -### `POST /api/v1/questions/audio-answer` - -**Old style:** multipart with `question_id`, `question_set_id`, `file` -**New style:** JSON referencing uploaded audio object key - -#### JSON request body - -```json -{ - "question_id": 101, - "question_set_id": 5, - "object_key": "audio/uuid-audio-file.webm" -} -``` - -#### Success response - -```json -{ - "success": true, - "status_code": 200, - "message": "Audio answer submitted successfully", - "data": { - "id": 1, - "question_id": 101, - "question_set_id": 5, - "audio_url": "https://...", - "created_at": "2026-03-24T10:30:00Z" - } -} -``` - ---- - -## 3) Recommended Admin Panel Integration Flow - -For each image/audio/video field: - -1. Call `POST /api/v1/files/upload` with `multipart/form-data` -2. Read response `data` - - image/audio: use `url` (and keep `object_key` if needed) - - video: use `url` / `vimeo_id` / `embed_url` depending on target endpoint -3. Call business endpoint with JSON body using returned media reference - ---- - -## 4) Endpoint List (Quick Reference) - -- `POST /api/v1/files/upload` (new) -- `POST /api/v1/user/:id/profile-picture` (now supports JSON) -- `POST /api/v1/course-management/courses/:id/thumbnail` (now supports JSON) -- `POST /api/v1/course-management/sub-courses/:id/thumbnail` (now supports JSON) -- `POST /api/v1/questions/audio-answer` (now supports JSON) - ---- - -## 5) Backward Compatibility Note - -Legacy multipart behavior for the updated endpoints is still supported to avoid breaking existing clients during migration. -Admin panel should migrate to the new JSON-reference flow for consistency. - diff --git a/docs/LEARNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md b/docs/LEARNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md deleted file mode 100644 index 2089975..0000000 --- a/docs/LEARNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md +++ /dev/null @@ -1,218 +0,0 @@ -# Learner Progress Tracker Admin Integration Guide - -This guide explains how to integrate learner sub-course progress tracking into the admin panel using the backend endpoint implemented for admin usage. - -## Scope - -- Track a specific learner's progress across all sub-courses inside a course. -- Show lock state, progress percentage, and completion timestamps. -- Integrate as a read-focused admin experience. - -## New Admin Endpoint - -- **Method:** `GET` -- **Path:** `/api/v1/admin/users/:userId/progress/courses/:courseId` -- **Auth:** Bearer token -- **Required permission:** `progress.get_any_user` - -## Course-level Summary Endpoint - -- **Method:** `GET` -- **Path:** `/api/v1/admin/users/:userId/progress/courses/:courseId/summary` -- **Auth:** Bearer token -- **Required permission:** `progress.get_any_user` - -### Success Response (`200`) - -```json -{ - "message": "Learner course progress summary retrieved successfully", - "data": { - "course_id": 1, - "learner_user_id": 10, - "overall_progress_percentage": 40, - "total_sub_courses": 5, - "completed_sub_courses": 2, - "in_progress_sub_courses": 1, - "not_started_sub_courses": 2, - "locked_sub_courses": 2 - } -} -``` - -### Path Parameters - -- `userId` (number): target learner user ID -- `courseId` (number): course ID - -### Success Response (`200`) - -```json -{ - "message": "Learner course progress retrieved successfully", - "data": [ - { - "sub_course_id": 11, - "title": "Beginner Conversation Basics", - "description": "Foundational speaking patterns", - "thumbnail": "https://cdn.example.com/sc-11.png", - "display_order": 1, - "level": "BEGINNER", - "progress_status": "IN_PROGRESS", - "progress_percentage": 45, - "started_at": "2026-03-07T09:10:11Z", - "completed_at": null, - "is_locked": false - }, - { - "sub_course_id": 12, - "title": "Beginner Listening Drills", - "description": "Daily listening practice", - "thumbnail": null, - "display_order": 2, - "level": "BEGINNER", - "progress_status": "NOT_STARTED", - "progress_percentage": 0, - "started_at": null, - "completed_at": null, - "is_locked": true - } - ] -} -``` - -### Error Responses - -- `400`: invalid `userId` or `courseId` -- `401`: missing/invalid token -- `403`: missing `progress.get_any_user` -- `500`: server/database issue - -## Data Semantics - -- `progress_status` values: - - `NOT_STARTED` - - `IN_PROGRESS` - - `COMPLETED` -- `progress_percentage` is `0..100` -- `is_locked` is computed from unmet sub-course prerequisites for that learner -- list is ordered by `display_order` -- only active sub-courses are included - -## Progress Calculation Model - -Sub-course progress is automatically aggregated from completion records: - -- completed published videos in the sub-course (`user_sub_course_video_progress`) -- completed published practices in the sub-course (`user_practice_progress`) - -Formula: - -- `total_items = published_videos + published_practices` -- `completed_items = completed_videos + completed_practices` -- `progress_percentage = round((completed_items / total_items) * 100)` -- if `total_items = 0`, `progress_percentage = 0` - -Status transitions: - -- `NOT_STARTED` when `completed_items = 0` -- `IN_PROGRESS` when `0 < completed_items < total_items` -- `COMPLETED` when `completed_items >= total_items` and `total_items > 0` - -Auto-recalculation triggers: - -- `POST /api/v1/progress/videos/:id/complete` -- `POST /api/v1/progress/practices/:id/complete` - -## Backend Rollout Steps - -After pulling this backend change: - -1. Restart backend service. -2. Sync permissions: - - `POST /api/v1/rbac/permissions/sync` -3. Ensure admin role includes `progress.get_any_user`. - - If your system uses explicit role-permission assignment, update role permissions after sync. - -## Admin Panel Integration Flow - -1. User opens learner progress screen. -2. Admin selects learner and course. -3. Frontend requests: - - `GET /api/v1/admin/users/{userId}/progress/courses/{courseId}` - - `GET /api/v1/admin/users/{userId}/progress/courses/{courseId}/summary` -4. Render returned `data` as ordered progress items. - -## Recommended UI Sections - -- Header: - - learner identity - - selected course -- Metrics row: - - total sub-courses - - completed count - - in-progress count - - locked count - - average progress percentage -- Ordered list/table: - - sub-course title - - level - - status badge - - progress bar - - locked icon - - started/completed timestamps - -## Frontend Mapping Example - -For each item: - -- `statusLabel = progress_status` -- `isCompleted = progress_status === "COMPLETED"` -- `isInProgress = progress_status === "IN_PROGRESS"` -- `isNotStarted = progress_status === "NOT_STARTED"` -- `canOpenDetails = !is_locked` - -## Suggested API Client Contract - -```ts -type LearnerCourseProgressItem = { - sub_course_id: number; - title: string; - description?: string | null; - thumbnail?: string | null; - display_order: number; - level: string; - progress_status: "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED"; - progress_percentage: number; - started_at?: string | null; - completed_at?: string | null; - is_locked: boolean; -}; - -type LearnerCourseProgressResponse = { - message: string; - data: LearnerCourseProgressItem[]; -}; -``` - -## Example Request - -```bash -curl -X GET "http://localhost:8432/api/v1/admin/users/7/progress/courses/3" \ - -H "Authorization: Bearer " -``` - -## Operational Notes - -- This endpoint is intended for admin/super-admin workflows. -- Existing learner self endpoint remains available: - - `GET /api/v1/progress/courses/:courseId` -- Do not expose `progress.get_any_user` to learner-facing roles. - -## QA Checklist - -- valid admin token + permission returns `200` -- token without permission returns `403` -- invalid `userId` or `courseId` returns `400` -- locked sub-courses correctly show `is_locked: true` -- ordering in UI follows `display_order` diff --git a/docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md b/docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md deleted file mode 100644 index 871af95..0000000 --- a/docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md +++ /dev/null @@ -1,300 +0,0 @@ -# Learning Tree + Sequential Access Integration (Admin Panel) - -Complete backend-compatible documentation for the changes implemented today: - -- flexible drag-and-drop reorder for learning tree entities -- sub-course level/sub-level model -- sub-course entry assessment model -- learner sequential unlock rules for videos and practices - ---- - -## 1) What Changed Today - -### 1.1 Reorder + Route/RBAC hardening - -- Reorder endpoints are active and route-safe (static `/reorder` routes registered before dynamic `/:id` routes). -- Reorder permissions are now seeded in RBAC and included in default `ADMIN` permissions: - - `course_categories.reorder` - - `courses.reorder` - - `subcourses.reorder` - - `videos.reorder` - - `practices.reorder` - -### 1.2 Sub-course academic structure - -- Added `sub_courses.sub_level` with enforced mapping: - - `BEGINNER` -> `A1`, `A2`, `A3` - - `INTERMEDIATE` -> `B1`, `B2`, `B3` - - `ADVANCED` -> `C1`, `C2`, `C3` - -### 1.3 Entry assessment per sub-course - -- Each sub-course has its own `INITIAL_ASSESSMENT` question set (`owner_type=SUB_COURSE`). -- Backfill creates missing sets for existing sub-courses. -- Trigger auto-creates entry assessment for newly created sub-courses. -- Existing default standalone initial-assessment questions are cloned into sub-course entry sets when empty. - -### 1.4 Learner sequential unlock (videos + practices) - -- Videos: learner cannot access higher `display_order` videos before completing earlier ones. -- Practices: learner cannot access higher `display_order` practices before completing earlier ones. -- Completion is tracked per learner using dedicated progress tables. - ---- - -## 2) Migrations Added - -Apply in order: - -1. `000024_subcourse_entry_assessment_and_sub_levels` -2. `000025_video_sequence_progress` -3. `000026_practice_sequence_progress` - -### 2.1 `000024_subcourse_entry_assessment_and_sub_levels` - -- adds `sub_courses.sub_level` -- adds level/sub-level check constraint -- adds `idx_sub_courses_level_sub_level` -- enforces one active sub-course entry assessment set (unique index on `question_sets`) -- backfills missing sub-course entry assessment sets -- adds trigger for automatic entry assessment creation - -### 2.2 `000025_video_sequence_progress` - -- creates `user_sub_course_video_progress` -- tracks learner completion by `user_id + video_id` - -### 2.3 `000026_practice_sequence_progress` - -- creates `user_practice_progress` -- tracks learner completion by `user_id + question_set_id` (practice set) - ---- - -## 3) Required Permissions - -### 3.1 Admin panel (read + reorder + content management) - -- `learning_tree.get` -- `course_categories.reorder` -- `courses.reorder` -- `subcourses.reorder` -- `videos.reorder` -- `practices.reorder` -- `question_sets.list_by_owner` (entry-assessment fetch by sub-course) - -### 3.2 Learner runtime (sequential flow) - -- `videos.get` -- `progress.update` (for completion endpoints) -- `question_sets.get` -- `question_set_items.list` - ---- - -## 4) Reorder APIs (Admin) - -All reorder APIs expect: - -```json -{ - "items": [ - { "id": 12, "position": 0 }, - { "id": 7, "position": 1 } - ] -} -``` - -- `id`: entity ID -- `position`: target 0-based `display_order` -- send the full sibling list in the target order - -| Method | Endpoint | Effect | -|---|---|---| -| `PUT` | `/api/v1/course-management/categories/reorder` | updates `course_categories.display_order` | -| `PUT` | `/api/v1/course-management/courses/reorder` | updates `courses.display_order` | -| `PUT` | `/api/v1/course-management/sub-courses/reorder` | updates `sub_courses.display_order` | -| `PUT` | `/api/v1/course-management/videos/reorder` | updates `sub_course_videos.display_order` | -| `PUT` | `/api/v1/course-management/practices/reorder` | updates `question_sets.display_order` (practice sets) | - -Common 400 causes: -- invalid body shape -- empty `items` - ---- - -## 5) Data Fetch APIs for Admin UI - -| Method | Endpoint | Purpose | -|---|---|---| -| `GET` | `/api/v1/course-management/learning-tree` | high-level tree | -| `GET` | `/api/v1/course-management/courses/{courseId}/learning-path` | detailed nested structure | -| `GET` | `/api/v1/question-sets/by-owner?owner_type=SUB_COURSE&owner_id={subCourseId}` | sets under a sub-course | -| `GET` | `/api/v1/question-sets/sub-courses/{subCourseId}/entry-assessment` | entry assessment set for sub-course | - ---- - -## 6) Sub-course Create/Update Contract (Admin) - -### 6.1 Create sub-course - -`POST /api/v1/course-management/sub-courses` - -Required fields now include: - -- `course_id` -- `title` -- `level` (`BEGINNER|INTERMEDIATE|ADVANCED`) -- `sub_level` (`A1..C3` mapped by level) - -Example: - -```json -{ - "course_id": 1, - "title": "Speaking Foundations", - "level": "BEGINNER", - "sub_level": "A2", - "display_order": 1 -} -``` - -### 6.2 Update sub-course - -`PATCH /api/v1/course-management/sub-courses/{id}` - -If `level` or `sub_level` is changed, backend validates the pair. - ---- - -## 7) Entry Assessment Model (Sub-course) - -Each sub-course has one entry assessment set: - -- `set_type = INITIAL_ASSESSMENT` -- `owner_type = SUB_COURSE` -- `owner_id = {subCourseId}` - -### Behavior - -- created automatically for new sub-courses -- backfilled for existing sub-courses -- intended to evaluate learner before joining/starting that sub-course - ---- - -## 8) Learner Sequential Unlock Rules (Runtime, Not Admin Panel) - -## 8.1 Videos - -Access endpoint: - -- `GET /api/v1/course-management/videos/{id}` - -Completion endpoint: - -- `POST /api/v1/progress/videos/{id}/complete` - -Rule: -- learner must complete all previous published videos in same sub-course by `display_order` (and `id` tie-breaker) - -## 8.2 Practices - -Access endpoints: - -- `GET /api/v1/question-sets/{id}` (for practice set) -- `GET /api/v1/question-sets/{setId}/questions` - -Completion endpoint: - -- `POST /api/v1/progress/practices/{id}/complete` - -Rule: -- applies to sets with: - - `set_type = PRACTICE` - - `owner_type = SUB_COURSE` - - `status = PUBLISHED` -- learner must complete all previous published practices in same sub-course by `display_order` (and `id` tie-breaker) - -## 8.3 Role scope - -- Sequence enforcement currently applies to `STUDENT` role. -- Admin/instructor/support are not sequence-blocked. -- Admin panel does not need to call completion endpoints. - ---- - -## 9) Mid-Progress Reorder Behavior - -Current behavior (by design): - -- Access checks always use the **latest `display_order` in DB**. -- If admin reorders while learner is in progress, next unlock path updates immediately. -- Completion records are by entity ID, so already completed items remain completed. - -Implication: -- learner may be asked to complete a newly moved earlier item before continuing. - ---- - -## 10) Recommended Frontend Integration Flow - -### 10.1 Admin panel - -1. Load learning path/tree. -2. Render sortable lists per sibling scope. -3. On drop: - - optimistic reorder in UI - - call matching reorder endpoint with full sibling list -4. Handle `400/401/403/500` and rollback on failure. - ---- - -## 11) Quick API Examples - -### Reorder videos - -```bash -curl -X PUT "http://localhost:8080/api/v1/course-management/videos/reorder" \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"items":[{"id":45,"position":0},{"id":42,"position":1},{"id":50,"position":2}]}' -``` - -### Reorder practices - -```bash -curl -X PUT "http://localhost:8080/api/v1/course-management/practices/reorder" \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"items":[{"id":88,"position":0},{"id":91,"position":1},{"id":95,"position":2}]}' -``` - -### Get sub-course entry assessment - -```bash -curl -X GET "http://localhost:8080/api/v1/question-sets/sub-courses/10/entry-assessment" \ - -H "Authorization: Bearer " -``` - -### Runtime-only completion endpoints (learner app, optional reference) - -```text -POST /api/v1/progress/videos/{id}/complete -POST /api/v1/progress/practices/{id}/complete -``` - ---- - -## 12) Validation and Testing Checklist - -- [ ] Run migrations 000024, 000025, 000026 -- [ ] Sync RBAC permissions and ensure admin role contains reorder keys -- [ ] Create/update sub-course with valid `level + sub_level` -- [ ] Verify auto-created entry assessment for new sub-course -- [ ] Reorder categories/courses/sub-courses/videos/practices and confirm persistence -- [ ] Learner cannot access video N+1 before completing N -- [ ] Learner cannot access practice N+1 before completing N -- [ ] Reorder during learner progress and confirm latest-order behavior - diff --git a/docs/PRACTICE_CREATION_API_GUIDE.md b/docs/PRACTICE_CREATION_API_GUIDE.md deleted file mode 100644 index 87762cb..0000000 --- a/docs/PRACTICE_CREATION_API_GUIDE.md +++ /dev/null @@ -1,544 +0,0 @@ -# Practice Creation API Guide (Lesson Scope) - -This guide provides the full step-by-step API process to create a lesson practice when using: - -- system-defined question types (`MCQ`, `TRUE_FALSE`, `SHORT_ANSWER`, `AUDIO`) -- dynamic question types (`DYNAMIC` with `question_type_definition_id` + `dynamic_payload`) - -All endpoints below are relative to `/api/v1` and require bearer authentication. - ---- - -## Standard Response Envelope - -Most successful responses follow: - -```json -{ - "message": "Human-readable message", - "data": {}, - "success": true, - "status_code": 200, - "metadata": null -} -``` - -Most errors follow: - -```json -{ - "message": "Error summary", - "error": "Detailed reason" -} -``` - ---- - -## Required Permissions - -At minimum, your role should have: - -- `questions.create` -- `question_sets.create` -- `question_set_items.add` -- `practices.create` - -If you create/update dynamic definitions: - -- `questions.update` -- `questions.delete` (if you also delete definitions) - ---- - -## End-to-End Flow - -1. (Optional) Upload media assets -2. Create question(s): - - system-defined path, or - - dynamic path (definition + question) -3. Create `PRACTICE` question set -4. Add question(s) to the set -5. Create lesson practice linked to that set -6. Verify under lesson - ---- - -## Step 0 (Optional): Upload Media - -Use this when question content references audio/image/PDF URLs (e.g. dynamic `IMAGE`, `AUDIO_PROMPT`, or `PDF_ATTACHMENT` stimulus). - -### Endpoint - -`POST /files/upload` (multipart form-data) - -### Form fields - -- `file`: binary -- `media_type`: `image`, `audio`, `video`, or `pdf` (PDF is stored in MinIO; response includes presigned `url` and `object_key`) - -### Example success response (shape) - -```json -{ - "message": "Media uploaded successfully", - "data": { - "url": "https://your-host/static/uploads/audio/abc.mp3", - "object_key": "audio/abc.mp3" - }, - "success": true, - "status_code": 201 -} -``` - -### Common errors - -- `400` invalid media type/content type -- `500` upload/storage failure - -Capture and reuse: - -- `data.url` (or equivalent resolved file URL) - ---- - -## Step 1A: Create System-Defined Question(s) - -### Endpoint - -`POST /questions` - -### Request example (MCQ) - -```json -{ - "question_text": "Choose the correct sentence.", - "question_type": "MCQ", - "difficulty_level": "EASY", - "points": 1, - "status": "PUBLISHED", - "options": [ - { "option_text": "He go to school.", "is_correct": false }, - { "option_text": "He goes to school.", "is_correct": true } - ] -} -``` - -### Request example (SHORT_ANSWER) - -```json -{ - "question_text": "Write one sentence using the word 'improve'.", - "question_type": "SHORT_ANSWER", - "difficulty_level": "MEDIUM", - "points": 2, - "status": "PUBLISHED", - "short_answers": [ - { "acceptable_answer": "I want to improve my English.", "match_type": "CASE_INSENSITIVE" } - ] -} -``` - -### Example success response (shape) - -```json -{ - "message": "Question created successfully", - "data": { - "id": 456, - "question_text": "Choose the correct sentence.", - "question_type": "MCQ", - "status": "PUBLISHED" - }, - "success": true, - "status_code": 201 -} -``` - -### Common errors - -- `400` validation/body errors -- `500` create failure - -Capture: - -- `data.id` as `question_id` - ---- - -## Step 1B: Dynamic Question Path - -If you use dynamic questions, follow these sub-steps. - -### 1B.1 Validate component-kind selection (optional but recommended) - -#### Endpoint - -`POST /questions/validate-question-type-definition` - -#### Request - -```json -{ - "stimulus_component_kinds": ["QUESTION_TEXT", "AUDIO_PROMPT", "IMAGE"], - "response_component_kinds": ["AUDIO_RESPONSE", "TEXT_INPUT"] -} -``` - -#### Success response - -```json -{ - "message": "Question type definition is valid", - "data": { "valid": true } -} -``` - -#### Error response example - -```json -{ - "message": "Invalid question type definition", - "error": "response: unknown component kind \"AUDIO_PROMPT\"" -} -``` - ---- - -### 1B.2 Create or reuse a dynamic type definition - -#### Endpoint - -`POST /questions/type-definitions` - -#### Request - -```json -{ - "key": "dialogue_audio_avatar_v1", - "display_name": "Dialogue Audio + Avatar", - "description": "Question text + prompt audio + two avatar images, with audio/text answer", - "stimulus_component_kinds": ["QUESTION_TEXT", "AUDIO_PROMPT", "IMAGE"], - "response_component_kinds": ["AUDIO_RESPONSE", "TEXT_INPUT"], - "stimulus_schema": [ - { "id": "question_text", "kind": "QUESTION_TEXT", "required": true }, - { "id": "prompt_audio", "kind": "AUDIO_PROMPT", "required": true }, - { "id": "speaker_a_avatar", "kind": "IMAGE", "required": true }, - { "id": "speaker_b_avatar", "kind": "IMAGE", "required": true } - ], - "response_schema": [ - { "id": "answer_audio", "kind": "AUDIO_RESPONSE", "required": true }, - { "id": "answer_text", "kind": "TEXT_INPUT", "required": true } - ], - "status": "ACTIVE" -} -``` - -#### Success response example - -```json -{ - "message": "Question type definition created", - "data": { - "id": 123, - "key": "dialogue_audio_avatar_v1", - "status": "ACTIVE" - } -} -``` - -#### Common errors - -- `400` invalid schema/kinds/mapping -- `500` unexpected persistence errors - -Capture: - -- `data.id` as `question_type_definition_id` - ---- - -### 1B.3 Create dynamic question - -#### Endpoint - -`POST /questions` - -#### Request - -```json -{ - "question_type": "DYNAMIC", - "question_type_definition_id": 123, - "difficulty_level": "MEDIUM", - "points": 2, - "status": "PUBLISHED", - "dynamic_payload": { - "stimulus": [ - { "id": "question_text", "kind": "QUESTION_TEXT", "value": "Respond to the conversation." }, - { "id": "prompt_audio", "kind": "AUDIO_PROMPT", "value": "https://cdn.example.com/audio/prompt-1.mp3" }, - { "id": "speaker_a_avatar", "kind": "IMAGE", "value": "https://cdn.example.com/images/a.webp" }, - { "id": "speaker_b_avatar", "kind": "IMAGE", "value": "https://cdn.example.com/images/b.webp" } - ], - "response": [ - { "id": "answer_audio", "kind": "AUDIO_RESPONSE", "value": { "instructions": "Record your answer" } }, - { "id": "answer_text", "kind": "TEXT_INPUT", "value": { "placeholder": "Type your answer" } } - ] - } -} -``` - -#### Success response example - -```json -{ - "message": "Question created successfully", - "data": { - "id": 789, - "question_type": "DYNAMIC", - "question_type_definition_id": 123, - "status": "PUBLISHED" - }, - "success": true, - "status_code": 201 -} -``` - -#### Common errors - -- `400` missing/invalid `dynamic_payload` -- `400` missing `question_type_definition_id` -- `500` persistence failure - -Capture: - -- `data.id` as `question_id` - ---- - -## Step 2: Create PRACTICE Question Set - -### Endpoint - -`POST /question-sets` - -### Request - -```json -{ - "title": "Lesson 12 - Practice Set", - "description": "Question set for lesson-level practice", - "set_type": "PRACTICE", - "owner_type": "LESSON", - "owner_id": 12, - "status": "PUBLISHED", - "shuffle_questions": false -} -``` - -### Success response example - -```json -{ - "message": "Question set created successfully", - "data": { - "id": 55, - "title": "Lesson 12 - Practice Set", - "set_type": "PRACTICE", - "owner_type": "LESSON", - "owner_id": 12, - "status": "PUBLISHED" - }, - "success": true, - "status_code": 201 -} -``` - -### Common errors - -- `400` invalid input -- `500` create failure - -Capture: - -- `data.id` as `set_id` - ---- - -## Step 3: Add Question(s) to Set - -Run this once per `question_id`. - -### Endpoint - -`POST /question-sets/:setId/questions` - -### Request - -```json -{ - "question_id": 456, - "display_order": 1 -} -``` - -### Success response example - -```json -{ - "message": "Question added to set successfully", - "data": { - "id": 901, - "set_id": 55, - "question_id": 456, - "display_order": 1 - }, - "success": true, - "status_code": 201 -} -``` - -### Common errors - -- `400` invalid `setId` or body -- `500` link/create failure - ---- - -## Step 4: Create Lesson Practice - -This creates the practice record scoped to lesson. - -### Endpoint - -`POST /practices` - -### Request - -`title` is optional; omit it or use an empty string to create a practice without a display title (stored as empty). - -Include `publish_status`: `DRAFT` to hide the practice from subscribed learners until you set it to `PUBLISHED` (via create or `PUT /practices/:id`). Omit the field or send `PUBLISHED` to go live immediately (backward compatible). - -```json -{ - "parent_kind": "LESSON", - "parent_id": 12, - "title": "Lesson 12 Conversation Drill", - "story_description": "A short two-speaker scenario.", - "story_image": "https://cdn.example.com/images/story.webp", - "question_set_id": 55, - "quick_tips": "Listen carefully before answering.", - "publish_status": "DRAFT" -} -``` - -### Success response example - -```json -{ - "message": "Practice created successfully", - "data": { - "id": 37, - "parent_kind": "LESSON", - "parent_id": 12, - "title": "Lesson 12 Conversation Drill", - "question_set_id": 55 - }, - "success": true, - "status_code": 201 -} -``` - -### Common errors - -- `400` validation failed / invalid parent kind -- `404` lesson not found -- `404` question set not found -- `500` create failure - -Capture: - -- `data.id` as `practice_id` - ---- - -## Step 5: Verify Practice Under Lesson - -### Endpoint - -`GET /lessons/:id/practices` - -Example: - -`GET /lessons/12/practices` - -### Success response example - -```json -{ - "message": "Practices retrieved successfully", - "data": { - "practices": [ - { - "id": 37, - "parent_kind": "LESSON", - "parent_id": 12, - "title": "Lesson 12 Conversation Drill", - "question_set_id": 55 - } - ], - "total_count": 1, - "limit": 20, - "offset": 0 - }, - "success": true, - "status_code": 200 -} -``` - ---- - -## Optional Learner Completion Step - -### Endpoint - -`POST /progress/practices/:id/complete` - -Use `practice_id` as `:id` for current behavior. - -### Success response example - -```json -{ - "message": "Practice completed", - "success": true, - "status_code": 200 -} -``` - -### Common errors - -- `403` sequence gating violation -- `404` practice not found -- `500` completion/persistence failure - ---- - -## Quick Checklist (IDs to Carry Forward) - -- From question create: `question_id` -- From dynamic definition create (if used): `question_type_definition_id` -- From question set create: `set_id` -- From practice create: `practice_id` - ---- - -## Notes and Pitfalls - -- For dynamic questions, `question_type` must be `DYNAMIC`. -- For dynamic questions, both `question_type_definition_id` and `dynamic_payload` are required. -- `AUDIO_PROMPT` is stimulus-side; response-side audio uses `AUDIO_RESPONSE`. -- `question_set_id` in `POST /practices` must reference an existing set. -- For lesson practice always use: - - `parent_kind = "LESSON"` - - `parent_id = ` -- Publish questions and question set (`status = "PUBLISHED"`) if learners must complete immediately. diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md deleted file mode 100644 index 0611d38..0000000 --- a/docs/TEST_PLAN.md +++ /dev/null @@ -1,514 +0,0 @@ -# Yimaru LMS Backend — Test Plan - -## 1. System Overview - -| Component | Tech | -|-----------|------| -| Language | Go 1.x | -| Framework | Fiber v2 | -| Database | PostgreSQL (pgx/v5, sqlc) | -| Auth | JWT (access + refresh tokens) | -| Video | Vimeo API, CloudConvert | -| Payments | ArifPay | -| Notifications | WebSocket, SMS (AfroSMS), Resend, Push | -| Logging | zap (MongoDB), slog | - -**Roles:** `SUPER_ADMIN`, `ADMIN`, `STUDENT`, `INSTRUCTOR`, `SUPPORT` -**Team Roles:** `super_admin`, `admin`, `content_manager`, `support_agent`, `instructor`, `finance`, `hr`, `analyst` - ---- - -## 2. Test Strategy - -### Test Pyramid - -``` - ┌─────────┐ - │ E2E / │ ← API integration tests (Postman/httptest) - │ API │ - ┌┴─────────┴┐ - │ Service │ ← Business logic unit tests (mocked stores) - ┌┴────────────┴┐ - │ Repository │ ← DB integration tests (test DB + sqlc) - ┌┴──────────────┴┐ - │ Domain/Utils │ ← Pure unit tests (no deps) - └────────────────┘ -``` - -### Priority Levels -- **P0 — Critical:** Auth, Payments, Subscriptions — money + access -- **P1 — High:** Course CRUD, Video upload pipeline, Ratings -- **P2 — Medium:** Notifications, Questions, Issue Reporting, Team Mgmt -- **P3 — Low:** Activity Logs, Analytics, Settings, Static files - ---- - -## 3. Module Test Plans - -### 3.1 Authentication & Authorization (P0) - -#### Unit Tests -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| AUTH-01 | Register user with valid data | valid phone, email, password | 201, user created with status=PENDING | -| AUTH-02 | Register with existing email | duplicate email | 400/409, `ErrEmailAlreadyRegistered` | -| AUTH-03 | Register with existing phone | duplicate phone | 400/409, `ErrPhoneAlreadyRegistered` | -| AUTH-04 | Login with correct credentials | valid email+password | 200, access_token + refresh_token | -| AUTH-05 | Login with wrong password | valid email, bad password | 401 | -| AUTH-06 | Login unverified user | PENDING user | 401, `ErrUserNotVerified` | -| AUTH-07 | Refresh token (valid) | valid refresh token | 200, new access_token | -| AUTH-08 | Refresh token (expired/revoked) | expired/revoked token | 401 | -| AUTH-09 | Google OAuth login (Android) | valid Google ID token | 200, tokens returned | -| AUTH-10 | Logout | valid token | 200, refresh token revoked | -| AUTH-11 | OTP send | valid phone/email | 200, OTP generated | -| AUTH-12 | OTP verify (correct) | correct OTP | 200, user status → ACTIVE | -| AUTH-13 | OTP verify (wrong/expired) | wrong OTP | 400 | -| AUTH-14 | OTP resend | valid user | 200, new OTP | -| AUTH-15 | Password reset flow | sendResetCode → verify → resetPassword | 200 at each step | - -#### Middleware Tests -| ID | Test Case | Expected | -|----|-----------|----------| -| MW-01 | Request without Authorization header | 401 | -| MW-02 | Request with malformed JWT | 401 | -| MW-03 | Request with expired JWT | 401 | -| MW-04 | Valid token → user_id/role in Locals | Next handler receives correct locals | -| MW-05 | `SuperAdminOnly` — ADMIN role | 403 | -| MW-06 | `SuperAdminOnly` — SUPER_ADMIN role | Next called | -| MW-07 | `OnlyAdminAndAbove` — STUDENT role | 403 | -| MW-08 | `OnlyAdminAndAbove` — ADMIN role | Next called | - ---- - -### 3.2 User Management (P1) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| USR-01 | Get user profile (own) | auth token | 200, profile data | -| USR-02 | Update user profile | valid fields | 200, updated | -| USR-03 | Upload profile picture (jpg) | ≤5MB jpg | 200, `/static/profile_pictures/.webp` (if CloudConvert on) | -| USR-04 | Upload profile picture (>5MB) | 6MB file | 400, file too large | -| USR-05 | Upload profile picture (invalid type) | .pdf file | 400, invalid file type | -| USR-06 | Profile picture CloudConvert fallback | CloudConvert disabled | 200, saved as original format | -| USR-07 | Check profile completed | user_id | 200, boolean | -| USR-08 | Update knowledge level | valid level | 200 | -| USR-09 | Get all users (admin) | admin token | 200, paginated list | -| USR-10 | Delete user | admin token, user_id | 200, user deleted | -| USR-11 | Search user by name/phone | search term | 200, results | -| USR-12 | Check phone/email exist | phone+email | 200, exists flags | - ---- - -### 3.3 Admin Management (P1) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| ADM-01 | Create admin (SUPER_ADMIN) | valid admin data | 201 | -| ADM-02 | Create admin (ADMIN role) | valid data | 403, SuperAdminOnly | -| ADM-03 | Get all admins | SUPER_ADMIN token | 200, list | -| ADM-04 | Update admin | SUPER_ADMIN token | 200 | - ---- - -### 3.4 Course Categories (P1) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| CAT-01 | Create category | `{ "name": "Math" }` | 201, category returned | -| CAT-02 | Create category (empty name) | `{ "name": "" }` | 400 | -| CAT-03 | Get all categories (paginated) | limit=10, offset=0 | 200, list + total_count | -| CAT-04 | Get category by ID | valid ID | 200 | -| CAT-05 | Get category by ID (not found) | ID=999999 | 404 | -| CAT-06 | Update category | new name | 200 | -| CAT-07 | Delete category | valid ID | 200 | -| CAT-08 | Delete category (cascade) | category with courses | 200, courses also deleted | - ---- - -### 3.5 Courses (P1) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| CRS-01 | Create course | category_id + title | 201, sends notifications to students | -| CRS-02 | Create course (invalid category) | nonexistent category_id | 500 (FK violation) | -| CRS-03 | Get course by ID | valid ID | 200, with thumbnail | -| CRS-04 | Get courses by category (paginated) | category_id, limit, offset | 200 | -| CRS-05 | Update course | title, description, thumbnail | 200, activity log recorded | -| CRS-06 | Upload course thumbnail (jpg→webp) | valid image | 200, stored as webp if CloudConvert enabled | -| CRS-07 | Upload course thumbnail (>10MB) | large file | 400 | -| CRS-08 | Delete course | valid ID | 200, cascades to sub-courses | - ---- - -### 3.6 Sub-Courses (P1) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| SUB-01 | Create sub-course | course_id, title, level=BEGINNER | 201, notifications sent | -| SUB-02 | Create sub-course (invalid level) | level=EXPERT | 500 (CHECK constraint) | -| SUB-03 | List sub-courses by course | course_id | 200, list + count | -| SUB-04 | List active sub-courses | — | 200, only is_active=true | -| SUB-05 | Update sub-course | title, display_order | 200 | -| SUB-06 | Upload sub-course thumbnail | valid image | 200 | -| SUB-07 | Deactivate sub-course | valid ID | 200, is_active=false | -| SUB-08 | Delete sub-course | valid ID | 200, cascades to videos | - ---- - -### 3.7 Sub-Course Videos (P1) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| VID-01 | Create video (direct URL) | video_url, title | 201, provider=DIRECT | -| VID-02 | Upload video file (with Vimeo) | file + Vimeo configured | 201, provider=VIMEO, status=DRAFT | -| VID-03 | Upload video (CloudConvert + Vimeo) | file + both configured | 201, video compressed then uploaded | -| VID-04 | Upload video (>500MB) | large file | 400 | -| VID-05 | Create video via Vimeo pull URL | source_url | 201 | -| VID-06 | Import from existing Vimeo ID | vimeo_video_id | 201, thumbnail from Vimeo | -| VID-07 | Publish video | video_id | 200, is_published=true | -| VID-08 | Get published videos by sub-course | sub_course_id | 200, only published | -| VID-09 | Update video metadata | title, description, status | 200 | -| VID-10 | Delete video | video_id | 200 | -| VID-11 | CloudConvert disabled fallback | upload without CC | 201, no compression step | - ---- - -### 3.8 Learning Tree (P2) - -| ID | Test Case | Expected | -|----|-----------|----------| -| TREE-01 | Full learning tree (populated) | 200, nested courses → sub-courses | -| TREE-02 | Full learning tree (empty) | 200, empty array | - ---- - -### 3.9 Questions System (P2) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| Q-01 | Create MCQ question | question_text, type=MCQ, options | 201 | -| Q-02 | Create TRUE_FALSE question | type=TRUE_FALSE | 201 | -| Q-03 | Create SHORT_ANSWER question | type=SHORT_ANSWER, acceptable answers | 201 | -| Q-04 | List questions (filtered) | type=MCQ, difficulty=EASY | 200, filtered list | -| Q-05 | Search questions | search query | 200, matching results | -| Q-06 | Update question with options | new options | 200, old options replaced | -| Q-07 | Delete question | question_id | 200, cascades options/answers | - ---- - -### 3.10 Question Sets (P2) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| QS-01 | Create question set (PRACTICE) | type=PRACTICE, owner | 201 | -| QS-02 | Create question set (INITIAL_ASSESSMENT) | type=INITIAL_ASSESSMENT | 201 | -| QS-03 | Add question to set | set_id, question_id | 201 | -| QS-04 | Add duplicate question to set | same set_id+question_id | 409/error | -| QS-05 | Reorder question in set | display_order | 200 | -| QS-06 | Remove question from set | set_id, question_id | 200 | -| QS-07 | Get questions in set | set_id | 200, ordered list | -| QS-08 | Add user persona | set_id, user_id | 200 | -| QS-09 | Remove user persona | set_id, user_id | 200 | - ---- - -### 3.11 Subscription Plans (P0) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| PLAN-01 | Create plan (admin) | name, price, duration | 201 | -| PLAN-02 | List plans (public, no auth) | — | 200 | -| PLAN-03 | Get plan by ID (public) | plan_id | 200 | -| PLAN-04 | Update plan | new price | 200 | -| PLAN-05 | Delete plan | plan_id | 200 | -| PLAN-06 | Duration calculation (MONTH) | start + 3 MONTH | +3 months | -| PLAN-07 | Duration calculation (YEAR) | start + 1 YEAR | +1 year | -| PLAN-08 | Duration calculation (DAY) | start + 30 DAY | +30 days | - ---- - -### 3.12 User Subscriptions (P0) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| SUBS-01 | Admin creates subscription (no payment) | user_id, plan_id | 201, status=ACTIVE | -| SUBS-02 | User subscribes with payment | plan_id | 201, status=PENDING, payment initiated | -| SUBS-03 | Get my subscription | auth token | 200, current active sub | -| SUBS-04 | Get subscription history | auth token | 200, all subs | -| SUBS-05 | Check subscription status | auth token | 200, active/expired | -| SUBS-06 | Cancel subscription | subscription_id | 200, status=CANCELLED | -| SUBS-07 | Set auto-renew on | subscription_id, true | 200 | -| SUBS-08 | Set auto-renew off | subscription_id, false | 200 | -| SUBS-09 | Expired subscription status check | expired sub | status=EXPIRED | - ---- - -### 3.13 Payments — ArifPay (P0) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| PAY-01 | Initiate payment | plan_id, phone, email | 201, payment URL returned | -| PAY-02 | Verify payment (success) | valid session_id | 200, status=SUCCESS, subscription activated | -| PAY-03 | Verify payment (failed) | failed session | 200, status=FAILED | -| PAY-04 | Get my payments | auth token | 200, payment list | -| PAY-05 | Cancel payment | payment_id | 200, status=CANCELLED | -| PAY-06 | Webhook handler (valid) | valid ArifPay webhook | 200, payment updated | -| PAY-07 | Webhook handler (invalid signature) | tampered data | 400/401 | -| PAY-08 | Get payment methods (public) | — | 200, methods list | -| PAY-09 | Direct payment (OTP flow) | phone, method | 200, OTP sent | -| PAY-10 | Verify direct payment OTP | correct OTP | 200, payment confirmed | - ---- - -### 3.14 Ratings (P1) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| RAT-01 | Rate app (5 stars) | target_type=app, target_id=0, stars=5 | 201 | -| RAT-02 | Rate course (3 stars + review) | target_type=course, target_id=1, stars=3, review="good" | 201 | -| RAT-03 | Rate sub-course | target_type=sub_course, target_id=1, stars=4 | 201 | -| RAT-04 | Update existing rating (upsert) | same user+target, new stars | 200, stars updated | -| RAT-05 | Invalid stars (0) | stars=0 | 400 | -| RAT-06 | Invalid stars (6) | stars=6 | 400 | -| RAT-07 | Invalid target_type | target_type=video | 400 | -| RAT-08 | Get my rating for target | target_type=course, target_id=1 | 200, my rating | -| RAT-09 | Get my rating (not rated yet) | — | 404 | -| RAT-10 | Get ratings by target (paginated) | target_type=course, limit=20 | 200, list | -| RAT-11 | Get rating summary | target_type=course, target_id=1 | 200, avg + count | -| RAT-12 | Get rating summary (no ratings) | unrated target | 200, avg=0, count=0 | -| RAT-13 | Get all my ratings | auth token | 200, list | -| RAT-14 | Delete own rating | rating_id | 200 | -| RAT-15 | Delete other user's rating | other's rating_id | fails (no rows) | - ---- - -### 3.15 Notifications (P2) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| NOT-01 | WebSocket connection | valid auth | 101 upgrade | -| NOT-02 | Get user notifications | auth token | 200, list | -| NOT-03 | Mark as read | notification_id | 200 | -| NOT-04 | Mark all as read | — | 200 | -| NOT-05 | Mark as unread | notification_id | 200 | -| NOT-06 | Count unread | — | 200, count | -| NOT-07 | Delete user notifications | — | 200 | -| NOT-08 | Auto-notify on course creation | create course | students receive in-app notification | -| NOT-09 | Auto-notify on sub-course creation | create sub-course | students notified | -| NOT-10 | Send SMS (AfroSMS) | phone + message | 200 | -| NOT-11 | Register device token (push) | device token | 200 | -| NOT-12 | Unregister device token | device token | 200 | - ---- - -### 3.16 Issue Reporting (P2) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| ISS-01 | Create issue | subject, description, type=bug | 201 | -| ISS-02 | Get my issues | auth token | 200 | -| ISS-03 | Get all issues (admin) | admin token | 200 | -| ISS-04 | Update issue status (admin) | status=resolved | 200, notifications sent | -| ISS-05 | Delete issue (admin) | issue_id | 200 | -| ISS-06 | Get user issues (admin) | user_id | 200 | - ---- - -### 3.17 Team Management (P2) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| TM-01 | Team member login | email + password | 200, tokens | -| TM-02 | Create team member (admin) | valid data, role=instructor | 201 | -| TM-03 | Create team member (duplicate email) | existing email | 409 | -| TM-04 | Get all team members (admin) | admin token | 200, list | -| TM-05 | Get team member stats (admin) | admin token | 200 | -| TM-06 | Update team member status | status=suspended | 200 | -| TM-07 | Delete team member (SUPER_ADMIN only) | super_admin token | 200 | -| TM-08 | Delete team member (ADMIN) | admin token | 403 | -| TM-09 | Change team member password | old+new password | 200 | -| TM-10 | Get my team profile | team auth token | 200 | - ---- - -### 3.18 Vimeo Integration (P2) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| VIM-01 | Get video info | vimeo_video_id | 200, video details | -| VIM-02 | Get embed code | vimeo_video_id | 200, iframe HTML | -| VIM-03 | Get transcode status | vimeo_video_id | 200, status | -| VIM-04 | Delete Vimeo video | vimeo_video_id | 200 | -| VIM-05 | Pull upload | source URL | 201 | -| VIM-06 | TUS upload | file size + title | 201, upload link | -| VIM-07 | OEmbed | Vimeo URL | 200, embed data | -| VIM-08 | Vimeo service disabled | no config | graceful handling | - ---- - -### 3.19 CloudConvert (P2) - -| ID | Test Case | Input | Expected | -|----|-----------|-------|----------| -| CC-01 | Video compression job | video file | compressed mp4 returned | -| CC-02 | Image optimization job | jpg file | webp returned | -| CC-03 | Job timeout | slow job | error after 30min (video) / 5min (image) | -| CC-04 | Job failure | invalid file | error with task message | -| CC-05 | Service disabled | no config | nil service, callers skip | - ---- - -### 3.20 Activity Logs & Analytics (P3) - -| ID | Test Case | Expected | -|----|-----------|----------| -| LOG-01 | Get activity logs (admin, filtered) | 200, filtered by action/resource/date | -| LOG-02 | Get activity log by ID | 200 | -| LOG-03 | Activity log recorded on course CRUD | log entry with actor, IP, UA | -| LOG-04 | Analytics dashboard (admin) | 200, dashboard data | -| LOG-05 | MongoDB logs endpoint | 200, log entries | - ---- - -### 3.21 Settings (P3) - -| ID | Test Case | Expected | -|----|-----------|----------| -| SET-01 | Get settings (SUPER_ADMIN) | 200, settings list | -| SET-02 | Get settings (ADMIN) | 403 | -| SET-03 | Update settings | 200 | -| SET-04 | Get setting by key | 200 | - ---- - -### 3.22 Assessment (P2) - -| ID | Test Case | Expected | -|----|-----------|----------| -| ASM-01 | Create assessment question | 201 | -| ASM-02 | List assessment questions | 200 | -| ASM-03 | Get assessment question by ID | 200 | - ---- - -## 4. Cross-Cutting Concerns - -### 4.1 Input Validation - -| ID | Test | Expected | -|----|------|----------| -| VAL-01 | Empty required fields | 400 with field-level errors | -| VAL-02 | Invalid email format | 400 | -| VAL-03 | Negative IDs in path params | 400 | -| VAL-04 | Non-numeric path params (e.g., `/courses/abc`) | 400 | -| VAL-05 | Malformed JSON body | 400 | -| VAL-06 | Oversized request body (>500MB) | 413 | - -### 4.2 Cascading Deletes - -| ID | Test | Expected | -|----|------|----------| -| CAS-01 | Delete category → courses deleted | verified via get | -| CAS-02 | Delete course → sub-courses deleted | verified via get | -| CAS-03 | Delete sub-course → videos deleted | verified via get | -| CAS-04 | Delete user → ratings deleted | verified via get | - -### 4.3 Concurrency / Race Conditions - -| ID | Test | Expected | -|----|------|----------| -| RACE-01 | Two users rating same target simultaneously | both succeed, no duplicates | -| RACE-02 | Same user submitting duplicate rating | upsert, only one record | -| RACE-03 | Concurrent subscription creation for same user | one succeeds or both idempotent | - -### 4.4 File Upload Security - -| ID | Test | Expected | -|----|------|----------| -| SEC-01 | Upload with spoofed content-type header (exe as jpg) | 400 (content sniffing rejects) | -| SEC-02 | Path traversal in filename | UUID-renamed, no traversal | -| SEC-03 | Null bytes in filename | handled safely | - ---- - -## 5. Integration Test Scenarios (E2E Flows) - -### Flow 1: Student Registration → Subscription → Learning -``` -1. POST /user/register → PENDING -2. POST /user/verify-otp → ACTIVE -3. POST /auth/customer-login → tokens -4. GET /subscription-plans → choose plan -5. POST /subscriptions/checkout → payment URL -6. POST /payments/webhook → payment SUCCESS -7. GET /subscriptions/status → ACTIVE -8. GET /course-management/learning-tree → courses -9. GET /sub-courses/:id/videos/published → watch -10. POST /ratings → rate course -``` - -### Flow 2: Admin Content Management -``` -1. POST /auth/admin-login → tokens -2. POST /course-management/categories → create category -3. POST /course-management/courses → create course -4. POST /courses/:id/thumbnail → upload thumbnail -5. POST /course-management/sub-courses → create sub-course -6. POST /sub-courses/:id/thumbnail → upload thumbnail -7. POST /course-management/videos/upload → upload video (CloudConvert → Vimeo) -8. PUT /videos/:id/publish → publish -9. GET /activity-logs → verify all actions logged -``` - -### Flow 3: Issue Resolution -``` -1. Student: POST /issues → bug report -2. Admin: GET /issues → see all -3. Admin: PATCH /issues/:id/status → resolved -4. Student: GET /notifications → sees status update -``` - -### Flow 4: Team Onboarding -``` -1. SUPER_ADMIN: POST /team/members → create instructor -2. Instructor: POST /team/login → tokens -3. Instructor: GET /team/me → profile -4. SUPER_ADMIN: PATCH /team/members/:id/status → active -``` - ---- - -## 6. Performance & Load Testing - -| Test | Target | Threshold | -|------|--------|-----------| -| Login throughput | POST /auth/customer-login | <200ms p95, 100 RPS | -| Course listing | GET /categories/:id/courses | <100ms p95 | -| Video listing | GET /sub-courses/:id/videos | <100ms p95 | -| Rating summary | GET /ratings/summary | <50ms p95 | -| Notification list | GET /notifications | <100ms p95 | -| File upload (5MB image) | POST /user/:id/profile-picture | <5s (without CC) | -| Concurrent ratings | 50 users rating simultaneously | no errors, correct counts | - ---- - -## 7. Test Environment Requirements - -- **Test database:** Isolated PostgreSQL instance, migrated with all 17 migrations -- **External services:** Vimeo, CloudConvert, ArifPay, AfroSMS — stub/mock in tests -- **Test data seeding:** Create fixtures for users (each role), categories, courses, sub-courses, plans -- **Cleanup:** Each test suite truncates its tables or runs in a transaction with rollback - ---- - -## 8. Recommended Test Tooling - -| Purpose | Tool | -|---------|------| -| Go tests | `testing` + `testify/assert` | -| HTTP testing | `net/http/httptest` or Fiber's `app.Test()` | -| DB integration | `testcontainers-go` (Postgres) | -| Mocking | `testify/mock` or `gomock` | -| API collection | Postman / Bruno (manual & CI) | -| Load testing | `k6` or `vegeta` | -| CI runner | GitHub Actions / Gitea Actions | diff --git a/docs/audio-practice-integration.md b/docs/audio-practice-integration.md deleted file mode 100644 index 7936ce9..0000000 --- a/docs/audio-practice-integration.md +++ /dev/null @@ -1,303 +0,0 @@ -# Audio Practice (Speaking Practice) — Admin Panel Integration Guide - -## Overview - -The Yimaru backend **fully supports** audio/speaking practices. This guide covers the steps needed to integrate the feature into the **admin panel frontend**. - ---- - -## Backend Status (✅ Already Complete) - -| Layer | File | What It Does | -|-------|------|--------------| -| Domain | `internal/domain/questions.go` | `QuestionTypeAudio = "AUDIO"` constant, `VoicePrompt`, `SampleAnswerVoicePrompt`, `AudioCorrectAnswerText` fields | -| Handler | `internal/web_server/handlers/questions.go` | `CreateQuestion` accepts `question_type: "AUDIO"` with audio-specific fields | -| Handler | `internal/web_server/handlers/file_handler.go` | `UploadAudio` (general audio upload) and `SubmitAudioAnswer` (learner recording) | -| Migration | `db/migrations/000022_audio_questions.up.sql` | `AUDIO` type constraint + `question_audio_answers` table | -| Migration | `db/migrations/000028_user_audio_responses.up.sql` | `user_audio_responses` table for learner recordings | -| SQL | `db/query/question_audio_answers.sql` | CRUD for correct answer text per audio question | -| SQL | `db/query/user_audio_responses.sql` | CRUD for learner audio submissions | -| Routes | `internal/web_server/routes.go` | `POST /files/audio`, `POST /questions/audio-answer` | - ---- - -## Admin Panel Frontend Steps - -### Step 1: Add "AUDIO" Option to the Question Type Selector - -When creating/editing a question, the admin should be able to select `AUDIO` as a question type alongside `MCQ`, `TRUE_FALSE`, and `SHORT_ANSWER`. - ---- - -### Step 2: Build the AUDIO Question Form - -When `question_type = "AUDIO"` is selected, render these fields: - -| Field | API Field | Type | Required | Description | -|-------|-----------|------|----------|-------------| -| Question Text | `question_text` | `string` | ✅ | The prompt/instruction text shown to the learner | -| Voice Prompt | `voice_prompt` | `string` (URL) | Optional | Audio file URL — the question read aloud or a listening prompt | -| Sample Answer Voice | `sample_answer_voice_prompt` | `string` (URL) | Optional | Audio URL of a model/reference answer | -| Correct Answer Text | `audio_correct_answer_text` | `string` | Optional | Expected textual answer (used for grading/comparison) | -| Image | `image_url` | `string` (URL) | Optional | Supporting image for the question | -| Explanation | `explanation` | `string` | Optional | Shown to the learner after answering | -| Tips | `tips` | `string` | Optional | Hints shown before/during the question | -| Difficulty Level | `difficulty_level` | `string` | Optional | `EASY`, `MEDIUM`, or `HARD` | -| Points | `points` | `int` | Optional | Score value (defaults to 1) | - -> **Note:** Hide the `options` and `short_answers` fields when AUDIO is selected — they are not used for this type. - ---- - -### Step 3: Build an Audio Uploader Component - -Create a reusable audio uploader used for both `voice_prompt` and `sample_answer_voice_prompt`. - -**API Call:** - -``` -POST /files/audio -Content-Type: multipart/form-data - -Form field: "file" — the audio file -``` - -**Constraints:** -- Max file size: **50 MB** -- Allowed formats: `mp3`, `wav`, `ogg`, `m4a`, `aac`, `webm`, `flac` - -**Response:** - -```json -{ - "message": "Audio file uploaded successfully", - "data": { - "object_key": "audio/1710000000_recording.mp3", - "url": "https://...", - "content_type": "audio/mpeg" - }, - "success": true -} -``` - -**Usage:** -1. Admin clicks "Upload Voice Prompt" → file picker opens -2. File is uploaded via `POST /files/audio` -3. Store the returned `object_key` as the value for `voice_prompt` or `sample_answer_voice_prompt` -4. Display the audio player preview (see Step 4) - ---- - -### Step 4: Build an Audio Player / Preview Component - -After uploading, let admins preview the audio. - -**To get a playable URL from an object key:** - -``` -GET /files/url?key= -``` - -**Frontend implementation:** - -```html - -``` - -This component should be shown: -- Next to the voice prompt upload field (after upload) -- Next to the sample answer voice prompt upload field (after upload) -- When viewing/editing an existing AUDIO question - ---- - -### Step 5: Create a Practice with Audio Questions - -The full admin workflow: - -#### 5.1 Create the Practice Set (Question Set) - -``` -POST /question-sets - -{ - "title": "Speaking Practice - Lesson 1", - "description": "Practice your pronunciation", - "set_type": "PRACTICE", - "owner_type": "SUB_COURSE", - "owner_id": 42, - "status": "DRAFT" -} -``` - -#### 5.2 Upload Audio Files - -``` -POST /files/audio → voice_prompt object_key -POST /files/audio → sample_answer_voice_prompt object_key -``` - -#### 5.3 Create AUDIO Questions - -``` -POST /questions - -{ - "question_text": "Listen and repeat the following phrase", - "question_type": "AUDIO", - "voice_prompt": "minio://audio/1710000000_prompt.mp3", - "sample_answer_voice_prompt": "minio://audio/1710000000_sample.mp3", - "audio_correct_answer_text": "Hello, how are you?", - "difficulty_level": "EASY", - "points": 10 -} -``` - -#### 5.4 Link Questions to the Practice Set - -``` -POST /question-sets/:setId/questions - -{ - "question_id": 123, - "display_order": 1 -} -``` - -#### 5.5 (Optional) Add Personas - -``` -POST /question-sets/:setId/personas - -{ - "user_id": 5, - "display_order": 1 -} -``` - -#### 5.6 (Optional) Reorder Practices in a Sub-course - -``` -PUT /course-management/practices/reorder - -{ - "sub_course_id": 42, - "ordered_ids": [10, 11, 12] -} -``` - ---- - -### Step 6: Display Audio Questions in the Question List - -When listing questions (`GET /questions?question_type=AUDIO`), show: - -- An 🔊 audio icon or "AUDIO" badge for the question type -- A mini audio player if `voice_prompt` is set -- The `audio_correct_answer_text` value (if present) - -When viewing a single question (`GET /questions/:id`), the response includes: - -```json -{ - "question_type": "AUDIO", - "voice_prompt": "minio://audio/...", - "sample_answer_voice_prompt": "minio://audio/...", - "audio_correct_answer_text": "Hello, how are you?" -} -``` - ---- - -### Step 7: (Optional) Admin Review of Learner Audio Submissions - -> **⚠️ Not yet built** — requires a new backend endpoint if needed. - -Currently, learner audio submissions are stored in `user_audio_responses` but there's no admin-facing endpoint to list/review them. - -**If this is needed, add:** - -1. **New SQL query** in `db/query/user_audio_responses.sql`: - - ```sql - -- name: ListAudioResponsesByQuestionSet :many - SELECT uar.*, u.first_name, u.last_name - FROM user_audio_responses uar - JOIN users u ON u.id = uar.user_id - WHERE uar.question_set_id = $1 - ORDER BY uar.created_at DESC - LIMIT $2 OFFSET $3; - ``` - -2. **New endpoint** in `routes.go`: - - ``` - GET /admin/question-sets/:setId/audio-responses - ``` - -3. **Admin UI**: A table showing learner name, audio player for their recording, timestamp, and the correct answer text for comparison. - ---- - -## API Flow Summary - -``` -┌─────────────────────────────────────────────────────────┐ -│ ADMIN CREATES PRACTICE │ -├─────────────────────────────────────────────────────────┤ -│ │ -│ POST /question-sets │ -│ → Creates practice shell (set_type: "PRACTICE") │ -│ │ -│ POST /files/audio │ -│ → Uploads voice_prompt audio file │ -│ │ -│ POST /files/audio │ -│ → Uploads sample_answer_voice_prompt audio file │ -│ │ -│ POST /questions │ -│ → Creates AUDIO question with voice prompt URLs │ -│ and correct_answer_text │ -│ │ -│ POST /question-sets/:id/questions │ -│ → Links question to practice set │ -│ │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ LEARNER COMPLETES PRACTICE │ -├─────────────────────────────────────────────────────────┤ -│ │ -│ GET /files/url?key=... │ -│ → Streams voice prompt audio │ -│ │ -│ POST /questions/audio-answer │ -│ → Submits learner's audio recording │ -│ │ -│ POST /progress/practices/:id/complete │ -│ → Marks practice as completed │ -│ │ -└─────────────────────────────────────────────────────────┘ -``` - ---- - -## Required RBAC Permissions - -Ensure the admin role has these permissions: - -| Permission | Used By | -|------------|---------| -| `questions.create` | Creating AUDIO questions | -| `questions.update` | Editing AUDIO questions | -| `questions.list` | Listing questions (filter by AUDIO) | -| `questions.get` | Viewing a single question | -| `questions.delete` | Deleting questions | -| `question_sets.create` | Creating practice sets | -| `question_sets.update` | Updating practice sets | -| `question_set_items.add` | Adding questions to a set | -| `question_set_items.list` | Listing questions in a set | -| `question_set_items.remove` | Removing questions from a set | -| `question_set_items.update_order` | Reordering questions | -| `question_set_personas.add` | Adding personas | -| `practices.reorder` | Reordering practices in a sub-course | diff --git a/docs/docs.go b/docs/docs.go index 674caf9..3b1415c 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1360,6 +1360,173 @@ const docTemplate = `{ } } }, + "/api/v1/admin/payments": { + "get": { + "description": "Returns paginated payments across Chapa and ArifPay with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "List all payments (admin)", + "parameters": [ + { + "type": "integer", + "description": "Filter by learner user ID", + "name": "user_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by subscription plan ID", + "name": "plan_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by user subscription ID", + "name": "subscription_id", + "in": "query" + }, + { + "type": "string", + "description": "Payment status (PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED, EXPIRED)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Payment provider (CHAPA, ARIFPAY)", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Alias for provider", + "name": "payment_method", + "in": "query" + }, + { + "type": "string", + "description": "Currency code (e.g. ETB)", + "name": "currency", + "in": "query" + }, + { + "type": "string", + "description": "Plan category (LEARN_ENGLISH, IELTS, DUOLINGO)", + "name": "plan_category", + "in": "query" + }, + { + "type": "string", + "description": "Search session_id, nonce, or transaction_id", + "name": "reference", + "in": "query" + }, + { + "type": "string", + "description": "Created at from (RFC3339 or YYYY-MM-DD)", + "name": "created_from", + "in": "query" + }, + { + "type": "string", + "description": "Created at to (exclusive, RFC3339 or YYYY-MM-DD)", + "name": "created_to", + "in": "query" + }, + { + "type": "string", + "description": "Paid at from (RFC3339 or YYYY-MM-DD)", + "name": "paid_from", + "in": "query" + }, + { + "type": "string", + "description": "Paid at to (exclusive, RFC3339 or YYYY-MM-DD)", + "name": "paid_to", + "in": "query" + }, + { + "type": "number", + "description": "Minimum amount", + "name": "min_amount", + "in": "query" + }, + { + "type": "number", + "description": "Maximum amount", + "name": "max_amount", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/payments/{id}": { + "get": { + "description": "Returns any payment record by ID without learner ownership restriction", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get payment by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "Payment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin/roles/{role}/bulk-deactivate": { "post": { "security": [ @@ -2031,6 +2198,33 @@ const docTemplate = `{ } } }, + "/api/v1/auth/apple": { + "post": { + "description": "Validates an Apple identity token (iOS, Android, or web). On first sign-in, include email and name if Apple only returns them to the client once.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login via Sign in with Apple identity token", + "parameters": [ + { + "description": "Apple login payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": {} + } + }, "/api/v1/auth/google/android": { "post": { "tags": [ @@ -3290,7 +3484,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Media type: image|audio|video", + "description": "Media type: image|audio|video|pdf", "name": "media_type", "in": "formData", "required": true @@ -4583,6 +4777,59 @@ const docTemplate = `{ } } }, + "/api/v1/notifications/{id}": { + "get": { + "description": "Returns a single in-app notification. Users may only fetch their own notifications unless they have list-all access.", + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Get in-app notification by ID", + "parameters": [ + { + "type": "integer", + "description": "Notification ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/payments": { "get": { "description": "Returns the authenticated user's payment history", @@ -4699,6 +4946,52 @@ const docTemplate = `{ } } }, + "/api/v1/payments/chapa/success": { + "get": { + "description": "Displays the Yimaru Academy success page after Chapa redirects the learner to return_url", + "produces": [ + "text/html" + ], + "tags": [ + "payments" + ], + "summary": "Chapa payment success page", + "parameters": [ + { + "type": "string", + "description": "Chapa transaction reference (tx_ref)", + "name": "trx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa transaction reference", + "name": "tx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa reference ID", + "name": "ref_id", + "in": "query" + }, + { + "type": "string", + "description": "Payment status from Chapa redirect", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTML success page", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/payments/direct": { "post": { "description": "Creates a payment session and initiates direct payment (OTP-based)", @@ -6115,6 +6408,53 @@ const docTemplate = `{ } } }, + "/api/v1/question-sets/{setId}/question-types": { + "get": { + "description": "Returns distinct question type definitions (key, display_name, counts) for non-archived questions in the set. Legacy stored question_type values (e.g. AUDIO) are resolved to builder definitions when possible.", + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "List question types in a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/question-sets/{setId}/questions": { "get": { "description": "Returns all questions in a question set with details", @@ -6381,7 +6721,7 @@ const docTemplate = `{ } }, "post": { - "description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.", + "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.", "consumes": [ "application/json" ], @@ -6560,9 +6900,24 @@ const docTemplate = `{ }, { "type": "boolean", + "default": true, "description": "Include system seeded definitions", "name": "include_system", "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page size (default 20, max 200)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" } ], "responses": { @@ -6572,6 +6927,12 @@ const docTemplate = `{ "$ref": "#/definitions/domain.Response" } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -11308,6 +11669,52 @@ const docTemplate = `{ } } } + }, + "/payment/success": { + "get": { + "description": "Displays the Yimaru Academy success page after Chapa redirects the learner to return_url", + "produces": [ + "text/html" + ], + "tags": [ + "payments" + ], + "summary": "Chapa payment success page", + "parameters": [ + { + "type": "string", + "description": "Chapa transaction reference (tx_ref)", + "name": "trx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa transaction reference", + "name": "tx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa reference ID", + "name": "ref_id", + "in": "query" + }, + { + "type": "string", + "description": "Payment status from Chapa redirect", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTML success page", + "schema": { + "type": "string" + } + } + } + } } }, "definitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 001d189..d66146b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1352,6 +1352,173 @@ } } }, + "/api/v1/admin/payments": { + "get": { + "description": "Returns paginated payments across Chapa and ArifPay with optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "List all payments (admin)", + "parameters": [ + { + "type": "integer", + "description": "Filter by learner user ID", + "name": "user_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by subscription plan ID", + "name": "plan_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by user subscription ID", + "name": "subscription_id", + "in": "query" + }, + { + "type": "string", + "description": "Payment status (PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED, EXPIRED)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Payment provider (CHAPA, ARIFPAY)", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Alias for provider", + "name": "payment_method", + "in": "query" + }, + { + "type": "string", + "description": "Currency code (e.g. ETB)", + "name": "currency", + "in": "query" + }, + { + "type": "string", + "description": "Plan category (LEARN_ENGLISH, IELTS, DUOLINGO)", + "name": "plan_category", + "in": "query" + }, + { + "type": "string", + "description": "Search session_id, nonce, or transaction_id", + "name": "reference", + "in": "query" + }, + { + "type": "string", + "description": "Created at from (RFC3339 or YYYY-MM-DD)", + "name": "created_from", + "in": "query" + }, + { + "type": "string", + "description": "Created at to (exclusive, RFC3339 or YYYY-MM-DD)", + "name": "created_to", + "in": "query" + }, + { + "type": "string", + "description": "Paid at from (RFC3339 or YYYY-MM-DD)", + "name": "paid_from", + "in": "query" + }, + { + "type": "string", + "description": "Paid at to (exclusive, RFC3339 or YYYY-MM-DD)", + "name": "paid_to", + "in": "query" + }, + { + "type": "number", + "description": "Minimum amount", + "name": "min_amount", + "in": "query" + }, + { + "type": "number", + "description": "Maximum amount", + "name": "max_amount", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/payments/{id}": { + "get": { + "description": "Returns any payment record by ID without learner ownership restriction", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Get payment by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "Payment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin/roles/{role}/bulk-deactivate": { "post": { "security": [ @@ -2023,6 +2190,33 @@ } } }, + "/api/v1/auth/apple": { + "post": { + "description": "Validates an Apple identity token (iOS, Android, or web). On first sign-in, include email and name if Apple only returns them to the client once.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login via Sign in with Apple identity token", + "parameters": [ + { + "description": "Apple login payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": {} + } + }, "/api/v1/auth/google/android": { "post": { "tags": [ @@ -3282,7 +3476,7 @@ "parameters": [ { "type": "string", - "description": "Media type: image|audio|video", + "description": "Media type: image|audio|video|pdf", "name": "media_type", "in": "formData", "required": true @@ -4575,6 +4769,59 @@ } } }, + "/api/v1/notifications/{id}": { + "get": { + "description": "Returns a single in-app notification. Users may only fetch their own notifications unless they have list-all access.", + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Get in-app notification by ID", + "parameters": [ + { + "type": "integer", + "description": "Notification ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/payments": { "get": { "description": "Returns the authenticated user's payment history", @@ -4691,6 +4938,52 @@ } } }, + "/api/v1/payments/chapa/success": { + "get": { + "description": "Displays the Yimaru Academy success page after Chapa redirects the learner to return_url", + "produces": [ + "text/html" + ], + "tags": [ + "payments" + ], + "summary": "Chapa payment success page", + "parameters": [ + { + "type": "string", + "description": "Chapa transaction reference (tx_ref)", + "name": "trx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa transaction reference", + "name": "tx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa reference ID", + "name": "ref_id", + "in": "query" + }, + { + "type": "string", + "description": "Payment status from Chapa redirect", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTML success page", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/payments/direct": { "post": { "description": "Creates a payment session and initiates direct payment (OTP-based)", @@ -6107,6 +6400,53 @@ } } }, + "/api/v1/question-sets/{setId}/question-types": { + "get": { + "description": "Returns distinct question type definitions (key, display_name, counts) for non-archived questions in the set. Legacy stored question_type values (e.g. AUDIO) are resolved to builder definitions when possible.", + "produces": [ + "application/json" + ], + "tags": [ + "question-set-items" + ], + "summary": "List question types in a question set", + "parameters": [ + { + "type": "integer", + "description": "Question Set ID", + "name": "setId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/question-sets/{setId}/questions": { "get": { "description": "Returns all questions in a question set with details", @@ -6373,7 +6713,7 @@ } }, "post": { - "description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.", + "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.", "consumes": [ "application/json" ], @@ -6552,9 +6892,24 @@ }, { "type": "boolean", + "default": true, "description": "Include system seeded definitions", "name": "include_system", "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page size (default 20, max 200)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" } ], "responses": { @@ -6564,6 +6919,12 @@ "$ref": "#/definitions/domain.Response" } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -11300,6 +11661,52 @@ } } } + }, + "/payment/success": { + "get": { + "description": "Displays the Yimaru Academy success page after Chapa redirects the learner to return_url", + "produces": [ + "text/html" + ], + "tags": [ + "payments" + ], + "summary": "Chapa payment success page", + "parameters": [ + { + "type": "string", + "description": "Chapa transaction reference (tx_ref)", + "name": "trx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa transaction reference", + "name": "tx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa reference ID", + "name": "ref_id", + "in": "query" + }, + { + "type": "string", + "description": "Payment status from Chapa redirect", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTML success page", + "schema": { + "type": "string" + } + } + } + } } }, "definitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ff97772..673563c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3815,6 +3815,119 @@ paths: summary: Update field option (admin) tags: - field-options + /api/v1/admin/payments: + get: + description: Returns paginated payments across Chapa and ArifPay with optional + filters + parameters: + - description: Filter by learner user ID + in: query + name: user_id + type: integer + - description: Filter by subscription plan ID + in: query + name: plan_id + type: integer + - description: Filter by user subscription ID + in: query + name: subscription_id + type: integer + - description: Payment status (PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED, + EXPIRED) + in: query + name: status + type: string + - description: Payment provider (CHAPA, ARIFPAY) + in: query + name: provider + type: string + - description: Alias for provider + in: query + name: payment_method + type: string + - description: Currency code (e.g. ETB) + in: query + name: currency + type: string + - description: Plan category (LEARN_ENGLISH, IELTS, DUOLINGO) + in: query + name: plan_category + type: string + - description: Search session_id, nonce, or transaction_id + in: query + name: reference + type: string + - description: Created at from (RFC3339 or YYYY-MM-DD) + in: query + name: created_from + type: string + - description: Created at to (exclusive, RFC3339 or YYYY-MM-DD) + in: query + name: created_to + type: string + - description: Paid at from (RFC3339 or YYYY-MM-DD) + in: query + name: paid_from + type: string + - description: Paid at to (exclusive, RFC3339 or YYYY-MM-DD) + in: query + name: paid_to + type: string + - description: Minimum amount + in: query + name: min_amount + type: number + - description: Maximum amount + in: query + name: max_amount + type: number + - default: 20 + description: Page size + in: query + name: limit + type: integer + - default: 0 + description: Page offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List all payments (admin) + tags: + - payments + /api/v1/admin/payments/{id}: + get: + description: Returns any payment record by ID without learner ownership restriction + parameters: + - description: Payment ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get payment by ID (admin) + tags: + - payments /api/v1/admin/roles/{role}/bulk-deactivate: post: consumes: @@ -4215,6 +4328,25 @@ paths: summary: Get assessment question by ID tags: - assessment-question + /api/v1/auth/apple: + post: + consumes: + - application/json + description: Validates an Apple identity token (iOS, Android, or web). On first + sign-in, include email and name if Apple only returns them to the client once. + parameters: + - description: Apple login payload + in: body + name: body + required: true + schema: + type: object + produces: + - application/json + responses: {} + summary: Login via Sign in with Apple identity token + tags: + - auth /api/v1/auth/google/android: post: parameters: @@ -5053,7 +5185,7 @@ paths: consumes: - multipart/form-data parameters: - - description: 'Media type: image|audio|video' + - description: 'Media type: image|audio|video|pdf' in: formData name: media_type required: true @@ -5612,6 +5744,42 @@ paths: summary: Create lesson tags: - lessons + /api/v1/notifications/{id}: + get: + description: Returns a single in-app notification. Users may only fetch their + own notifications unless they have list-all access. + parameters: + - description: Notification ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get in-app notification by ID + tags: + - notifications /api/v1/notifications/bulk-email: post: consumes: @@ -6020,6 +6188,37 @@ paths: summary: Chapa payment callback tags: - payments + /api/v1/payments/chapa/success: + get: + description: Displays the Yimaru Academy success page after Chapa redirects + the learner to return_url + parameters: + - description: Chapa transaction reference (tx_ref) + in: query + name: trx_ref + type: string + - description: Chapa transaction reference + in: query + name: tx_ref + type: string + - description: Chapa reference ID + in: query + name: ref_id + type: string + - description: Payment status from Chapa redirect + in: query + name: status + type: string + produces: + - text/html + responses: + "200": + description: HTML success page + schema: + type: string + summary: Chapa payment success page + tags: + - payments /api/v1/payments/direct: post: consumes: @@ -6887,6 +7086,39 @@ paths: summary: Remove a user persona from a question set tags: - question-sets + /api/v1/question-sets/{setId}/question-types: + get: + description: Returns distinct question type definitions (key, display_name, + counts) for non-archived questions in the set. Legacy stored question_type + values (e.g. AUDIO) are resolved to builder definitions when possible. + parameters: + - description: Question Set ID + in: path + name: setId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List question types in a question set + tags: + - question-set-items /api/v1/question-sets/{setId}/questions: get: description: Returns all questions in a question set with details @@ -7100,8 +7332,8 @@ paths: consumes: - application/json description: Creates a new question with options (for MCQ/TRUE_FALSE) or short - answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic - builder-linked questions. + answers (for SHORT_ANSWER). DYNAMIC questions must not send question_text; + use dynamic_payload stimulus instead. Legacy types require question_text. parameters: - description: Create question payload in: body @@ -7301,10 +7533,21 @@ paths: in: query name: status type: string - - description: Include system seeded definitions + - default: true + description: Include system seeded definitions in: query name: include_system type: boolean + - default: 20 + description: Page size (default 20, max 200) + in: query + name: limit + type: integer + - default: 0 + description: Page offset + in: query + name: offset + type: integer produces: - application/json responses: @@ -7312,6 +7555,10 @@ paths: description: OK schema: $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' "500": description: Internal Server Error schema: @@ -9953,6 +10200,37 @@ paths: summary: Get transcode status of a Vimeo video tags: - Vimeo + /payment/success: + get: + description: Displays the Yimaru Academy success page after Chapa redirects + the learner to return_url + parameters: + - description: Chapa transaction reference (tx_ref) + in: query + name: trx_ref + type: string + - description: Chapa transaction reference + in: query + name: tx_ref + type: string + - description: Chapa reference ID + in: query + name: ref_id + type: string + - description: Payment status from Chapa redirect + in: query + name: status + type: string + produces: + - text/html + responses: + "200": + description: HTML success page + schema: + type: string + summary: Chapa payment success page + tags: + - payments securityDefinitions: Bearer: in: header diff --git a/gen/db/payments.sql.go b/gen/db/payments.sql.go index 5ba191a..f036fac 100644 --- a/gen/db/payments.sql.go +++ b/gen/db/payments.sql.go @@ -167,6 +167,24 @@ func (q *Queries) ExpirePayment(ctx context.Context, id int64) error { return err } +const ExpireStalePendingPayments = `-- name: ExpireStalePendingPayments :execrows +UPDATE payments +SET + status = 'EXPIRED', + updated_at = CURRENT_TIMESTAMP +WHERE status = 'PENDING' + AND expires_at IS NOT NULL + AND expires_at <= CURRENT_TIMESTAMP +` + +func (q *Queries) ExpireStalePendingPayments(ctx context.Context) (int64, error) { + result, err := q.db.Exec(ctx, ExpireStalePendingPayments) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const GetExpiredPendingPayments = `-- name: GetExpiredPendingPayments :many SELECT id, user_id, plan_id, subscription_id, session_id, transaction_id, nonce, amount, currency, payment_method, status, payment_url, paid_at, expires_at, created_at, updated_at FROM payments WHERE status = 'PENDING' diff --git a/go.mod b/go.mod index d20b191..ac37662 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/resend/resend-go/v2 v2.28.0 github.com/shopspring/decimal v1.4.0 github.com/swaggo/fiber-swagger v1.3.0 - github.com/swaggo/swag v1.16.6 golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.34.0 google.golang.org/api v0.239.0 @@ -52,6 +51,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/rs/xid v1.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/swaggo/swag v1.16.6 // direct github.com/tinylib/msgp v1.6.1 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index fe6e671..a5c0f6f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -145,6 +145,8 @@ type Config struct { AccountDeletionPurgeEnabled bool AccountDeletionPurgeInterval time.Duration AccountDeletionPurgeBatchSize int32 + PaymentExpiryWorkerEnabled bool + PaymentExpiryWorkerInterval time.Duration InactiveSubModuleContentPurgeEnabled bool InactiveSubModuleContentPurgeInterval time.Duration InactiveSubModuleContentRetentionDays int @@ -590,6 +592,25 @@ func (c *Config) loadEnv() error { } } + paymentExpiryWorkerEnabled := strings.TrimSpace(os.Getenv("PAYMENT_EXPIRY_WORKER_ENABLED")) + if paymentExpiryWorkerEnabled == "" { + c.PaymentExpiryWorkerEnabled = true + } else { + c.PaymentExpiryWorkerEnabled = paymentExpiryWorkerEnabled == "true" || paymentExpiryWorkerEnabled == "1" + } + + paymentExpiryWorkerInterval := strings.TrimSpace(os.Getenv("PAYMENT_EXPIRY_WORKER_INTERVAL")) + if paymentExpiryWorkerInterval == "" { + c.PaymentExpiryWorkerInterval = 15 * time.Minute + } else { + interval, err := time.ParseDuration(paymentExpiryWorkerInterval) + if err != nil || interval <= 0 { + c.PaymentExpiryWorkerInterval = 15 * time.Minute + } else { + c.PaymentExpiryWorkerInterval = interval + } + } + // Hard-delete inactive submodule lessons / practices / capstones after a retention period inactiveContentPurge := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_PURGE_ENABLED")) if inactiveContentPurge == "" { diff --git a/internal/ports/payment.go b/internal/ports/payment.go index 1bc7c3b..c2bc061 100644 --- a/internal/ports/payment.go +++ b/internal/ports/payment.go @@ -20,5 +20,6 @@ type PaymentStore interface { LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error) ExpirePayment(ctx context.Context, id int64) error + ExpireStalePendingPayments(ctx context.Context) (int64, error) ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error) } diff --git a/internal/repository/payments.go b/internal/repository/payments.go index ee884af..3601920 100644 --- a/internal/repository/payments.go +++ b/internal/repository/payments.go @@ -166,6 +166,10 @@ func (s *Store) ExpirePayment(ctx context.Context, id int64) error { return s.queries.ExpirePayment(ctx, id) } +func (s *Store) ExpireStalePendingPayments(ctx context.Context) (int64, error) { + return s.queries.ExpireStalePendingPayments(ctx) +} + func (s *Store) ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error) { params := buildPaymentsAdminFilterParams(filter) diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 254497e..6b7faf1 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -426,6 +426,11 @@ func (s *Service) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment return s.paymentStore.GetPaymentByID(ctx, id) } +// ExpireStalePendingPayments marks PENDING payments past expires_at as EXPIRED. +func (s *Service) ExpireStalePendingPayments(ctx context.Context) (int64, error) { + return s.paymentStore.ExpireStalePendingPayments(ctx) +} + func (s *Service) CancelPayment(ctx context.Context, paymentID int64, userID int64) error { payment, err := s.paymentStore.GetPaymentByID(ctx, paymentID) if err != nil { diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 3786f6d..38532f3 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -94,7 +94,8 @@ type App struct { analyticsDB *dbgen.Queries rbacSvc *rbacservice.Service videoEngagementSvc *videoengagement.Service - stopPurgeWorker context.CancelFunc + stopPurgeWorker context.CancelFunc + stopPaymentExpiryWorker context.CancelFunc } func NewApp( @@ -208,6 +209,8 @@ func NewApp( func (a *App) Run() error { a.startAccountDeletionPurgeWorker() defer a.stopAccountDeletionPurgeWorker() + a.startPaymentExpiryWorker() + defer a.stopPaymentExpiryWorkerFunc() return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) } @@ -272,3 +275,55 @@ func (a *App) runAccountDeletionPurgeOnce(ctx context.Context, batchSize int32) a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize) } } + +func (a *App) startPaymentExpiryWorker() { + if a.cfg == nil || !a.cfg.PaymentExpiryWorkerEnabled { + a.logger.Info("payment expiry worker disabled") + return + } + + interval := a.cfg.PaymentExpiryWorkerInterval + if interval <= 0 { + interval = 15 * time.Minute + } + + ctx, cancel := context.WithCancel(context.Background()) + a.stopPaymentExpiryWorker = cancel + + a.logger.Info("starting payment expiry worker", "interval", interval.String()) + + go func() { + a.runPaymentExpiryOnce(ctx) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + a.logger.Info("payment expiry worker stopped") + return + case <-ticker.C: + a.runPaymentExpiryOnce(ctx) + } + } + }() +} + +func (a *App) stopPaymentExpiryWorkerFunc() { + if a.stopPaymentExpiryWorker != nil { + a.stopPaymentExpiryWorker() + } +} + +func (a *App) runPaymentExpiryOnce(ctx context.Context) { + expiredCount, err := a.chapaSvc.ExpireStalePendingPayments(ctx) + if err != nil { + a.logger.Error("payment expiry run failed", "error", err) + return + } + + if expiredCount > 0 { + a.logger.Info("payment expiry run completed", "expired_count", expiredCount) + } +}