Compare commits
12 Commits
3d1b3ad9b8
...
c711df68b9
| Author | SHA1 | Date | |
|---|---|---|---|
| c711df68b9 | |||
| eae87b40b5 | |||
| 7e75d79dc8 | |||
| 4509fe2dc0 | |||
| 23322c69cc | |||
| 6f1cb24c63 | |||
| cd0ae19d03 | |||
| 75353f8bdd | |||
| b2a72c2f6e | |||
| 6a4fe68628 | |||
| bc2357374b | |||
| 9da9eb77e5 |
23
cmd/main.go
23
cmd/main.go
|
|
@ -10,31 +10,32 @@ import (
|
|||
"Yimaru-Backend/internal/domain"
|
||||
customlogger "Yimaru-Backend/internal/logger"
|
||||
"Yimaru-Backend/internal/logger/mongoLogger"
|
||||
minioclient "Yimaru-Backend/internal/pkgs/minio"
|
||||
"Yimaru-Backend/internal/repository"
|
||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||
"Yimaru-Backend/internal/services/arifpay"
|
||||
"Yimaru-Backend/internal/services/assessment"
|
||||
"Yimaru-Backend/internal/services/authentication"
|
||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||
"Yimaru-Backend/internal/services/messenger"
|
||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||
coursesservice "Yimaru-Backend/internal/services/courses"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
"Yimaru-Backend/internal/services/faqs"
|
||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||
lessonsservice "Yimaru-Backend/internal/services/lessons"
|
||||
"Yimaru-Backend/internal/services/lmsprogress"
|
||||
"Yimaru-Backend/internal/services/messenger"
|
||||
minioservice "Yimaru-Backend/internal/services/minio"
|
||||
moduleservice "Yimaru-Backend/internal/services/modules"
|
||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
practicesservice "Yimaru-Backend/internal/services/practices"
|
||||
programsservice "Yimaru-Backend/internal/services/programs"
|
||||
"Yimaru-Backend/internal/services/questions"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||
"Yimaru-Backend/internal/services/recommendation"
|
||||
"Yimaru-Backend/internal/services/settings"
|
||||
"Yimaru-Backend/internal/services/subscriptions"
|
||||
"Yimaru-Backend/internal/services/team"
|
||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||
minioservice "Yimaru-Backend/internal/services/minio"
|
||||
minioclient "Yimaru-Backend/internal/pkgs/minio"
|
||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||
"context"
|
||||
|
||||
|
|
@ -393,6 +394,7 @@ func main() {
|
|||
|
||||
// Questions service (unified questions system)
|
||||
questionsSvc := questions.NewService(store)
|
||||
faqSvc := faqs.NewService(repository.NewFAQStore(store))
|
||||
examPrepSvc := examprep.NewService(store)
|
||||
|
||||
// LMS programs (top-level hierarchy)
|
||||
|
|
@ -453,6 +455,7 @@ func main() {
|
|||
app := httpserver.NewApp(
|
||||
assessmentSvc,
|
||||
questionsSvc,
|
||||
faqSvc,
|
||||
examPrepSvc,
|
||||
programSvc,
|
||||
courseSvc,
|
||||
|
|
|
|||
5
db/migrations/000059_faqs.down.sql
Normal file
5
db/migrations/000059_faqs.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
DROP INDEX IF EXISTS idx_faqs_display_order;
|
||||
DROP INDEX IF EXISTS idx_faqs_category;
|
||||
DROP INDEX IF EXISTS idx_faqs_status;
|
||||
|
||||
DROP TABLE IF EXISTS faqs;
|
||||
14
db/migrations/000059_faqs.up.sql
Normal file
14
db/migrations/000059_faqs.up.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE IF NOT EXISTS faqs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
category VARCHAR(100),
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_faqs_status ON faqs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_faqs_category ON faqs(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_faqs_display_order ON faqs(display_order);
|
||||
|
|
@ -12,9 +12,18 @@ RETURNING
|
|||
*;
|
||||
|
||||
-- name: ExamPrepGetCatalogCourseByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.catalog_courses
|
||||
WHERE id = $1;
|
||||
SELECT
|
||||
c.*,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
|
||||
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
|
||||
INNER JOIN exam_prep.units u ON u.id = m.unit_id
|
||||
WHERE u.catalog_course_id = c.id
|
||||
) AS has_practice
|
||||
FROM exam_prep.catalog_courses c
|
||||
WHERE c.id = $1;
|
||||
|
||||
-- name: ExamPrepListCatalogCourses :many
|
||||
WITH catalog_course_counts AS (
|
||||
|
|
@ -38,6 +47,14 @@ SELECT
|
|||
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
|
||||
COALESCE(cc.modules_count, 0)::BIGINT AS modules_count,
|
||||
COALESCE(cc.lessons_count, 0)::BIGINT AS lessons_count,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
|
||||
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
|
||||
INNER JOIN exam_prep.units u ON u.id = m.unit_id
|
||||
WHERE u.catalog_course_id = c.id
|
||||
) AS has_practice,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM exam_prep.catalog_courses c
|
||||
|
|
|
|||
|
|
@ -16,9 +16,15 @@ RETURNING
|
|||
*;
|
||||
|
||||
-- name: ExamPrepGetUnitModuleLessonByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.unit_module_lessons
|
||||
WHERE id = $1;
|
||||
SELECT
|
||||
l.*,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
WHERE p.unit_module_lesson_id = l.id
|
||||
) AS has_practice
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
WHERE l.id = $1;
|
||||
|
||||
-- name: ExamPrepListUnitModuleLessonIDsByUnitModule :many
|
||||
SELECT
|
||||
|
|
@ -39,6 +45,11 @@ SELECT
|
|||
l.thumbnail,
|
||||
l.description,
|
||||
l.sort_order,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
WHERE p.unit_module_lesson_id = l.id
|
||||
) AS has_practice,
|
||||
l.created_at,
|
||||
l.updated_at
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
|
|
|
|||
|
|
@ -16,9 +16,16 @@ RETURNING
|
|||
*;
|
||||
|
||||
-- name: ExamPrepGetUnitModuleByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.unit_modules
|
||||
WHERE id = $1;
|
||||
SELECT
|
||||
m.*,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
|
||||
WHERE l.unit_module_id = m.id
|
||||
) AS has_practice
|
||||
FROM exam_prep.unit_modules m
|
||||
WHERE m.id = $1;
|
||||
|
||||
-- name: ExamPrepListUnitModuleIDsByUnit :many
|
||||
SELECT
|
||||
|
|
@ -51,6 +58,7 @@ SELECT
|
|||
m.sort_order,
|
||||
COALESCE(mc.lessons_count, 0)::BIGINT AS lessons_count,
|
||||
COALESCE(mc.practices_count, 0)::BIGINT AS practices_count,
|
||||
(COALESCE(mc.practices_count, 0)::BIGINT > 0) AS has_practice,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM exam_prep.unit_modules m
|
||||
|
|
|
|||
|
|
@ -15,9 +15,17 @@ RETURNING
|
|||
*;
|
||||
|
||||
-- name: ExamPrepGetUnitByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.units
|
||||
WHERE id = $1;
|
||||
SELECT
|
||||
u.*,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
|
||||
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
|
||||
WHERE m.unit_id = u.id
|
||||
) AS has_practice
|
||||
FROM exam_prep.units u
|
||||
WHERE u.id = $1;
|
||||
|
||||
-- name: ExamPrepListUnitIDsByCatalogCourse :many
|
||||
SELECT
|
||||
|
|
@ -52,6 +60,7 @@ SELECT
|
|||
COALESCE(uc.modules_count, 0)::BIGINT AS modules_count,
|
||||
COALESCE(uc.lessons_count, 0)::BIGINT AS lessons_count,
|
||||
COALESCE(uc.practices_count, 0)::BIGINT AS practices_count,
|
||||
(COALESCE(uc.practices_count, 0)::BIGINT > 0) AS has_practice,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM exam_prep.units u
|
||||
|
|
|
|||
|
|
@ -15,9 +15,18 @@ RETURNING
|
|||
*;
|
||||
|
||||
-- name: GetCourseByID :one
|
||||
SELECT *
|
||||
SELECT
|
||||
c.*,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL
|
||||
) AS has_practice
|
||||
FROM courses
|
||||
WHERE id = $1;
|
||||
c
|
||||
WHERE c.id = $1;
|
||||
|
||||
-- name: ListCourseIDsByProgram :many
|
||||
SELECT
|
||||
|
|
@ -65,7 +74,14 @@ SELECT
|
|||
WHERE
|
||||
p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL) AS practice_count
|
||||
AND p.lesson_id IS NULL) AS practice_count,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL
|
||||
) AS has_practice
|
||||
FROM
|
||||
courses c
|
||||
WHERE
|
||||
|
|
|
|||
|
|
@ -16,9 +16,16 @@ RETURNING
|
|||
*;
|
||||
|
||||
-- name: GetLessonByID :one
|
||||
SELECT *
|
||||
SELECT
|
||||
l.*,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = l.id
|
||||
) AS has_practice
|
||||
FROM lessons
|
||||
WHERE id = $1;
|
||||
l
|
||||
WHERE l.id = $1;
|
||||
|
||||
-- name: ListLessonsByModuleID :many
|
||||
SELECT
|
||||
|
|
@ -31,7 +38,12 @@ SELECT
|
|||
l.description,
|
||||
l.sort_order,
|
||||
l.created_at,
|
||||
l.updated_at
|
||||
l.updated_at,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = l.id
|
||||
) AS has_practice
|
||||
FROM
|
||||
lessons l
|
||||
WHERE
|
||||
|
|
|
|||
|
|
@ -16,9 +16,17 @@ RETURNING
|
|||
*;
|
||||
|
||||
-- name: GetModuleByID :one
|
||||
SELECT *
|
||||
SELECT
|
||||
m.*,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.module_id = m.id
|
||||
AND p.lesson_id IS NULL
|
||||
) AS has_practice
|
||||
FROM modules
|
||||
WHERE id = $1;
|
||||
m
|
||||
WHERE m.id = $1;
|
||||
|
||||
-- name: ListModuleIDsByCourse :many
|
||||
SELECT
|
||||
|
|
@ -41,7 +49,13 @@ SELECT
|
|||
m.icon,
|
||||
m.sort_order,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
m.updated_at,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.module_id = m.id
|
||||
AND p.lesson_id IS NULL
|
||||
) AS has_practice
|
||||
FROM
|
||||
modules m
|
||||
WHERE
|
||||
|
|
|
|||
412
docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md
Normal file
412
docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
# Dynamic Question Type Builder — Admin Panel Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to integrate the backend **Dynamic Question Type Builder** into your admin panel app so admins can:
|
||||
|
||||
1. Create reusable dynamic question type definitions
|
||||
2. Configure schema-driven stimulus/response fields
|
||||
3. Create `DYNAMIC` questions using those definitions
|
||||
4. Add dynamic questions into question sets/practices
|
||||
|
||||
The backend already supports:
|
||||
- Component catalog discovery
|
||||
- Definition validation + CRUD
|
||||
- Dynamic question create/update/list/get with payload validation
|
||||
- Persistence of `question_type_definition_id` and `dynamic_payload`
|
||||
|
||||
---
|
||||
|
||||
## Feature Architecture (Admin Perspective)
|
||||
|
||||
Treat the integration as two linked modules:
|
||||
|
||||
1. **Definition Builder Module**
|
||||
- Creates reusable templates (question type definitions)
|
||||
- Managed by admins before creating actual questions
|
||||
|
||||
2. **Dynamic Question Authoring Module**
|
||||
- Creates question instances using a selected definition
|
||||
- Stores content in `dynamic_payload`
|
||||
|
||||
Recommended admin menu structure:
|
||||
- `Questions > Type Builder > Definitions`
|
||||
- `Questions > Create Question` (includes `DYNAMIC` path)
|
||||
- `Question Sets > Manage Set Items`
|
||||
|
||||
---
|
||||
|
||||
## Backend Endpoints Used
|
||||
|
||||
All endpoints are under `/api/v1` and require bearer auth.
|
||||
|
||||
### Builder + Definition Endpoints
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/questions/component-catalog` | Fetch valid component kind codes |
|
||||
| `POST` | `/questions/validate-question-type-definition` | Validate component-kind selection |
|
||||
| `POST` | `/questions/type-definitions` | Create reusable definition |
|
||||
| `GET` | `/questions/type-definitions` | List definitions |
|
||||
| `GET` | `/questions/type-definitions/:id` | Get one definition |
|
||||
| `PUT` | `/questions/type-definitions/:id` | Update definition |
|
||||
| `DELETE` | `/questions/type-definitions/:id` | Delete non-system definition |
|
||||
|
||||
### Dynamic Question Endpoints
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/questions` | Create a dynamic question |
|
||||
| `PUT` | `/questions/:id` | Update dynamic question |
|
||||
| `GET` | `/questions/:id` | Fetch question details |
|
||||
| `GET` | `/questions?question_type=DYNAMIC` | List dynamic questions |
|
||||
|
||||
### Question Set Linking Endpoints
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/question-sets` | Create set (if needed) |
|
||||
| `POST` | `/question-sets/:setId/questions` | Link question to set |
|
||||
| `GET` | `/question-sets/:setId/questions` | View set questions |
|
||||
|
||||
---
|
||||
|
||||
## Required RBAC Permissions
|
||||
|
||||
Ensure the admin role includes at least:
|
||||
|
||||
- `questions.create`
|
||||
- `questions.list`
|
||||
- `questions.get`
|
||||
- `questions.update`
|
||||
- `questions.delete`
|
||||
- `question_sets.create`
|
||||
- `question_set_items.add`
|
||||
- `question_set_items.list`
|
||||
- `question_set_items.remove`
|
||||
- `question_set_items.update_order`
|
||||
|
||||
If your UI supports definition delete/update/list, include corresponding question permissions already used by those routes (`questions.update`, `questions.delete`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Data Contracts
|
||||
|
||||
### 1) Definition Create Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "dynamic_visual_mcq_001",
|
||||
"display_name": "Dynamic Visual MCQ",
|
||||
"description": "Reusable dynamic MCQ-style type",
|
||||
"stimulus_component_kinds": ["QUESTION_TEXT", "IMAGE", "TABLE"],
|
||||
"response_component_kinds": ["OPTION", "ANSWER_TIMER"],
|
||||
"stimulus_schema": [
|
||||
{
|
||||
"id": "prompt",
|
||||
"kind": "QUESTION_TEXT",
|
||||
"label": "Prompt",
|
||||
"required": true,
|
||||
"config": { "max_length": 1000 }
|
||||
}
|
||||
],
|
||||
"response_schema": [
|
||||
{
|
||||
"id": "choices",
|
||||
"kind": "OPTION",
|
||||
"label": "Answer Choices",
|
||||
"required": true,
|
||||
"config": { "min_options": 2, "max_options": 6, "allow_multiple": false }
|
||||
},
|
||||
{
|
||||
"id": "timer",
|
||||
"kind": "ANSWER_TIMER",
|
||||
"label": "Timer",
|
||||
"required": false,
|
||||
"config": { "min_seconds": 5, "max_seconds": 180 }
|
||||
}
|
||||
],
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
```
|
||||
|
||||
### 2) Dynamic Question Create Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"question_text": "Choose the grammatically correct sentence.",
|
||||
"question_type": "DYNAMIC",
|
||||
"question_type_definition_id": 123,
|
||||
"difficulty_level": "MEDIUM",
|
||||
"points": 2,
|
||||
"status": "DRAFT",
|
||||
"dynamic_payload": {
|
||||
"stimulus": [
|
||||
{
|
||||
"id": "prompt",
|
||||
"kind": "QUESTION_TEXT",
|
||||
"value": "Pick the best sentence."
|
||||
},
|
||||
{
|
||||
"id": "illustration",
|
||||
"kind": "IMAGE",
|
||||
"value": "https://cdn.example.com/image.png"
|
||||
}
|
||||
],
|
||||
"response": [
|
||||
{
|
||||
"id": "choices",
|
||||
"kind": "OPTION",
|
||||
"value": {
|
||||
"options": [
|
||||
{ "id": "a", "text": "He go yesterday", "is_correct": false },
|
||||
{ "id": "b", "text": "He went yesterday", "is_correct": true }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "timer",
|
||||
"kind": "ANSWER_TIMER",
|
||||
"value": { "seconds": 30 }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## End-to-End Admin Workflow
|
||||
|
||||
## Step 1: Load Component Catalog
|
||||
|
||||
Call:
|
||||
- `GET /questions/component-catalog`
|
||||
|
||||
Use response values to populate:
|
||||
- Stimulus component type selector
|
||||
- Response component type selector
|
||||
|
||||
Do not hardcode component kinds in UI.
|
||||
|
||||
## Step 2: Build "Create Definition" Form
|
||||
|
||||
Form fields:
|
||||
- `key` (slug-like identifier, unique)
|
||||
- `display_name`
|
||||
- `description` (optional)
|
||||
- `stimulus_component_kinds` (multi-select)
|
||||
- `response_component_kinds` (multi-select)
|
||||
- `stimulus_schema` (repeater builder)
|
||||
- `response_schema` (repeater builder)
|
||||
- `status` (`ACTIVE` or `INACTIVE`)
|
||||
|
||||
Schema item editor fields:
|
||||
- `id` (string, unique per side)
|
||||
- `kind` (from catalog)
|
||||
- `label` (optional)
|
||||
- `required` (boolean)
|
||||
- `config` (JSON object, optional)
|
||||
|
||||
## Step 3: Validate Component Kind Selection (Optional Pre-check)
|
||||
|
||||
Call:
|
||||
- `POST /questions/validate-question-type-definition`
|
||||
|
||||
Use this before submit for immediate feedback.
|
||||
Important: this endpoint validates component selection, while create endpoint enforces full persistence checks.
|
||||
|
||||
## Step 4: Create Definition
|
||||
|
||||
Call:
|
||||
- `POST /questions/type-definitions`
|
||||
|
||||
On success:
|
||||
- store returned definition `id`
|
||||
- navigate to "Definition Details" view
|
||||
- show copyable key + id
|
||||
|
||||
## Step 5: Build Definitions List Screen
|
||||
|
||||
Call:
|
||||
- `GET /questions/type-definitions?include_system=true&status=ACTIVE`
|
||||
|
||||
Recommended columns:
|
||||
- `id`
|
||||
- `key`
|
||||
- `display_name`
|
||||
- `is_system`
|
||||
- `status`
|
||||
- stimulus/response kind counts
|
||||
- created date
|
||||
|
||||
Actions:
|
||||
- View details
|
||||
- Edit
|
||||
- Delete (disable for `is_system=true`)
|
||||
|
||||
## Step 6: Create Dynamic Question Using Definition
|
||||
|
||||
In question create UI:
|
||||
|
||||
1. Admin chooses question mode: `DYNAMIC`
|
||||
2. Admin selects definition from searchable list (`GET /questions/type-definitions`)
|
||||
3. UI generates form sections from selected definition schema
|
||||
4. UI builds `dynamic_payload.stimulus[]` and `dynamic_payload.response[]`
|
||||
5. Submit `POST /questions`
|
||||
|
||||
Rules to enforce in UI:
|
||||
- `question_type` must be `DYNAMIC`
|
||||
- `question_type_definition_id` is required
|
||||
- `dynamic_payload` is required
|
||||
- include all required schema item IDs
|
||||
- each payload item `kind` must match allowed kinds for selected definition
|
||||
|
||||
## Step 7: Edit Dynamic Question
|
||||
|
||||
Call:
|
||||
- `PUT /questions/:id`
|
||||
|
||||
When loading edit page:
|
||||
- `GET /questions/:id`
|
||||
- if `question_type = DYNAMIC`, hydrate schema-based editors from `dynamic_payload`
|
||||
|
||||
## Step 8: Add Dynamic Question to Set
|
||||
|
||||
Call:
|
||||
- `POST /question-sets/:setId/questions`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"question_id": 456,
|
||||
"display_order": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend UI/State Design
|
||||
|
||||
Recommended frontend state model:
|
||||
|
||||
```ts
|
||||
type DynamicElementDefinition = {
|
||||
id: string;
|
||||
kind: string;
|
||||
label?: string;
|
||||
required: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type DynamicElementInstance = {
|
||||
id: string;
|
||||
kind: string;
|
||||
value?: unknown;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type DynamicQuestionPayload = {
|
||||
stimulus: DynamicElementInstance[];
|
||||
response: DynamicElementInstance[];
|
||||
};
|
||||
```
|
||||
|
||||
Suggested screen components:
|
||||
- `DefinitionForm`
|
||||
- `SchemaBuilderTable`
|
||||
- `DynamicQuestionComposer`
|
||||
- `DynamicPayloadPreviewJson`
|
||||
- `DefinitionPickerModal`
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules You Should Mirror Client-Side
|
||||
|
||||
To reduce failed submits, mirror these checks:
|
||||
|
||||
1. At least one stimulus component kind
|
||||
2. At least one response component kind
|
||||
3. No duplicate component kinds
|
||||
4. `ANSWER_TIMER` cannot be the only response kind
|
||||
5. Schema item IDs are required and unique per side
|
||||
6. Schema kinds must be valid catalog kinds
|
||||
7. Dynamic question must include definition ID + payload together
|
||||
8. For dynamic questions, `question_type` must be `DYNAMIC`
|
||||
9. Payload must include required schema IDs
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
Backend commonly returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Invalid dynamic_payload",
|
||||
"error": "dynamic_payload.response: required element id \"choices\" is missing"
|
||||
}
|
||||
```
|
||||
|
||||
UI recommendations:
|
||||
- Show `message` as toast title
|
||||
- Show `error` inline near relevant section
|
||||
- Keep submitted form state (do not reset on 400)
|
||||
- If schema mismatch occurs, provide "Rebuild from Definition" action
|
||||
|
||||
---
|
||||
|
||||
## Practical Example: Dynamic MCQ Type
|
||||
|
||||
Use this pattern to represent multiple-choice dynamically:
|
||||
|
||||
- `response_component_kinds` includes `OPTION`
|
||||
- `response_schema` has required element with `kind: "OPTION"` and id `choices`
|
||||
- `dynamic_payload.response` contains an element with same id (`choices`) and options array in `value`
|
||||
|
||||
This creates an MCQ-like authoring experience through the dynamic builder.
|
||||
|
||||
---
|
||||
|
||||
## Important Product Note
|
||||
|
||||
This backend fully supports authoring/storage/serving dynamic questions.
|
||||
If your admin panel expects automatic learner scoring for all dynamic payload shapes, verify assessment runtime behavior separately before release.
|
||||
|
||||
---
|
||||
|
||||
## QA Checklist (Admin Panel)
|
||||
|
||||
- [ ] Catalog loads and renders kind options correctly
|
||||
- [ ] Definition create succeeds with valid schema
|
||||
- [ ] Invalid schema shows clear inline errors
|
||||
- [ ] System definitions cannot be deleted in UI
|
||||
- [ ] Dynamic question cannot submit without definition ID
|
||||
- [ ] Dynamic question cannot submit without payload
|
||||
- [ ] Required schema fields enforce data entry
|
||||
- [ ] Created question appears in `question_type=DYNAMIC` list
|
||||
- [ ] Dynamic question can be edited and re-saved
|
||||
- [ ] Dynamic question can be linked to question set
|
||||
- [ ] Permission errors (`403`) are surfaced with actionable text
|
||||
|
||||
---
|
||||
|
||||
## Suggested Delivery Plan
|
||||
|
||||
1. Implement definition list/create/edit screens
|
||||
2. Implement dynamic question composer tied to selected definition
|
||||
3. Add question set linking support
|
||||
4. Add role/permission guard handling in UI
|
||||
5. Add integration tests for create/edit flows
|
||||
6. Run UAT with at least one MCQ-like dynamic definition and one non-MCQ definition
|
||||
|
||||
---
|
||||
|
||||
## Reference Artifact
|
||||
|
||||
There is a Postman collection in this repository that covers runtime flow:
|
||||
|
||||
- `postman/Dynamic-Question-Type-Builder.postman_collection.json`
|
||||
|
||||
Use it as the source of truth for request examples while building admin API services.
|
||||
475
docs/docs.go
475
docs/docs.go
|
|
@ -254,6 +254,257 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/faqs": {
|
||||
"get": {
|
||||
"description": "Returns FAQs for admin management with status/category filtering",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "List FAQs (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ACTIVE or INACTIVE",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by category",
|
||||
"name": "category",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit (default 20)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Offset (default 0)",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new FAQ item",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Create FAQ",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Create FAQ payload",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.createFAQReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/faqs/{id}": {
|
||||
"get": {
|
||||
"description": "Returns one FAQ regardless of status",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Get FAQ by ID (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "FAQ 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"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Updates an existing FAQ item",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Update FAQ",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "FAQ ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Update FAQ payload",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.updateFAQReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes an FAQ item",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Delete FAQ",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "FAQ 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"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/deletion-requests": {
|
||||
"get": {
|
||||
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
|
||||
|
|
@ -1608,6 +1859,99 @@ const docTemplate = `{
|
|||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/faqs": {
|
||||
"get": {
|
||||
"description": "Returns active FAQs for public/help center usage",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "List published FAQs",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by category",
|
||||
"name": "category",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit (default 50)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Offset (default 0)",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/faqs/{id}": {
|
||||
"get": {
|
||||
"description": "Returns one active FAQ item",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Get published FAQ by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "FAQ 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"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/files/audio": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
|
|
@ -9405,6 +9749,60 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.DynamicElementDefinition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.DynamicElementInstance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"value": {}
|
||||
}
|
||||
},
|
||||
"domain.DynamicQuestionPayload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"response": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementInstance"
|
||||
}
|
||||
},
|
||||
"stimulus": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementInstance"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.EmploymentType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
@ -9638,6 +10036,9 @@ const docTemplate = `{
|
|||
"difficultyLevel": {
|
||||
"type": "string"
|
||||
},
|
||||
"dynamicPayload": {
|
||||
"$ref": "#/definitions/domain.DynamicQuestionPayload"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -10851,6 +11252,30 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"handlers.createFAQReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"answer",
|
||||
"question"
|
||||
],
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"question": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.createIssueReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -10927,6 +11352,9 @@ const docTemplate = `{
|
|||
"difficulty_level": {
|
||||
"type": "string"
|
||||
},
|
||||
"dynamic_payload": {
|
||||
"$ref": "#/definitions/domain.DynamicQuestionPayload"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11046,6 +11474,12 @@ const docTemplate = `{
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"response_schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementDefinition"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11054,6 +11488,12 @@ const docTemplate = `{
|
|||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stimulus_schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementDefinition"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -11346,6 +11786,26 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"handlers.updateFAQReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"question": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.updateIssueStatusReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -11409,6 +11869,9 @@ const docTemplate = `{
|
|||
"difficulty_level": {
|
||||
"type": "string"
|
||||
},
|
||||
"dynamic_payload": {
|
||||
"$ref": "#/definitions/domain.DynamicQuestionPayload"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11500,6 +11963,12 @@ const docTemplate = `{
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"response_schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementDefinition"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11508,6 +11977,12 @@ const docTemplate = `{
|
|||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stimulus_schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementDefinition"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -246,6 +246,257 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/faqs": {
|
||||
"get": {
|
||||
"description": "Returns FAQs for admin management with status/category filtering",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "List FAQs (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ACTIVE or INACTIVE",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by category",
|
||||
"name": "category",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit (default 20)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Offset (default 0)",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new FAQ item",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Create FAQ",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Create FAQ payload",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.createFAQReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/faqs/{id}": {
|
||||
"get": {
|
||||
"description": "Returns one FAQ regardless of status",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Get FAQ by ID (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "FAQ 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"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Updates an existing FAQ item",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Update FAQ",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "FAQ ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Update FAQ payload",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.updateFAQReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes an FAQ item",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Delete FAQ",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "FAQ 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"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/deletion-requests": {
|
||||
"get": {
|
||||
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
|
||||
|
|
@ -1600,6 +1851,99 @@
|
|||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/faqs": {
|
||||
"get": {
|
||||
"description": "Returns active FAQs for public/help center usage",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "List published FAQs",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by category",
|
||||
"name": "category",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit (default 50)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Offset (default 0)",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/faqs/{id}": {
|
||||
"get": {
|
||||
"description": "Returns one active FAQ item",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"faqs"
|
||||
],
|
||||
"summary": "Get published FAQ by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "FAQ 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"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/files/audio": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
|
|
@ -9397,6 +9741,60 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.DynamicElementDefinition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.DynamicElementInstance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"value": {}
|
||||
}
|
||||
},
|
||||
"domain.DynamicQuestionPayload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"response": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementInstance"
|
||||
}
|
||||
},
|
||||
"stimulus": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementInstance"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.EmploymentType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
@ -9630,6 +10028,9 @@
|
|||
"difficultyLevel": {
|
||||
"type": "string"
|
||||
},
|
||||
"dynamicPayload": {
|
||||
"$ref": "#/definitions/domain.DynamicQuestionPayload"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -10843,6 +11244,30 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"handlers.createFAQReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"answer",
|
||||
"question"
|
||||
],
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"question": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.createIssueReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -10919,6 +11344,9 @@
|
|||
"difficulty_level": {
|
||||
"type": "string"
|
||||
},
|
||||
"dynamic_payload": {
|
||||
"$ref": "#/definitions/domain.DynamicQuestionPayload"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11038,6 +11466,12 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"response_schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementDefinition"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11046,6 +11480,12 @@
|
|||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stimulus_schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementDefinition"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -11338,6 +11778,26 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"handlers.updateFAQReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"question": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.updateIssueStatusReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -11401,6 +11861,9 @@
|
|||
"difficulty_level": {
|
||||
"type": "string"
|
||||
},
|
||||
"dynamic_payload": {
|
||||
"$ref": "#/definitions/domain.DynamicQuestionPayload"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11492,6 +11955,12 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"response_schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementDefinition"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11500,6 +11969,12 @@
|
|||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stimulus_schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.DynamicElementDefinition"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -212,6 +212,42 @@ definitions:
|
|||
- password
|
||||
- team_role
|
||||
type: object
|
||||
domain.DynamicElementDefinition:
|
||||
properties:
|
||||
config:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
id:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
required:
|
||||
type: boolean
|
||||
type: object
|
||||
domain.DynamicElementInstance:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
meta:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
value: {}
|
||||
type: object
|
||||
domain.DynamicQuestionPayload:
|
||||
properties:
|
||||
response:
|
||||
items:
|
||||
$ref: '#/definitions/domain.DynamicElementInstance'
|
||||
type: array
|
||||
stimulus:
|
||||
items:
|
||||
$ref: '#/definitions/domain.DynamicElementInstance'
|
||||
type: array
|
||||
type: object
|
||||
domain.EmploymentType:
|
||||
enum:
|
||||
- full_time
|
||||
|
|
@ -371,6 +407,8 @@ definitions:
|
|||
type: string
|
||||
difficultyLevel:
|
||||
type: string
|
||||
dynamicPayload:
|
||||
$ref: '#/definitions/domain.DynamicQuestionPayload'
|
||||
explanation:
|
||||
type: string
|
||||
id:
|
||||
|
|
@ -1188,6 +1226,22 @@ definitions:
|
|||
- current_password
|
||||
- new_password
|
||||
type: object
|
||||
handlers.createFAQReq:
|
||||
properties:
|
||||
answer:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
display_order:
|
||||
type: integer
|
||||
question:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- answer
|
||||
- question
|
||||
type: object
|
||||
handlers.createIssueReq:
|
||||
properties:
|
||||
description:
|
||||
|
|
@ -1240,6 +1294,8 @@ definitions:
|
|||
type: string
|
||||
difficulty_level:
|
||||
type: string
|
||||
dynamic_payload:
|
||||
$ref: '#/definitions/domain.DynamicQuestionPayload'
|
||||
explanation:
|
||||
type: string
|
||||
image_url:
|
||||
|
|
@ -1320,12 +1376,20 @@ definitions:
|
|||
items:
|
||||
type: string
|
||||
type: array
|
||||
response_schema:
|
||||
items:
|
||||
$ref: '#/definitions/domain.DynamicElementDefinition'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
stimulus_component_kinds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
stimulus_schema:
|
||||
items:
|
||||
$ref: '#/definitions/domain.DynamicElementDefinition'
|
||||
type: array
|
||||
required:
|
||||
- display_name
|
||||
- key
|
||||
|
|
@ -1525,6 +1589,19 @@ definitions:
|
|||
example: false
|
||||
type: boolean
|
||||
type: object
|
||||
handlers.updateFAQReq:
|
||||
properties:
|
||||
answer:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
display_order:
|
||||
type: integer
|
||||
question:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
handlers.updateIssueStatusReq:
|
||||
properties:
|
||||
status:
|
||||
|
|
@ -1567,6 +1644,8 @@ definitions:
|
|||
type: string
|
||||
difficulty_level:
|
||||
type: string
|
||||
dynamic_payload:
|
||||
$ref: '#/definitions/domain.DynamicQuestionPayload'
|
||||
explanation:
|
||||
type: string
|
||||
image_url:
|
||||
|
|
@ -1627,12 +1706,20 @@ definitions:
|
|||
items:
|
||||
type: string
|
||||
type: array
|
||||
response_schema:
|
||||
items:
|
||||
$ref: '#/definitions/domain.DynamicElementDefinition'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
stimulus_component_kinds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
stimulus_schema:
|
||||
items:
|
||||
$ref: '#/definitions/domain.DynamicElementDefinition'
|
||||
type: array
|
||||
type: object
|
||||
handlers.validateQuestionTypeDefinitionReq:
|
||||
properties:
|
||||
|
|
@ -2284,6 +2371,172 @@ paths:
|
|||
summary: Update Admin
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/faqs:
|
||||
get:
|
||||
description: Returns FAQs for admin management with status/category filtering
|
||||
parameters:
|
||||
- description: ACTIVE or INACTIVE
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
- description: Filter by category
|
||||
in: query
|
||||
name: category
|
||||
type: string
|
||||
- description: Limit (default 20)
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Offset (default 0)
|
||||
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'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: List FAQs (admin)
|
||||
tags:
|
||||
- faqs
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Creates a new FAQ item
|
||||
parameters:
|
||||
- description: Create FAQ payload
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.createFAQReq'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/domain.Response'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: Create FAQ
|
||||
tags:
|
||||
- faqs
|
||||
/api/v1/admin/faqs/{id}:
|
||||
delete:
|
||||
description: Deletes an FAQ item
|
||||
parameters:
|
||||
- description: FAQ 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'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: Delete FAQ
|
||||
tags:
|
||||
- faqs
|
||||
get:
|
||||
description: Returns one FAQ regardless of status
|
||||
parameters:
|
||||
- description: FAQ 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'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: Get FAQ by ID (admin)
|
||||
tags:
|
||||
- faqs
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Updates an existing FAQ item
|
||||
parameters:
|
||||
- description: FAQ ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Update FAQ payload
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.updateFAQReq'
|
||||
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: Update FAQ
|
||||
tags:
|
||||
- faqs
|
||||
/api/v1/admin/users/deletion-requests:
|
||||
get:
|
||||
consumes:
|
||||
|
|
@ -3121,6 +3374,67 @@ paths:
|
|||
summary: Reorder modules within a unit
|
||||
tags:
|
||||
- exam-prep
|
||||
/api/v1/faqs:
|
||||
get:
|
||||
description: Returns active FAQs for public/help center usage
|
||||
parameters:
|
||||
- description: Filter by category
|
||||
in: query
|
||||
name: category
|
||||
type: string
|
||||
- description: Limit (default 50)
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Offset (default 0)
|
||||
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'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: List published FAQs
|
||||
tags:
|
||||
- faqs
|
||||
/api/v1/faqs/{id}:
|
||||
get:
|
||||
description: Returns one active FAQ item
|
||||
parameters:
|
||||
- description: FAQ 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'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: Get published FAQ by ID
|
||||
tags:
|
||||
- faqs
|
||||
/api/v1/files/audio:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
|||
|
|
@ -57,14 +57,34 @@ func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) err
|
|||
}
|
||||
|
||||
const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one
|
||||
SELECT id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||
FROM exam_prep.catalog_courses
|
||||
WHERE id = $1
|
||||
SELECT
|
||||
c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
|
||||
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
|
||||
INNER JOIN exam_prep.units u ON u.id = m.unit_id
|
||||
WHERE u.catalog_course_id = c.id
|
||||
) AS has_practice
|
||||
FROM exam_prep.catalog_courses c
|
||||
WHERE c.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (ExamPrepCatalogCourse, error) {
|
||||
type ExamPrepGetCatalogCourseByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (ExamPrepGetCatalogCourseByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetCatalogCourseByID, id)
|
||||
var i ExamPrepCatalogCourse
|
||||
var i ExamPrepGetCatalogCourseByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
|
|
@ -73,6 +93,7 @@ func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (E
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -126,6 +147,14 @@ SELECT
|
|||
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
|
||||
COALESCE(cc.modules_count, 0)::BIGINT AS modules_count,
|
||||
COALESCE(cc.lessons_count, 0)::BIGINT AS lessons_count,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
|
||||
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
|
||||
INNER JOIN exam_prep.units u ON u.id = m.unit_id
|
||||
WHERE u.catalog_course_id = c.id
|
||||
) AS has_practice,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM exam_prep.catalog_courses c
|
||||
|
|
@ -149,6 +178,7 @@ type ExamPrepListCatalogCoursesRow struct {
|
|||
UnitsCount int64 `json:"units_count"`
|
||||
ModulesCount int64 `json:"modules_count"`
|
||||
LessonsCount int64 `json:"lessons_count"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -172,6 +202,7 @@ func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepLi
|
|||
&i.UnitsCount,
|
||||
&i.ModulesCount,
|
||||
&i.LessonsCount,
|
||||
&i.HasPractice,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
|
|||
|
|
@ -71,14 +71,33 @@ func (q *Queries) ExamPrepDeleteUnitModuleLesson(ctx context.Context, id int64)
|
|||
}
|
||||
|
||||
const ExamPrepGetUnitModuleLessonByID = `-- name: ExamPrepGetUnitModuleLessonByID :one
|
||||
SELECT id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at
|
||||
FROM exam_prep.unit_module_lessons
|
||||
WHERE id = $1
|
||||
SELECT
|
||||
l.id, l.unit_module_id, l.title, l.video_url, l.thumbnail, l.description, l.sort_order, l.created_at, l.updated_at,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
WHERE p.unit_module_lesson_id = l.id
|
||||
) AS has_practice
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
WHERE l.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) (ExamPrepUnitModuleLesson, error) {
|
||||
type ExamPrepGetUnitModuleLessonByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
UnitModuleID int64 `json:"unit_module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) (ExamPrepGetUnitModuleLessonByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetUnitModuleLessonByID, id)
|
||||
var i ExamPrepUnitModuleLesson
|
||||
var i ExamPrepGetUnitModuleLessonByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitModuleID,
|
||||
|
|
@ -89,6 +108,7 @@ func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64)
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -133,6 +153,11 @@ SELECT
|
|||
l.thumbnail,
|
||||
l.description,
|
||||
l.sort_order,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
WHERE p.unit_module_lesson_id = l.id
|
||||
) AS has_practice,
|
||||
l.created_at,
|
||||
l.updated_at
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
|
|
@ -160,6 +185,7 @@ type ExamPrepListUnitModuleLessonsByUnitModuleIDRow struct {
|
|||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -182,6 +208,7 @@ func (q *Queries) ExamPrepListUnitModuleLessonsByUnitModuleID(ctx context.Contex
|
|||
&i.Thumbnail,
|
||||
&i.Description,
|
||||
&i.SortOrder,
|
||||
&i.HasPractice,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
|
|||
|
|
@ -71,14 +71,34 @@ func (q *Queries) ExamPrepDeleteUnitModule(ctx context.Context, id int64) error
|
|||
}
|
||||
|
||||
const ExamPrepGetUnitModuleByID = `-- name: ExamPrepGetUnitModuleByID :one
|
||||
SELECT id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at
|
||||
FROM exam_prep.unit_modules
|
||||
WHERE id = $1
|
||||
SELECT
|
||||
m.id, m.unit_id, m.name, m.description, m.thumbnail, m.icon, m.sort_order, m.created_at, m.updated_at,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
|
||||
WHERE l.unit_module_id = m.id
|
||||
) AS has_practice
|
||||
FROM exam_prep.unit_modules m
|
||||
WHERE m.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (ExamPrepUnitModule, error) {
|
||||
type ExamPrepGetUnitModuleByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
UnitID int64 `json:"unit_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Icon pgtype.Text `json:"icon"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (ExamPrepGetUnitModuleByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetUnitModuleByID, id)
|
||||
var i ExamPrepUnitModule
|
||||
var i ExamPrepGetUnitModuleByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitID,
|
||||
|
|
@ -89,6 +109,7 @@ func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (Exam
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -145,6 +166,7 @@ SELECT
|
|||
m.sort_order,
|
||||
COALESCE(mc.lessons_count, 0)::BIGINT AS lessons_count,
|
||||
COALESCE(mc.practices_count, 0)::BIGINT AS practices_count,
|
||||
(COALESCE(mc.practices_count, 0)::BIGINT > 0) AS has_practice,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM exam_prep.unit_modules m
|
||||
|
|
@ -175,6 +197,7 @@ type ExamPrepListUnitModulesByUnitRow struct {
|
|||
SortOrder int32 `json:"sort_order"`
|
||||
LessonsCount int64 `json:"lessons_count"`
|
||||
PracticesCount int64 `json:"practices_count"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -199,6 +222,7 @@ func (q *Queries) ExamPrepListUnitModulesByUnit(ctx context.Context, arg ExamPre
|
|||
&i.SortOrder,
|
||||
&i.LessonsCount,
|
||||
&i.PracticesCount,
|
||||
&i.HasPractice,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
|
|||
|
|
@ -67,14 +67,34 @@ func (q *Queries) ExamPrepDeleteUnit(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const ExamPrepGetUnitByID = `-- name: ExamPrepGetUnitByID :one
|
||||
SELECT id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||
FROM exam_prep.units
|
||||
WHERE id = $1
|
||||
SELECT
|
||||
u.id, u.catalog_course_id, u.name, u.description, u.thumbnail, u.sort_order, u.created_at, u.updated_at,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM exam_prep.lesson_practices p
|
||||
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
|
||||
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
|
||||
WHERE m.unit_id = u.id
|
||||
) AS has_practice
|
||||
FROM exam_prep.units u
|
||||
WHERE u.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepUnit, error) {
|
||||
type ExamPrepGetUnitByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
CatalogCourseID int64 `json:"catalog_course_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepGetUnitByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetUnitByID, id)
|
||||
var i ExamPrepUnit
|
||||
var i ExamPrepGetUnitByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CatalogCourseID,
|
||||
|
|
@ -84,6 +104,7 @@ func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepUn
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -142,6 +163,7 @@ SELECT
|
|||
COALESCE(uc.modules_count, 0)::BIGINT AS modules_count,
|
||||
COALESCE(uc.lessons_count, 0)::BIGINT AS lessons_count,
|
||||
COALESCE(uc.practices_count, 0)::BIGINT AS practices_count,
|
||||
(COALESCE(uc.practices_count, 0)::BIGINT > 0) AS has_practice,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM exam_prep.units u
|
||||
|
|
@ -172,6 +194,7 @@ type ExamPrepListUnitsByCatalogCourseRow struct {
|
|||
ModulesCount int64 `json:"modules_count"`
|
||||
LessonsCount int64 `json:"lessons_count"`
|
||||
PracticesCount int64 `json:"practices_count"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -196,6 +219,7 @@ func (q *Queries) ExamPrepListUnitsByCatalogCourse(ctx context.Context, arg Exam
|
|||
&i.ModulesCount,
|
||||
&i.LessonsCount,
|
||||
&i.PracticesCount,
|
||||
&i.HasPractice,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
|
|||
|
|
@ -67,14 +67,35 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const GetCourseByID = `-- name: GetCourseByID :one
|
||||
SELECT id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
SELECT
|
||||
c.id, c.program_id, c.name, c.description, c.thumbnail, c.created_at, c.updated_at, c.sort_order,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL
|
||||
) AS has_practice
|
||||
FROM courses
|
||||
WHERE id = $1
|
||||
c
|
||||
WHERE c.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
|
||||
type GetCourseByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (GetCourseByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetCourseByID, id)
|
||||
var i Course
|
||||
var i GetCourseByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProgramID,
|
||||
|
|
@ -84,6 +105,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -155,7 +177,14 @@ SELECT
|
|||
WHERE
|
||||
p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL) AS practice_count
|
||||
AND p.lesson_id IS NULL) AS practice_count,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL
|
||||
) AS has_practice
|
||||
FROM
|
||||
courses c
|
||||
WHERE
|
||||
|
|
@ -185,6 +214,7 @@ type ListCoursesByProgramIDRow struct {
|
|||
ModuleCount int64 `json:"module_count"`
|
||||
LessonCount int64 `json:"lesson_count"`
|
||||
PracticeCount int64 `json:"practice_count"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByProgramIDParams) ([]ListCoursesByProgramIDRow, error) {
|
||||
|
|
@ -209,6 +239,7 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP
|
|||
&i.ModuleCount,
|
||||
&i.LessonCount,
|
||||
&i.PracticeCount,
|
||||
&i.HasPractice,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,14 +71,34 @@ func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const GetLessonByID = `-- name: GetLessonByID :one
|
||||
SELECT id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
|
||||
SELECT
|
||||
l.id, l.module_id, l.title, l.video_url, l.thumbnail, l.description, l.created_at, l.updated_at, l.sort_order,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = l.id
|
||||
) AS has_practice
|
||||
FROM lessons
|
||||
WHERE id = $1
|
||||
l
|
||||
WHERE l.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) {
|
||||
type GetLessonByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetLessonByID, id)
|
||||
var i Lesson
|
||||
var i GetLessonByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ModuleID,
|
||||
|
|
@ -89,6 +109,7 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) {
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -104,7 +125,12 @@ SELECT
|
|||
l.description,
|
||||
l.sort_order,
|
||||
l.created_at,
|
||||
l.updated_at
|
||||
l.updated_at,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = l.id
|
||||
) AS has_practice
|
||||
FROM
|
||||
lessons l
|
||||
WHERE
|
||||
|
|
@ -133,6 +159,7 @@ type ListLessonsByModuleIDRow struct {
|
|||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByModuleIDParams) ([]ListLessonsByModuleIDRow, error) {
|
||||
|
|
@ -155,6 +182,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HasPractice,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,14 +71,35 @@ func (q *Queries) DeleteModule(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const GetModuleByID = `-- name: GetModuleByID :one
|
||||
SELECT id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
|
||||
SELECT
|
||||
m.id, m.program_id, m.course_id, m.name, m.description, m.icon, m.created_at, m.updated_at, m.sort_order,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.module_id = m.id
|
||||
AND p.lesson_id IS NULL
|
||||
) AS has_practice
|
||||
FROM modules
|
||||
WHERE id = $1
|
||||
m
|
||||
WHERE m.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) {
|
||||
type GetModuleByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Icon pgtype.Text `json:"icon"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetModuleByID(ctx context.Context, id int64) (GetModuleByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetModuleByID, id)
|
||||
var i Module
|
||||
var i GetModuleByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProgramID,
|
||||
|
|
@ -89,6 +110,7 @@ func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) {
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -135,7 +157,13 @@ SELECT
|
|||
m.icon,
|
||||
m.sort_order,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
m.updated_at,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.module_id = m.id
|
||||
AND p.lesson_id IS NULL
|
||||
) AS has_practice
|
||||
FROM
|
||||
modules m
|
||||
WHERE
|
||||
|
|
@ -166,6 +194,7 @@ type ListModulesByProgramAndCourseRow struct {
|
|||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListModulesByProgramAndCourseParams) ([]ListModulesByProgramAndCourseRow, error) {
|
||||
|
|
@ -193,6 +222,7 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HasPractice,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type Course struct {
|
|||
ModuleCount int `json:"module_count"`
|
||||
LessonCount int `json:"lesson_count"`
|
||||
PracticeCount int `json:"practice_count"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type ExamPrepCatalogCourse struct {
|
|||
UnitsCount *int64 `json:"units_count,omitempty"`
|
||||
ModulesCount *int64 `json:"modules_count,omitempty"`
|
||||
LessonsCount *int64 `json:"lessons_count,omitempty"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type ExamPrepLesson struct {
|
|||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type ExamPrepModule struct {
|
|||
SortOrder int `json:"sort_order"`
|
||||
LessonsCount *int64 `json:"lessons_count,omitempty"`
|
||||
PracticesCount *int64 `json:"practices_count,omitempty"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type ExamPrepUnit struct {
|
|||
ModulesCount *int64 `json:"modules_count,omitempty"`
|
||||
LessonsCount *int64 `json:"lessons_count,omitempty"`
|
||||
PracticesCount *int64 `json:"practices_count,omitempty"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
|
|
|||
35
internal/domain/faq.go
Normal file
35
internal/domain/faq.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
FAQStatusActive = "ACTIVE"
|
||||
FAQStatusInactive = "INACTIVE"
|
||||
)
|
||||
|
||||
type FAQ struct {
|
||||
ID int64 `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type CreateFAQInput struct {
|
||||
Question string
|
||||
Answer string
|
||||
Category *string
|
||||
DisplayOrder *int32
|
||||
Status *string
|
||||
}
|
||||
|
||||
type UpdateFAQInput struct {
|
||||
Question *string
|
||||
Answer *string
|
||||
Category *string
|
||||
DisplayOrder *int32
|
||||
Status *string
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ type Lesson struct {
|
|||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type Module struct {
|
|||
Description *string `json:"description,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
|
|
|
|||
|
|
@ -221,7 +221,11 @@ func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string
|
|||
return "TRUE_FALSE"
|
||||
}
|
||||
|
||||
hasNonAuxiliary := false
|
||||
for _, kind := range normalizeKindList(responseKinds) {
|
||||
if _, aux := responseKindsAuxiliary[kind]; !aux {
|
||||
hasNonAuxiliary = true
|
||||
}
|
||||
switch kind {
|
||||
case string(ResponseAudioResponse):
|
||||
return "AUDIO"
|
||||
|
|
@ -237,6 +241,11 @@ func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string
|
|||
}
|
||||
}
|
||||
|
||||
// Builder-native response kinds should still be persistable/executable as DYNAMIC.
|
||||
if hasNonAuxiliary {
|
||||
return "DYNAMIC"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,13 @@ func TestResolveRuntimeQuestionTypeFromDefinition_skipsAuxiliaryKinds(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveRuntimeQuestionTypeFromDefinition_builderNativeKinds(t *testing.T) {
|
||||
got := ResolveRuntimeQuestionTypeFromDefinition("dynamic_builder", []string{"OPTION", "ANSWER_TIMER"})
|
||||
if got != "DYNAMIC" {
|
||||
t.Fatalf("expected DYNAMIC, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePersistableQuestionTypeDefinition_unmappable(t *testing.T) {
|
||||
err := ValidatePersistableQuestionTypeDefinition("custom", []string{"ANSWER_TIMER"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unable to map definition") {
|
||||
|
|
|
|||
14
internal/ports/faq.go
Normal file
14
internal/ports/faq.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package ports
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"context"
|
||||
)
|
||||
|
||||
type FAQStore interface {
|
||||
CreateFAQ(ctx context.Context, input domain.CreateFAQInput) (domain.FAQ, error)
|
||||
UpdateFAQ(ctx context.Context, id int64, input domain.UpdateFAQInput) (domain.FAQ, error)
|
||||
GetFAQByID(ctx context.Context, id int64, includeInactive bool) (domain.FAQ, error)
|
||||
ListFAQs(ctx context.Context, status *string, category *string, limit int32, offset int32) ([]domain.FAQ, int64, error)
|
||||
DeleteFAQ(ctx context.Context, id int64) error
|
||||
}
|
||||
|
|
@ -47,7 +47,17 @@ func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (dom
|
|||
}
|
||||
return domain.ExamPrepCatalogCourse{}, err
|
||||
}
|
||||
return examPrepCatalogCourseToDomain(c), nil
|
||||
out := examPrepCatalogCourseToDomain(dbgen.ExamPrepCatalogCourse{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Description: c.Description,
|
||||
Thumbnail: c.Thumbnail,
|
||||
SortOrder: c.SortOrder,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
})
|
||||
out.HasPractice = c.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) {
|
||||
|
|
@ -79,6 +89,7 @@ func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset in
|
|||
item.UnitsCount = &r.UnitsCount
|
||||
item.ModulesCount = &r.ModulesCount
|
||||
item.LessonsCount = &r.LessonsCount
|
||||
item.HasPractice = r.HasPractice
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, total, nil
|
||||
|
|
|
|||
|
|
@ -51,7 +51,19 @@ func (s *Store) GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) (
|
|||
}
|
||||
return domain.ExamPrepLesson{}, err
|
||||
}
|
||||
return examPrepLessonToDomain(l), nil
|
||||
out := examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{
|
||||
ID: l.ID,
|
||||
UnitModuleID: l.UnitModuleID,
|
||||
Title: l.Title,
|
||||
VideoUrl: l.VideoUrl,
|
||||
Thumbnail: l.Thumbnail,
|
||||
Description: l.Description,
|
||||
SortOrder: l.SortOrder,
|
||||
CreatedAt: l.CreatedAt,
|
||||
UpdatedAt: l.UpdatedAt,
|
||||
})
|
||||
out.HasPractice = l.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) {
|
||||
|
|
@ -72,7 +84,7 @@ func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context,
|
|||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{
|
||||
item := examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{
|
||||
ID: r.ID,
|
||||
UnitModuleID: r.UnitModuleID,
|
||||
Title: r.Title,
|
||||
|
|
@ -82,7 +94,9 @@ func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context,
|
|||
SortOrder: r.SortOrder,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}))
|
||||
})
|
||||
item.HasPractice = r.HasPractice
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,19 @@ func (s *Store) GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain
|
|||
}
|
||||
return domain.ExamPrepModule{}, err
|
||||
}
|
||||
return examPrepModuleToDomain(m), nil
|
||||
out := examPrepModuleToDomain(dbgen.ExamPrepUnitModule{
|
||||
ID: m.ID,
|
||||
UnitID: m.UnitID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
Thumbnail: m.Thumbnail,
|
||||
Icon: m.Icon,
|
||||
SortOrder: m.SortOrder,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
})
|
||||
out.HasPractice = m.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) {
|
||||
|
|
@ -85,6 +97,7 @@ func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64,
|
|||
})
|
||||
item.LessonsCount = &r.LessonsCount
|
||||
item.PracticesCount = &r.PracticesCount
|
||||
item.HasPractice = r.HasPractice
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, total, nil
|
||||
|
|
|
|||
|
|
@ -49,7 +49,18 @@ func (s *Store) GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamP
|
|||
}
|
||||
return domain.ExamPrepUnit{}, err
|
||||
}
|
||||
return examPrepUnitToDomain(u), nil
|
||||
out := examPrepUnitToDomain(dbgen.ExamPrepUnit{
|
||||
ID: u.ID,
|
||||
CatalogCourseID: u.CatalogCourseID,
|
||||
Name: u.Name,
|
||||
Description: u.Description,
|
||||
Thumbnail: u.Thumbnail,
|
||||
SortOrder: u.SortOrder,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
})
|
||||
out.HasPractice = u.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) {
|
||||
|
|
@ -83,6 +94,7 @@ func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCou
|
|||
item.ModulesCount = &r.ModulesCount
|
||||
item.LessonsCount = &r.LessonsCount
|
||||
item.PracticesCount = &r.PracticesCount
|
||||
item.HasPractice = r.HasPractice
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, total, nil
|
||||
|
|
|
|||
199
internal/repository/faqs.go
Normal file
199
internal/repository/faqs.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/ports"
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func NewFAQStore(s *Store) ports.FAQStore { return s }
|
||||
|
||||
func faqToDomain(
|
||||
id int64,
|
||||
question string,
|
||||
answer string,
|
||||
category pgtype.Text,
|
||||
displayOrder int32,
|
||||
status string,
|
||||
createdAt pgtype.Timestamptz,
|
||||
updatedAt pgtype.Timestamptz,
|
||||
) domain.FAQ {
|
||||
return domain.FAQ{
|
||||
ID: id,
|
||||
Question: question,
|
||||
Answer: answer,
|
||||
Category: fromPgText(category),
|
||||
DisplayOrder: displayOrder,
|
||||
Status: status,
|
||||
CreatedAt: createdAt.Time,
|
||||
UpdatedAt: timePtr(updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateFAQ(ctx context.Context, input domain.CreateFAQInput) (domain.FAQ, error) {
|
||||
displayOrder := int32(0)
|
||||
if input.DisplayOrder != nil {
|
||||
displayOrder = *input.DisplayOrder
|
||||
}
|
||||
status := domain.FAQStatusActive
|
||||
if input.Status != nil {
|
||||
status = *input.Status
|
||||
}
|
||||
|
||||
row := s.conn.QueryRow(ctx, `
|
||||
INSERT INTO faqs (question, answer, category, display_order, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, question, answer, category, display_order, status, created_at, updated_at
|
||||
`, input.Question, input.Answer, toPgText(input.Category), displayOrder, status)
|
||||
|
||||
var (
|
||||
id int64
|
||||
question string
|
||||
answer string
|
||||
category pgtype.Text
|
||||
orderVal int32
|
||||
faqStatus string
|
||||
createdAt pgtype.Timestamptz
|
||||
updatedAt pgtype.Timestamptz
|
||||
)
|
||||
if err := row.Scan(&id, &question, &answer, &category, &orderVal, &faqStatus, &createdAt, &updatedAt); err != nil {
|
||||
return domain.FAQ{}, err
|
||||
}
|
||||
|
||||
return faqToDomain(id, question, answer, category, orderVal, faqStatus, createdAt, updatedAt), nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFAQ(ctx context.Context, id int64, input domain.UpdateFAQInput) (domain.FAQ, error) {
|
||||
categorySet := input.Category != nil
|
||||
var categoryValue pgtype.Text
|
||||
if categorySet {
|
||||
if *input.Category == "" {
|
||||
categoryValue = pgtype.Text{Valid: false}
|
||||
} else {
|
||||
categoryValue = pgtype.Text{String: *input.Category, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
row := s.conn.QueryRow(ctx, `
|
||||
UPDATE faqs
|
||||
SET question = COALESCE($2, question),
|
||||
answer = COALESCE($3, answer),
|
||||
category = CASE WHEN $4::boolean THEN $5::text ELSE category END,
|
||||
display_order = COALESCE($6, display_order),
|
||||
status = COALESCE($7, status),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, question, answer, category, display_order, status, created_at, updated_at
|
||||
`,
|
||||
id,
|
||||
input.Question,
|
||||
input.Answer,
|
||||
categorySet,
|
||||
categoryValue,
|
||||
input.DisplayOrder,
|
||||
input.Status,
|
||||
)
|
||||
|
||||
var (
|
||||
faqID int64
|
||||
question string
|
||||
answer string
|
||||
rowCategory pgtype.Text
|
||||
orderVal int32
|
||||
faqStatus string
|
||||
createdAt pgtype.Timestamptz
|
||||
updatedAt pgtype.Timestamptz
|
||||
)
|
||||
if err := row.Scan(&faqID, &question, &answer, &rowCategory, &orderVal, &faqStatus, &createdAt, &updatedAt); err != nil {
|
||||
return domain.FAQ{}, err
|
||||
}
|
||||
|
||||
return faqToDomain(faqID, question, answer, rowCategory, orderVal, faqStatus, createdAt, updatedAt), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetFAQByID(ctx context.Context, id int64, includeInactive bool) (domain.FAQ, error) {
|
||||
row := s.conn.QueryRow(ctx, `
|
||||
SELECT id, question, answer, category, display_order, status, created_at, updated_at
|
||||
FROM faqs
|
||||
WHERE id = $1
|
||||
AND ($2::boolean = TRUE OR status = 'ACTIVE')
|
||||
`, id, includeInactive)
|
||||
|
||||
var (
|
||||
faqID int64
|
||||
question string
|
||||
answer string
|
||||
category pgtype.Text
|
||||
orderVal int32
|
||||
faqStatus string
|
||||
createdAt pgtype.Timestamptz
|
||||
updatedAt pgtype.Timestamptz
|
||||
)
|
||||
if err := row.Scan(&faqID, &question, &answer, &category, &orderVal, &faqStatus, &createdAt, &updatedAt); err != nil {
|
||||
return domain.FAQ{}, err
|
||||
}
|
||||
|
||||
return faqToDomain(faqID, question, answer, category, orderVal, faqStatus, createdAt, updatedAt), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFAQs(ctx context.Context, status *string, category *string, limit int32, offset int32) ([]domain.FAQ, int64, error) {
|
||||
rows, err := s.conn.Query(ctx, `
|
||||
SELECT id, question, answer, category, display_order, status, created_at, updated_at
|
||||
FROM faqs
|
||||
WHERE ($1::text IS NULL OR status = $1)
|
||||
AND ($2::text IS NULL OR category = $2)
|
||||
ORDER BY display_order ASC, id ASC
|
||||
LIMIT $3 OFFSET $4
|
||||
`, toPgText(status), toPgText(category), limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
faqs := make([]domain.FAQ, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
faqID int64
|
||||
question string
|
||||
answer string
|
||||
rowCategory pgtype.Text
|
||||
orderVal int32
|
||||
faqStatus string
|
||||
createdAt pgtype.Timestamptz
|
||||
updatedAt pgtype.Timestamptz
|
||||
)
|
||||
if err := rows.Scan(&faqID, &question, &answer, &rowCategory, &orderVal, &faqStatus, &createdAt, &updatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
faqs = append(faqs, faqToDomain(faqID, question, answer, rowCategory, orderVal, faqStatus, createdAt, updatedAt))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var totalCount int64
|
||||
if err := s.conn.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM faqs
|
||||
WHERE ($1::text IS NULL OR status = $1)
|
||||
AND ($2::text IS NULL OR category = $2)
|
||||
`, toPgText(status), toPgText(category)).Scan(&totalCount); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return faqs, totalCount, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFAQ(ctx context.Context, id int64) error {
|
||||
cmd, err := s.conn.Exec(ctx, `DELETE FROM faqs WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -53,7 +53,18 @@ func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, err
|
|||
}
|
||||
return domain.Course{}, err
|
||||
}
|
||||
return courseToDomain(c), nil
|
||||
out := courseToDomain(dbgen.Course{
|
||||
ID: c.ID,
|
||||
ProgramID: c.ProgramID,
|
||||
Name: c.Name,
|
||||
Description: c.Description,
|
||||
Thumbnail: c.Thumbnail,
|
||||
SortOrder: c.SortOrder,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
})
|
||||
out.HasPractice = c.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) {
|
||||
|
|
@ -87,6 +98,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
|
|||
co.ModuleCount = int(r.ModuleCount)
|
||||
co.LessonCount = int(r.LessonCount)
|
||||
co.PracticeCount = int(r.PracticeCount)
|
||||
co.HasPractice = r.HasPractice
|
||||
out = append(out, co)
|
||||
}
|
||||
return out, total, nil
|
||||
|
|
|
|||
|
|
@ -51,7 +51,19 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err
|
|||
}
|
||||
return domain.Lesson{}, err
|
||||
}
|
||||
return lessonToDomain(l), nil
|
||||
out := lessonToDomain(dbgen.Lesson{
|
||||
ID: l.ID,
|
||||
ModuleID: l.ModuleID,
|
||||
Title: l.Title,
|
||||
VideoUrl: l.VideoUrl,
|
||||
Thumbnail: l.Thumbnail,
|
||||
Description: l.Description,
|
||||
SortOrder: l.SortOrder,
|
||||
CreatedAt: l.CreatedAt,
|
||||
UpdatedAt: l.UpdatedAt,
|
||||
})
|
||||
out.HasPractice = l.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) {
|
||||
|
|
@ -72,7 +84,7 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
|
|||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, lessonToDomain(dbgen.Lesson{
|
||||
lesson := lessonToDomain(dbgen.Lesson{
|
||||
ID: r.ID,
|
||||
ModuleID: r.ModuleID,
|
||||
Title: r.Title,
|
||||
|
|
@ -82,7 +94,9 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
|
|||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
SortOrder: r.SortOrder,
|
||||
}))
|
||||
})
|
||||
lesson.HasPractice = r.HasPractice
|
||||
out = append(out, lesson)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,19 @@ func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, err
|
|||
}
|
||||
return domain.Module{}, err
|
||||
}
|
||||
return moduleToDomain(m), nil
|
||||
out := moduleToDomain(dbgen.Module{
|
||||
ID: m.ID,
|
||||
ProgramID: m.ProgramID,
|
||||
CourseID: m.CourseID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
Icon: m.Icon,
|
||||
SortOrder: m.SortOrder,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
})
|
||||
out.HasPractice = m.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error) {
|
||||
|
|
@ -77,7 +89,7 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
|
|||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, moduleToDomain(dbgen.Module{
|
||||
mod := moduleToDomain(dbgen.Module{
|
||||
ID: r.ID,
|
||||
ProgramID: r.ProgramID,
|
||||
CourseID: r.CourseID,
|
||||
|
|
@ -87,7 +99,9 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
|
|||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
SortOrder: r.SortOrder,
|
||||
}))
|
||||
})
|
||||
mod.HasPractice = r.HasPractice
|
||||
out = append(out, mod)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
|
|
|||
129
internal/services/faqs/service.go
Normal file
129
internal/services/faqs/service.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package faqs
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/ports"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
faqStore ports.FAQStore
|
||||
}
|
||||
|
||||
func NewService(faqStore ports.FAQStore) *Service {
|
||||
return &Service{faqStore: faqStore}
|
||||
}
|
||||
|
||||
func normalizeFAQStatus(status *string) (string, error) {
|
||||
if status == nil || strings.TrimSpace(*status) == "" {
|
||||
return domain.FAQStatusActive, nil
|
||||
}
|
||||
value := strings.ToUpper(strings.TrimSpace(*status))
|
||||
switch value {
|
||||
case domain.FAQStatusActive, domain.FAQStatusInactive:
|
||||
return value, nil
|
||||
default:
|
||||
return "", fmt.Errorf("status must be one of %s, %s", domain.FAQStatusActive, domain.FAQStatusInactive)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CreateFAQ(ctx context.Context, input domain.CreateFAQInput) (domain.FAQ, error) {
|
||||
input.Question = strings.TrimSpace(input.Question)
|
||||
input.Answer = strings.TrimSpace(input.Answer)
|
||||
if input.Question == "" {
|
||||
return domain.FAQ{}, fmt.Errorf("question is required")
|
||||
}
|
||||
if input.Answer == "" {
|
||||
return domain.FAQ{}, fmt.Errorf("answer is required")
|
||||
}
|
||||
if input.Category != nil {
|
||||
c := strings.TrimSpace(*input.Category)
|
||||
if c == "" {
|
||||
input.Category = nil
|
||||
} else {
|
||||
input.Category = &c
|
||||
}
|
||||
}
|
||||
status, err := normalizeFAQStatus(input.Status)
|
||||
if err != nil {
|
||||
return domain.FAQ{}, err
|
||||
}
|
||||
input.Status = &status
|
||||
return s.faqStore.CreateFAQ(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateFAQ(ctx context.Context, id int64, input domain.UpdateFAQInput) (domain.FAQ, error) {
|
||||
if id <= 0 {
|
||||
return domain.FAQ{}, fmt.Errorf("invalid faq id")
|
||||
}
|
||||
if input.Question != nil {
|
||||
trimmed := strings.TrimSpace(*input.Question)
|
||||
if trimmed == "" {
|
||||
return domain.FAQ{}, fmt.Errorf("question cannot be empty")
|
||||
}
|
||||
input.Question = &trimmed
|
||||
}
|
||||
if input.Answer != nil {
|
||||
trimmed := strings.TrimSpace(*input.Answer)
|
||||
if trimmed == "" {
|
||||
return domain.FAQ{}, fmt.Errorf("answer cannot be empty")
|
||||
}
|
||||
input.Answer = &trimmed
|
||||
}
|
||||
if input.Category != nil {
|
||||
c := strings.TrimSpace(*input.Category)
|
||||
input.Category = &c
|
||||
}
|
||||
if input.Status != nil {
|
||||
status, err := normalizeFAQStatus(input.Status)
|
||||
if err != nil {
|
||||
return domain.FAQ{}, err
|
||||
}
|
||||
input.Status = &status
|
||||
}
|
||||
return s.faqStore.UpdateFAQ(ctx, id, input)
|
||||
}
|
||||
|
||||
func (s *Service) GetFAQByID(ctx context.Context, id int64, includeInactive bool) (domain.FAQ, error) {
|
||||
if id <= 0 {
|
||||
return domain.FAQ{}, fmt.Errorf("invalid faq id")
|
||||
}
|
||||
return s.faqStore.GetFAQByID(ctx, id, includeInactive)
|
||||
}
|
||||
|
||||
func (s *Service) ListFAQs(ctx context.Context, status *string, category *string, limit int32, offset int32) ([]domain.FAQ, int64, error) {
|
||||
if status != nil {
|
||||
normalized, err := normalizeFAQStatus(status)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
status = &normalized
|
||||
}
|
||||
if category != nil {
|
||||
trimmed := strings.TrimSpace(*category)
|
||||
if trimmed == "" {
|
||||
category = nil
|
||||
} else {
|
||||
category = &trimmed
|
||||
}
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.faqStore.ListFAQs(ctx, status, category, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFAQ(ctx context.Context, id int64) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("invalid faq id")
|
||||
}
|
||||
return s.faqStore.DeleteFAQ(ctx, id)
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ func (s *Service) SendEmail(ctx context.Context, receiverEmail, message string,
|
|||
func (s *Service) SendEmailWithAttachments(ctx context.Context, receiverEmail, message string, messageHTML string, subject string, attachments []*resend.Attachment) error {
|
||||
apiKey := s.config.ResendApiKey
|
||||
client := resend.NewClient(apiKey)
|
||||
formattedSenderEmail := "Y <" + s.config.ResendSenderEmail + ">"
|
||||
formattedSenderEmail := "Yimaru - Academy <" + s.config.ResendSenderEmail + ">"
|
||||
params := &resend.SendEmailRequest{
|
||||
From: formattedSenderEmail,
|
||||
To: []string{receiverEmail},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
// "errors"
|
||||
"log/slog"
|
||||
|
|
@ -22,11 +23,11 @@ import (
|
|||
"time"
|
||||
|
||||
// "github.com/segmentio/kafka-go"
|
||||
"go.uber.org/zap"
|
||||
firebase "firebase.google.com/go/v4"
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/resend/resend-go/v2"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/api/option"
|
||||
// "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
|
@ -68,6 +69,10 @@ func New(
|
|||
config: cfg,
|
||||
}
|
||||
|
||||
mongoLogger.Info("FCM_SERVICE_ACCOUNT_KEY value at startup",
|
||||
zap.String("fcm_service_account_key", cfg.FCMServiceAccountKey),
|
||||
)
|
||||
|
||||
// Initialize FCM client if service account key is provided
|
||||
if cfg.FCMServiceAccountKey != "" {
|
||||
if err := svc.initFCMClient(); err != nil {
|
||||
|
|
@ -90,12 +95,25 @@ func (s *Service) initFCMClient() error {
|
|||
|
||||
// Prepare client options; if a service account JSON string is provided, use it.
|
||||
var opts []option.ClientOption
|
||||
var fbConfig *firebase.Config
|
||||
if s.config.FCMServiceAccountKey != "" {
|
||||
var sa struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s.config.FCMServiceAccountKey), &sa); err != nil {
|
||||
return fmt.Errorf("invalid FCM_SERVICE_ACCOUNT_KEY JSON: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(sa.ProjectID) == "" {
|
||||
return fmt.Errorf("FCM_SERVICE_ACCOUNT_KEY is missing project_id")
|
||||
}
|
||||
fbConfig = &firebase.Config{
|
||||
ProjectID: strings.TrimSpace(sa.ProjectID),
|
||||
}
|
||||
opts = append(opts, option.WithCredentialsJSON([]byte(s.config.FCMServiceAccountKey)))
|
||||
}
|
||||
|
||||
// Initialize Firebase app
|
||||
app, err := firebase.NewApp(ctx, nil, opts...)
|
||||
app, err := firebase.NewApp(ctx, fbConfig, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize Firebase app: %w", err)
|
||||
}
|
||||
|
|
@ -110,6 +128,19 @@ func (s *Service) initFCMClient() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureFCMClient() error {
|
||||
if s.fcmClient != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.config.FCMServiceAccountKey) == "" {
|
||||
return fmt.Errorf("FCM_SERVICE_ACCOUNT_KEY is empty")
|
||||
}
|
||||
if err := s.initFCMClient(); err != nil {
|
||||
return fmt.Errorf("failed to initialize FCM client: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SendAfroMessageSMSTemp(
|
||||
ctx context.Context,
|
||||
receiverPhone string,
|
||||
|
|
@ -535,8 +566,8 @@ func (s *Service) SendNotificationEmail(ctx context.Context, recipientID int64,
|
|||
}
|
||||
|
||||
func (s *Service) SendPushNotification(ctx context.Context, notification *domain.Notification) error {
|
||||
if s.fcmClient == nil {
|
||||
return fmt.Errorf("FCM client not initialized")
|
||||
if err := s.ensureFCMClient(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get user device tokens
|
||||
|
|
@ -618,8 +649,8 @@ func (s *Service) MessengerSvc() *messenger.Service {
|
|||
}
|
||||
|
||||
func (s *Service) SendBulkPushNotification(ctx context.Context, userIDs []int64, notification *domain.Notification) (sent int, failed int, err error) {
|
||||
if s.fcmClient == nil {
|
||||
return 0, 0, fmt.Errorf("FCM client not initialized")
|
||||
if err := s.ensureFCMClient(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// Collect all device tokens for the given users
|
||||
|
|
|
|||
|
|
@ -232,6 +232,13 @@ var AllPermissions = []domain.PermissionSeed{
|
|||
{Key: "settings.get", Name: "Get Setting", Description: "Get setting by key", GroupName: "Settings"},
|
||||
{Key: "settings.update", Name: "Update Settings", Description: "Update settings", GroupName: "Settings"},
|
||||
|
||||
// FAQs
|
||||
{Key: "faqs.create", Name: "Create FAQ", Description: "Create a FAQ item", GroupName: "FAQs"},
|
||||
{Key: "faqs.list", Name: "List FAQs (Admin)", Description: "List FAQs for admin management", GroupName: "FAQs"},
|
||||
{Key: "faqs.get", Name: "Get FAQ (Admin)", Description: "Get FAQ by ID for admin management", GroupName: "FAQs"},
|
||||
{Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"},
|
||||
{Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"},
|
||||
|
||||
// Analytics
|
||||
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"},
|
||||
|
||||
|
|
@ -366,6 +373,9 @@ var DefaultRolePermissions = map[string][]string{
|
|||
// Settings (previously SuperAdminOnly, now accessible to ADMIN too)
|
||||
"settings.list", "settings.get", "settings.update",
|
||||
|
||||
// FAQs
|
||||
"faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete",
|
||||
|
||||
// Analytics (previously OnlyAdminAndAbove)
|
||||
"analytics.dashboard",
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,15 @@ import (
|
|||
"Yimaru-Backend/internal/services/assessment"
|
||||
"Yimaru-Backend/internal/services/authentication"
|
||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||
minioservice "Yimaru-Backend/internal/services/minio"
|
||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"Yimaru-Backend/internal/services/courses"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
"Yimaru-Backend/internal/services/faqs"
|
||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||
"Yimaru-Backend/internal/services/lessons"
|
||||
"Yimaru-Backend/internal/services/lmsprogress"
|
||||
minioservice "Yimaru-Backend/internal/services/minio"
|
||||
"Yimaru-Backend/internal/services/modules"
|
||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"Yimaru-Backend/internal/services/practices"
|
||||
"Yimaru-Backend/internal/services/programs"
|
||||
"Yimaru-Backend/internal/services/questions"
|
||||
|
|
@ -44,16 +45,17 @@ import (
|
|||
)
|
||||
|
||||
type App struct {
|
||||
assessmentSvc *assessment.Service
|
||||
questionsSvc *questions.Service
|
||||
examPrepSvc *examprep.Service
|
||||
programSvc *programs.Service
|
||||
courseSvc *courses.Service
|
||||
moduleSvc *modules.Service
|
||||
lessonSvc *lessons.Service
|
||||
lmsProgressSvc *lmsprogress.Service
|
||||
practiceSvc *practices.Service
|
||||
subscriptionsSvc *subscriptions.Service
|
||||
assessmentSvc *assessment.Service
|
||||
questionsSvc *questions.Service
|
||||
faqSvc *faqs.Service
|
||||
examPrepSvc *examprep.Service
|
||||
programSvc *programs.Service
|
||||
courseSvc *courses.Service
|
||||
moduleSvc *modules.Service
|
||||
lessonSvc *lessons.Service
|
||||
lmsProgressSvc *lmsprogress.Service
|
||||
practiceSvc *practices.Service
|
||||
subscriptionsSvc *subscriptions.Service
|
||||
arifpaySvc *arifpay.ArifpayService
|
||||
issueReportingSvc *issuereporting.Service
|
||||
vimeoSvc *vimeoservice.Service
|
||||
|
|
@ -75,15 +77,16 @@ type App struct {
|
|||
validator *customvalidator.CustomValidator
|
||||
JwtConfig jwtutil.JwtConfig
|
||||
Logger *slog.Logger
|
||||
mongoLoggerSvc *zap.Logger
|
||||
analyticsDB *dbgen.Queries
|
||||
rbacSvc *rbacservice.Service
|
||||
stopPurgeWorker context.CancelFunc
|
||||
mongoLoggerSvc *zap.Logger
|
||||
analyticsDB *dbgen.Queries
|
||||
rbacSvc *rbacservice.Service
|
||||
stopPurgeWorker context.CancelFunc
|
||||
}
|
||||
|
||||
func NewApp(
|
||||
assessmentSvc *assessment.Service,
|
||||
questionsSvc *questions.Service,
|
||||
faqSvc *faqs.Service,
|
||||
examPrepSvc *examprep.Service,
|
||||
programSvc *programs.Service,
|
||||
courseSvc *courses.Service,
|
||||
|
|
@ -134,6 +137,7 @@ func NewApp(
|
|||
s := &App{
|
||||
assessmentSvc: assessmentSvc,
|
||||
questionsSvc: questionsSvc,
|
||||
faqSvc: faqSvc,
|
||||
examPrepSvc: examPrepSvc,
|
||||
programSvc: programSvc,
|
||||
courseSvc: courseSvc,
|
||||
|
|
|
|||
396
internal/web_server/handlers/faq.go
Normal file
396
internal/web_server/handlers/faq.go
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type createFAQReq struct {
|
||||
Question string `json:"question" validate:"required"`
|
||||
Answer string `json:"answer" validate:"required"`
|
||||
Category *string `json:"category"`
|
||||
DisplayOrder *int32 `json:"display_order"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
type updateFAQReq struct {
|
||||
Question *string `json:"question"`
|
||||
Answer *string `json:"answer"`
|
||||
Category *string `json:"category"`
|
||||
DisplayOrder *int32 `json:"display_order"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
type faqRes struct {
|
||||
ID int64 `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt *string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type listFAQsRes struct {
|
||||
FAQs []faqRes `json:"faqs"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
}
|
||||
|
||||
func mapFAQToRes(f domain.FAQ) faqRes {
|
||||
var updatedAt *string
|
||||
if f.UpdatedAt != nil {
|
||||
value := f.UpdatedAt.String()
|
||||
updatedAt = &value
|
||||
}
|
||||
return faqRes{
|
||||
ID: f.ID,
|
||||
Question: f.Question,
|
||||
Answer: f.Answer,
|
||||
Category: f.Category,
|
||||
DisplayOrder: f.DisplayOrder,
|
||||
Status: f.Status,
|
||||
CreatedAt: f.CreatedAt.String(),
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ListPublicFAQs godoc
|
||||
// @Summary List published FAQs
|
||||
// @Description Returns active FAQs for public/help center usage
|
||||
// @Tags faqs
|
||||
// @Produce json
|
||||
// @Param category query string false "Filter by category"
|
||||
// @Param limit query int false "Limit (default 50)"
|
||||
// @Param offset query int false "Offset (default 0)"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/faqs [get]
|
||||
func (h *Handler) ListPublicFAQs(c *fiber.Ctx) error {
|
||||
category := strings.TrimSpace(c.Query("category"))
|
||||
var categoryPtr *string
|
||||
if category != "" {
|
||||
categoryPtr = &category
|
||||
}
|
||||
|
||||
limit, err := strconv.Atoi(c.Query("limit", "50"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid limit",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
offset, err := strconv.Atoi(c.Query("offset", "0"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid offset",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
status := domain.FAQStatusActive
|
||||
faqs, total, err := h.faqSvc.ListFAQs(c.Context(), &status, categoryPtr, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to list FAQs",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
out := make([]faqRes, 0, len(faqs))
|
||||
for _, f := range faqs {
|
||||
out = append(out, mapFAQToRes(f))
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "FAQs retrieved successfully",
|
||||
Data: listFAQsRes{
|
||||
FAQs: out,
|
||||
TotalCount: total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetPublicFAQByID godoc
|
||||
// @Summary Get published FAQ by ID
|
||||
// @Description Returns one active FAQ item
|
||||
// @Tags faqs
|
||||
// @Produce json
|
||||
// @Param id path int true "FAQ ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 404 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/faqs/{id} [get]
|
||||
func (h *Handler) GetPublicFAQByID(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid FAQ ID",
|
||||
Error: "id must be a positive integer",
|
||||
})
|
||||
}
|
||||
|
||||
faq, err := h.faqSvc.GetFAQByID(c.Context(), id, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "FAQ not found",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to get FAQ",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "FAQ retrieved successfully",
|
||||
Data: mapFAQToRes(faq),
|
||||
})
|
||||
}
|
||||
|
||||
// ListFAQsAdmin godoc
|
||||
// @Summary List FAQs (admin)
|
||||
// @Description Returns FAQs for admin management with status/category filtering
|
||||
// @Tags faqs
|
||||
// @Produce json
|
||||
// @Param status query string false "ACTIVE or INACTIVE"
|
||||
// @Param category query string false "Filter by category"
|
||||
// @Param limit query int false "Limit (default 20)"
|
||||
// @Param offset query int false "Offset (default 0)"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/faqs [get]
|
||||
func (h *Handler) ListFAQsAdmin(c *fiber.Ctx) error {
|
||||
status := strings.ToUpper(strings.TrimSpace(c.Query("status")))
|
||||
var statusPtr *string
|
||||
if status != "" {
|
||||
statusPtr = &status
|
||||
}
|
||||
category := strings.TrimSpace(c.Query("category"))
|
||||
var categoryPtr *string
|
||||
if category != "" {
|
||||
categoryPtr = &category
|
||||
}
|
||||
|
||||
limit, err := strconv.Atoi(c.Query("limit", "20"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid limit",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
offset, err := strconv.Atoi(c.Query("offset", "0"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid offset",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
faqs, total, err := h.faqSvc.ListFAQs(c.Context(), statusPtr, categoryPtr, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to list FAQs",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
out := make([]faqRes, 0, len(faqs))
|
||||
for _, f := range faqs {
|
||||
out = append(out, mapFAQToRes(f))
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "FAQs retrieved successfully",
|
||||
Data: listFAQsRes{
|
||||
FAQs: out,
|
||||
TotalCount: total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetFAQByIDAdmin godoc
|
||||
// @Summary Get FAQ by ID (admin)
|
||||
// @Description Returns one FAQ regardless of status
|
||||
// @Tags faqs
|
||||
// @Produce json
|
||||
// @Param id path int true "FAQ ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 404 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/faqs/{id} [get]
|
||||
func (h *Handler) GetFAQByIDAdmin(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid FAQ ID",
|
||||
Error: "id must be a positive integer",
|
||||
})
|
||||
}
|
||||
|
||||
faq, err := h.faqSvc.GetFAQByID(c.Context(), id, true)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "FAQ not found",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to get FAQ",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "FAQ retrieved successfully",
|
||||
Data: mapFAQToRes(faq),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateFAQ godoc
|
||||
// @Summary Create FAQ
|
||||
// @Description Creates a new FAQ item
|
||||
// @Tags faqs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body createFAQReq true "Create FAQ payload"
|
||||
// @Success 201 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/faqs [post]
|
||||
func (h *Handler) CreateFAQ(c *fiber.Ctx) error {
|
||||
var req createFAQReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Validation failed",
|
||||
Error: firstValidationError(valErrs),
|
||||
})
|
||||
}
|
||||
|
||||
faq, err := h.faqSvc.CreateFAQ(c.Context(), domain.CreateFAQInput{
|
||||
Question: req.Question,
|
||||
Answer: req.Answer,
|
||||
Category: req.Category,
|
||||
DisplayOrder: req.DisplayOrder,
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to create FAQ",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "FAQ created successfully",
|
||||
Data: mapFAQToRes(faq),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateFAQ godoc
|
||||
// @Summary Update FAQ
|
||||
// @Description Updates an existing FAQ item
|
||||
// @Tags faqs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "FAQ ID"
|
||||
// @Param body body updateFAQReq true "Update FAQ payload"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 404 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/faqs/{id} [put]
|
||||
func (h *Handler) UpdateFAQ(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid FAQ ID",
|
||||
Error: "id must be a positive integer",
|
||||
})
|
||||
}
|
||||
|
||||
var req updateFAQReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
faq, err := h.faqSvc.UpdateFAQ(c.Context(), id, domain.UpdateFAQInput{
|
||||
Question: req.Question,
|
||||
Answer: req.Answer,
|
||||
Category: req.Category,
|
||||
DisplayOrder: req.DisplayOrder,
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "FAQ not found",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to update FAQ",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "FAQ updated successfully",
|
||||
Data: mapFAQToRes(faq),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteFAQ godoc
|
||||
// @Summary Delete FAQ
|
||||
// @Description Deletes an FAQ item
|
||||
// @Tags faqs
|
||||
// @Produce json
|
||||
// @Param id path int true "FAQ ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 404 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/faqs/{id} [delete]
|
||||
func (h *Handler) DeleteFAQ(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid FAQ ID",
|
||||
Error: "id must be a positive integer",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.faqSvc.DeleteFAQ(c.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "FAQ not found",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to delete FAQ",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "FAQ deleted successfully",
|
||||
Data: fiber.Map{"id": id},
|
||||
})
|
||||
}
|
||||
|
|
@ -11,19 +11,20 @@ import (
|
|||
"Yimaru-Backend/internal/services/assessment"
|
||||
"Yimaru-Backend/internal/services/authentication"
|
||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||
minioservice "Yimaru-Backend/internal/services/minio"
|
||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"Yimaru-Backend/internal/services/courses"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
"Yimaru-Backend/internal/services/faqs"
|
||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||
"Yimaru-Backend/internal/services/lessons"
|
||||
"Yimaru-Backend/internal/services/lmsprogress"
|
||||
minioservice "Yimaru-Backend/internal/services/minio"
|
||||
"Yimaru-Backend/internal/services/modules"
|
||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"Yimaru-Backend/internal/services/practices"
|
||||
"Yimaru-Backend/internal/services/programs"
|
||||
"Yimaru-Backend/internal/services/questions"
|
||||
ratingsservice "Yimaru-Backend/internal/services/ratings"
|
||||
rbacservice "Yimaru-Backend/internal/services/rbac"
|
||||
"Yimaru-Backend/internal/services/recommendation"
|
||||
"Yimaru-Backend/internal/services/subscriptions"
|
||||
"Yimaru-Backend/internal/services/team"
|
||||
|
|
@ -43,16 +44,17 @@ import (
|
|||
)
|
||||
|
||||
type Handler struct {
|
||||
assessmentSvc *assessment.Service
|
||||
questionsSvc *questions.Service
|
||||
examPrepSvc *examprep.Service
|
||||
programSvc *programs.Service
|
||||
courseSvc *courses.Service
|
||||
moduleSvc *modules.Service
|
||||
lessonSvc *lessons.Service
|
||||
lmsProgressSvc *lmsprogress.Service
|
||||
practiceSvc *practices.Service
|
||||
subscriptionsSvc *subscriptions.Service
|
||||
assessmentSvc *assessment.Service
|
||||
questionsSvc *questions.Service
|
||||
faqSvc *faqs.Service
|
||||
examPrepSvc *examprep.Service
|
||||
programSvc *programs.Service
|
||||
courseSvc *courses.Service
|
||||
moduleSvc *modules.Service
|
||||
lessonSvc *lessons.Service
|
||||
lmsProgressSvc *lmsprogress.Service
|
||||
practiceSvc *practices.Service
|
||||
subscriptionsSvc *subscriptions.Service
|
||||
arifpaySvc *arifpay.ArifpayService
|
||||
logger *slog.Logger
|
||||
settingSvc *settings.Service
|
||||
|
|
@ -79,6 +81,7 @@ type Handler struct {
|
|||
func New(
|
||||
assessmentSvc *assessment.Service,
|
||||
questionsSvc *questions.Service,
|
||||
faqSvc *faqs.Service,
|
||||
examPrepSvc *examprep.Service,
|
||||
programSvc *programs.Service,
|
||||
courseSvc *courses.Service,
|
||||
|
|
@ -112,6 +115,7 @@ func New(
|
|||
return &Handler{
|
||||
assessmentSvc: assessmentSvc,
|
||||
questionsSvc: questionsSvc,
|
||||
faqSvc: faqSvc,
|
||||
examPrepSvc: examPrepSvc,
|
||||
programSvc: programSvc,
|
||||
courseSvc: courseSvc,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
|
@ -16,6 +17,7 @@ import (
|
|||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/resend/resend-go/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
|
@ -111,7 +113,7 @@ type hijackResponseWriter struct {
|
|||
h http.Header
|
||||
}
|
||||
|
||||
func (w *hijackResponseWriter) Header() http.Header { return w.h }
|
||||
func (w *hijackResponseWriter) Header() http.Header { return w.h }
|
||||
func (w *hijackResponseWriter) WriteHeader(statusCode int) {}
|
||||
func (w *hijackResponseWriter) Write(b []byte) (int, error) { return w.conn.Write(b) }
|
||||
func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
|
|
@ -662,6 +664,12 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
|
|||
)
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Validation failed",
|
||||
Error: firstValidationError(valErrs),
|
||||
})
|
||||
}
|
||||
|
||||
userID, ok := c.Locals("user_id").(int64)
|
||||
if !ok || userID == 0 {
|
||||
|
|
@ -673,6 +681,14 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
|
|||
}
|
||||
|
||||
if err := h.userSvc.RegisterDevice(c.Context(), userID, req.DeviceToken, req.Platform); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == "devices_user_fk" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid authenticated user",
|
||||
Error: "authenticated user does not exist in users table",
|
||||
})
|
||||
}
|
||||
|
||||
h.mongoLoggerSvc.Error("[NotificationHandler.RegisterDeviceToken] Failed to register device token",
|
||||
zap.Int64("userID", userID),
|
||||
zap.String("platform", req.Platform),
|
||||
|
|
@ -711,6 +727,10 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
|
|||
func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
||||
title := c.FormValue("title", "Test Push Notification")
|
||||
message := c.FormValue("message", "This is a test push notification from Yimaru Backend")
|
||||
h.mongoLoggerSvc.Info("FCM_SERVICE_ACCOUNT_KEY value during test-push call",
|
||||
zap.String("fcm_service_account_key", h.Cfg.FCMServiceAccountKey),
|
||||
zap.String("db_url", h.Cfg.DbUrl),
|
||||
)
|
||||
|
||||
userID, ok := c.Locals("user_id").(int64)
|
||||
if !ok || userID == 0 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package handlers
|
|||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
|
@ -132,6 +135,27 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
actorID := c.Locals("user_id").(int64)
|
||||
actorRole := string(c.Locals("role").(domain.Role))
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"question_type_definition_id": def.ID,
|
||||
"key": def.Key,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(
|
||||
context.Background(),
|
||||
&actorID,
|
||||
&actorRole,
|
||||
domain.ActionQuestionCreated,
|
||||
domain.ResourceQuestion,
|
||||
&def.ID,
|
||||
"Created question type definition: "+def.DisplayName,
|
||||
meta,
|
||||
&ip,
|
||||
&ua,
|
||||
)
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Question type definition created",
|
||||
Data: def,
|
||||
|
|
@ -251,6 +275,26 @@ func (h *Handler) UpdateQuestionTypeDefinition(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
actorID := c.Locals("user_id").(int64)
|
||||
actorRole := string(c.Locals("role").(domain.Role))
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"question_type_definition_id": id,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(
|
||||
context.Background(),
|
||||
&actorID,
|
||||
&actorRole,
|
||||
domain.ActionQuestionUpdated,
|
||||
domain.ResourceQuestion,
|
||||
&id,
|
||||
fmt.Sprintf("Updated question type definition ID: %d", id),
|
||||
meta,
|
||||
&ip,
|
||||
&ua,
|
||||
)
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Question type definition updated",
|
||||
Data: fiber.Map{"id": id},
|
||||
|
|
@ -282,6 +326,26 @@ func (h *Handler) DeleteQuestionTypeDefinition(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
actorID := c.Locals("user_id").(int64)
|
||||
actorRole := string(c.Locals("role").(domain.Role))
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"question_type_definition_id": id,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(
|
||||
context.Background(),
|
||||
&actorID,
|
||||
&actorRole,
|
||||
domain.ActionQuestionDeleted,
|
||||
domain.ResourceQuestion,
|
||||
&id,
|
||||
fmt.Sprintf("Deleted question type definition ID: %d", id),
|
||||
meta,
|
||||
&ip,
|
||||
&ua,
|
||||
)
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Question type definition deleted",
|
||||
Data: fiber.Map{"id": id},
|
||||
|
|
|
|||
|
|
@ -731,7 +731,7 @@ func isSequenceGatedPractice(set domain.QuestionSet) bool {
|
|||
return false
|
||||
}
|
||||
ot := strings.ToUpper(strings.TrimSpace(*set.OwnerType))
|
||||
return ot == "SUB_COURSE" || ot == "SUB_MODULE"
|
||||
return ot == "SUB_COURSE" || ot == "SUB_MODULE" || ot == "COURSE" || ot == "MODULE" || ot == "LESSON"
|
||||
}
|
||||
|
||||
func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error {
|
||||
|
|
@ -1299,6 +1299,17 @@ func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
actorID := c.Locals("user_id").(int64)
|
||||
actorRole := string(c.Locals("role").(domain.Role))
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"question_set_id": setID,
|
||||
"question_id": req.QuestionID,
|
||||
"display_order": req.DisplayOrder,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Added question %d to question set %d", req.QuestionID, setID), meta, &ip, &ua)
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Question added to set successfully",
|
||||
Data: map[string]interface{}{
|
||||
|
|
@ -1528,7 +1539,7 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
|||
}
|
||||
userID := c.Locals("user_id").(int64)
|
||||
|
||||
setID, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid practice ID",
|
||||
|
|
@ -1536,12 +1547,23 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID)
|
||||
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
// Backward/UX compatibility: accept either question_set.id or lms_practices.id.
|
||||
practice, practiceErr := h.practiceSvc.GetByID(c.Context(), id)
|
||||
if practiceErr != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
set, err = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if !isSequenceGatedPractice(set) || !strings.EqualFold(set.Status, "PUBLISHED") {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
|
|
@ -1605,6 +1627,16 @@ func (h *Handler) RemoveQuestionFromSet(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
actorID := c.Locals("user_id").(int64)
|
||||
actorRole := string(c.Locals("role").(domain.Role))
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"question_set_id": setID,
|
||||
"question_id": questionID,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Removed question %d from question set %d", questionID, setID), meta, &ip, &ua)
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Question removed from set successfully",
|
||||
})
|
||||
|
|
@ -1662,6 +1694,17 @@ func (h *Handler) UpdateQuestionOrderInSet(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
actorID := c.Locals("user_id").(int64)
|
||||
actorRole := string(c.Locals("role").(domain.Role))
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"question_set_id": setID,
|
||||
"question_id": questionID,
|
||||
"display_order": req.DisplayOrder,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Updated question %d display_order to %d in set %d", questionID, req.DisplayOrder, setID), meta, &ip, &ua)
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Question order updated successfully",
|
||||
})
|
||||
|
|
@ -1769,6 +1812,17 @@ func (h *Handler) AddUserPersonaToQuestionSet(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
actorID := c.Locals("user_id").(int64)
|
||||
actorRole := string(c.Locals("role").(domain.Role))
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"question_set_id": setID,
|
||||
"user_id": req.UserID,
|
||||
"display_order": req.DisplayOrder,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Added persona user %d to question set %d", req.UserID, setID), meta, &ip, &ua)
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Persona added to question set successfully",
|
||||
})
|
||||
|
|
@ -1812,6 +1866,16 @@ func (h *Handler) RemoveUserPersonaFromQuestionSet(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
actorID := c.Locals("user_id").(int64)
|
||||
actorRole := string(c.Locals("role").(domain.Role))
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"question_set_id": setID,
|
||||
"user_id": userID,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Removed persona user %d from question set %d", userID, setID), meta, &ip, &ua)
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Persona removed from question set successfully",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ func (a *App) initAppRoutes() {
|
|||
h := handlers.New(
|
||||
a.assessmentSvc,
|
||||
a.questionsSvc,
|
||||
a.faqSvc,
|
||||
a.examPrepSvc,
|
||||
a.programSvc,
|
||||
a.courseSvc,
|
||||
|
|
@ -140,6 +141,7 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
|
||||
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
|
||||
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
||||
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("progress.complete"), h.CompletePractice)
|
||||
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson)
|
||||
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
|
||||
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
|
||||
|
|
@ -176,6 +178,15 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
|
||||
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
|
||||
|
||||
// FAQs
|
||||
groupV1.Get("/faqs", h.ListPublicFAQs)
|
||||
groupV1.Get("/faqs/:id", h.GetPublicFAQByID)
|
||||
groupV1.Get("/admin/faqs", a.authMiddleware, a.RequirePermission("faqs.list"), h.ListFAQsAdmin)
|
||||
groupV1.Get("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.get"), h.GetFAQByIDAdmin)
|
||||
groupV1.Post("/admin/faqs", a.authMiddleware, a.RequirePermission("faqs.create"), h.CreateFAQ)
|
||||
groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ)
|
||||
groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ)
|
||||
|
||||
// Question Sets
|
||||
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
|
||||
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
|
||||
|
|
@ -310,6 +321,18 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Delete("/notifications", a.authMiddleware, a.RequirePermission("notifications.delete_mine"), h.DeleteUserNotifications)
|
||||
groupV1.Get("/notifications/unread", a.authMiddleware, a.RequirePermission("notifications.count_unread"), h.CountUnreadNotifications)
|
||||
groupV1.Post("/notifications/create", a.authMiddleware, a.RequirePermission("notifications.create"), h.CreateAndSendNotification)
|
||||
groupV1.Post("/notifications/test-push", a.authMiddleware, a.RequirePermission("notifications.test_push"), h.SendTestPushNotification)
|
||||
|
||||
// Bulk Notifications
|
||||
groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification)
|
||||
groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS)
|
||||
groupV1.Post("/notifications/send-email", a.authMiddleware, a.RequirePermission("notifications.send_email"), h.SendSingleEmail)
|
||||
groupV1.Post("/notifications/bulk-email", a.authMiddleware, a.RequirePermission("notifications.bulk_email"), h.SendBulkEmail)
|
||||
|
||||
// Scheduled Notifications
|
||||
groupV1.Get("/notifications/scheduled", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications)
|
||||
groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification)
|
||||
groupV1.Post("/notifications/scheduled/:id/cancel", a.authMiddleware, a.RequirePermission("notifications_scheduled.cancel"), h.CancelScheduledNotification)
|
||||
|
||||
// Issues
|
||||
groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)
|
||||
|
|
@ -324,18 +347,6 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Post("/devices/register", a.authMiddleware, a.RequirePermission("devices.register"), h.RegisterDeviceToken)
|
||||
groupV1.Post("/devices/unregister", a.authMiddleware, a.RequirePermission("devices.unregister"), h.UnregisterDeviceToken)
|
||||
|
||||
// Push Notifications
|
||||
groupV1.Post("/notifications/test-push", a.authMiddleware, a.RequirePermission("notifications.test_push"), h.SendTestPushNotification)
|
||||
groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification)
|
||||
groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS)
|
||||
groupV1.Post("/notifications/send-email", a.authMiddleware, a.RequirePermission("notifications.send_email"), h.SendSingleEmail)
|
||||
groupV1.Post("/notifications/bulk-email", a.authMiddleware, a.RequirePermission("notifications.bulk_email"), h.SendBulkEmail)
|
||||
|
||||
// Scheduled Notifications
|
||||
groupV1.Get("/notifications/scheduled", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications)
|
||||
groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification)
|
||||
groupV1.Post("/notifications/scheduled/:id/cancel", a.authMiddleware, a.RequirePermission("notifications_scheduled.cancel"), h.CancelScheduledNotification)
|
||||
|
||||
// Settings
|
||||
groupV1.Get("/settings", a.authMiddleware, a.RequirePermission("settings.list"), h.GetGlobalSettingList)
|
||||
groupV1.Get("/settings/:key", a.authMiddleware, a.RequirePermission("settings.get"), h.GetGlobalSettingByKey)
|
||||
|
|
|
|||
623
postman/FAQ-Management.postman_collection.json
Normal file
623
postman/FAQ-Management.postman_collection.json
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
{
|
||||
"info": {
|
||||
"_postman_id": "8bbdb568-d64a-4f8e-8f89-0f57f6e7a65e",
|
||||
"name": "FAQ Management - Complete Flow",
|
||||
"description": "Complete collection for FAQ feature: public listing/detail and admin CRUD management with validation/error cases.",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{
|
||||
"key": "token",
|
||||
"value": "{{access_token}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"variable": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "http://localhost:8080"
|
||||
},
|
||||
{
|
||||
"key": "access_token",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "faq_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "faq_id_inactive",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "01 - Admin FAQ CRUD",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create FAQ (ACTIVE)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 201\", function () {",
|
||||
" pm.response.to.have.status(201);",
|
||||
"});",
|
||||
"const body = pm.response.json();",
|
||||
"pm.test(\"FAQ ID exists\", function () {",
|
||||
" pm.expect(body.data.id).to.be.a(\"number\");",
|
||||
"});",
|
||||
"pm.collectionVariables.set(\"faq_id\", body.data.id);"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"question\": \"How do I reset my password?\",\n \"answer\": \"Go to login and click 'Forgot Password'.\",\n \"category\": \"Account\",\n \"display_order\": 1,\n \"status\": \"ACTIVE\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create FAQ (INACTIVE)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 201\", function () {",
|
||||
" pm.response.to.have.status(201);",
|
||||
"});",
|
||||
"const body = pm.response.json();",
|
||||
"pm.collectionVariables.set(\"faq_id_inactive\", body.data.id);"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"question\": \"How can I change app language?\",\n \"answer\": \"Open settings and choose language preference.\",\n \"category\": \"General\",\n \"display_order\": 50,\n \"status\": \"INACTIVE\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "List FAQs (Admin - All)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs?limit=20&offset=0",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "20"
|
||||
},
|
||||
{
|
||||
"key": "offset",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "List FAQs (Admin - Filter ACTIVE)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs?status=ACTIVE&limit=20&offset=0",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "status",
|
||||
"value": "ACTIVE"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "20"
|
||||
},
|
||||
{
|
||||
"key": "offset",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get FAQ By ID (Admin)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs/{{faq_id}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs",
|
||||
"{{faq_id}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update FAQ (Admin)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 200\", function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"question\": \"How do I reset my account password?\",\n \"answer\": \"Tap 'Forgot Password' on login and follow OTP steps.\",\n \"category\": \"Account\",\n \"display_order\": 2,\n \"status\": \"ACTIVE\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs/{{faq_id}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs",
|
||||
"{{faq_id}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Archive FAQ (Set INACTIVE)",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"status\": \"INACTIVE\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs/{{faq_id}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs",
|
||||
"{{faq_id}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "02 - Public FAQ Endpoints",
|
||||
"item": [
|
||||
{
|
||||
"name": "List Public FAQs (Only ACTIVE)",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "noauth"
|
||||
},
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/faqs?limit=20&offset=0",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"faqs"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "20"
|
||||
},
|
||||
{
|
||||
"key": "offset",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "List Public FAQs by Category",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "noauth"
|
||||
},
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/faqs?category=Account&limit=20&offset=0",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"faqs"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "category",
|
||||
"value": "Account"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "20"
|
||||
},
|
||||
{
|
||||
"key": "offset",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get Public FAQ By ID (ACTIVE only)",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "noauth"
|
||||
},
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/faqs/{{faq_id}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"faqs",
|
||||
"{{faq_id}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get Public FAQ By ID (INACTIVE should be 404)",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 404\", function () {",
|
||||
" pm.response.to.have.status(404);",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "noauth"
|
||||
},
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/faqs/{{faq_id_inactive}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"faqs",
|
||||
"{{faq_id_inactive}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "03 - Validation & Auth Errors",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create FAQ Missing Question - Expect 400",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 400\", function () {",
|
||||
" pm.response.to.have.status(400);",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"answer\": \"Missing question should fail\",\n \"status\": \"ACTIVE\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create FAQ Invalid Status - Expect 400",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 400\", function () {",
|
||||
" pm.response.to.have.status(400);",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"question\": \"Sample question\",\n \"answer\": \"Sample answer\",\n \"status\": \"PUBLISHED\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "List Admin FAQs Without Auth - Expect 401/403",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "noauth"
|
||||
},
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get Missing FAQ (Admin) - Expect 404",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 404\", function () {",
|
||||
" pm.response.to.have.status(404);",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs/99999999",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs",
|
||||
"99999999"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "04 - Cleanup",
|
||||
"item": [
|
||||
{
|
||||
"name": "Delete FAQ (ACTIVE/UPDATED)",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs/{{faq_id}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs",
|
||||
"{{faq_id}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Delete FAQ (INACTIVE)",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/admin/faqs/{{faq_id_inactive}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"admin",
|
||||
"faqs",
|
||||
"{{faq_id_inactive}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user