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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-09 04:10:17 -07:00
parent be7946955d
commit b780db5307
21 changed files with 1215 additions and 4956 deletions

View File

@ -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;

View File

@ -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

View File

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

View File

@ -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 <access_token>`
**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 routes 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": <lesson_id>, "set_type": "PRACTICE" }
// Practice
{ "parent_kind": "LESSON", "parent_id": <lesson_id>, "question_set_id": <set_id> }
```
**Verify:** `GET /lessons/<lesson_id>/practices`
### Module practice
```json
{ "owner_type": "MODULE", "owner_id": <module_id> }
{ "parent_kind": "MODULE", "parent_id": <module_id> }
```
**Verify:** `GET /modules/<module_id>/practices`
### Course practice
```json
{ "owner_type": "COURSE", "owner_id": <course_id> }
{ "parent_kind": "COURSE", "parent_id": <course_id> }
```
**Verify:** `GET /courses/<course_id>/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 definitions 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.*

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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 <ACCESS_TOKEN>"
```
## 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`

View File

@ -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 <TOKEN>" \
-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 <TOKEN>" \
-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 <TOKEN>"
```
### 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

View File

@ -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 = <lesson_id>`
- Publish questions and question set (`status = "PUBLISHED"`) if learners must complete immediately.

View File

@ -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/<uuid>.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 |

View File

@ -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=<object_key>
```
**Frontend implementation:**
```html
<audio controls src="<resolved_url>"></audio>
```
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 |

View File

@ -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": {

View File

@ -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": {

View File

@ -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

View File

@ -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'

2
go.mod
View File

@ -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

View File

@ -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 == "" {

View File

@ -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)
}

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}
}