diff --git a/cmd/main.go b/cmd/main.go index 6e7251b..2fba5ca 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,31 +10,32 @@ import ( "Yimaru-Backend/internal/domain" customlogger "Yimaru-Backend/internal/logger" "Yimaru-Backend/internal/logger/mongoLogger" + minioclient "Yimaru-Backend/internal/pkgs/minio" "Yimaru-Backend/internal/repository" + activitylogservice "Yimaru-Backend/internal/services/activity_log" "Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" - issuereporting "Yimaru-Backend/internal/services/issue_reporting" - "Yimaru-Backend/internal/services/messenger" - notificationservice "Yimaru-Backend/internal/services/notification" + cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" coursesservice "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/examprep" + "Yimaru-Backend/internal/services/faqs" + issuereporting "Yimaru-Backend/internal/services/issue_reporting" lessonsservice "Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lmsprogress" + "Yimaru-Backend/internal/services/messenger" + minioservice "Yimaru-Backend/internal/services/minio" moduleservice "Yimaru-Backend/internal/services/modules" + notificationservice "Yimaru-Backend/internal/services/notification" practicesservice "Yimaru-Backend/internal/services/practices" programsservice "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" - "Yimaru-Backend/internal/services/examprep" + ratingsservice "Yimaru-Backend/internal/services/ratings" + rbacservice "Yimaru-Backend/internal/services/rbac" "Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/team" - activitylogservice "Yimaru-Backend/internal/services/activity_log" - cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" - minioservice "Yimaru-Backend/internal/services/minio" - minioclient "Yimaru-Backend/internal/pkgs/minio" - ratingsservice "Yimaru-Backend/internal/services/ratings" - rbacservice "Yimaru-Backend/internal/services/rbac" vimeoservice "Yimaru-Backend/internal/services/vimeo" "context" @@ -393,6 +394,7 @@ func main() { // Questions service (unified questions system) questionsSvc := questions.NewService(store) + faqSvc := faqs.NewService(repository.NewFAQStore(store)) examPrepSvc := examprep.NewService(store) // LMS programs (top-level hierarchy) @@ -453,6 +455,7 @@ func main() { app := httpserver.NewApp( assessmentSvc, questionsSvc, + faqSvc, examPrepSvc, programSvc, courseSvc, diff --git a/db/migrations/000059_faqs.down.sql b/db/migrations/000059_faqs.down.sql new file mode 100644 index 0000000..0413035 --- /dev/null +++ b/db/migrations/000059_faqs.down.sql @@ -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; diff --git a/db/migrations/000059_faqs.up.sql b/db/migrations/000059_faqs.up.sql new file mode 100644 index 0000000..49f6fcc --- /dev/null +++ b/db/migrations/000059_faqs.up.sql @@ -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); diff --git a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md new file mode 100644 index 0000000..261002d --- /dev/null +++ b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md @@ -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; +}; + +type DynamicElementInstance = { + id: string; + kind: string; + value?: unknown; + meta?: Record; +}; + +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. diff --git a/docs/docs.go b/docs/docs.go index ca0a090..b8e21bc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -254,6 +254,257 @@ const docTemplate = `{ } } }, + "/api/v1/admin/faqs": { + "get": { + "description": "Returns FAQs for admin management with status/category filtering", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "List FAQs (admin)", + "parameters": [ + { + "type": "string", + "description": "ACTIVE or INACTIVE", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Filter by category", + "name": "category", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new FAQ item", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Create FAQ", + "parameters": [ + { + "description": "Create FAQ payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createFAQReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/faqs/{id}": { + "get": { + "description": "Returns one FAQ regardless of status", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Get FAQ by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "FAQ ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates an existing FAQ item", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Update FAQ", + "parameters": [ + { + "type": "integer", + "description": "FAQ ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update FAQ payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateFAQReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes an FAQ item", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Delete FAQ", + "parameters": [ + { + "type": "integer", + "description": "FAQ ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin/users/deletion-requests": { "get": { "description": "Returns account deletion requests for admin panel tracking with filtering and pagination", @@ -1608,6 +1859,99 @@ const docTemplate = `{ "responses": {} } }, + "/api/v1/faqs": { + "get": { + "description": "Returns active FAQs for public/help center usage", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "List published FAQs", + "parameters": [ + { + "type": "string", + "description": "Filter by category", + "name": "category", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 50)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/faqs/{id}": { + "get": { + "description": "Returns one active FAQ item", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Get published FAQ by ID", + "parameters": [ + { + "type": "integer", + "description": "FAQ ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/files/audio": { "post": { "consumes": [ @@ -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": { "type": "object", "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": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index bbd1698..4184b37 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -246,6 +246,257 @@ } } }, + "/api/v1/admin/faqs": { + "get": { + "description": "Returns FAQs for admin management with status/category filtering", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "List FAQs (admin)", + "parameters": [ + { + "type": "string", + "description": "ACTIVE or INACTIVE", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Filter by category", + "name": "category", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new FAQ item", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Create FAQ", + "parameters": [ + { + "description": "Create FAQ payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createFAQReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/faqs/{id}": { + "get": { + "description": "Returns one FAQ regardless of status", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Get FAQ by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "FAQ ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates an existing FAQ item", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Update FAQ", + "parameters": [ + { + "type": "integer", + "description": "FAQ ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update FAQ payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateFAQReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes an FAQ item", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Delete FAQ", + "parameters": [ + { + "type": "integer", + "description": "FAQ ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin/users/deletion-requests": { "get": { "description": "Returns account deletion requests for admin panel tracking with filtering and pagination", @@ -1600,6 +1851,99 @@ "responses": {} } }, + "/api/v1/faqs": { + "get": { + "description": "Returns active FAQs for public/help center usage", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "List published FAQs", + "parameters": [ + { + "type": "string", + "description": "Filter by category", + "name": "category", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 50)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/faqs/{id}": { + "get": { + "description": "Returns one active FAQ item", + "produces": [ + "application/json" + ], + "tags": [ + "faqs" + ], + "summary": "Get published FAQ by ID", + "parameters": [ + { + "type": "integer", + "description": "FAQ ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/files/audio": { "post": { "consumes": [ @@ -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": { "type": "object", "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": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ca526e3..ae7b2f7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1226,6 +1226,22 @@ definitions: - current_password - new_password type: object + handlers.createFAQReq: + properties: + answer: + type: string + category: + type: string + display_order: + type: integer + question: + type: string + status: + type: string + required: + - answer + - question + type: object handlers.createIssueReq: properties: description: @@ -1573,6 +1589,19 @@ definitions: example: false type: boolean type: object + handlers.updateFAQReq: + properties: + answer: + type: string + category: + type: string + display_order: + type: integer + question: + type: string + status: + type: string + type: object handlers.updateIssueStatusReq: properties: status: @@ -2342,6 +2371,172 @@ paths: summary: Update Admin tags: - admin + /api/v1/admin/faqs: + get: + description: Returns FAQs for admin management with status/category filtering + parameters: + - description: ACTIVE or INACTIVE + in: query + name: status + type: string + - description: Filter by category + in: query + name: category + type: string + - description: Limit (default 20) + in: query + name: limit + type: integer + - description: Offset (default 0) + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List FAQs (admin) + tags: + - faqs + post: + consumes: + - application/json + description: Creates a new FAQ item + parameters: + - description: Create FAQ payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createFAQReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create FAQ + tags: + - faqs + /api/v1/admin/faqs/{id}: + delete: + description: Deletes an FAQ item + parameters: + - description: FAQ ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete FAQ + tags: + - faqs + get: + description: Returns one FAQ regardless of status + parameters: + - description: FAQ ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get FAQ by ID (admin) + tags: + - faqs + put: + consumes: + - application/json + description: Updates an existing FAQ item + parameters: + - description: FAQ ID + in: path + name: id + required: true + type: integer + - description: Update FAQ payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateFAQReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update FAQ + tags: + - faqs /api/v1/admin/users/deletion-requests: get: consumes: @@ -3179,6 +3374,67 @@ paths: summary: Reorder modules within a unit tags: - exam-prep + /api/v1/faqs: + get: + description: Returns active FAQs for public/help center usage + parameters: + - description: Filter by category + in: query + name: category + type: string + - description: Limit (default 50) + in: query + name: limit + type: integer + - description: Offset (default 0) + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List published FAQs + tags: + - faqs + /api/v1/faqs/{id}: + get: + description: Returns one active FAQ item + parameters: + - description: FAQ ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get published FAQ by ID + tags: + - faqs /api/v1/files/audio: post: consumes: diff --git a/internal/domain/faq.go b/internal/domain/faq.go new file mode 100644 index 0000000..fc80862 --- /dev/null +++ b/internal/domain/faq.go @@ -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 +} diff --git a/internal/ports/faq.go b/internal/ports/faq.go new file mode 100644 index 0000000..8bae696 --- /dev/null +++ b/internal/ports/faq.go @@ -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 +} diff --git a/internal/repository/faqs.go b/internal/repository/faqs.go new file mode 100644 index 0000000..c6a1d3d --- /dev/null +++ b/internal/repository/faqs.go @@ -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 +} diff --git a/internal/services/faqs/service.go b/internal/services/faqs/service.go new file mode 100644 index 0000000..a4295d4 --- /dev/null +++ b/internal/services/faqs/service.go @@ -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) +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index caed007..646291a 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -232,6 +232,13 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "settings.get", Name: "Get Setting", Description: "Get setting by key", GroupName: "Settings"}, {Key: "settings.update", Name: "Update Settings", Description: "Update settings", GroupName: "Settings"}, + // FAQs + {Key: "faqs.create", Name: "Create FAQ", Description: "Create a FAQ item", GroupName: "FAQs"}, + {Key: "faqs.list", Name: "List FAQs (Admin)", Description: "List FAQs for admin management", GroupName: "FAQs"}, + {Key: "faqs.get", Name: "Get FAQ (Admin)", Description: "Get FAQ by ID for admin management", GroupName: "FAQs"}, + {Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"}, + {Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"}, + // Analytics {Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"}, @@ -366,6 +373,9 @@ var DefaultRolePermissions = map[string][]string{ // Settings (previously SuperAdminOnly, now accessible to ADMIN too) "settings.list", "settings.get", "settings.update", + // FAQs + "faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete", + // Analytics (previously OnlyAdminAndAbove) "analytics.dashboard", diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 74f3d70..ee7b4c4 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -8,14 +8,15 @@ import ( "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" - minioservice "Yimaru-Backend/internal/services/minio" - issuereporting "Yimaru-Backend/internal/services/issue_reporting" - notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/examprep" + "Yimaru-Backend/internal/services/faqs" + issuereporting "Yimaru-Backend/internal/services/issue_reporting" "Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lmsprogress" + minioservice "Yimaru-Backend/internal/services/minio" "Yimaru-Backend/internal/services/modules" + notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" @@ -44,16 +45,17 @@ import ( ) type App struct { - assessmentSvc *assessment.Service - questionsSvc *questions.Service - examPrepSvc *examprep.Service - programSvc *programs.Service - courseSvc *courses.Service - moduleSvc *modules.Service - lessonSvc *lessons.Service - lmsProgressSvc *lmsprogress.Service - practiceSvc *practices.Service - subscriptionsSvc *subscriptions.Service + assessmentSvc *assessment.Service + questionsSvc *questions.Service + faqSvc *faqs.Service + examPrepSvc *examprep.Service + programSvc *programs.Service + courseSvc *courses.Service + moduleSvc *modules.Service + lessonSvc *lessons.Service + lmsProgressSvc *lmsprogress.Service + practiceSvc *practices.Service + subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService issueReportingSvc *issuereporting.Service vimeoSvc *vimeoservice.Service @@ -75,15 +77,16 @@ type App struct { validator *customvalidator.CustomValidator JwtConfig jwtutil.JwtConfig Logger *slog.Logger - mongoLoggerSvc *zap.Logger - analyticsDB *dbgen.Queries - rbacSvc *rbacservice.Service - stopPurgeWorker context.CancelFunc + mongoLoggerSvc *zap.Logger + analyticsDB *dbgen.Queries + rbacSvc *rbacservice.Service + stopPurgeWorker context.CancelFunc } func NewApp( assessmentSvc *assessment.Service, questionsSvc *questions.Service, + faqSvc *faqs.Service, examPrepSvc *examprep.Service, programSvc *programs.Service, courseSvc *courses.Service, @@ -134,6 +137,7 @@ func NewApp( s := &App{ assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, + faqSvc: faqSvc, examPrepSvc: examPrepSvc, programSvc: programSvc, courseSvc: courseSvc, diff --git a/internal/web_server/handlers/faq.go b/internal/web_server/handlers/faq.go new file mode 100644 index 0000000..bb0ee5a --- /dev/null +++ b/internal/web_server/handlers/faq.go @@ -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}, + }) +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 02f4615..42a1bc6 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -11,19 +11,20 @@ import ( "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" - issuereporting "Yimaru-Backend/internal/services/issue_reporting" - minioservice "Yimaru-Backend/internal/services/minio" - ratingsservice "Yimaru-Backend/internal/services/ratings" - rbacservice "Yimaru-Backend/internal/services/rbac" - notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/examprep" + "Yimaru-Backend/internal/services/faqs" + issuereporting "Yimaru-Backend/internal/services/issue_reporting" "Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lmsprogress" + minioservice "Yimaru-Backend/internal/services/minio" "Yimaru-Backend/internal/services/modules" + notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" + ratingsservice "Yimaru-Backend/internal/services/ratings" + rbacservice "Yimaru-Backend/internal/services/rbac" "Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/team" @@ -43,16 +44,17 @@ import ( ) type Handler struct { - assessmentSvc *assessment.Service - questionsSvc *questions.Service - examPrepSvc *examprep.Service - programSvc *programs.Service - courseSvc *courses.Service - moduleSvc *modules.Service - lessonSvc *lessons.Service - lmsProgressSvc *lmsprogress.Service - practiceSvc *practices.Service - subscriptionsSvc *subscriptions.Service + assessmentSvc *assessment.Service + questionsSvc *questions.Service + faqSvc *faqs.Service + examPrepSvc *examprep.Service + programSvc *programs.Service + courseSvc *courses.Service + moduleSvc *modules.Service + lessonSvc *lessons.Service + lmsProgressSvc *lmsprogress.Service + practiceSvc *practices.Service + subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService logger *slog.Logger settingSvc *settings.Service @@ -79,6 +81,7 @@ type Handler struct { func New( assessmentSvc *assessment.Service, questionsSvc *questions.Service, + faqSvc *faqs.Service, examPrepSvc *examprep.Service, programSvc *programs.Service, courseSvc *courses.Service, @@ -112,6 +115,7 @@ func New( return &Handler{ assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, + faqSvc: faqSvc, examPrepSvc: examPrepSvc, programSvc: programSvc, courseSvc: courseSvc, diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 3f9c428..53f7cbb 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -15,6 +15,7 @@ func (a *App) initAppRoutes() { h := handlers.New( a.assessmentSvc, a.questionsSvc, + a.faqSvc, a.examPrepSvc, a.programSvc, a.courseSvc, @@ -176,6 +177,15 @@ func (a *App) initAppRoutes() { groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion) groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion) + // FAQs + groupV1.Get("/faqs", h.ListPublicFAQs) + groupV1.Get("/faqs/:id", h.GetPublicFAQByID) + groupV1.Get("/admin/faqs", a.authMiddleware, a.RequirePermission("faqs.list"), h.ListFAQsAdmin) + groupV1.Get("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.get"), h.GetFAQByIDAdmin) + groupV1.Post("/admin/faqs", a.authMiddleware, a.RequirePermission("faqs.create"), h.CreateFAQ) + groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ) + groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ) + // Question Sets groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType) @@ -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.Delete("/notifications", a.authMiddleware, a.RequirePermission("notifications.delete_mine"), h.DeleteUserNotifications) groupV1.Get("/notifications/unread", a.authMiddleware, a.RequirePermission("notifications.count_unread"), h.CountUnreadNotifications) - groupV1.Post("/notifications/create", a.authMiddleware, a.RequirePermission("notifications.create"), h.CreateAndSendNotification) + groupV1.Post("/notifications/create", a.authMiddleware, a.RequirePermission("notifications.create"), h.CreateAndSendNotification) groupV1.Post("/notifications/test-push", a.authMiddleware, a.RequirePermission("notifications.test_push"), h.SendTestPushNotification) - + // Bulk Notifications groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification) groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS) diff --git a/postman/FAQ-Management.postman_collection.json b/postman/FAQ-Management.postman_collection.json new file mode 100644 index 0000000..1c8b2b3 --- /dev/null +++ b/postman/FAQ-Management.postman_collection.json @@ -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": [] + } + ] + } + ] +}