Compare commits

...

12 Commits

Author SHA1 Message Date
c711df68b9 Fix practice completion lookup for progress endpoint.
Accept either question-set IDs or LMS practice IDs and recognize LMS owner types so valid practice completions no longer return practice-not-found responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 02:57:05 -07:00
eae87b40b5 Add practice completion progress endpoint.
Expose POST /progress/practices/:id/complete so practice completions are recorded through the existing CompletePractice flow and included in learner progress tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:20:33 -07:00
7e75d79dc8 Pass Firebase project_id explicitly from service account JSON.
Parse FCM_SERVICE_ACCOUNT_KEY, validate project_id, and provide firebase.Config{ProjectID} during FCM client initialization to prevent missing-project-id messaging failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:02:01 -07:00
4509fe2dc0 Initialize FCM client lazily during push send.
Add ensureFCMClient() so push APIs retry FCM initialization at request time and return actionable initialization errors when the service account key is empty or invalid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:58:42 -07:00
23322c69cc Remove stale commented env_file lines from compose app service.
Clean up docker-compose by dropping commented env_file entries that were only used during temporary runtime-env debugging.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:28:12 -07:00
6f1cb24c63 Add runtime config debug logging for test push flow.
Log DB_URL alongside FCM_SERVICE_ACCOUNT_KEY during test-push requests and keep compose env-file wiring aligned with current local debug setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:25:53 -07:00
cd0ae19d03 Log FCM env value on test-push requests.
Add request-time logging in the test push endpoint so FCM_SERVICE_ACCOUNT_KEY can be verified during each API call, not only at service startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:56:45 -07:00
75353f8bdd Log FCM service account env value at startup.
Add a notification-service startup log to print FCM_SERVICE_ACCOUNT_KEY for runtime verification during push notification troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:54:22 -07:00
b2a72c2f6e Fix device registration error mapping for invalid user IDs.
Validate device registration input and translate devices_user_fk violations into a clear bad-request response so invalid auth contexts no longer return opaque 500 errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 08:32:42 -07:00
6a4fe68628 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>
2026-05-11 07:58:17 -07:00
bc2357374b Add practice-existence flags and refresh API contracts.
Expose has_practice booleans for LMS and pre-exam hierarchy entities, wire SQL/repository mappings, and regenerate SQLC/Swagger artifacts. Also update the Resend sender display name for outbound emails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 11:57:11 -07:00
9da9eb77e5 fix dynamic builder runtime mapping for option responses
Allow builder-native response kinds like OPTION to resolve to DYNAMIC so schema-driven definition creation succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 10:56:41 -07:00
52 changed files with 3835 additions and 138 deletions

View File

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

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

@ -12,9 +12,18 @@ RETURNING
*;
-- name: ExamPrepGetCatalogCourseByID :one
SELECT *
FROM exam_prep.catalog_courses
WHERE id = $1;
SELECT
c.*,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN exam_prep.units u ON u.id = m.unit_id
WHERE u.catalog_course_id = c.id
) AS has_practice
FROM exam_prep.catalog_courses c
WHERE c.id = $1;
-- name: ExamPrepListCatalogCourses :many
WITH catalog_course_counts AS (
@ -38,6 +47,14 @@ SELECT
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
COALESCE(cc.modules_count, 0)::BIGINT AS modules_count,
COALESCE(cc.lessons_count, 0)::BIGINT AS lessons_count,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN exam_prep.units u ON u.id = m.unit_id
WHERE u.catalog_course_id = c.id
) AS has_practice,
c.created_at,
c.updated_at
FROM exam_prep.catalog_courses c

View File

@ -16,9 +16,15 @@ RETURNING
*;
-- name: ExamPrepGetUnitModuleLessonByID :one
SELECT *
FROM exam_prep.unit_module_lessons
WHERE id = $1;
SELECT
l.*,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = l.id
) AS has_practice
FROM exam_prep.unit_module_lessons l
WHERE l.id = $1;
-- name: ExamPrepListUnitModuleLessonIDsByUnitModule :many
SELECT
@ -39,6 +45,11 @@ SELECT
l.thumbnail,
l.description,
l.sort_order,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = l.id
) AS has_practice,
l.created_at,
l.updated_at
FROM exam_prep.unit_module_lessons l

View File

@ -16,9 +16,16 @@ RETURNING
*;
-- name: ExamPrepGetUnitModuleByID :one
SELECT *
FROM exam_prep.unit_modules
WHERE id = $1;
SELECT
m.*,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
WHERE l.unit_module_id = m.id
) AS has_practice
FROM exam_prep.unit_modules m
WHERE m.id = $1;
-- name: ExamPrepListUnitModuleIDsByUnit :many
SELECT
@ -51,6 +58,7 @@ SELECT
m.sort_order,
COALESCE(mc.lessons_count, 0)::BIGINT AS lessons_count,
COALESCE(mc.practices_count, 0)::BIGINT AS practices_count,
(COALESCE(mc.practices_count, 0)::BIGINT > 0) AS has_practice,
m.created_at,
m.updated_at
FROM exam_prep.unit_modules m

View File

@ -15,9 +15,17 @@ RETURNING
*;
-- name: ExamPrepGetUnitByID :one
SELECT *
FROM exam_prep.units
WHERE id = $1;
SELECT
u.*,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
WHERE m.unit_id = u.id
) AS has_practice
FROM exam_prep.units u
WHERE u.id = $1;
-- name: ExamPrepListUnitIDsByCatalogCourse :many
SELECT
@ -52,6 +60,7 @@ SELECT
COALESCE(uc.modules_count, 0)::BIGINT AS modules_count,
COALESCE(uc.lessons_count, 0)::BIGINT AS lessons_count,
COALESCE(uc.practices_count, 0)::BIGINT AS practices_count,
(COALESCE(uc.practices_count, 0)::BIGINT > 0) AS has_practice,
u.created_at,
u.updated_at
FROM exam_prep.units u

View File

@ -15,9 +15,18 @@ RETURNING
*;
-- name: GetCourseByID :one
SELECT *
SELECT
c.*,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.course_id = c.id
AND p.module_id IS NULL
AND p.lesson_id IS NULL
) AS has_practice
FROM courses
WHERE id = $1;
c
WHERE c.id = $1;
-- name: ListCourseIDsByProgram :many
SELECT
@ -65,7 +74,14 @@ SELECT
WHERE
p.course_id = c.id
AND p.module_id IS NULL
AND p.lesson_id IS NULL) AS practice_count
AND p.lesson_id IS NULL) AS practice_count,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.course_id = c.id
AND p.module_id IS NULL
AND p.lesson_id IS NULL
) AS has_practice
FROM
courses c
WHERE

View File

@ -16,9 +16,16 @@ RETURNING
*;
-- name: GetLessonByID :one
SELECT *
SELECT
l.*,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.lesson_id = l.id
) AS has_practice
FROM lessons
WHERE id = $1;
l
WHERE l.id = $1;
-- name: ListLessonsByModuleID :many
SELECT
@ -31,7 +38,12 @@ SELECT
l.description,
l.sort_order,
l.created_at,
l.updated_at
l.updated_at,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.lesson_id = l.id
) AS has_practice
FROM
lessons l
WHERE

View File

@ -16,9 +16,17 @@ RETURNING
*;
-- name: GetModuleByID :one
SELECT *
SELECT
m.*,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.module_id = m.id
AND p.lesson_id IS NULL
) AS has_practice
FROM modules
WHERE id = $1;
m
WHERE m.id = $1;
-- name: ListModuleIDsByCourse :many
SELECT
@ -41,7 +49,13 @@ SELECT
m.icon,
m.sort_order,
m.created_at,
m.updated_at
m.updated_at,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.module_id = m.id
AND p.lesson_id IS NULL
) AS has_practice
FROM
modules m
WHERE

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": {
"get": {
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
@ -1608,6 +1859,99 @@ const docTemplate = `{
"responses": {}
}
},
"/api/v1/faqs": {
"get": {
"description": "Returns active FAQs for public/help center usage",
"produces": [
"application/json"
],
"tags": [
"faqs"
],
"summary": "List published FAQs",
"parameters": [
{
"type": "string",
"description": "Filter by category",
"name": "category",
"in": "query"
},
{
"type": "integer",
"description": "Limit (default 50)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset (default 0)",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/faqs/{id}": {
"get": {
"description": "Returns one active FAQ item",
"produces": [
"application/json"
],
"tags": [
"faqs"
],
"summary": "Get published FAQ by ID",
"parameters": [
{
"type": "integer",
"description": "FAQ ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/files/audio": {
"post": {
"consumes": [
@ -9405,6 +9749,60 @@ const docTemplate = `{
}
}
},
"domain.DynamicElementDefinition": {
"type": "object",
"properties": {
"config": {
"type": "object",
"additionalProperties": true
},
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"label": {
"type": "string"
},
"required": {
"type": "boolean"
}
}
},
"domain.DynamicElementInstance": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"meta": {
"type": "object",
"additionalProperties": true
},
"value": {}
}
},
"domain.DynamicQuestionPayload": {
"type": "object",
"properties": {
"response": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementInstance"
}
},
"stimulus": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementInstance"
}
}
}
},
"domain.EmploymentType": {
"type": "string",
"enum": [
@ -9638,6 +10036,9 @@ const docTemplate = `{
"difficultyLevel": {
"type": "string"
},
"dynamicPayload": {
"$ref": "#/definitions/domain.DynamicQuestionPayload"
},
"explanation": {
"type": "string"
},
@ -10851,6 +11252,30 @@ const docTemplate = `{
}
}
},
"handlers.createFAQReq": {
"type": "object",
"required": [
"answer",
"question"
],
"properties": {
"answer": {
"type": "string"
},
"category": {
"type": "string"
},
"display_order": {
"type": "integer"
},
"question": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"handlers.createIssueReq": {
"type": "object",
"required": [
@ -10927,6 +11352,9 @@ const docTemplate = `{
"difficulty_level": {
"type": "string"
},
"dynamic_payload": {
"$ref": "#/definitions/domain.DynamicQuestionPayload"
},
"explanation": {
"type": "string"
},
@ -11046,6 +11474,12 @@ const docTemplate = `{
"type": "string"
}
},
"response_schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementDefinition"
}
},
"status": {
"type": "string"
},
@ -11054,6 +11488,12 @@ const docTemplate = `{
"items": {
"type": "string"
}
},
"stimulus_schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementDefinition"
}
}
}
},
@ -11346,6 +11786,26 @@ const docTemplate = `{
}
}
},
"handlers.updateFAQReq": {
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"category": {
"type": "string"
},
"display_order": {
"type": "integer"
},
"question": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"handlers.updateIssueStatusReq": {
"type": "object",
"required": [
@ -11409,6 +11869,9 @@ const docTemplate = `{
"difficulty_level": {
"type": "string"
},
"dynamic_payload": {
"$ref": "#/definitions/domain.DynamicQuestionPayload"
},
"explanation": {
"type": "string"
},
@ -11500,6 +11963,12 @@ const docTemplate = `{
"type": "string"
}
},
"response_schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementDefinition"
}
},
"status": {
"type": "string"
},
@ -11508,6 +11977,12 @@ const docTemplate = `{
"items": {
"type": "string"
}
},
"stimulus_schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementDefinition"
}
}
}
},

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": {
"get": {
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
@ -1600,6 +1851,99 @@
"responses": {}
}
},
"/api/v1/faqs": {
"get": {
"description": "Returns active FAQs for public/help center usage",
"produces": [
"application/json"
],
"tags": [
"faqs"
],
"summary": "List published FAQs",
"parameters": [
{
"type": "string",
"description": "Filter by category",
"name": "category",
"in": "query"
},
{
"type": "integer",
"description": "Limit (default 50)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset (default 0)",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/faqs/{id}": {
"get": {
"description": "Returns one active FAQ item",
"produces": [
"application/json"
],
"tags": [
"faqs"
],
"summary": "Get published FAQ by ID",
"parameters": [
{
"type": "integer",
"description": "FAQ ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/files/audio": {
"post": {
"consumes": [
@ -9397,6 +9741,60 @@
}
}
},
"domain.DynamicElementDefinition": {
"type": "object",
"properties": {
"config": {
"type": "object",
"additionalProperties": true
},
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"label": {
"type": "string"
},
"required": {
"type": "boolean"
}
}
},
"domain.DynamicElementInstance": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"meta": {
"type": "object",
"additionalProperties": true
},
"value": {}
}
},
"domain.DynamicQuestionPayload": {
"type": "object",
"properties": {
"response": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementInstance"
}
},
"stimulus": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementInstance"
}
}
}
},
"domain.EmploymentType": {
"type": "string",
"enum": [
@ -9630,6 +10028,9 @@
"difficultyLevel": {
"type": "string"
},
"dynamicPayload": {
"$ref": "#/definitions/domain.DynamicQuestionPayload"
},
"explanation": {
"type": "string"
},
@ -10843,6 +11244,30 @@
}
}
},
"handlers.createFAQReq": {
"type": "object",
"required": [
"answer",
"question"
],
"properties": {
"answer": {
"type": "string"
},
"category": {
"type": "string"
},
"display_order": {
"type": "integer"
},
"question": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"handlers.createIssueReq": {
"type": "object",
"required": [
@ -10919,6 +11344,9 @@
"difficulty_level": {
"type": "string"
},
"dynamic_payload": {
"$ref": "#/definitions/domain.DynamicQuestionPayload"
},
"explanation": {
"type": "string"
},
@ -11038,6 +11466,12 @@
"type": "string"
}
},
"response_schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementDefinition"
}
},
"status": {
"type": "string"
},
@ -11046,6 +11480,12 @@
"items": {
"type": "string"
}
},
"stimulus_schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementDefinition"
}
}
}
},
@ -11338,6 +11778,26 @@
}
}
},
"handlers.updateFAQReq": {
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"category": {
"type": "string"
},
"display_order": {
"type": "integer"
},
"question": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"handlers.updateIssueStatusReq": {
"type": "object",
"required": [
@ -11401,6 +11861,9 @@
"difficulty_level": {
"type": "string"
},
"dynamic_payload": {
"$ref": "#/definitions/domain.DynamicQuestionPayload"
},
"explanation": {
"type": "string"
},
@ -11492,6 +11955,12 @@
"type": "string"
}
},
"response_schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementDefinition"
}
},
"status": {
"type": "string"
},
@ -11500,6 +11969,12 @@
"items": {
"type": "string"
}
},
"stimulus_schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.DynamicElementDefinition"
}
}
}
},

View File

@ -212,6 +212,42 @@ definitions:
- password
- team_role
type: object
domain.DynamicElementDefinition:
properties:
config:
additionalProperties: true
type: object
id:
type: string
kind:
type: string
label:
type: string
required:
type: boolean
type: object
domain.DynamicElementInstance:
properties:
id:
type: string
kind:
type: string
meta:
additionalProperties: true
type: object
value: {}
type: object
domain.DynamicQuestionPayload:
properties:
response:
items:
$ref: '#/definitions/domain.DynamicElementInstance'
type: array
stimulus:
items:
$ref: '#/definitions/domain.DynamicElementInstance'
type: array
type: object
domain.EmploymentType:
enum:
- full_time
@ -371,6 +407,8 @@ definitions:
type: string
difficultyLevel:
type: string
dynamicPayload:
$ref: '#/definitions/domain.DynamicQuestionPayload'
explanation:
type: string
id:
@ -1188,6 +1226,22 @@ definitions:
- current_password
- new_password
type: object
handlers.createFAQReq:
properties:
answer:
type: string
category:
type: string
display_order:
type: integer
question:
type: string
status:
type: string
required:
- answer
- question
type: object
handlers.createIssueReq:
properties:
description:
@ -1240,6 +1294,8 @@ definitions:
type: string
difficulty_level:
type: string
dynamic_payload:
$ref: '#/definitions/domain.DynamicQuestionPayload'
explanation:
type: string
image_url:
@ -1320,12 +1376,20 @@ definitions:
items:
type: string
type: array
response_schema:
items:
$ref: '#/definitions/domain.DynamicElementDefinition'
type: array
status:
type: string
stimulus_component_kinds:
items:
type: string
type: array
stimulus_schema:
items:
$ref: '#/definitions/domain.DynamicElementDefinition'
type: array
required:
- display_name
- key
@ -1525,6 +1589,19 @@ definitions:
example: false
type: boolean
type: object
handlers.updateFAQReq:
properties:
answer:
type: string
category:
type: string
display_order:
type: integer
question:
type: string
status:
type: string
type: object
handlers.updateIssueStatusReq:
properties:
status:
@ -1567,6 +1644,8 @@ definitions:
type: string
difficulty_level:
type: string
dynamic_payload:
$ref: '#/definitions/domain.DynamicQuestionPayload'
explanation:
type: string
image_url:
@ -1627,12 +1706,20 @@ definitions:
items:
type: string
type: array
response_schema:
items:
$ref: '#/definitions/domain.DynamicElementDefinition'
type: array
status:
type: string
stimulus_component_kinds:
items:
type: string
type: array
stimulus_schema:
items:
$ref: '#/definitions/domain.DynamicElementDefinition'
type: array
type: object
handlers.validateQuestionTypeDefinitionReq:
properties:
@ -2284,6 +2371,172 @@ paths:
summary: Update Admin
tags:
- admin
/api/v1/admin/faqs:
get:
description: Returns FAQs for admin management with status/category filtering
parameters:
- description: ACTIVE or INACTIVE
in: query
name: status
type: string
- description: Filter by category
in: query
name: category
type: string
- description: Limit (default 20)
in: query
name: limit
type: integer
- description: Offset (default 0)
in: query
name: offset
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List FAQs (admin)
tags:
- faqs
post:
consumes:
- application/json
description: Creates a new FAQ item
parameters:
- description: Create FAQ payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.createFAQReq'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Create FAQ
tags:
- faqs
/api/v1/admin/faqs/{id}:
delete:
description: Deletes an FAQ item
parameters:
- description: FAQ ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Delete FAQ
tags:
- faqs
get:
description: Returns one FAQ regardless of status
parameters:
- description: FAQ ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get FAQ by ID (admin)
tags:
- faqs
put:
consumes:
- application/json
description: Updates an existing FAQ item
parameters:
- description: FAQ ID
in: path
name: id
required: true
type: integer
- description: Update FAQ payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.updateFAQReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Update FAQ
tags:
- faqs
/api/v1/admin/users/deletion-requests:
get:
consumes:
@ -3121,6 +3374,67 @@ paths:
summary: Reorder modules within a unit
tags:
- exam-prep
/api/v1/faqs:
get:
description: Returns active FAQs for public/help center usage
parameters:
- description: Filter by category
in: query
name: category
type: string
- description: Limit (default 50)
in: query
name: limit
type: integer
- description: Offset (default 0)
in: query
name: offset
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List published FAQs
tags:
- faqs
/api/v1/faqs/{id}:
get:
description: Returns one active FAQ item
parameters:
- description: FAQ ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get published FAQ by ID
tags:
- faqs
/api/v1/files/audio:
post:
consumes:

View File

@ -57,14 +57,34 @@ func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) err
}
const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one
SELECT id, name, description, thumbnail, sort_order, created_at, updated_at
FROM exam_prep.catalog_courses
WHERE id = $1
SELECT
c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN exam_prep.units u ON u.id = m.unit_id
WHERE u.catalog_course_id = c.id
) AS has_practice
FROM exam_prep.catalog_courses c
WHERE c.id = $1
`
func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (ExamPrepCatalogCourse, error) {
type ExamPrepGetCatalogCourseByIDRow struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (ExamPrepGetCatalogCourseByIDRow, error) {
row := q.db.QueryRow(ctx, ExamPrepGetCatalogCourseByID, id)
var i ExamPrepCatalogCourse
var i ExamPrepGetCatalogCourseByIDRow
err := row.Scan(
&i.ID,
&i.Name,
@ -73,6 +93,7 @@ func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (E
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.HasPractice,
)
return i, err
}
@ -126,6 +147,14 @@ SELECT
COALESCE(cc.units_count, 0)::BIGINT AS units_count,
COALESCE(cc.modules_count, 0)::BIGINT AS modules_count,
COALESCE(cc.lessons_count, 0)::BIGINT AS lessons_count,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
INNER JOIN exam_prep.units u ON u.id = m.unit_id
WHERE u.catalog_course_id = c.id
) AS has_practice,
c.created_at,
c.updated_at
FROM exam_prep.catalog_courses c
@ -149,6 +178,7 @@ type ExamPrepListCatalogCoursesRow struct {
UnitsCount int64 `json:"units_count"`
ModulesCount int64 `json:"modules_count"`
LessonsCount int64 `json:"lessons_count"`
HasPractice bool `json:"has_practice"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
@ -172,6 +202,7 @@ func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepLi
&i.UnitsCount,
&i.ModulesCount,
&i.LessonsCount,
&i.HasPractice,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {

View File

@ -71,14 +71,33 @@ func (q *Queries) ExamPrepDeleteUnitModuleLesson(ctx context.Context, id int64)
}
const ExamPrepGetUnitModuleLessonByID = `-- name: ExamPrepGetUnitModuleLessonByID :one
SELECT id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at
FROM exam_prep.unit_module_lessons
WHERE id = $1
SELECT
l.id, l.unit_module_id, l.title, l.video_url, l.thumbnail, l.description, l.sort_order, l.created_at, l.updated_at,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = l.id
) AS has_practice
FROM exam_prep.unit_module_lessons l
WHERE l.id = $1
`
func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) (ExamPrepUnitModuleLesson, error) {
type ExamPrepGetUnitModuleLessonByIDRow struct {
ID int64 `json:"id"`
UnitModuleID int64 `json:"unit_module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) (ExamPrepGetUnitModuleLessonByIDRow, error) {
row := q.db.QueryRow(ctx, ExamPrepGetUnitModuleLessonByID, id)
var i ExamPrepUnitModuleLesson
var i ExamPrepGetUnitModuleLessonByIDRow
err := row.Scan(
&i.ID,
&i.UnitModuleID,
@ -89,6 +108,7 @@ func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64)
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.HasPractice,
)
return i, err
}
@ -133,6 +153,11 @@ SELECT
l.thumbnail,
l.description,
l.sort_order,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = l.id
) AS has_practice,
l.created_at,
l.updated_at
FROM exam_prep.unit_module_lessons l
@ -160,6 +185,7 @@ type ExamPrepListUnitModuleLessonsByUnitModuleIDRow struct {
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"`
HasPractice bool `json:"has_practice"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
@ -182,6 +208,7 @@ func (q *Queries) ExamPrepListUnitModuleLessonsByUnitModuleID(ctx context.Contex
&i.Thumbnail,
&i.Description,
&i.SortOrder,
&i.HasPractice,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {

View File

@ -71,14 +71,34 @@ func (q *Queries) ExamPrepDeleteUnitModule(ctx context.Context, id int64) error
}
const ExamPrepGetUnitModuleByID = `-- name: ExamPrepGetUnitModuleByID :one
SELECT id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at
FROM exam_prep.unit_modules
WHERE id = $1
SELECT
m.id, m.unit_id, m.name, m.description, m.thumbnail, m.icon, m.sort_order, m.created_at, m.updated_at,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
WHERE l.unit_module_id = m.id
) AS has_practice
FROM exam_prep.unit_modules m
WHERE m.id = $1
`
func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (ExamPrepUnitModule, error) {
type ExamPrepGetUnitModuleByIDRow struct {
ID int64 `json:"id"`
UnitID int64 `json:"unit_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
Icon pgtype.Text `json:"icon"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (ExamPrepGetUnitModuleByIDRow, error) {
row := q.db.QueryRow(ctx, ExamPrepGetUnitModuleByID, id)
var i ExamPrepUnitModule
var i ExamPrepGetUnitModuleByIDRow
err := row.Scan(
&i.ID,
&i.UnitID,
@ -89,6 +109,7 @@ func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (Exam
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.HasPractice,
)
return i, err
}
@ -145,6 +166,7 @@ SELECT
m.sort_order,
COALESCE(mc.lessons_count, 0)::BIGINT AS lessons_count,
COALESCE(mc.practices_count, 0)::BIGINT AS practices_count,
(COALESCE(mc.practices_count, 0)::BIGINT > 0) AS has_practice,
m.created_at,
m.updated_at
FROM exam_prep.unit_modules m
@ -175,6 +197,7 @@ type ExamPrepListUnitModulesByUnitRow struct {
SortOrder int32 `json:"sort_order"`
LessonsCount int64 `json:"lessons_count"`
PracticesCount int64 `json:"practices_count"`
HasPractice bool `json:"has_practice"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
@ -199,6 +222,7 @@ func (q *Queries) ExamPrepListUnitModulesByUnit(ctx context.Context, arg ExamPre
&i.SortOrder,
&i.LessonsCount,
&i.PracticesCount,
&i.HasPractice,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {

View File

@ -67,14 +67,34 @@ func (q *Queries) ExamPrepDeleteUnit(ctx context.Context, id int64) error {
}
const ExamPrepGetUnitByID = `-- name: ExamPrepGetUnitByID :one
SELECT id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
FROM exam_prep.units
WHERE id = $1
SELECT
u.id, u.catalog_course_id, u.name, u.description, u.thumbnail, u.sort_order, u.created_at, u.updated_at,
EXISTS (
SELECT 1
FROM exam_prep.lesson_practices p
INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id
INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id
WHERE m.unit_id = u.id
) AS has_practice
FROM exam_prep.units u
WHERE u.id = $1
`
func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepUnit, error) {
type ExamPrepGetUnitByIDRow struct {
ID int64 `json:"id"`
CatalogCourseID int64 `json:"catalog_course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepGetUnitByIDRow, error) {
row := q.db.QueryRow(ctx, ExamPrepGetUnitByID, id)
var i ExamPrepUnit
var i ExamPrepGetUnitByIDRow
err := row.Scan(
&i.ID,
&i.CatalogCourseID,
@ -84,6 +104,7 @@ func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepUn
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.HasPractice,
)
return i, err
}
@ -142,6 +163,7 @@ SELECT
COALESCE(uc.modules_count, 0)::BIGINT AS modules_count,
COALESCE(uc.lessons_count, 0)::BIGINT AS lessons_count,
COALESCE(uc.practices_count, 0)::BIGINT AS practices_count,
(COALESCE(uc.practices_count, 0)::BIGINT > 0) AS has_practice,
u.created_at,
u.updated_at
FROM exam_prep.units u
@ -172,6 +194,7 @@ type ExamPrepListUnitsByCatalogCourseRow struct {
ModulesCount int64 `json:"modules_count"`
LessonsCount int64 `json:"lessons_count"`
PracticesCount int64 `json:"practices_count"`
HasPractice bool `json:"has_practice"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
@ -196,6 +219,7 @@ func (q *Queries) ExamPrepListUnitsByCatalogCourse(ctx context.Context, arg Exam
&i.ModulesCount,
&i.LessonsCount,
&i.PracticesCount,
&i.HasPractice,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {

View File

@ -67,14 +67,35 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
}
const GetCourseByID = `-- name: GetCourseByID :one
SELECT id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
SELECT
c.id, c.program_id, c.name, c.description, c.thumbnail, c.created_at, c.updated_at, c.sort_order,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.course_id = c.id
AND p.module_id IS NULL
AND p.lesson_id IS NULL
) AS has_practice
FROM courses
WHERE id = $1
c
WHERE c.id = $1
`
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
type GetCourseByIDRow struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (GetCourseByIDRow, error) {
row := q.db.QueryRow(ctx, GetCourseByID, id)
var i Course
var i GetCourseByIDRow
err := row.Scan(
&i.ID,
&i.ProgramID,
@ -84,6 +105,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
&i.HasPractice,
)
return i, err
}
@ -155,7 +177,14 @@ SELECT
WHERE
p.course_id = c.id
AND p.module_id IS NULL
AND p.lesson_id IS NULL) AS practice_count
AND p.lesson_id IS NULL) AS practice_count,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.course_id = c.id
AND p.module_id IS NULL
AND p.lesson_id IS NULL
) AS has_practice
FROM
courses c
WHERE
@ -185,6 +214,7 @@ type ListCoursesByProgramIDRow struct {
ModuleCount int64 `json:"module_count"`
LessonCount int64 `json:"lesson_count"`
PracticeCount int64 `json:"practice_count"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByProgramIDParams) ([]ListCoursesByProgramIDRow, error) {
@ -209,6 +239,7 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP
&i.ModuleCount,
&i.LessonCount,
&i.PracticeCount,
&i.HasPractice,
); err != nil {
return nil, err
}

View File

@ -71,14 +71,34 @@ func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
}
const GetLessonByID = `-- name: GetLessonByID :one
SELECT id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
SELECT
l.id, l.module_id, l.title, l.video_url, l.thumbnail, l.description, l.created_at, l.updated_at, l.sort_order,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.lesson_id = l.id
) AS has_practice
FROM lessons
WHERE id = $1
l
WHERE l.id = $1
`
func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) {
type GetLessonByIDRow struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow, error) {
row := q.db.QueryRow(ctx, GetLessonByID, id)
var i Lesson
var i GetLessonByIDRow
err := row.Scan(
&i.ID,
&i.ModuleID,
@ -89,6 +109,7 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
&i.HasPractice,
)
return i, err
}
@ -104,7 +125,12 @@ SELECT
l.description,
l.sort_order,
l.created_at,
l.updated_at
l.updated_at,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.lesson_id = l.id
) AS has_practice
FROM
lessons l
WHERE
@ -133,6 +159,7 @@ type ListLessonsByModuleIDRow struct {
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByModuleIDParams) ([]ListLessonsByModuleIDRow, error) {
@ -155,6 +182,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.HasPractice,
); err != nil {
return nil, err
}

View File

@ -71,14 +71,35 @@ func (q *Queries) DeleteModule(ctx context.Context, id int64) error {
}
const GetModuleByID = `-- name: GetModuleByID :one
SELECT id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
SELECT
m.id, m.program_id, m.course_id, m.name, m.description, m.icon, m.created_at, m.updated_at, m.sort_order,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.module_id = m.id
AND p.lesson_id IS NULL
) AS has_practice
FROM modules
WHERE id = $1
m
WHERE m.id = $1
`
func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) {
type GetModuleByIDRow struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) GetModuleByID(ctx context.Context, id int64) (GetModuleByIDRow, error) {
row := q.db.QueryRow(ctx, GetModuleByID, id)
var i Module
var i GetModuleByIDRow
err := row.Scan(
&i.ID,
&i.ProgramID,
@ -89,6 +110,7 @@ func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
&i.HasPractice,
)
return i, err
}
@ -135,7 +157,13 @@ SELECT
m.icon,
m.sort_order,
m.created_at,
m.updated_at
m.updated_at,
EXISTS (
SELECT 1
FROM lms_practices p
WHERE p.module_id = m.id
AND p.lesson_id IS NULL
) AS has_practice
FROM
modules m
WHERE
@ -166,6 +194,7 @@ type ListModulesByProgramAndCourseRow struct {
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
HasPractice bool `json:"has_practice"`
}
func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListModulesByProgramAndCourseParams) ([]ListModulesByProgramAndCourseRow, error) {
@ -193,6 +222,7 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.HasPractice,
); err != nil {
return nil, err
}

View File

@ -28,6 +28,7 @@ type Course struct {
ModuleCount int `json:"module_count"`
LessonCount int `json:"lesson_count"`
PracticeCount int `json:"practice_count"`
HasPractice bool `json:"has_practice"`
Access *LMSEntityAccess `json:"access,omitempty"`
}

View File

@ -12,6 +12,7 @@ type ExamPrepCatalogCourse struct {
UnitsCount *int64 `json:"units_count,omitempty"`
ModulesCount *int64 `json:"modules_count,omitempty"`
LessonsCount *int64 `json:"lessons_count,omitempty"`
HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

View File

@ -11,6 +11,7 @@ type ExamPrepLesson struct {
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"`
HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

View File

@ -13,6 +13,7 @@ type ExamPrepModule struct {
SortOrder int `json:"sort_order"`
LessonsCount *int64 `json:"lessons_count,omitempty"`
PracticesCount *int64 `json:"practices_count,omitempty"`
HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

View File

@ -13,6 +13,7 @@ type ExamPrepUnit struct {
ModulesCount *int64 `json:"modules_count,omitempty"`
LessonsCount *int64 `json:"lessons_count,omitempty"`
PracticesCount *int64 `json:"practices_count,omitempty"`
HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

35
internal/domain/faq.go Normal file
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
}

View File

@ -11,6 +11,7 @@ type Lesson struct {
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"`
HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`

View File

@ -11,6 +11,7 @@ type Module struct {
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
SortOrder int `json:"sort_order"`
HasPractice bool `json:"has_practice"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`

View File

@ -221,7 +221,11 @@ func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string
return "TRUE_FALSE"
}
hasNonAuxiliary := false
for _, kind := range normalizeKindList(responseKinds) {
if _, aux := responseKindsAuxiliary[kind]; !aux {
hasNonAuxiliary = true
}
switch kind {
case string(ResponseAudioResponse):
return "AUDIO"
@ -237,6 +241,11 @@ func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string
}
}
// Builder-native response kinds should still be persistable/executable as DYNAMIC.
if hasNonAuxiliary {
return "DYNAMIC"
}
return ""
}

View File

@ -86,6 +86,13 @@ func TestResolveRuntimeQuestionTypeFromDefinition_skipsAuxiliaryKinds(t *testing
}
}
func TestResolveRuntimeQuestionTypeFromDefinition_builderNativeKinds(t *testing.T) {
got := ResolveRuntimeQuestionTypeFromDefinition("dynamic_builder", []string{"OPTION", "ANSWER_TIMER"})
if got != "DYNAMIC" {
t.Fatalf("expected DYNAMIC, got %q", got)
}
}
func TestValidatePersistableQuestionTypeDefinition_unmappable(t *testing.T) {
err := ValidatePersistableQuestionTypeDefinition("custom", []string{"ANSWER_TIMER"})
if err == nil || !strings.Contains(err.Error(), "unable to map definition") {

14
internal/ports/faq.go Normal file
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
}

View File

@ -47,7 +47,17 @@ func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (dom
}
return domain.ExamPrepCatalogCourse{}, err
}
return examPrepCatalogCourseToDomain(c), nil
out := examPrepCatalogCourseToDomain(dbgen.ExamPrepCatalogCourse{
ID: c.ID,
Name: c.Name,
Description: c.Description,
Thumbnail: c.Thumbnail,
SortOrder: c.SortOrder,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
})
out.HasPractice = c.HasPractice
return out, nil
}
func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) {
@ -79,6 +89,7 @@ func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset in
item.UnitsCount = &r.UnitsCount
item.ModulesCount = &r.ModulesCount
item.LessonsCount = &r.LessonsCount
item.HasPractice = r.HasPractice
out = append(out, item)
}
return out, total, nil

View File

@ -51,7 +51,19 @@ func (s *Store) GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) (
}
return domain.ExamPrepLesson{}, err
}
return examPrepLessonToDomain(l), nil
out := examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{
ID: l.ID,
UnitModuleID: l.UnitModuleID,
Title: l.Title,
VideoUrl: l.VideoUrl,
Thumbnail: l.Thumbnail,
Description: l.Description,
SortOrder: l.SortOrder,
CreatedAt: l.CreatedAt,
UpdatedAt: l.UpdatedAt,
})
out.HasPractice = l.HasPractice
return out, nil
}
func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) {
@ -72,7 +84,7 @@ func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context,
if i == 0 {
total = r.TotalCount
}
out = append(out, examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{
item := examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{
ID: r.ID,
UnitModuleID: r.UnitModuleID,
Title: r.Title,
@ -82,7 +94,9 @@ func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context,
SortOrder: r.SortOrder,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}))
})
item.HasPractice = r.HasPractice
out = append(out, item)
}
return out, total, nil
}

View File

@ -51,7 +51,19 @@ func (s *Store) GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain
}
return domain.ExamPrepModule{}, err
}
return examPrepModuleToDomain(m), nil
out := examPrepModuleToDomain(dbgen.ExamPrepUnitModule{
ID: m.ID,
UnitID: m.UnitID,
Name: m.Name,
Description: m.Description,
Thumbnail: m.Thumbnail,
Icon: m.Icon,
SortOrder: m.SortOrder,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
})
out.HasPractice = m.HasPractice
return out, nil
}
func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) {
@ -85,6 +97,7 @@ func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64,
})
item.LessonsCount = &r.LessonsCount
item.PracticesCount = &r.PracticesCount
item.HasPractice = r.HasPractice
out = append(out, item)
}
return out, total, nil

View File

@ -49,7 +49,18 @@ func (s *Store) GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamP
}
return domain.ExamPrepUnit{}, err
}
return examPrepUnitToDomain(u), nil
out := examPrepUnitToDomain(dbgen.ExamPrepUnit{
ID: u.ID,
CatalogCourseID: u.CatalogCourseID,
Name: u.Name,
Description: u.Description,
Thumbnail: u.Thumbnail,
SortOrder: u.SortOrder,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
})
out.HasPractice = u.HasPractice
return out, nil
}
func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) {
@ -83,6 +94,7 @@ func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCou
item.ModulesCount = &r.ModulesCount
item.LessonsCount = &r.LessonsCount
item.PracticesCount = &r.PracticesCount
item.HasPractice = r.HasPractice
out = append(out, item)
}
return out, total, nil

199
internal/repository/faqs.go Normal file
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

@ -53,7 +53,18 @@ func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, err
}
return domain.Course{}, err
}
return courseToDomain(c), nil
out := courseToDomain(dbgen.Course{
ID: c.ID,
ProgramID: c.ProgramID,
Name: c.Name,
Description: c.Description,
Thumbnail: c.Thumbnail,
SortOrder: c.SortOrder,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
})
out.HasPractice = c.HasPractice
return out, nil
}
func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) {
@ -87,6 +98,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
co.ModuleCount = int(r.ModuleCount)
co.LessonCount = int(r.LessonCount)
co.PracticeCount = int(r.PracticeCount)
co.HasPractice = r.HasPractice
out = append(out, co)
}
return out, total, nil

View File

@ -51,7 +51,19 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err
}
return domain.Lesson{}, err
}
return lessonToDomain(l), nil
out := lessonToDomain(dbgen.Lesson{
ID: l.ID,
ModuleID: l.ModuleID,
Title: l.Title,
VideoUrl: l.VideoUrl,
Thumbnail: l.Thumbnail,
Description: l.Description,
SortOrder: l.SortOrder,
CreatedAt: l.CreatedAt,
UpdatedAt: l.UpdatedAt,
})
out.HasPractice = l.HasPractice
return out, nil
}
func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) {
@ -72,7 +84,7 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
if i == 0 {
total = r.TotalCount
}
out = append(out, lessonToDomain(dbgen.Lesson{
lesson := lessonToDomain(dbgen.Lesson{
ID: r.ID,
ModuleID: r.ModuleID,
Title: r.Title,
@ -82,7 +94,9 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
})
lesson.HasPractice = r.HasPractice
out = append(out, lesson)
}
return out, total, nil
}

View File

@ -55,7 +55,19 @@ func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, err
}
return domain.Module{}, err
}
return moduleToDomain(m), nil
out := moduleToDomain(dbgen.Module{
ID: m.ID,
ProgramID: m.ProgramID,
CourseID: m.CourseID,
Name: m.Name,
Description: m.Description,
Icon: m.Icon,
SortOrder: m.SortOrder,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
})
out.HasPractice = m.HasPractice
return out, nil
}
func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error) {
@ -77,7 +89,7 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
if i == 0 {
total = r.TotalCount
}
out = append(out, moduleToDomain(dbgen.Module{
mod := moduleToDomain(dbgen.Module{
ID: r.ID,
ProgramID: r.ProgramID,
CourseID: r.CourseID,
@ -87,7 +99,9 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
})
mod.HasPractice = r.HasPractice
out = append(out, mod)
}
return out, total, nil
}

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

@ -12,7 +12,7 @@ func (s *Service) SendEmail(ctx context.Context, receiverEmail, message string,
func (s *Service) SendEmailWithAttachments(ctx context.Context, receiverEmail, message string, messageHTML string, subject string, attachments []*resend.Attachment) error {
apiKey := s.config.ResendApiKey
client := resend.NewClient(apiKey)
formattedSenderEmail := "Y <" + s.config.ResendSenderEmail + ">"
formattedSenderEmail := "Yimaru - Academy <" + s.config.ResendSenderEmail + ">"
params := &resend.SendEmailRequest{
From: formattedSenderEmail,
To: []string{receiverEmail},

View File

@ -15,6 +15,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
// "errors"
"log/slog"
@ -22,11 +23,11 @@ import (
"time"
// "github.com/segmentio/kafka-go"
"go.uber.org/zap"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"github.com/gorilla/websocket"
"github.com/resend/resend-go/v2"
"go.uber.org/zap"
"google.golang.org/api/option"
// "github.com/redis/go-redis/v9"
)
@ -68,6 +69,10 @@ func New(
config: cfg,
}
mongoLogger.Info("FCM_SERVICE_ACCOUNT_KEY value at startup",
zap.String("fcm_service_account_key", cfg.FCMServiceAccountKey),
)
// Initialize FCM client if service account key is provided
if cfg.FCMServiceAccountKey != "" {
if err := svc.initFCMClient(); err != nil {
@ -90,12 +95,25 @@ func (s *Service) initFCMClient() error {
// Prepare client options; if a service account JSON string is provided, use it.
var opts []option.ClientOption
var fbConfig *firebase.Config
if s.config.FCMServiceAccountKey != "" {
var sa struct {
ProjectID string `json:"project_id"`
}
if err := json.Unmarshal([]byte(s.config.FCMServiceAccountKey), &sa); err != nil {
return fmt.Errorf("invalid FCM_SERVICE_ACCOUNT_KEY JSON: %w", err)
}
if strings.TrimSpace(sa.ProjectID) == "" {
return fmt.Errorf("FCM_SERVICE_ACCOUNT_KEY is missing project_id")
}
fbConfig = &firebase.Config{
ProjectID: strings.TrimSpace(sa.ProjectID),
}
opts = append(opts, option.WithCredentialsJSON([]byte(s.config.FCMServiceAccountKey)))
}
// Initialize Firebase app
app, err := firebase.NewApp(ctx, nil, opts...)
app, err := firebase.NewApp(ctx, fbConfig, opts...)
if err != nil {
return fmt.Errorf("failed to initialize Firebase app: %w", err)
}
@ -110,6 +128,19 @@ func (s *Service) initFCMClient() error {
return nil
}
func (s *Service) ensureFCMClient() error {
if s.fcmClient != nil {
return nil
}
if strings.TrimSpace(s.config.FCMServiceAccountKey) == "" {
return fmt.Errorf("FCM_SERVICE_ACCOUNT_KEY is empty")
}
if err := s.initFCMClient(); err != nil {
return fmt.Errorf("failed to initialize FCM client: %w", err)
}
return nil
}
func (s *Service) SendAfroMessageSMSTemp(
ctx context.Context,
receiverPhone string,
@ -535,8 +566,8 @@ func (s *Service) SendNotificationEmail(ctx context.Context, recipientID int64,
}
func (s *Service) SendPushNotification(ctx context.Context, notification *domain.Notification) error {
if s.fcmClient == nil {
return fmt.Errorf("FCM client not initialized")
if err := s.ensureFCMClient(); err != nil {
return err
}
// Get user device tokens
@ -618,8 +649,8 @@ func (s *Service) MessengerSvc() *messenger.Service {
}
func (s *Service) SendBulkPushNotification(ctx context.Context, userIDs []int64, notification *domain.Notification) (sent int, failed int, err error) {
if s.fcmClient == nil {
return 0, 0, fmt.Errorf("FCM client not initialized")
if err := s.ensureFCMClient(); err != nil {
return 0, 0, err
}
// Collect all device tokens for the given users

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

View File

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

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

View File

@ -6,6 +6,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@ -16,6 +17,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgconn"
"github.com/resend/resend-go/v2"
"go.uber.org/zap"
)
@ -111,7 +113,7 @@ type hijackResponseWriter struct {
h http.Header
}
func (w *hijackResponseWriter) Header() http.Header { return w.h }
func (w *hijackResponseWriter) Header() http.Header { return w.h }
func (w *hijackResponseWriter) WriteHeader(statusCode int) {}
func (w *hijackResponseWriter) Write(b []byte) (int, error) { return w.conn.Write(b) }
func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
@ -662,6 +664,12 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
)
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
@ -673,6 +681,14 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
}
if err := h.userSvc.RegisterDevice(c.Context(), userID, req.DeviceToken, req.Platform); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == "devices_user_fk" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid authenticated user",
Error: "authenticated user does not exist in users table",
})
}
h.mongoLoggerSvc.Error("[NotificationHandler.RegisterDeviceToken] Failed to register device token",
zap.Int64("userID", userID),
zap.String("platform", req.Platform),
@ -711,6 +727,10 @@ func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
title := c.FormValue("title", "Test Push Notification")
message := c.FormValue("message", "This is a test push notification from Yimaru Backend")
h.mongoLoggerSvc.Info("FCM_SERVICE_ACCOUNT_KEY value during test-push call",
zap.String("fcm_service_account_key", h.Cfg.FCMServiceAccountKey),
zap.String("db_url", h.Cfg.DbUrl),
)
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {

View File

@ -2,6 +2,9 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
@ -132,6 +135,27 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"question_type_definition_id": def.ID,
"key": def.Key,
})
go h.activityLogSvc.RecordAction(
context.Background(),
&actorID,
&actorRole,
domain.ActionQuestionCreated,
domain.ResourceQuestion,
&def.ID,
"Created question type definition: "+def.DisplayName,
meta,
&ip,
&ua,
)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question type definition created",
Data: def,
@ -251,6 +275,26 @@ func (h *Handler) UpdateQuestionTypeDefinition(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"question_type_definition_id": id,
})
go h.activityLogSvc.RecordAction(
context.Background(),
&actorID,
&actorRole,
domain.ActionQuestionUpdated,
domain.ResourceQuestion,
&id,
fmt.Sprintf("Updated question type definition ID: %d", id),
meta,
&ip,
&ua,
)
return c.JSON(domain.Response{
Message: "Question type definition updated",
Data: fiber.Map{"id": id},
@ -282,6 +326,26 @@ func (h *Handler) DeleteQuestionTypeDefinition(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"question_type_definition_id": id,
})
go h.activityLogSvc.RecordAction(
context.Background(),
&actorID,
&actorRole,
domain.ActionQuestionDeleted,
domain.ResourceQuestion,
&id,
fmt.Sprintf("Deleted question type definition ID: %d", id),
meta,
&ip,
&ua,
)
return c.JSON(domain.Response{
Message: "Question type definition deleted",
Data: fiber.Map{"id": id},

View File

@ -731,7 +731,7 @@ func isSequenceGatedPractice(set domain.QuestionSet) bool {
return false
}
ot := strings.ToUpper(strings.TrimSpace(*set.OwnerType))
return ot == "SUB_COURSE" || ot == "SUB_MODULE"
return ot == "SUB_COURSE" || ot == "SUB_MODULE" || ot == "COURSE" || ot == "MODULE" || ot == "LESSON"
}
func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error {
@ -1299,6 +1299,17 @@ func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"question_set_id": setID,
"question_id": req.QuestionID,
"display_order": req.DisplayOrder,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Added question %d to question set %d", req.QuestionID, setID), meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question added to set successfully",
Data: map[string]interface{}{
@ -1528,7 +1539,7 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
}
userID := c.Locals("user_id").(int64)
setID, err := strconv.ParseInt(c.Params("id"), 10, 64)
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid practice ID",
@ -1536,12 +1547,23 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
})
}
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID)
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
// Backward/UX compatibility: accept either question_set.id or lms_practices.id.
practice, practiceErr := h.practiceSvc.GetByID(c.Context(), id)
if practiceErr != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
set, err = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
}
if !isSequenceGatedPractice(set) || !strings.EqualFold(set.Status, "PUBLISHED") {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
@ -1605,6 +1627,16 @@ func (h *Handler) RemoveQuestionFromSet(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"question_set_id": setID,
"question_id": questionID,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Removed question %d from question set %d", questionID, setID), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question removed from set successfully",
})
@ -1662,6 +1694,17 @@ func (h *Handler) UpdateQuestionOrderInSet(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"question_set_id": setID,
"question_id": questionID,
"display_order": req.DisplayOrder,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Updated question %d display_order to %d in set %d", questionID, req.DisplayOrder, setID), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question order updated successfully",
})
@ -1769,6 +1812,17 @@ func (h *Handler) AddUserPersonaToQuestionSet(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"question_set_id": setID,
"user_id": req.UserID,
"display_order": req.DisplayOrder,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Added persona user %d to question set %d", req.UserID, setID), meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Persona added to question set successfully",
})
@ -1812,6 +1866,16 @@ func (h *Handler) RemoveUserPersonaFromQuestionSet(c *fiber.Ctx) error {
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"question_set_id": setID,
"user_id": userID,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &setID, fmt.Sprintf("Removed persona user %d from question set %d", userID, setID), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Persona removed from question set successfully",
})

View File

@ -15,6 +15,7 @@ func (a *App) initAppRoutes() {
h := handlers.New(
a.assessmentSvc,
a.questionsSvc,
a.faqSvc,
a.examPrepSvc,
a.programSvc,
a.courseSvc,
@ -140,6 +141,7 @@ func (a *App) initAppRoutes() {
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("progress.complete"), h.CompletePractice)
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson)
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
@ -176,6 +178,15 @@ func (a *App) initAppRoutes() {
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
// FAQs
groupV1.Get("/faqs", h.ListPublicFAQs)
groupV1.Get("/faqs/:id", h.GetPublicFAQByID)
groupV1.Get("/admin/faqs", a.authMiddleware, a.RequirePermission("faqs.list"), h.ListFAQsAdmin)
groupV1.Get("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.get"), h.GetFAQByIDAdmin)
groupV1.Post("/admin/faqs", a.authMiddleware, a.RequirePermission("faqs.create"), h.CreateFAQ)
groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ)
groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ)
// Question Sets
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
@ -310,6 +321,18 @@ func (a *App) initAppRoutes() {
groupV1.Delete("/notifications", a.authMiddleware, a.RequirePermission("notifications.delete_mine"), h.DeleteUserNotifications)
groupV1.Get("/notifications/unread", a.authMiddleware, a.RequirePermission("notifications.count_unread"), h.CountUnreadNotifications)
groupV1.Post("/notifications/create", a.authMiddleware, a.RequirePermission("notifications.create"), h.CreateAndSendNotification)
groupV1.Post("/notifications/test-push", a.authMiddleware, a.RequirePermission("notifications.test_push"), h.SendTestPushNotification)
// Bulk Notifications
groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification)
groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS)
groupV1.Post("/notifications/send-email", a.authMiddleware, a.RequirePermission("notifications.send_email"), h.SendSingleEmail)
groupV1.Post("/notifications/bulk-email", a.authMiddleware, a.RequirePermission("notifications.bulk_email"), h.SendBulkEmail)
// Scheduled Notifications
groupV1.Get("/notifications/scheduled", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications)
groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification)
groupV1.Post("/notifications/scheduled/:id/cancel", a.authMiddleware, a.RequirePermission("notifications_scheduled.cancel"), h.CancelScheduledNotification)
// Issues
groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)
@ -324,18 +347,6 @@ func (a *App) initAppRoutes() {
groupV1.Post("/devices/register", a.authMiddleware, a.RequirePermission("devices.register"), h.RegisterDeviceToken)
groupV1.Post("/devices/unregister", a.authMiddleware, a.RequirePermission("devices.unregister"), h.UnregisterDeviceToken)
// Push Notifications
groupV1.Post("/notifications/test-push", a.authMiddleware, a.RequirePermission("notifications.test_push"), h.SendTestPushNotification)
groupV1.Post("/notifications/bulk-push", a.authMiddleware, a.RequirePermission("notifications.bulk_push"), h.SendBulkPushNotification)
groupV1.Post("/notifications/bulk-sms", a.authMiddleware, a.RequirePermission("notifications.bulk_sms"), h.SendBulkSMS)
groupV1.Post("/notifications/send-email", a.authMiddleware, a.RequirePermission("notifications.send_email"), h.SendSingleEmail)
groupV1.Post("/notifications/bulk-email", a.authMiddleware, a.RequirePermission("notifications.bulk_email"), h.SendBulkEmail)
// Scheduled Notifications
groupV1.Get("/notifications/scheduled", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications)
groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification)
groupV1.Post("/notifications/scheduled/:id/cancel", a.authMiddleware, a.RequirePermission("notifications_scheduled.cancel"), h.CancelScheduledNotification)
// Settings
groupV1.Get("/settings", a.authMiddleware, a.RequirePermission("settings.list"), h.GetGlobalSettingList)
groupV1.Get("/settings/:key", a.authMiddleware, a.RequirePermission("settings.get"), h.GetGlobalSettingByKey)

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