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:
parent
be7946955d
commit
b780db5307
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 route’s path parameter is named `practiceId` in OpenAPI but is implemented against **`question_sets.id`**. For admin, prefer **`GET /question-sets/:setId/questions`** using `practice.question_set_id` from Step 14.2.
|
||||
|
||||
**Paginated response shape (`GET /practices/.../questions`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": 901,
|
||||
"set_id": 55,
|
||||
"question_id": 1001,
|
||||
"display_order": 1,
|
||||
"question_type": "DYNAMIC",
|
||||
"dynamic_payload": { "stimulus": [ ], "response": [ ] },
|
||||
"points": 2,
|
||||
"question_status": "PUBLISHED"
|
||||
}
|
||||
],
|
||||
"total_count": 1,
|
||||
"limit": 10,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Optional — Reorder, update, publish
|
||||
|
||||
### Reorder question in set
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **PUT** | `/question-sets/:setId/questions/:questionId/order` |
|
||||
| **Permission** | `question_set_items.update_order` |
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"display_order": 2
|
||||
}
|
||||
```
|
||||
|
||||
**Success `200`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Question order updated successfully",
|
||||
"success": true,
|
||||
"status_code": 200
|
||||
}
|
||||
```
|
||||
|
||||
### Publish practice shell
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **PUT** | `/practices/:id` |
|
||||
| **Permission** | `practices.update` |
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"publish_status": "PUBLISHED"
|
||||
}
|
||||
```
|
||||
|
||||
**Success `200` — `data`:** updated `Practice` with `publish_status: "PUBLISHED"`.
|
||||
|
||||
### Update dynamic question content
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **PUT** | `/questions/:id` |
|
||||
| **Permission** | `questions.update` |
|
||||
|
||||
Send updated `dynamic_payload` (and optional metadata). Do not send `question_text` for `DYNAMIC`.
|
||||
|
||||
### Remove question from set
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DELETE** | `/question-sets/:setId/questions/:questionId` |
|
||||
| **Permission** | `question_set_items.remove` |
|
||||
|
||||
---
|
||||
|
||||
## 16. Worked example — Lesson practice with TABLE + OPTION
|
||||
|
||||
**Goal:** Lesson `12` gets one practice with one dynamic TABLE+MCQ question.
|
||||
|
||||
| Step | API | Key ids |
|
||||
|------|-----|---------|
|
||||
| 1 | `POST /questions/type-definitions` | `definition_id = 42` |
|
||||
| 2 | `POST /questions` | `question_id = 1001` |
|
||||
| 3 | `POST /question-sets` (`owner_type: LESSON`, `owner_id: 12`) | `set_id = 55` |
|
||||
| 4 | `POST /question-sets/55/questions` | links `1001` order `1` |
|
||||
| 5 | `POST /practices` (`parent_kind: LESSON`, `parent_id: 12`, `question_set_id: 55`) | `practice_id = 37` |
|
||||
| 6 | `GET /lessons/12/practices` | confirms practice listed |
|
||||
| 7 | `GET /question-sets/55/questions` | confirms TABLE payload |
|
||||
| 8 | `PUT /practices/37` `{ "publish_status": "PUBLISHED" }` | go live |
|
||||
|
||||
**Admin UI table editor → API:** bind columns/rows UI to stimulus slot `data_table` / kind `TABLE` before Step 2 (`POST /questions`).
|
||||
|
||||
---
|
||||
|
||||
## 17. Scope-specific quick reference
|
||||
|
||||
### Lesson practice
|
||||
|
||||
```json
|
||||
// Question set
|
||||
{ "owner_type": "LESSON", "owner_id": <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 definition’s response shapes before release.
|
||||
|
||||
---
|
||||
|
||||
*Last aligned with backend: LMS practices (`COURSE`/`MODULE`/`LESSON`), dynamic questions, `PDF_ATTACHMENT`, `TABLE` stimulus, practice `publish_status`, DYNAMIC `question_text` API omission.*
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
411
docs/docs.go
411
docs/docs.go
|
|
@ -1360,6 +1360,173 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/payments": {
|
||||
"get": {
|
||||
"description": "Returns paginated payments across Chapa and ArifPay with optional filters",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"payments"
|
||||
],
|
||||
"summary": "List all payments (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Filter by learner user ID",
|
||||
"name": "user_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Filter by subscription plan ID",
|
||||
"name": "plan_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Filter by user subscription ID",
|
||||
"name": "subscription_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Payment status (PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED, EXPIRED)",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Payment provider (CHAPA, ARIFPAY)",
|
||||
"name": "provider",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Alias for provider",
|
||||
"name": "payment_method",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Currency code (e.g. ETB)",
|
||||
"name": "currency",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Plan category (LEARN_ENGLISH, IELTS, DUOLINGO)",
|
||||
"name": "plan_category",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search session_id, nonce, or transaction_id",
|
||||
"name": "reference",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Created at from (RFC3339 or YYYY-MM-DD)",
|
||||
"name": "created_from",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Created at to (exclusive, RFC3339 or YYYY-MM-DD)",
|
||||
"name": "created_to",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Paid at from (RFC3339 or YYYY-MM-DD)",
|
||||
"name": "paid_from",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Paid at to (exclusive, RFC3339 or YYYY-MM-DD)",
|
||||
"name": "paid_to",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"description": "Minimum amount",
|
||||
"name": "min_amount",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"description": "Maximum amount",
|
||||
"name": "max_amount",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"description": "Page size",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "Page offset",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/payments/{id}": {
|
||||
"get": {
|
||||
"description": "Returns any payment record by ID without learner ownership restriction",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"payments"
|
||||
],
|
||||
"summary": "Get payment by ID (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Payment ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/roles/{role}/bulk-deactivate": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
|
@ -2031,6 +2198,33 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/apple": {
|
||||
"post": {
|
||||
"description": "Validates an Apple identity token (iOS, Android, or web). On first sign-in, include email and name if Apple only returns them to the client once.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Login via Sign in with Apple identity token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Apple login payload",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/google/android": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
@ -3290,7 +3484,7 @@ const docTemplate = `{
|
|||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Media type: image|audio|video",
|
||||
"description": "Media type: image|audio|video|pdf",
|
||||
"name": "media_type",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
|
|
@ -4583,6 +4777,59 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/notifications/{id}": {
|
||||
"get": {
|
||||
"description": "Returns a single in-app notification. Users may only fetch their own notifications unless they have list-all access.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"notifications"
|
||||
],
|
||||
"summary": "Get in-app notification by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Notification ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/payments": {
|
||||
"get": {
|
||||
"description": "Returns the authenticated user's payment history",
|
||||
|
|
@ -4699,6 +4946,52 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/payments/chapa/success": {
|
||||
"get": {
|
||||
"description": "Displays the Yimaru Academy success page after Chapa redirects the learner to return_url",
|
||||
"produces": [
|
||||
"text/html"
|
||||
],
|
||||
"tags": [
|
||||
"payments"
|
||||
],
|
||||
"summary": "Chapa payment success page",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Chapa transaction reference (tx_ref)",
|
||||
"name": "trx_ref",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Chapa transaction reference",
|
||||
"name": "tx_ref",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Chapa reference ID",
|
||||
"name": "ref_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Payment status from Chapa redirect",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML success page",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/payments/direct": {
|
||||
"post": {
|
||||
"description": "Creates a payment session and initiates direct payment (OTP-based)",
|
||||
|
|
@ -6115,6 +6408,53 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/question-sets/{setId}/question-types": {
|
||||
"get": {
|
||||
"description": "Returns distinct question type definitions (key, display_name, counts) for non-archived questions in the set. Legacy stored question_type values (e.g. AUDIO) are resolved to builder definitions when possible.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"question-set-items"
|
||||
],
|
||||
"summary": "List question types in a question set",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Question Set ID",
|
||||
"name": "setId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/question-sets/{setId}/questions": {
|
||||
"get": {
|
||||
"description": "Returns all questions in a question set with details",
|
||||
|
|
@ -6381,7 +6721,7 @@ const docTemplate = `{
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.",
|
||||
"description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). DYNAMIC questions must not send question_text; use dynamic_payload stimulus instead. Legacy types require question_text.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -6560,9 +6900,24 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Include system seeded definitions",
|
||||
"name": "include_system",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"description": "Page size (default 20, max 200)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "Page offset",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
@ -6572,6 +6927,12 @@ const docTemplate = `{
|
|||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
|
|
@ -11308,6 +11669,52 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/payment/success": {
|
||||
"get": {
|
||||
"description": "Displays the Yimaru Academy success page after Chapa redirects the learner to return_url",
|
||||
"produces": [
|
||||
"text/html"
|
||||
],
|
||||
"tags": [
|
||||
"payments"
|
||||
],
|
||||
"summary": "Chapa payment success page",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Chapa transaction reference (tx_ref)",
|
||||
"name": "trx_ref",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Chapa transaction reference",
|
||||
"name": "tx_ref",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Chapa reference ID",
|
||||
"name": "ref_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Payment status from Chapa redirect",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML success page",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -10,7 +10,6 @@ require (
|
|||
github.com/resend/resend-go/v2 v2.28.0
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/swaggo/fiber-swagger v1.3.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
google.golang.org/api v0.239.0
|
||||
|
|
@ -52,6 +51,7 @@ require (
|
|||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // direct
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user