Add full FAQ management APIs and integration assets.
Implement public FAQ read endpoints and admin CRUD with RBAC, persistence, and migrations, then regenerate Swagger and add a complete Postman collection so frontend/admin teams can integrate and validate the feature end-to-end. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bc2357374b
commit
6a4fe68628
23
cmd/main.go
23
cmd/main.go
|
|
@ -10,31 +10,32 @@ import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
customlogger "Yimaru-Backend/internal/logger"
|
customlogger "Yimaru-Backend/internal/logger"
|
||||||
"Yimaru-Backend/internal/logger/mongoLogger"
|
"Yimaru-Backend/internal/logger/mongoLogger"
|
||||||
|
minioclient "Yimaru-Backend/internal/pkgs/minio"
|
||||||
"Yimaru-Backend/internal/repository"
|
"Yimaru-Backend/internal/repository"
|
||||||
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
"Yimaru-Backend/internal/services/arifpay"
|
"Yimaru-Backend/internal/services/arifpay"
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
"Yimaru-Backend/internal/services/messenger"
|
|
||||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
|
||||||
coursesservice "Yimaru-Backend/internal/services/courses"
|
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"
|
lessonsservice "Yimaru-Backend/internal/services/lessons"
|
||||||
"Yimaru-Backend/internal/services/lmsprogress"
|
"Yimaru-Backend/internal/services/lmsprogress"
|
||||||
|
"Yimaru-Backend/internal/services/messenger"
|
||||||
|
minioservice "Yimaru-Backend/internal/services/minio"
|
||||||
moduleservice "Yimaru-Backend/internal/services/modules"
|
moduleservice "Yimaru-Backend/internal/services/modules"
|
||||||
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
practicesservice "Yimaru-Backend/internal/services/practices"
|
practicesservice "Yimaru-Backend/internal/services/practices"
|
||||||
programsservice "Yimaru-Backend/internal/services/programs"
|
programsservice "Yimaru-Backend/internal/services/programs"
|
||||||
"Yimaru-Backend/internal/services/questions"
|
"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/recommendation"
|
||||||
"Yimaru-Backend/internal/services/settings"
|
"Yimaru-Backend/internal/services/settings"
|
||||||
"Yimaru-Backend/internal/services/subscriptions"
|
"Yimaru-Backend/internal/services/subscriptions"
|
||||||
"Yimaru-Backend/internal/services/team"
|
"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"
|
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
|
@ -393,6 +394,7 @@ func main() {
|
||||||
|
|
||||||
// Questions service (unified questions system)
|
// Questions service (unified questions system)
|
||||||
questionsSvc := questions.NewService(store)
|
questionsSvc := questions.NewService(store)
|
||||||
|
faqSvc := faqs.NewService(repository.NewFAQStore(store))
|
||||||
examPrepSvc := examprep.NewService(store)
|
examPrepSvc := examprep.NewService(store)
|
||||||
|
|
||||||
// LMS programs (top-level hierarchy)
|
// LMS programs (top-level hierarchy)
|
||||||
|
|
@ -453,6 +455,7 @@ func main() {
|
||||||
app := httpserver.NewApp(
|
app := httpserver.NewApp(
|
||||||
assessmentSvc,
|
assessmentSvc,
|
||||||
questionsSvc,
|
questionsSvc,
|
||||||
|
faqSvc,
|
||||||
examPrepSvc,
|
examPrepSvc,
|
||||||
programSvc,
|
programSvc,
|
||||||
courseSvc,
|
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);
|
||||||
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.
|
||||||
388
docs/docs.go
388
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": {
|
"/api/v1/admin/users/deletion-requests": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
|
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
|
||||||
|
|
@ -1608,6 +1859,99 @@ const docTemplate = `{
|
||||||
"responses": {}
|
"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": {
|
"/api/v1/files/audio": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
|
|
@ -10908,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": {
|
"handlers.createIssueReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|
@ -11418,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": {
|
"handlers.updateIssueStatusReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"/api/v1/admin/users/deletion-requests": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
|
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
|
||||||
|
|
@ -1600,6 +1851,99 @@
|
||||||
"responses": {}
|
"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": {
|
"/api/v1/files/audio": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
|
|
@ -10900,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": {
|
"handlers.createIssueReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|
@ -11410,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": {
|
"handlers.updateIssueStatusReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|
|
||||||
|
|
@ -1226,6 +1226,22 @@ definitions:
|
||||||
- current_password
|
- current_password
|
||||||
- new_password
|
- new_password
|
||||||
type: object
|
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:
|
handlers.createIssueReq:
|
||||||
properties:
|
properties:
|
||||||
description:
|
description:
|
||||||
|
|
@ -1573,6 +1589,19 @@ definitions:
|
||||||
example: false
|
example: false
|
||||||
type: boolean
|
type: boolean
|
||||||
type: object
|
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:
|
handlers.updateIssueStatusReq:
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
|
|
@ -2342,6 +2371,172 @@ paths:
|
||||||
summary: Update Admin
|
summary: Update Admin
|
||||||
tags:
|
tags:
|
||||||
- admin
|
- 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:
|
/api/v1/admin/users/deletion-requests:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
@ -3179,6 +3374,67 @@ paths:
|
||||||
summary: Reorder modules within a unit
|
summary: Reorder modules within a unit
|
||||||
tags:
|
tags:
|
||||||
- exam-prep
|
- 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:
|
/api/v1/files/audio:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
@ -232,6 +232,13 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "settings.get", Name: "Get Setting", Description: "Get setting by key", GroupName: "Settings"},
|
{Key: "settings.get", Name: "Get Setting", Description: "Get setting by key", GroupName: "Settings"},
|
||||||
{Key: "settings.update", Name: "Update Settings", Description: "Update settings", 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
|
// Analytics
|
||||||
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "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 (previously SuperAdminOnly, now accessible to ADMIN too)
|
||||||
"settings.list", "settings.get", "settings.update",
|
"settings.list", "settings.get", "settings.update",
|
||||||
|
|
||||||
|
// FAQs
|
||||||
|
"faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete",
|
||||||
|
|
||||||
// Analytics (previously OnlyAdminAndAbove)
|
// Analytics (previously OnlyAdminAndAbove)
|
||||||
"analytics.dashboard",
|
"analytics.dashboard",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,15 @@ import (
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
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/courses"
|
||||||
"Yimaru-Backend/internal/services/examprep"
|
"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/lessons"
|
||||||
"Yimaru-Backend/internal/services/lmsprogress"
|
"Yimaru-Backend/internal/services/lmsprogress"
|
||||||
|
minioservice "Yimaru-Backend/internal/services/minio"
|
||||||
"Yimaru-Backend/internal/services/modules"
|
"Yimaru-Backend/internal/services/modules"
|
||||||
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/services/practices"
|
"Yimaru-Backend/internal/services/practices"
|
||||||
"Yimaru-Backend/internal/services/programs"
|
"Yimaru-Backend/internal/services/programs"
|
||||||
"Yimaru-Backend/internal/services/questions"
|
"Yimaru-Backend/internal/services/questions"
|
||||||
|
|
@ -44,16 +45,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
assessmentSvc *assessment.Service
|
assessmentSvc *assessment.Service
|
||||||
questionsSvc *questions.Service
|
questionsSvc *questions.Service
|
||||||
examPrepSvc *examprep.Service
|
faqSvc *faqs.Service
|
||||||
programSvc *programs.Service
|
examPrepSvc *examprep.Service
|
||||||
courseSvc *courses.Service
|
programSvc *programs.Service
|
||||||
moduleSvc *modules.Service
|
courseSvc *courses.Service
|
||||||
lessonSvc *lessons.Service
|
moduleSvc *modules.Service
|
||||||
lmsProgressSvc *lmsprogress.Service
|
lessonSvc *lessons.Service
|
||||||
practiceSvc *practices.Service
|
lmsProgressSvc *lmsprogress.Service
|
||||||
subscriptionsSvc *subscriptions.Service
|
practiceSvc *practices.Service
|
||||||
|
subscriptionsSvc *subscriptions.Service
|
||||||
arifpaySvc *arifpay.ArifpayService
|
arifpaySvc *arifpay.ArifpayService
|
||||||
issueReportingSvc *issuereporting.Service
|
issueReportingSvc *issuereporting.Service
|
||||||
vimeoSvc *vimeoservice.Service
|
vimeoSvc *vimeoservice.Service
|
||||||
|
|
@ -75,15 +77,16 @@ type App struct {
|
||||||
validator *customvalidator.CustomValidator
|
validator *customvalidator.CustomValidator
|
||||||
JwtConfig jwtutil.JwtConfig
|
JwtConfig jwtutil.JwtConfig
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
mongoLoggerSvc *zap.Logger
|
mongoLoggerSvc *zap.Logger
|
||||||
analyticsDB *dbgen.Queries
|
analyticsDB *dbgen.Queries
|
||||||
rbacSvc *rbacservice.Service
|
rbacSvc *rbacservice.Service
|
||||||
stopPurgeWorker context.CancelFunc
|
stopPurgeWorker context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(
|
func NewApp(
|
||||||
assessmentSvc *assessment.Service,
|
assessmentSvc *assessment.Service,
|
||||||
questionsSvc *questions.Service,
|
questionsSvc *questions.Service,
|
||||||
|
faqSvc *faqs.Service,
|
||||||
examPrepSvc *examprep.Service,
|
examPrepSvc *examprep.Service,
|
||||||
programSvc *programs.Service,
|
programSvc *programs.Service,
|
||||||
courseSvc *courses.Service,
|
courseSvc *courses.Service,
|
||||||
|
|
@ -134,6 +137,7 @@ func NewApp(
|
||||||
s := &App{
|
s := &App{
|
||||||
assessmentSvc: assessmentSvc,
|
assessmentSvc: assessmentSvc,
|
||||||
questionsSvc: questionsSvc,
|
questionsSvc: questionsSvc,
|
||||||
|
faqSvc: faqSvc,
|
||||||
examPrepSvc: examPrepSvc,
|
examPrepSvc: examPrepSvc,
|
||||||
programSvc: programSvc,
|
programSvc: programSvc,
|
||||||
courseSvc: courseSvc,
|
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/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
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/courses"
|
||||||
"Yimaru-Backend/internal/services/examprep"
|
"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/lessons"
|
||||||
"Yimaru-Backend/internal/services/lmsprogress"
|
"Yimaru-Backend/internal/services/lmsprogress"
|
||||||
|
minioservice "Yimaru-Backend/internal/services/minio"
|
||||||
"Yimaru-Backend/internal/services/modules"
|
"Yimaru-Backend/internal/services/modules"
|
||||||
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/services/practices"
|
"Yimaru-Backend/internal/services/practices"
|
||||||
"Yimaru-Backend/internal/services/programs"
|
"Yimaru-Backend/internal/services/programs"
|
||||||
"Yimaru-Backend/internal/services/questions"
|
"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/recommendation"
|
||||||
"Yimaru-Backend/internal/services/subscriptions"
|
"Yimaru-Backend/internal/services/subscriptions"
|
||||||
"Yimaru-Backend/internal/services/team"
|
"Yimaru-Backend/internal/services/team"
|
||||||
|
|
@ -43,16 +44,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
assessmentSvc *assessment.Service
|
assessmentSvc *assessment.Service
|
||||||
questionsSvc *questions.Service
|
questionsSvc *questions.Service
|
||||||
examPrepSvc *examprep.Service
|
faqSvc *faqs.Service
|
||||||
programSvc *programs.Service
|
examPrepSvc *examprep.Service
|
||||||
courseSvc *courses.Service
|
programSvc *programs.Service
|
||||||
moduleSvc *modules.Service
|
courseSvc *courses.Service
|
||||||
lessonSvc *lessons.Service
|
moduleSvc *modules.Service
|
||||||
lmsProgressSvc *lmsprogress.Service
|
lessonSvc *lessons.Service
|
||||||
practiceSvc *practices.Service
|
lmsProgressSvc *lmsprogress.Service
|
||||||
subscriptionsSvc *subscriptions.Service
|
practiceSvc *practices.Service
|
||||||
|
subscriptionsSvc *subscriptions.Service
|
||||||
arifpaySvc *arifpay.ArifpayService
|
arifpaySvc *arifpay.ArifpayService
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
settingSvc *settings.Service
|
settingSvc *settings.Service
|
||||||
|
|
@ -79,6 +81,7 @@ type Handler struct {
|
||||||
func New(
|
func New(
|
||||||
assessmentSvc *assessment.Service,
|
assessmentSvc *assessment.Service,
|
||||||
questionsSvc *questions.Service,
|
questionsSvc *questions.Service,
|
||||||
|
faqSvc *faqs.Service,
|
||||||
examPrepSvc *examprep.Service,
|
examPrepSvc *examprep.Service,
|
||||||
programSvc *programs.Service,
|
programSvc *programs.Service,
|
||||||
courseSvc *courses.Service,
|
courseSvc *courses.Service,
|
||||||
|
|
@ -112,6 +115,7 @@ func New(
|
||||||
return &Handler{
|
return &Handler{
|
||||||
assessmentSvc: assessmentSvc,
|
assessmentSvc: assessmentSvc,
|
||||||
questionsSvc: questionsSvc,
|
questionsSvc: questionsSvc,
|
||||||
|
faqSvc: faqSvc,
|
||||||
examPrepSvc: examPrepSvc,
|
examPrepSvc: examPrepSvc,
|
||||||
programSvc: programSvc,
|
programSvc: programSvc,
|
||||||
courseSvc: courseSvc,
|
courseSvc: courseSvc,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ func (a *App) initAppRoutes() {
|
||||||
h := handlers.New(
|
h := handlers.New(
|
||||||
a.assessmentSvc,
|
a.assessmentSvc,
|
||||||
a.questionsSvc,
|
a.questionsSvc,
|
||||||
|
a.faqSvc,
|
||||||
a.examPrepSvc,
|
a.examPrepSvc,
|
||||||
a.programSvc,
|
a.programSvc,
|
||||||
a.courseSvc,
|
a.courseSvc,
|
||||||
|
|
@ -176,6 +177,15 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
|
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
|
||||||
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
|
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
|
// Question Sets
|
||||||
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
|
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)
|
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
|
||||||
|
|
|
||||||
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