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:
Yared Yemane 2026-05-11 07:58:17 -07:00
parent bc2357374b
commit 6a4fe68628
17 changed files with 2934 additions and 44 deletions

View File

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

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

View 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);

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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},
})
}

View File

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

View File

@ -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)
@ -309,9 +319,9 @@ func (a *App) initAppRoutes() {
groupV1.Post("/notifications/mark-all-unread", a.authMiddleware, a.RequirePermission("notifications.mark_all_unread"), h.MarkAllNotificationsAsUnread) groupV1.Post("/notifications/mark-all-unread", a.authMiddleware, a.RequirePermission("notifications.mark_all_unread"), h.MarkAllNotificationsAsUnread)
groupV1.Delete("/notifications", a.authMiddleware, a.RequirePermission("notifications.delete_mine"), h.DeleteUserNotifications) groupV1.Delete("/notifications", a.authMiddleware, a.RequirePermission("notifications.delete_mine"), h.DeleteUserNotifications)
groupV1.Get("/notifications/unread", a.authMiddleware, a.RequirePermission("notifications.count_unread"), h.CountUnreadNotifications) groupV1.Get("/notifications/unread", a.authMiddleware, a.RequirePermission("notifications.count_unread"), h.CountUnreadNotifications)
groupV1.Post("/notifications/create", a.authMiddleware, a.RequirePermission("notifications.create"), h.CreateAndSendNotification) groupV1.Post("/notifications/create", a.authMiddleware, a.RequirePermission("notifications.create"), h.CreateAndSendNotification)
groupV1.Post("/notifications/test-push", a.authMiddleware, a.RequirePermission("notifications.test_push"), h.SendTestPushNotification) groupV1.Post("/notifications/test-push", a.authMiddleware, a.RequirePermission("notifications.test_push"), h.SendTestPushNotification)
// Bulk Notifications // Bulk Notifications
groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification) groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification)
groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS) groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS)

View 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": []
}
]
}
]
}