diff --git a/db/data/008_account_deletion_requests_seed.sql b/db/data/008_account_deletion_requests_seed.sql new file mode 100644 index 0000000..1918e74 --- /dev/null +++ b/db/data/008_account_deletion_requests_seed.sql @@ -0,0 +1,29 @@ +-- Seed account deletion request states for admin panel tracking +-- Users referenced here are seeded in 001_initial_seed_data.sql (IDs: 10, 11, 12). + +-- Pending deletion request (within grace period) +UPDATE users +SET + deletion_requested_at = now() - interval '2 days', + deletion_scheduled_at = now() + interval '13 days', + deletion_cancelled_at = NULL, + updated_at = now() +WHERE id = 10; + +-- Due deletion request (grace period elapsed, awaiting purge worker) +UPDATE users +SET + deletion_requested_at = now() - interval '20 days', + deletion_scheduled_at = now() - interval '5 days', + deletion_cancelled_at = NULL, + updated_at = now() +WHERE id = 11; + +-- Cancelled deletion request (request made then cancelled) +UPDATE users +SET + deletion_requested_at = now() - interval '10 days', + deletion_scheduled_at = now() + interval '5 days', + deletion_cancelled_at = now() - interval '3 days', + updated_at = now() +WHERE id = 12; diff --git a/db/migrations/000027_two_phase_account_deletion.down.sql b/db/migrations/000027_two_phase_account_deletion.down.sql new file mode 100644 index 0000000..72907a2 --- /dev/null +++ b/db/migrations/000027_two_phase_account_deletion.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_users_deletion_due; +DROP INDEX IF EXISTS idx_users_deletion_scheduled_at; + +ALTER TABLE users +DROP COLUMN IF EXISTS deletion_cancelled_at, +DROP COLUMN IF EXISTS deletion_scheduled_at, +DROP COLUMN IF EXISTS deletion_requested_at; diff --git a/db/migrations/000027_two_phase_account_deletion.up.sql b/db/migrations/000027_two_phase_account_deletion.up.sql new file mode 100644 index 0000000..fa0e179 --- /dev/null +++ b/db/migrations/000027_two_phase_account_deletion.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE users +ADD COLUMN IF NOT EXISTS deletion_requested_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS deletion_scheduled_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS deletion_cancelled_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_users_deletion_scheduled_at +ON users (deletion_scheduled_at) +WHERE deletion_scheduled_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_users_deletion_due +ON users (deletion_scheduled_at, id) +WHERE deletion_scheduled_at IS NOT NULL; diff --git a/docs/docs.go b/docs/docs.go index 8905391..39b8775 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -254,6 +254,199 @@ const docTemplate = `{ } } }, + "/api/v1/admin/users/deletion-requests": { + "get": { + "description": "Returns account deletion requests for admin panel tracking with filtering and pagination", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List account deletion requests", + "parameters": [ + { + "type": "string", + "description": "Search in first_name, last_name, email, phone_number", + "name": "query", + "in": "query" + }, + { + "type": "string", + "description": "Role filter", + "name": "role", + "in": "query" + }, + { + "type": "string", + "description": "User status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Deletion state filter (PENDING, DUE, CANCELLED)", + "name": "state", + "in": "query" + }, + { + "type": "string", + "description": "Requested before (RFC3339)", + "name": "requested_before", + "in": "query" + }, + { + "type": "string", + "description": "Requested after (RFC3339)", + "name": "requested_after", + "in": "query" + }, + { + "type": "string", + "description": "Scheduled before (RFC3339)", + "name": "scheduled_before", + "in": "query" + }, + { + "type": "string", + "description": "Scheduled after (RFC3339)", + "name": "scheduled_after", + "in": "query" + }, + { + "type": "integer", + "description": "Page number (default 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 10, max 100)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/api/v1/admin/users/{userId}/progress/courses/{courseId}": { + "get": { + "description": "Returns a target learner's progress for all sub-courses in a course, including lock status", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Get learner's course progress (admin)", + "parameters": [ + { + "type": "integer", + "description": "Learner User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "in": "path", + "required": true + } + ], + "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/admin/users/{userId}/progress/courses/{courseId}/summary": { + "get": { + "description": "Returns course-level aggregated progress metrics for a target learner", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Get learner's course progress summary (admin)", + "parameters": [ + { + "type": "integer", + "description": "Learner User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "in": "path", + "required": true + } + ], + "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/admin/{id}": { "get": { "description": "Get a single admin by id", @@ -708,6 +901,52 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/categories/reorder": { + "put": { + "description": "Updates the display_order of course categories for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-categories" + ], + "summary": "Reorder course categories", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/categories/{categoryId}/courses": { "get": { "description": "Returns a paginated list of courses under a specific category", @@ -946,6 +1185,99 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/courses/reorder": { + "put": { + "description": "Updates the display_order of courses for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "courses" + ], + "summary": "Reorder courses within a category", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/courses/{courseId}/learning-path": { + "get": { + "description": "Returns the complete learning path for a course including sub-courses (by level),\nvideo lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration", + "produces": [ + "application/json" + ], + "tags": [ + "learning-tree" + ], + "summary": "Get course learning path", + "parameters": [ + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "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/course-management/courses/{courseId}/sub-courses": { "get": { "description": "Returns all sub-courses under a specific course", @@ -1242,6 +1574,52 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/practices/reorder": { + "put": { + "description": "Updates the display_order of practices for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Reorder practices (question sets) within a sub-course", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/sub-courses": { "post": { "description": "Creates a new sub-course under a specific course", @@ -1314,6 +1692,52 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/sub-courses/reorder": { + "put": { + "description": "Updates the display_order of sub-courses for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-courses" + ], + "summary": "Reorder sub-courses within a course", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/sub-courses/{id}": { "get": { "description": "Returns a single sub-course by its ID", @@ -1492,6 +1916,146 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/sub-courses/{id}/prerequisites": { + "get": { + "description": "Returns all prerequisites for a sub-course", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Get sub-course prerequisites", + "parameters": [ + { + "type": "integer", + "description": "Sub-course 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Link a prerequisite sub-course that must be completed before accessing this sub-course", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Add prerequisite to sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Prerequisite sub-course ID", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.addPrerequisiteReq" + } + } + ], + "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/course-management/sub-courses/{id}/prerequisites/{prerequisiteId}": { + "delete": { + "description": "Unlink a prerequisite from a sub-course", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Remove prerequisite from sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Prerequisite sub-course ID", + "name": "prerequisiteId", + "in": "path", + "required": true + } + ], + "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/course-management/sub-courses/{id}/thumbnail": { "post": { "description": "Uploads and optimizes a thumbnail image, then updates the sub-course", @@ -1671,6 +2235,52 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/videos/reorder": { + "put": { + "description": "Updates the display_order of videos for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Reorder videos within a sub-course", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/videos/upload": { "post": { "description": "Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record", @@ -2035,6 +2645,51 @@ const docTemplate = `{ } } }, + "/api/v1/internal/users/purge-due-deletions": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Worker-safe purge for due self-deletion requests", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Purge due account deletions", + "parameters": [ + { + "type": "integer", + "description": "Max users to purge in one run (default 100, max 1000)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/issues": { "get": { "security": [ @@ -2512,6 +3167,370 @@ const docTemplate = `{ } } }, + "/api/v1/notifications/bulk-email": { + "post": { + "description": "Sends an email to specified user IDs, all users of a role, or direct email addresses with optional image attachment. Optionally schedule for later with scheduled_at (RFC3339).", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send bulk email", + "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/notifications/bulk-push": { + "post": { + "description": "Sends a push notification to specified user IDs or all users matching a role. Optionally schedule for later with scheduled_at (RFC3339).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send bulk push notification", + "parameters": [ + { + "description": "Bulk push content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string" + }, + "message": { + "type": "string" + }, + "role": { + "type": "string" + }, + "scheduled_at": { + "type": "string" + }, + "title": { + "type": "string" + }, + "user_ids": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + ], + "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/notifications/bulk-sms": { + "post": { + "description": "Sends an SMS to specified user IDs, all users of a role, or direct phone numbers. Optionally schedule for later with scheduled_at (RFC3339).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send bulk SMS", + "parameters": [ + { + "description": "Bulk SMS content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "role": { + "type": "string" + }, + "scheduled_at": { + "type": "string" + }, + "user_ids": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + ], + "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/notifications/scheduled": { + "get": { + "description": "Returns paginated scheduled notifications with optional status, channel, and date filters", + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "List scheduled notifications", + "parameters": [ + { + "type": "string", + "description": "Filter by status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Filter by channel", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Filter after date (RFC3339)", + "name": "after", + "in": "query" + }, + { + "type": "string", + "description": "Filter before date (RFC3339)", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "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/notifications/scheduled/{id}": { + "get": { + "description": "Returns a single scheduled notification by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Get scheduled notification", + "parameters": [ + { + "type": "integer", + "description": "Scheduled Notification 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/notifications/scheduled/{id}/cancel": { + "post": { + "description": "Cancels a scheduled notification if it is still pending or processing", + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Cancel scheduled notification", + "parameters": [ + { + "type": "integer", + "description": "Scheduled Notification 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/notifications/send-email": { + "post": { + "description": "Sends an email to a single email address with optional image attachment", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send single email", + "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/notifications/test-push": { "post": { "description": "Sends a test push notification to all registered devices of the current user", @@ -2937,6 +3956,335 @@ const docTemplate = `{ } } }, + "/api/v1/progress/courses/{courseId}": { + "get": { + "description": "Returns the authenticated user's progress for all sub-courses in a course, including lock status", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Get user's course progress", + "parameters": [ + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "in": "path", + "required": true + } + ], + "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/progress/practices/{id}/complete": { + "post": { + "description": "Marks a practice question set as completed for the authenticated learner", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Mark practice as completed", + "parameters": [ + { + "type": "integer", + "description": "Practice Question Set 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" + } + }, + "403": { + "description": "Forbidden", + "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/progress/sub-courses/{id}": { + "put": { + "description": "Update the progress percentage for a sub-course", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Update sub-course progress", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Progress update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateProgressReq" + } + } + ], + "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/progress/sub-courses/{id}/access": { + "get": { + "description": "Check if the authenticated user has completed all prerequisites for a sub-course", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Check sub-course access", + "parameters": [ + { + "type": "integer", + "description": "Sub-course 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/progress/sub-courses/{id}/complete": { + "post": { + "description": "Mark a sub-course as completed for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Complete a sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/progress/sub-courses/{id}/start": { + "post": { + "description": "Mark a sub-course as started for the authenticated user (checks prerequisites)", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Start a sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/progress/videos/{id}/complete": { + "post": { + "description": "Marks the given video as completed for the authenticated learner", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Mark sub-course video as completed", + "parameters": [ + { + "type": "integer", + "description": "Video 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" + } + }, + "403": { + "description": "Forbidden", + "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/question-sets": { "get": { "description": "Returns a paginated list of question sets filtered by type", @@ -3084,6 +4432,53 @@ const docTemplate = `{ } } }, + "/api/v1/question-sets/sub-courses/{subCourseId}/entry-assessment": { + "get": { + "description": "Returns the published INITIAL_ASSESSMENT question set for the given sub-course", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get entry assessment set for a sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "subCourseId", + "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/question-sets/{id}": { "get": { "description": "Returns a question set with question count", @@ -4124,6 +5519,549 @@ const docTemplate = `{ } } }, + "/api/v1/rbac/permissions": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all permissions in the system grouped by group name", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "List all permissions", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Permission" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/permissions/groups": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all distinct permission group names", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "List permission groups", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/permissions/sync": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Re-seed permissions from code and reload the RBAC cache", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Sync permissions", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/roles": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all roles with optional filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "List all roles", + "parameters": [ + { + "type": "string", + "description": "Search by role name", + "name": "query", + "in": "query" + }, + { + "type": "boolean", + "description": "Filter by system role (true/false)", + "name": "is_system", + "in": "query" + }, + { + "type": "integer", + "description": "Page number (default: 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default: 20)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Create a new role with a name and description", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Create a new role", + "parameters": [ + { + "description": "Role creation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.CreateRoleReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.RoleRecord" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/roles/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get a role and its permissions by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Get a role by ID", + "parameters": [ + { + "type": "integer", + "description": "Role ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.RoleWithPermissions" + } + } + } + ] + } + }, + "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": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update an existing role's name and description", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Update a role", + "parameters": [ + { + "type": "integer", + "description": "Role ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Role update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateRoleReq" + } + } + ], + "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" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Delete a non-system role by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Delete a role", + "parameters": [ + { + "type": "integer", + "description": "Role 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/roles/{id}/permissions": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all permissions assigned to a role", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Get permissions for a role", + "parameters": [ + { + "type": "integer", + "description": "Role ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Permission" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Replace all permissions for a role with the given permission IDs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Set permissions for a role", + "parameters": [ + { + "type": "integer", + "description": "Role ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Permission IDs payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.SetRolePermissionsReq" + } + } + ], + "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/sendSMS": { "post": { "description": "Sends an SMS message to a single phone number using AfroMessage", @@ -5650,6 +7588,92 @@ const docTemplate = `{ } } }, + "/api/v1/user/me": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Starts account deletion with grace period before permanent purge", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Request deletion of my account", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/api/v1/user/me/deletion/cancel": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Cancels a pending self-deletion request during grace period", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Cancel my account deletion request", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/user/resetPassword": { "post": { "description": "Reset password", @@ -5838,6 +7862,52 @@ const docTemplate = `{ } } }, + "/api/v1/user/status": { + "patch": { + "description": "Activates, deactivates, or suspends a user account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user status", + "parameters": [ + { + "description": "Status update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/user/verify-otp": { "post": { "description": "Verify OTP for registration or other actions", @@ -6089,6 +8159,12 @@ const docTemplate = `{ "description": "Created after (RFC3339)", "name": "created_after", "in": "query" + }, + { + "type": "string", + "description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", + "name": "status", + "in": "query" } ], "responses": { @@ -6113,6 +8189,52 @@ const docTemplate = `{ } } }, + "/api/v1/users/summary": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns total users, active users, and users who joined this month", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user summary statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.UserSummary" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/vimeo/oembed": { "get": { "description": "Fetches oEmbed metadata for a Vimeo video URL", @@ -6169,6 +8291,58 @@ const docTemplate = `{ } } }, + "/api/v1/vimeo/sample": { + "get": { + "description": "Fetches a sample video from Vimeo and returns video details along with an embeddable iframe for client-side integration", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get a sample Vimeo video with iframe embed", + "parameters": [ + { + "type": "string", + "default": "76979871", + "description": "Vimeo Video ID to use as sample", + "name": "video_id", + "in": "query" + }, + { + "type": "integer", + "default": 640, + "description": "Player width", + "name": "width", + "in": "query" + }, + { + "type": "integer", + "default": 360, + "description": "Player height", + "name": "height", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/vimeo/uploads/pull": { "post": { "description": "Initiates a pull upload where Vimeo fetches the video from a URL", @@ -7015,6 +9189,22 @@ const docTemplate = `{ "Age55Plus" ] }, + "domain.CreateRoleReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 100, + "minLength": 2 + } + } + }, "domain.CreateTeamMemberReq": { "type": "object", "required": [ @@ -7195,6 +9385,48 @@ const docTemplate = `{ } } }, + "domain.Permission": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "group_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "domain.QuestionAudioAnswer": { + "type": "object", + "properties": { + "correctAnswerText": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "questionID": { + "type": "integer", + "format": "int64" + } + } + }, "domain.QuestionOption": { "type": "object", "properties": { @@ -7246,6 +9478,9 @@ const docTemplate = `{ "domain.QuestionWithDetails": { "type": "object", "properties": { + "audioAnswer": { + "$ref": "#/definitions/domain.QuestionAudioAnswer" + }, "createdAt": { "type": "string" }, @@ -7259,6 +9494,9 @@ const docTemplate = `{ "type": "integer", "format": "int64" }, + "imageURL": { + "type": "string" + }, "options": { "type": "array", "items": { @@ -7362,6 +9600,72 @@ const docTemplate = `{ "RoleSupport" ] }, + "domain.RoleRecord": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "domain.RoleWithPermissions": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Permission" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "domain.SetRolePermissionsReq": { + "type": "object", + "required": [ + "permission_ids" + ], + "properties": { + "permission_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "domain.TeamMemberLoginReq": { "type": "object", "required": [ @@ -7480,14 +9784,14 @@ const docTemplate = `{ "domain.TeamRole": { "type": "string", "enum": [ - "super_admin", - "admin", - "content_manager", - "support_agent", - "instructor", - "finance", - "hr", - "analyst" + "SUPER_ADMIN", + "ADMIN", + "CONTENT_MANAGER", + "SUPPORT_AGENT", + "INSTRUCTOR", + "FINANCE", + "HR", + "ANALYST" ], "x-enum-varnames": [ "TeamRoleSuperAdmin", @@ -7512,6 +9816,22 @@ const docTemplate = `{ } } }, + "domain.UpdateRoleReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 100, + "minLength": 2 + } + } + }, "domain.UpdateTeamMemberReq": { "type": "object", "properties": { @@ -7740,6 +10060,20 @@ const docTemplate = `{ "UserStatusDeactivated" ] }, + "domain.UserSummary": { + "type": "object", + "properties": { + "active_users": { + "type": "integer" + }, + "joined_this_month": { + "type": "integer" + }, + "total_users": { + "type": "integer" + } + } + }, "domain.VerifyOtpReq": { "type": "object", "required": [ @@ -8146,6 +10480,17 @@ const docTemplate = `{ } } }, + "handlers.addPrerequisiteReq": { + "type": "object", + "required": [ + "prerequisite_sub_course_id" + ], + "properties": { + "prerequisite_sub_course_id": { + "type": "integer" + } + } + }, "handlers.addQuestionToSetReq": { "type": "object", "required": [ @@ -8224,6 +10569,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "intro_video_url": { + "type": "string" + }, "thumbnail": { "type": "string" }, @@ -8303,12 +10651,18 @@ const docTemplate = `{ "question_type" ], "properties": { + "audio_correct_answer_text": { + "type": "string" + }, "difficulty_level": { "type": "string" }, "explanation": { "type": "string" }, + "image_url": { + "type": "string" + }, "options": { "type": "array", "items": { @@ -8326,7 +10680,8 @@ const docTemplate = `{ "enum": [ "MCQ", "TRUE_FALSE", - "SHORT_ANSWER" + "SHORT_ANSWER", + "AUDIO" ] }, "sample_answer_voice_prompt": { @@ -8406,6 +10761,7 @@ const docTemplate = `{ "required": [ "course_id", "level", + "sub_level", "title" ], "properties": { @@ -8422,6 +10778,10 @@ const docTemplate = `{ "description": "BEGINNER, INTERMEDIATE, ADVANCED", "type": "string" }, + "sub_level": { + "description": "A1..C3 depending on level", + "type": "string" + }, "thumbnail": { "type": "string" }, @@ -8706,6 +11066,35 @@ const docTemplate = `{ } } }, + "handlers.reorderItem": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "position": { + "type": "integer" + } + } + }, + "handlers.reorderReq": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/handlers.reorderItem" + } + } + } + }, "handlers.shortAnswerInput": { "type": "object", "required": [ @@ -8834,6 +11223,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "intro_video_url": { + "type": "string" + }, "is_active": { "type": "boolean" }, @@ -8888,6 +11280,19 @@ const docTemplate = `{ } } }, + "handlers.updateProgressReq": { + "type": "object", + "required": [ + "progress_percentage" + ], + "properties": { + "progress_percentage": { + "type": "integer", + "maximum": 100, + "minimum": 0 + } + } + }, "handlers.updateQuestionOrderReq": { "type": "object", "required": [ @@ -8902,12 +11307,18 @@ const docTemplate = `{ "handlers.updateQuestionReq": { "type": "object", "properties": { + "audio_correct_answer_text": { + "type": "string" + }, "difficulty_level": { "type": "string" }, "explanation": { "type": "string" }, + "image_url": { + "type": "string" + }, "options": { "type": "array", "items": { @@ -8990,6 +11401,9 @@ const docTemplate = `{ "level": { "type": "string" }, + "sub_level": { + "type": "string" + }, "thumbnail": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 854c8a7..f313fa6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -246,6 +246,199 @@ } } }, + "/api/v1/admin/users/deletion-requests": { + "get": { + "description": "Returns account deletion requests for admin panel tracking with filtering and pagination", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List account deletion requests", + "parameters": [ + { + "type": "string", + "description": "Search in first_name, last_name, email, phone_number", + "name": "query", + "in": "query" + }, + { + "type": "string", + "description": "Role filter", + "name": "role", + "in": "query" + }, + { + "type": "string", + "description": "User status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Deletion state filter (PENDING, DUE, CANCELLED)", + "name": "state", + "in": "query" + }, + { + "type": "string", + "description": "Requested before (RFC3339)", + "name": "requested_before", + "in": "query" + }, + { + "type": "string", + "description": "Requested after (RFC3339)", + "name": "requested_after", + "in": "query" + }, + { + "type": "string", + "description": "Scheduled before (RFC3339)", + "name": "scheduled_before", + "in": "query" + }, + { + "type": "string", + "description": "Scheduled after (RFC3339)", + "name": "scheduled_after", + "in": "query" + }, + { + "type": "integer", + "description": "Page number (default 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 10, max 100)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/api/v1/admin/users/{userId}/progress/courses/{courseId}": { + "get": { + "description": "Returns a target learner's progress for all sub-courses in a course, including lock status", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Get learner's course progress (admin)", + "parameters": [ + { + "type": "integer", + "description": "Learner User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "in": "path", + "required": true + } + ], + "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/admin/users/{userId}/progress/courses/{courseId}/summary": { + "get": { + "description": "Returns course-level aggregated progress metrics for a target learner", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Get learner's course progress summary (admin)", + "parameters": [ + { + "type": "integer", + "description": "Learner User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "in": "path", + "required": true + } + ], + "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/admin/{id}": { "get": { "description": "Get a single admin by id", @@ -700,6 +893,52 @@ } } }, + "/api/v1/course-management/categories/reorder": { + "put": { + "description": "Updates the display_order of course categories for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-categories" + ], + "summary": "Reorder course categories", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/categories/{categoryId}/courses": { "get": { "description": "Returns a paginated list of courses under a specific category", @@ -938,6 +1177,99 @@ } } }, + "/api/v1/course-management/courses/reorder": { + "put": { + "description": "Updates the display_order of courses for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "courses" + ], + "summary": "Reorder courses within a category", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/courses/{courseId}/learning-path": { + "get": { + "description": "Returns the complete learning path for a course including sub-courses (by level),\nvideo lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration", + "produces": [ + "application/json" + ], + "tags": [ + "learning-tree" + ], + "summary": "Get course learning path", + "parameters": [ + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "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/course-management/courses/{courseId}/sub-courses": { "get": { "description": "Returns all sub-courses under a specific course", @@ -1234,6 +1566,52 @@ } } }, + "/api/v1/course-management/practices/reorder": { + "put": { + "description": "Updates the display_order of practices for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Reorder practices (question sets) within a sub-course", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/sub-courses": { "post": { "description": "Creates a new sub-course under a specific course", @@ -1306,6 +1684,52 @@ } } }, + "/api/v1/course-management/sub-courses/reorder": { + "put": { + "description": "Updates the display_order of sub-courses for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-courses" + ], + "summary": "Reorder sub-courses within a course", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/sub-courses/{id}": { "get": { "description": "Returns a single sub-course by its ID", @@ -1484,6 +1908,146 @@ } } }, + "/api/v1/course-management/sub-courses/{id}/prerequisites": { + "get": { + "description": "Returns all prerequisites for a sub-course", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Get sub-course prerequisites", + "parameters": [ + { + "type": "integer", + "description": "Sub-course 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Link a prerequisite sub-course that must be completed before accessing this sub-course", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Add prerequisite to sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Prerequisite sub-course ID", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.addPrerequisiteReq" + } + } + ], + "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/course-management/sub-courses/{id}/prerequisites/{prerequisiteId}": { + "delete": { + "description": "Unlink a prerequisite from a sub-course", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Remove prerequisite from sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Prerequisite sub-course ID", + "name": "prerequisiteId", + "in": "path", + "required": true + } + ], + "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/course-management/sub-courses/{id}/thumbnail": { "post": { "description": "Uploads and optimizes a thumbnail image, then updates the sub-course", @@ -1663,6 +2227,52 @@ } } }, + "/api/v1/course-management/videos/reorder": { + "put": { + "description": "Updates the display_order of videos for drag-and-drop sorting", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "sub-course-videos" + ], + "summary": "Reorder videos within a sub-course", + "parameters": [ + { + "description": "Reorder payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reorderReq" + } + } + ], + "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/course-management/videos/upload": { "post": { "description": "Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record", @@ -2027,6 +2637,51 @@ } } }, + "/api/v1/internal/users/purge-due-deletions": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Worker-safe purge for due self-deletion requests", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Purge due account deletions", + "parameters": [ + { + "type": "integer", + "description": "Max users to purge in one run (default 100, max 1000)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/issues": { "get": { "security": [ @@ -2504,6 +3159,370 @@ } } }, + "/api/v1/notifications/bulk-email": { + "post": { + "description": "Sends an email to specified user IDs, all users of a role, or direct email addresses with optional image attachment. Optionally schedule for later with scheduled_at (RFC3339).", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send bulk email", + "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/notifications/bulk-push": { + "post": { + "description": "Sends a push notification to specified user IDs or all users matching a role. Optionally schedule for later with scheduled_at (RFC3339).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send bulk push notification", + "parameters": [ + { + "description": "Bulk push content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string" + }, + "message": { + "type": "string" + }, + "role": { + "type": "string" + }, + "scheduled_at": { + "type": "string" + }, + "title": { + "type": "string" + }, + "user_ids": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + ], + "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/notifications/bulk-sms": { + "post": { + "description": "Sends an SMS to specified user IDs, all users of a role, or direct phone numbers. Optionally schedule for later with scheduled_at (RFC3339).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send bulk SMS", + "parameters": [ + { + "description": "Bulk SMS content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "role": { + "type": "string" + }, + "scheduled_at": { + "type": "string" + }, + "user_ids": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + ], + "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/notifications/scheduled": { + "get": { + "description": "Returns paginated scheduled notifications with optional status, channel, and date filters", + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "List scheduled notifications", + "parameters": [ + { + "type": "string", + "description": "Filter by status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Filter by channel", + "name": "channel", + "in": "query" + }, + { + "type": "string", + "description": "Filter after date (RFC3339)", + "name": "after", + "in": "query" + }, + { + "type": "string", + "description": "Filter before date (RFC3339)", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "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/notifications/scheduled/{id}": { + "get": { + "description": "Returns a single scheduled notification by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Get scheduled notification", + "parameters": [ + { + "type": "integer", + "description": "Scheduled Notification 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/notifications/scheduled/{id}/cancel": { + "post": { + "description": "Cancels a scheduled notification if it is still pending or processing", + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Cancel scheduled notification", + "parameters": [ + { + "type": "integer", + "description": "Scheduled Notification 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/notifications/send-email": { + "post": { + "description": "Sends an email to a single email address with optional image attachment", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Send single email", + "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/notifications/test-push": { "post": { "description": "Sends a test push notification to all registered devices of the current user", @@ -2929,6 +3948,335 @@ } } }, + "/api/v1/progress/courses/{courseId}": { + "get": { + "description": "Returns the authenticated user's progress for all sub-courses in a course, including lock status", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Get user's course progress", + "parameters": [ + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "in": "path", + "required": true + } + ], + "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/progress/practices/{id}/complete": { + "post": { + "description": "Marks a practice question set as completed for the authenticated learner", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Mark practice as completed", + "parameters": [ + { + "type": "integer", + "description": "Practice Question Set 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" + } + }, + "403": { + "description": "Forbidden", + "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/progress/sub-courses/{id}": { + "put": { + "description": "Update the progress percentage for a sub-course", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Update sub-course progress", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Progress update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateProgressReq" + } + } + ], + "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/progress/sub-courses/{id}/access": { + "get": { + "description": "Check if the authenticated user has completed all prerequisites for a sub-course", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Check sub-course access", + "parameters": [ + { + "type": "integer", + "description": "Sub-course 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/progress/sub-courses/{id}/complete": { + "post": { + "description": "Mark a sub-course as completed for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Complete a sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/progress/sub-courses/{id}/start": { + "post": { + "description": "Mark a sub-course as started for the authenticated user (checks prerequisites)", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Start a sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/progress/videos/{id}/complete": { + "post": { + "description": "Marks the given video as completed for the authenticated learner", + "produces": [ + "application/json" + ], + "tags": [ + "progression" + ], + "summary": "Mark sub-course video as completed", + "parameters": [ + { + "type": "integer", + "description": "Video 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" + } + }, + "403": { + "description": "Forbidden", + "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/question-sets": { "get": { "description": "Returns a paginated list of question sets filtered by type", @@ -3076,6 +4424,53 @@ } } }, + "/api/v1/question-sets/sub-courses/{subCourseId}/entry-assessment": { + "get": { + "description": "Returns the published INITIAL_ASSESSMENT question set for the given sub-course", + "produces": [ + "application/json" + ], + "tags": [ + "question-sets" + ], + "summary": "Get entry assessment set for a sub-course", + "parameters": [ + { + "type": "integer", + "description": "Sub-course ID", + "name": "subCourseId", + "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/question-sets/{id}": { "get": { "description": "Returns a question set with question count", @@ -4116,6 +5511,549 @@ } } }, + "/api/v1/rbac/permissions": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all permissions in the system grouped by group name", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "List all permissions", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Permission" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/permissions/groups": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all distinct permission group names", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "List permission groups", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/permissions/sync": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Re-seed permissions from code and reload the RBAC cache", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Sync permissions", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/roles": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all roles with optional filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "List all roles", + "parameters": [ + { + "type": "string", + "description": "Search by role name", + "name": "query", + "in": "query" + }, + { + "type": "boolean", + "description": "Filter by system role (true/false)", + "name": "is_system", + "in": "query" + }, + { + "type": "integer", + "description": "Page number (default: 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default: 20)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Create a new role with a name and description", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Create a new role", + "parameters": [ + { + "description": "Role creation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.CreateRoleReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.RoleRecord" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/roles/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get a role and its permissions by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Get a role by ID", + "parameters": [ + { + "type": "integer", + "description": "Role ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.RoleWithPermissions" + } + } + } + ] + } + }, + "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": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update an existing role's name and description", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Update a role", + "parameters": [ + { + "type": "integer", + "description": "Role ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Role update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateRoleReq" + } + } + ], + "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" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Delete a non-system role by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Delete a role", + "parameters": [ + { + "type": "integer", + "description": "Role 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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/rbac/roles/{id}/permissions": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all permissions assigned to a role", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Get permissions for a role", + "parameters": [ + { + "type": "integer", + "description": "Role ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Permission" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Replace all permissions for a role with the given permission IDs", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rbac" + ], + "summary": "Set permissions for a role", + "parameters": [ + { + "type": "integer", + "description": "Role ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Permission IDs payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.SetRolePermissionsReq" + } + } + ], + "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/sendSMS": { "post": { "description": "Sends an SMS message to a single phone number using AfroMessage", @@ -5642,6 +7580,92 @@ } } }, + "/api/v1/user/me": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Starts account deletion with grace period before permanent purge", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Request deletion of my account", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/api/v1/user/me/deletion/cancel": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Cancels a pending self-deletion request during grace period", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Cancel my account deletion request", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/user/resetPassword": { "post": { "description": "Reset password", @@ -5830,6 +7854,52 @@ } } }, + "/api/v1/user/status": { + "patch": { + "description": "Activates, deactivates, or suspends a user account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user status", + "parameters": [ + { + "description": "Status update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/user/verify-otp": { "post": { "description": "Verify OTP for registration or other actions", @@ -6081,6 +8151,12 @@ "description": "Created after (RFC3339)", "name": "created_after", "in": "query" + }, + { + "type": "string", + "description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", + "name": "status", + "in": "query" } ], "responses": { @@ -6105,6 +8181,52 @@ } } }, + "/api/v1/users/summary": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns total users, active users, and users who joined this month", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user summary statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.UserSummary" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/vimeo/oembed": { "get": { "description": "Fetches oEmbed metadata for a Vimeo video URL", @@ -6161,6 +8283,58 @@ } } }, + "/api/v1/vimeo/sample": { + "get": { + "description": "Fetches a sample video from Vimeo and returns video details along with an embeddable iframe for client-side integration", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vimeo" + ], + "summary": "Get a sample Vimeo video with iframe embed", + "parameters": [ + { + "type": "string", + "default": "76979871", + "description": "Vimeo Video ID to use as sample", + "name": "video_id", + "in": "query" + }, + { + "type": "integer", + "default": 640, + "description": "Player width", + "name": "width", + "in": "query" + }, + { + "type": "integer", + "default": 360, + "description": "Player height", + "name": "height", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/vimeo/uploads/pull": { "post": { "description": "Initiates a pull upload where Vimeo fetches the video from a URL", @@ -7007,6 +9181,22 @@ "Age55Plus" ] }, + "domain.CreateRoleReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 100, + "minLength": 2 + } + } + }, "domain.CreateTeamMemberReq": { "type": "object", "required": [ @@ -7187,6 +9377,48 @@ } } }, + "domain.Permission": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "group_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "domain.QuestionAudioAnswer": { + "type": "object", + "properties": { + "correctAnswerText": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "questionID": { + "type": "integer", + "format": "int64" + } + } + }, "domain.QuestionOption": { "type": "object", "properties": { @@ -7238,6 +9470,9 @@ "domain.QuestionWithDetails": { "type": "object", "properties": { + "audioAnswer": { + "$ref": "#/definitions/domain.QuestionAudioAnswer" + }, "createdAt": { "type": "string" }, @@ -7251,6 +9486,9 @@ "type": "integer", "format": "int64" }, + "imageURL": { + "type": "string" + }, "options": { "type": "array", "items": { @@ -7354,6 +9592,72 @@ "RoleSupport" ] }, + "domain.RoleRecord": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "domain.RoleWithPermissions": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Permission" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "domain.SetRolePermissionsReq": { + "type": "object", + "required": [ + "permission_ids" + ], + "properties": { + "permission_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "domain.TeamMemberLoginReq": { "type": "object", "required": [ @@ -7472,14 +9776,14 @@ "domain.TeamRole": { "type": "string", "enum": [ - "super_admin", - "admin", - "content_manager", - "support_agent", - "instructor", - "finance", - "hr", - "analyst" + "SUPER_ADMIN", + "ADMIN", + "CONTENT_MANAGER", + "SUPPORT_AGENT", + "INSTRUCTOR", + "FINANCE", + "HR", + "ANALYST" ], "x-enum-varnames": [ "TeamRoleSuperAdmin", @@ -7504,6 +9808,22 @@ } } }, + "domain.UpdateRoleReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 100, + "minLength": 2 + } + } + }, "domain.UpdateTeamMemberReq": { "type": "object", "properties": { @@ -7732,6 +10052,20 @@ "UserStatusDeactivated" ] }, + "domain.UserSummary": { + "type": "object", + "properties": { + "active_users": { + "type": "integer" + }, + "joined_this_month": { + "type": "integer" + }, + "total_users": { + "type": "integer" + } + } + }, "domain.VerifyOtpReq": { "type": "object", "required": [ @@ -8138,6 +10472,17 @@ } } }, + "handlers.addPrerequisiteReq": { + "type": "object", + "required": [ + "prerequisite_sub_course_id" + ], + "properties": { + "prerequisite_sub_course_id": { + "type": "integer" + } + } + }, "handlers.addQuestionToSetReq": { "type": "object", "required": [ @@ -8216,6 +10561,9 @@ "description": { "type": "string" }, + "intro_video_url": { + "type": "string" + }, "thumbnail": { "type": "string" }, @@ -8295,12 +10643,18 @@ "question_type" ], "properties": { + "audio_correct_answer_text": { + "type": "string" + }, "difficulty_level": { "type": "string" }, "explanation": { "type": "string" }, + "image_url": { + "type": "string" + }, "options": { "type": "array", "items": { @@ -8318,7 +10672,8 @@ "enum": [ "MCQ", "TRUE_FALSE", - "SHORT_ANSWER" + "SHORT_ANSWER", + "AUDIO" ] }, "sample_answer_voice_prompt": { @@ -8398,6 +10753,7 @@ "required": [ "course_id", "level", + "sub_level", "title" ], "properties": { @@ -8414,6 +10770,10 @@ "description": "BEGINNER, INTERMEDIATE, ADVANCED", "type": "string" }, + "sub_level": { + "description": "A1..C3 depending on level", + "type": "string" + }, "thumbnail": { "type": "string" }, @@ -8698,6 +11058,35 @@ } } }, + "handlers.reorderItem": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "position": { + "type": "integer" + } + } + }, + "handlers.reorderReq": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/handlers.reorderItem" + } + } + } + }, "handlers.shortAnswerInput": { "type": "object", "required": [ @@ -8826,6 +11215,9 @@ "description": { "type": "string" }, + "intro_video_url": { + "type": "string" + }, "is_active": { "type": "boolean" }, @@ -8880,6 +11272,19 @@ } } }, + "handlers.updateProgressReq": { + "type": "object", + "required": [ + "progress_percentage" + ], + "properties": { + "progress_percentage": { + "type": "integer", + "maximum": 100, + "minimum": 0 + } + } + }, "handlers.updateQuestionOrderReq": { "type": "object", "required": [ @@ -8894,12 +11299,18 @@ "handlers.updateQuestionReq": { "type": "object", "properties": { + "audio_correct_answer_text": { + "type": "string" + }, "difficulty_level": { "type": "string" }, "explanation": { "type": "string" }, + "image_url": { + "type": "string" + }, "options": { "type": "array", "items": { @@ -8982,6 +11393,9 @@ "level": { "type": "string" }, + "sub_level": { + "type": "string" + }, "thumbnail": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d8ea26c..9ab583b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -17,6 +17,17 @@ definitions: - Age35To44 - Age45To54 - Age55Plus + domain.CreateRoleReq: + properties: + description: + type: string + name: + maxLength: 100 + minLength: 2 + type: string + required: + - name + type: object domain.CreateTeamMemberReq: properties: bio: @@ -140,6 +151,34 @@ definitions: total_pages: type: integer type: object + domain.Permission: + properties: + created_at: + type: string + description: + type: string + group_name: + type: string + id: + type: integer + key: + type: string + name: + type: string + type: object + domain.QuestionAudioAnswer: + properties: + correctAnswerText: + type: string + createdAt: + type: string + id: + format: int64 + type: integer + questionID: + format: int64 + type: integer + type: object domain.QuestionOption: properties: createdAt: @@ -175,6 +214,8 @@ definitions: type: object domain.QuestionWithDetails: properties: + audioAnswer: + $ref: '#/definitions/domain.QuestionAudioAnswer' createdAt: type: string difficultyLevel: @@ -184,6 +225,8 @@ definitions: id: format: int64 type: integer + imageURL: + type: string options: items: $ref: '#/definitions/domain.QuestionOption' @@ -255,6 +298,49 @@ definitions: - RoleStudent - RoleInstructor - RoleSupport + domain.RoleRecord: + properties: + created_at: + type: string + description: + type: string + id: + type: integer + is_system: + type: boolean + name: + type: string + updated_at: + type: string + type: object + domain.RoleWithPermissions: + properties: + created_at: + type: string + description: + type: string + id: + type: integer + is_system: + type: boolean + name: + type: string + permissions: + items: + $ref: '#/definitions/domain.Permission' + type: array + updated_at: + type: string + type: object + domain.SetRolePermissionsReq: + properties: + permission_ids: + items: + type: integer + type: array + required: + - permission_ids + type: object domain.TeamMemberLoginReq: properties: email: @@ -335,14 +421,14 @@ definitions: - TeamMemberStatusTerminated domain.TeamRole: enum: - - super_admin - - admin - - content_manager - - support_agent - - instructor - - finance - - hr - - analyst + - SUPER_ADMIN + - ADMIN + - CONTENT_MANAGER + - SUPPORT_AGENT + - INSTRUCTOR + - FINANCE + - HR + - ANALYST type: string x-enum-varnames: - TeamRoleSuperAdmin @@ -361,6 +447,17 @@ definitions: user_id: type: integer type: object + domain.UpdateRoleReq: + properties: + description: + type: string + name: + maxLength: 100 + minLength: 2 + type: string + required: + - name + type: object domain.UpdateTeamMemberReq: properties: bio: @@ -515,6 +612,15 @@ definitions: - UserStatusActive - UserStatusSuspended - UserStatusDeactivated + domain.UserSummary: + properties: + active_users: + type: integer + joined_this_month: + type: integer + total_users: + type: integer + type: object domain.VerifyOtpReq: properties: email: @@ -787,6 +893,13 @@ definitions: width: type: integer type: object + handlers.addPrerequisiteReq: + properties: + prerequisite_sub_course_id: + type: integer + required: + - prerequisite_sub_course_id + type: object handlers.addQuestionToSetReq: properties: display_order: @@ -836,6 +949,8 @@ definitions: type: integer description: type: string + intro_video_url: + type: string thumbnail: type: string title: @@ -892,10 +1007,14 @@ definitions: type: object handlers.createQuestionReq: properties: + audio_correct_answer_text: + type: string difficulty_level: type: string explanation: type: string + image_url: + type: string options: items: $ref: '#/definitions/handlers.optionInput' @@ -909,6 +1028,7 @@ definitions: - MCQ - TRUE_FALSE - SHORT_ANSWER + - AUDIO type: string sample_answer_voice_prompt: type: string @@ -973,6 +1093,9 @@ definitions: level: description: BEGINNER, INTERMEDIATE, ADVANCED type: string + sub_level: + description: A1..C3 depending on level + type: string thumbnail: type: string title: @@ -980,6 +1103,7 @@ definitions: required: - course_id - level + - sub_level - title type: object handlers.createSubCourseVideoReq: @@ -1169,6 +1293,25 @@ definitions: - access_token - refresh_token type: object + handlers.reorderItem: + properties: + id: + type: integer + position: + type: integer + required: + - id + type: object + handlers.reorderReq: + properties: + items: + items: + $ref: '#/definitions/handlers.reorderItem' + minItems: 1 + type: array + required: + - items + type: object handlers.shortAnswerInput: properties: acceptable_answer: @@ -1256,6 +1399,8 @@ definitions: properties: description: type: string + intro_video_url: + type: string is_active: type: boolean thumbnail: @@ -1292,6 +1437,15 @@ definitions: price: type: number type: object + handlers.updateProgressReq: + properties: + progress_percentage: + maximum: 100 + minimum: 0 + type: integer + required: + - progress_percentage + type: object handlers.updateQuestionOrderReq: properties: display_order: @@ -1301,10 +1455,14 @@ definitions: type: object handlers.updateQuestionReq: properties: + audio_correct_answer_text: + type: string difficulty_level: type: string explanation: type: string + image_url: + type: string options: items: $ref: '#/definitions/handlers.optionInput' @@ -1359,6 +1517,8 @@ definitions: type: boolean level: type: string + sub_level: + type: string thumbnail: type: string title: @@ -2025,6 +2185,136 @@ paths: summary: Update Admin tags: - admin + /api/v1/admin/users/{userId}/progress/courses/{courseId}: + get: + description: Returns a target learner's progress for all sub-courses in a course, + including lock status + parameters: + - description: Learner User ID + in: path + name: userId + required: true + type: integer + - description: Course ID + in: path + name: courseId + 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get learner's course progress (admin) + tags: + - progression + /api/v1/admin/users/{userId}/progress/courses/{courseId}/summary: + get: + description: Returns course-level aggregated progress metrics for a target learner + parameters: + - description: Learner User ID + in: path + name: userId + required: true + type: integer + - description: Course ID + in: path + name: courseId + 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get learner's course progress summary (admin) + tags: + - progression + /api/v1/admin/users/deletion-requests: + get: + consumes: + - application/json + description: Returns account deletion requests for admin panel tracking with + filtering and pagination + parameters: + - description: Search in first_name, last_name, email, phone_number + in: query + name: query + type: string + - description: Role filter + in: query + name: role + type: string + - description: User status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED) + in: query + name: status + type: string + - description: Deletion state filter (PENDING, DUE, CANCELLED) + in: query + name: state + type: string + - description: Requested before (RFC3339) + in: query + name: requested_before + type: string + - description: Requested after (RFC3339) + in: query + name: requested_after + type: string + - description: Scheduled before (RFC3339) + in: query + name: scheduled_before + type: string + - description: Scheduled after (RFC3339) + in: query + name: scheduled_after + type: string + - description: Page number (default 1) + in: query + name: page + type: integer + - description: Page size (default 10, max 100) + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: List account deletion requests + tags: + - user /api/v1/assessment/questions: get: description: Returns all active assessment questions from the initial assessment @@ -2387,6 +2677,37 @@ paths: summary: Update course category tags: - course-categories + /api/v1/course-management/categories/reorder: + put: + consumes: + - application/json + description: Updates the display_order of course categories for drag-and-drop + sorting + parameters: + - description: Reorder payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.reorderReq' + 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: Reorder course categories + tags: + - course-categories /api/v1/course-management/courses: post: consumes: @@ -2417,6 +2738,39 @@ paths: summary: Create a new course tags: - courses + /api/v1/course-management/courses/{courseId}/learning-path: + get: + description: |- + Returns the complete learning path for a course including sub-courses (by level), + video lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration + parameters: + - description: Course ID + in: path + name: courseId + 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 course learning path + tags: + - learning-tree /api/v1/course-management/courses/{courseId}/sub-courses: get: description: Returns all sub-courses under a specific course @@ -2596,6 +2950,36 @@ paths: summary: Upload a thumbnail image for a course tags: - courses + /api/v1/course-management/courses/reorder: + put: + consumes: + - application/json + description: Updates the display_order of courses for drag-and-drop sorting + parameters: + - description: Reorder payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.reorderReq' + 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: Reorder courses within a category + tags: + - courses /api/v1/course-management/learning-tree: get: description: Returns the complete learning tree structure with courses and sub-courses @@ -2613,6 +2997,36 @@ paths: summary: Get full learning tree tags: - learning-tree + /api/v1/course-management/practices/reorder: + put: + consumes: + - application/json + description: Updates the display_order of practices for drag-and-drop sorting + parameters: + - description: Reorder payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.reorderReq' + 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: Reorder practices (question sets) within a sub-course + tags: + - question-sets /api/v1/course-management/sub-courses: post: consumes: @@ -2761,6 +3175,100 @@ paths: summary: Deactivate sub-course tags: - sub-courses + /api/v1/course-management/sub-courses/{id}/prerequisites: + get: + description: Returns all prerequisites for a sub-course + parameters: + - description: Sub-course 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get sub-course prerequisites + tags: + - progression + post: + consumes: + - application/json + description: Link a prerequisite sub-course that must be completed before accessing + this sub-course + parameters: + - description: Sub-course ID + in: path + name: id + required: true + type: integer + - description: Prerequisite sub-course ID + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.addPrerequisiteReq' + 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: Add prerequisite to sub-course + tags: + - progression + /api/v1/course-management/sub-courses/{id}/prerequisites/{prerequisiteId}: + delete: + description: Unlink a prerequisite from a sub-course + parameters: + - description: Sub-course ID + in: path + name: id + required: true + type: integer + - description: Prerequisite sub-course ID + in: path + name: prerequisiteId + 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Remove prerequisite from sub-course + tags: + - progression /api/v1/course-management/sub-courses/{id}/thumbnail: post: consumes: @@ -2866,6 +3374,36 @@ paths: summary: List all active sub-courses tags: - sub-courses + /api/v1/course-management/sub-courses/reorder: + put: + consumes: + - application/json + description: Updates the display_order of sub-courses for drag-and-drop sorting + parameters: + - description: Reorder payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.reorderReq' + 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: Reorder sub-courses within a course + tags: + - sub-courses /api/v1/course-management/videos: post: consumes: @@ -3010,6 +3548,36 @@ paths: summary: Publish sub-course video tags: - sub-course-videos + /api/v1/course-management/videos/reorder: + put: + consumes: + - application/json + description: Updates the display_order of videos for drag-and-drop sorting + parameters: + - description: Reorder payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.reorderReq' + 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: Reorder videos within a sub-course + tags: + - sub-course-videos /api/v1/course-management/videos/upload: post: consumes: @@ -3138,6 +3706,34 @@ paths: summary: Create a sub-course video from existing Vimeo video tags: - sub-course-videos + /api/v1/internal/users/purge-due-deletions: + post: + description: Worker-safe purge for due self-deletion requests + parameters: + - description: Max users to purge in one run (default 100, max 1000) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - Bearer: [] + summary: Purge due account deletions + tags: + - user /api/v1/issues: get: description: Returns all reported issues with pagination (admin only) @@ -3433,6 +4029,251 @@ paths: summary: Retrieve application logs with filtering and pagination tags: - Logs + /api/v1/notifications/bulk-email: + post: + consumes: + - multipart/form-data + description: Sends an email to specified user IDs, all users of a role, or direct + email addresses with optional image attachment. Optionally schedule for later + with scheduled_at (RFC3339). + 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: Send bulk email + tags: + - notifications + /api/v1/notifications/bulk-push: + post: + consumes: + - application/json + description: Sends a push notification to specified user IDs or all users matching + a role. Optionally schedule for later with scheduled_at (RFC3339). + parameters: + - description: Bulk push content + in: body + name: body + required: true + schema: + properties: + image: + type: string + message: + type: string + role: + type: string + scheduled_at: + type: string + title: + type: string + user_ids: + items: + format: int64 + type: integer + type: array + type: object + 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: Send bulk push notification + tags: + - notifications + /api/v1/notifications/bulk-sms: + post: + consumes: + - application/json + description: Sends an SMS to specified user IDs, all users of a role, or direct + phone numbers. Optionally schedule for later with scheduled_at (RFC3339). + parameters: + - description: Bulk SMS content + in: body + name: body + required: true + schema: + properties: + message: + type: string + phone_numbers: + items: + type: string + type: array + role: + type: string + scheduled_at: + type: string + user_ids: + items: + format: int64 + type: integer + type: array + type: object + 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: Send bulk SMS + tags: + - notifications + /api/v1/notifications/scheduled: + get: + description: Returns paginated scheduled notifications with optional status, + channel, and date filters + parameters: + - description: Filter by status + in: query + name: status + type: string + - description: Filter by channel + in: query + name: channel + type: string + - description: Filter after date (RFC3339) + in: query + name: after + type: string + - description: Filter before date (RFC3339) + in: query + name: before + type: string + - default: 20 + description: Page size + in: query + name: limit + type: integer + - default: 1 + description: Page number + in: query + name: page + 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 scheduled notifications + tags: + - notifications + /api/v1/notifications/scheduled/{id}: + get: + description: Returns a single scheduled notification by its ID + parameters: + - description: Scheduled Notification 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get scheduled notification + tags: + - notifications + /api/v1/notifications/scheduled/{id}/cancel: + post: + description: Cancels a scheduled notification if it is still pending or processing + parameters: + - description: Scheduled Notification 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Cancel scheduled notification + tags: + - notifications + /api/v1/notifications/send-email: + post: + consumes: + - multipart/form-data + description: Sends an email to a single email address with optional image attachment + 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: Send single email + tags: + - notifications /api/v1/notifications/test-push: post: consumes: @@ -3713,6 +4554,227 @@ paths: summary: Handle ArifPay webhook tags: - payments + /api/v1/progress/courses/{courseId}: + get: + description: Returns the authenticated user's progress for all sub-courses in + a course, including lock status + parameters: + - description: Course ID + in: path + name: courseId + 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get user's course progress + tags: + - progression + /api/v1/progress/practices/{id}/complete: + post: + description: Marks a practice question set as completed for the authenticated + learner + parameters: + - description: Practice Question Set 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' + "403": + description: Forbidden + 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: Mark practice as completed + tags: + - progression + /api/v1/progress/sub-courses/{id}: + put: + consumes: + - application/json + description: Update the progress percentage for a sub-course + parameters: + - description: Sub-course ID + in: path + name: id + required: true + type: integer + - description: Progress update + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateProgressReq' + 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: Update sub-course progress + tags: + - progression + /api/v1/progress/sub-courses/{id}/access: + get: + description: Check if the authenticated user has completed all prerequisites + for a sub-course + parameters: + - description: Sub-course 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Check sub-course access + tags: + - progression + /api/v1/progress/sub-courses/{id}/complete: + post: + description: Mark a sub-course as completed for the authenticated user + parameters: + - description: Sub-course 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Complete a sub-course + tags: + - progression + /api/v1/progress/sub-courses/{id}/start: + post: + description: Mark a sub-course as started for the authenticated user (checks + prerequisites) + parameters: + - description: Sub-course ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Start a sub-course + tags: + - progression + /api/v1/progress/videos/{id}/complete: + post: + description: Marks the given video as completed for the authenticated learner + parameters: + - description: Video 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' + "403": + description: Forbidden + 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: Mark sub-course video as completed + tags: + - progression /api/v1/question-sets: get: description: Returns a paginated list of question sets filtered by type @@ -4125,6 +5187,38 @@ paths: summary: Get question sets by owner tags: - question-sets + /api/v1/question-sets/sub-courses/{subCourseId}/entry-assessment: + get: + description: Returns the published INITIAL_ASSESSMENT question set for the given + sub-course + parameters: + - description: Sub-course ID + in: path + name: subCourseId + 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 entry assessment set for a sub-course + tags: + - question-sets /api/v1/questions: get: description: Returns a paginated list of questions with optional filters @@ -4504,6 +5598,337 @@ paths: summary: Get rating summary for a target tags: - ratings + /api/v1/rbac/permissions: + get: + consumes: + - application/json + description: Get all permissions in the system grouped by group name + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + items: + $ref: '#/definitions/domain.Permission' + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: List all permissions + tags: + - rbac + /api/v1/rbac/permissions/groups: + get: + consumes: + - application/json + description: Get all distinct permission group names + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + items: + type: string + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: List permission groups + tags: + - rbac + /api/v1/rbac/permissions/sync: + post: + consumes: + - application/json + description: Re-seed permissions from code and reload the RBAC cache + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Sync permissions + tags: + - rbac + /api/v1/rbac/roles: + get: + consumes: + - application/json + description: Get all roles with optional filters + parameters: + - description: Search by role name + in: query + name: query + type: string + - description: Filter by system role (true/false) + in: query + name: is_system + type: boolean + - description: 'Page number (default: 1)' + in: query + name: page + type: integer + - description: 'Page size (default: 20)' + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: List all roles + tags: + - rbac + post: + consumes: + - application/json + description: Create a new role with a name and description + parameters: + - description: Role creation payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.CreateRoleReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/domain.RoleRecord' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Create a new role + tags: + - rbac + /api/v1/rbac/roles/{id}: + delete: + consumes: + - application/json + description: Delete a non-system role by ID + parameters: + - description: Role 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Delete a role + tags: + - rbac + get: + consumes: + - application/json + description: Get a role and its permissions by ID + parameters: + - description: Role ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/domain.RoleWithPermissions' + type: object + "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' + security: + - Bearer: [] + summary: Get a role by ID + tags: + - rbac + put: + consumes: + - application/json + description: Update an existing role's name and description + parameters: + - description: Role ID + in: path + name: id + required: true + type: integer + - description: Role update payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.UpdateRoleReq' + 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' + security: + - Bearer: [] + summary: Update a role + tags: + - rbac + /api/v1/rbac/roles/{id}/permissions: + get: + consumes: + - application/json + description: Get all permissions assigned to a role + parameters: + - description: Role ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + items: + $ref: '#/definitions/domain.Permission' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get permissions for a role + tags: + - rbac + put: + consumes: + - application/json + description: Replace all permissions for a role with the given permission IDs + parameters: + - description: Role ID + in: path + name: id + required: true + type: integer + - description: Permission IDs payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.SetRolePermissionsReq' + 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' + security: + - Bearer: [] + summary: Set permissions for a role + tags: + - rbac /api/v1/sendSMS: post: consumes: @@ -5587,6 +7012,60 @@ paths: summary: Delete user by ID tags: - user + /api/v1/user/me: + delete: + description: Starts account deletion with grace period before permanent purge + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - Bearer: [] + summary: Request deletion of my account + tags: + - user + /api/v1/user/me/deletion/cancel: + post: + description: Cancels a pending self-deletion request during grace period + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - Bearer: [] + summary: Cancel my account deletion request + tags: + - user /api/v1/user/resetPassword: post: consumes: @@ -5710,6 +7189,36 @@ paths: summary: Get user by id tags: - user + /api/v1/user/status: + patch: + consumes: + - application/json + description: Activates, deactivates, or suspends a user account + parameters: + - description: Status update payload + in: body + name: body + required: true + schema: + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Update user status + tags: + - user /api/v1/user/verify-otp: post: consumes: @@ -5770,6 +7279,10 @@ paths: in: query name: created_after type: string + - description: Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED) + in: query + name: status + type: string produces: - application/json responses: @@ -5788,6 +7301,32 @@ paths: summary: Get all users tags: - user + /api/v1/users/summary: + get: + consumes: + - application/json + description: Returns total users, active users, and users who joined this month + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/domain.UserSummary' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get user summary statistics + tags: + - user /api/v1/vimeo/oembed: get: consumes: @@ -5825,6 +7364,42 @@ paths: summary: Get oEmbed data for a Vimeo URL tags: - Vimeo + /api/v1/vimeo/sample: + get: + consumes: + - application/json + description: Fetches a sample video from Vimeo and returns video details along + with an embeddable iframe for client-side integration + parameters: + - default: "76979871" + description: Vimeo Video ID to use as sample + in: query + name: video_id + type: string + - default: 640 + description: Player width + in: query + name: width + type: integer + - default: 360 + description: Player height + in: query + name: height + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get a sample Vimeo video with iframe embed + tags: + - Vimeo /api/v1/vimeo/uploads/pull: post: consumes: diff --git a/internal/config/config.go b/internal/config/config.go index b785f33..bb766f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,9 +25,8 @@ var ( ErrInvalidReportExportPath = errors.New("report export path is invalid") ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") - ErrMissingResendApiKey = errors.New("missing Resend Api key") - ErrMissingResendSenderEmail = errors.New("missing Resend sender name") - + ErrMissingResendApiKey = errors.New("missing Resend Api key") + ErrMissingResendSenderEmail = errors.New("missing Resend sender name") ) type AFROSMSConfig struct { @@ -90,9 +89,9 @@ type Config struct { GoogleOAuthClientID string GoogleOAuthClientSecret string GoogleOAuthRedirectURL string - AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"` - Vimeo VimeoConfig `mapstructure:"vimeo_config"` - CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"` + AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"` + Vimeo VimeoConfig `mapstructure:"vimeo_config"` + CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"` APP_VERSION string FIXER_API_KEY string FIXER_BASE_URL string @@ -128,6 +127,9 @@ type Config struct { RedisAddr string KafkaBrokers []string FCMServiceAccountKey string + AccountDeletionPurgeEnabled bool + AccountDeletionPurgeInterval time.Duration + AccountDeletionPurgeBatchSize int32 } func NewConfig() (*Config, error) { @@ -473,6 +475,40 @@ func (c *Config) loadEnv() error { } c.CloudConvert.APIKey = os.Getenv("CLOUDCONVERT_API_KEY") + // Two-phase account deletion purge worker configuration + accountDeletionPurgeEnabled := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_ENABLED")) + if accountDeletionPurgeEnabled == "" { + c.AccountDeletionPurgeEnabled = true + } else { + c.AccountDeletionPurgeEnabled = accountDeletionPurgeEnabled == "true" || accountDeletionPurgeEnabled == "1" + } + + accountDeletionPurgeInterval := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_INTERVAL")) + if accountDeletionPurgeInterval == "" { + c.AccountDeletionPurgeInterval = time.Hour + } else { + interval, err := time.ParseDuration(accountDeletionPurgeInterval) + if err != nil || interval <= 0 { + c.AccountDeletionPurgeInterval = time.Hour + } else { + c.AccountDeletionPurgeInterval = interval + } + } + + accountDeletionPurgeBatchSize := strings.TrimSpace(os.Getenv("ACCOUNT_DELETION_PURGE_BATCH_SIZE")) + if accountDeletionPurgeBatchSize == "" { + c.AccountDeletionPurgeBatchSize = 100 + } else { + batchSize, err := strconv.Atoi(accountDeletionPurgeBatchSize) + if err != nil || batchSize <= 0 { + c.AccountDeletionPurgeBatchSize = 100 + } else if batchSize > 1000 { + c.AccountDeletionPurgeBatchSize = 1000 + } else { + c.AccountDeletionPurgeBatchSize = int32(batchSize) + } + } + return nil } diff --git a/internal/domain/user.go b/internal/domain/user.go index 53d57cb..a62512f 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -133,6 +133,43 @@ type UserFilter struct { CreatedAfter ValidTime } +type AccountDeletionState string + +const ( + AccountDeletionStatePending AccountDeletionState = "PENDING" + AccountDeletionStateDue AccountDeletionState = "DUE" + AccountDeletionStateCancelled AccountDeletionState = "CANCELLED" +) + +type AccountDeletionRequestFilter struct { + Query string + Role string + Status string + State string + + RequestedBefore ValidTime + RequestedAfter ValidTime + ScheduledBefore ValidTime + ScheduledAfter ValidTime + + Page int64 + PageSize int64 +} + +type AccountDeletionRequest struct { + UserID int64 + FirstName string + LastName string + Email string + PhoneNumber string + Role Role + Status UserStatus + DeletionRequestedAt *time.Time + DeletionScheduledAt *time.Time + DeletionCancelledAt *time.Time + DeletionState AccountDeletionState +} + type RegisterUserReq struct { Email string `json:"email"` PhoneNumber string `json:"phone_number"` diff --git a/internal/ports/user.go b/internal/ports/user.go index ad2ce6e..e5e067a 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -57,6 +57,7 @@ type UserStore interface { createdBefore, createdAfter *time.Time, limit, offset int32, ) ([]domain.User, int64, error) + ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error) GetTotalUsers(ctx context.Context, role *string) (int64, error) GetUserSummary(ctx context.Context) (domain.UserSummary, error) SearchUserByNameOrPhone( @@ -66,6 +67,9 @@ type UserStore interface { ) ([]domain.User, error) UpdateUser(ctx context.Context, req domain.UpdateUserReq) error DeleteUser(ctx context.Context, userID int64) error + RequestUserDeletion(ctx context.Context, userID int64, gracePeriod time.Duration) (time.Time, error) + CancelUserDeletion(ctx context.Context, userID int64) error + PurgeDueUserDeletions(ctx context.Context, limit int32) (int64, error) CheckPhoneEmailExist(ctx context.Context, phone, email string) (phoneExists, emailExists bool, err error) GetUserByEmailPhone( ctx context.Context, diff --git a/internal/repository/user.go b/internal/repository/user.go index 6fa7e02..9336d97 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -8,6 +8,8 @@ import ( "context" "database/sql" "errors" + "fmt" + "strings" "time" "github.com/jackc/pgx/v5" @@ -536,6 +538,172 @@ func (s *Store) GetTotalUsers(ctx context.Context, role *string) (int64, error) return count, nil } +func (s *Store) ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error) { + page := filter.Page + if page < 0 { + page = 0 + } + pageSize := filter.PageSize + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + offset := page * pageSize + + baseQuery := ` + FROM users u + WHERE ( + u.deletion_requested_at IS NOT NULL + OR u.deletion_cancelled_at IS NOT NULL + ) + ` + args := make([]interface{}, 0, 16) + argPos := 1 + + if filter.Query != "" { + baseQuery += fmt.Sprintf(` + AND ( + COALESCE(u.first_name, '') ILIKE $%d + OR COALESCE(u.last_name, '') ILIKE $%d + OR COALESCE(u.email, '') ILIKE $%d + OR COALESCE(u.phone_number, '') ILIKE $%d + ) + `, argPos, argPos, argPos, argPos) + args = append(args, "%"+filter.Query+"%") + argPos++ + } + + if filter.Role != "" { + baseQuery += fmt.Sprintf(" AND u.role = $%d", argPos) + args = append(args, filter.Role) + argPos++ + } + if filter.Status != "" { + baseQuery += fmt.Sprintf(" AND u.status = $%d", argPos) + args = append(args, filter.Status) + argPos++ + } + if filter.RequestedAfter.Valid { + baseQuery += fmt.Sprintf(" AND u.deletion_requested_at >= $%d", argPos) + args = append(args, filter.RequestedAfter.Value) + argPos++ + } + if filter.RequestedBefore.Valid { + baseQuery += fmt.Sprintf(" AND u.deletion_requested_at <= $%d", argPos) + args = append(args, filter.RequestedBefore.Value) + argPos++ + } + if filter.ScheduledAfter.Valid { + baseQuery += fmt.Sprintf(" AND u.deletion_scheduled_at >= $%d", argPos) + args = append(args, filter.ScheduledAfter.Value) + argPos++ + } + if filter.ScheduledBefore.Valid { + baseQuery += fmt.Sprintf(" AND u.deletion_scheduled_at <= $%d", argPos) + args = append(args, filter.ScheduledBefore.Value) + argPos++ + } + + state := strings.ToUpper(strings.TrimSpace(filter.State)) + switch state { + case string(domain.AccountDeletionStatePending): + baseQuery += " AND u.deletion_scheduled_at > CURRENT_TIMESTAMP AND u.deletion_cancelled_at IS NULL" + case string(domain.AccountDeletionStateDue): + baseQuery += " AND u.deletion_scheduled_at <= CURRENT_TIMESTAMP AND u.deletion_cancelled_at IS NULL" + case string(domain.AccountDeletionStateCancelled): + baseQuery += " AND u.deletion_cancelled_at IS NOT NULL" + } + + countQuery := "SELECT COUNT(*) " + baseQuery + var total int64 + if err := s.conn.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + + selectQuery := fmt.Sprintf(` + SELECT + u.id, + COALESCE(u.first_name, '') AS first_name, + COALESCE(u.last_name, '') AS last_name, + COALESCE(u.email, '') AS email, + COALESCE(u.phone_number, '') AS phone_number, + u.role, + u.status, + u.deletion_requested_at, + u.deletion_scheduled_at, + u.deletion_cancelled_at, + CASE + WHEN u.deletion_cancelled_at IS NOT NULL THEN 'CANCELLED' + WHEN u.deletion_scheduled_at <= CURRENT_TIMESTAMP THEN 'DUE' + ELSE 'PENDING' + END AS deletion_state + %s + ORDER BY u.deletion_requested_at DESC, u.id DESC + LIMIT $%d OFFSET $%d + `, baseQuery, argPos, argPos+1) + + queryArgs := append(args, pageSize, offset) + rows, err := s.conn.Query(ctx, selectQuery, queryArgs...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + out := make([]domain.AccountDeletionRequest, 0, pageSize) + for rows.Next() { + var ( + item domain.AccountDeletionRequest + role string + status string + stateVal string + requestedAt pgtype.Timestamptz + scheduledAt pgtype.Timestamptz + cancelledAt pgtype.Timestamptz + ) + + if err := rows.Scan( + &item.UserID, + &item.FirstName, + &item.LastName, + &item.Email, + &item.PhoneNumber, + &role, + &status, + &requestedAt, + &scheduledAt, + &cancelledAt, + &stateVal, + ); err != nil { + return nil, 0, err + } + + item.Role = domain.Role(role) + item.Status = domain.UserStatus(status) + item.DeletionState = domain.AccountDeletionState(stateVal) + if requestedAt.Valid { + t := requestedAt.Time + item.DeletionRequestedAt = &t + } + if scheduledAt.Valid { + t := scheduledAt.Time + item.DeletionScheduledAt = &t + } + if cancelledAt.Valid { + t := cancelledAt.Time + item.DeletionCancelledAt = &t + } + + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + return out, total, nil +} + // SearchUserByNameOrPhone searches users by name or phone func (s *Store) SearchUserByNameOrPhone( ctx context.Context, @@ -669,6 +837,90 @@ func (s *Store) DeleteUser(ctx context.Context, userID int64) error { return s.queries.DeleteUser(ctx, userID) } +func (s *Store) RequestUserDeletion(ctx context.Context, userID int64, gracePeriod time.Duration) (time.Time, error) { + scheduledAt := time.Now().UTC().Add(gracePeriod) + const query = ` + UPDATE users + SET + deletion_requested_at = CURRENT_TIMESTAMP, + deletion_scheduled_at = $1, + deletion_cancelled_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2; + ` + tag, err := s.conn.Exec(ctx, query, scheduledAt, userID) + if err != nil { + return time.Time{}, err + } + if tag.RowsAffected() == 0 { + return time.Time{}, domain.ErrUserNotFound + } + return scheduledAt, nil +} + +func (s *Store) CancelUserDeletion(ctx context.Context, userID int64) error { + const query = ` + UPDATE users + SET + deletion_requested_at = NULL, + deletion_scheduled_at = NULL, + deletion_cancelled_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1; + ` + tag, err := s.conn.Exec(ctx, query, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return domain.ErrUserNotFound + } + return nil +} + +func (s *Store) PurgeDueUserDeletions(ctx context.Context, limit int32) (int64, error) { + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + + tx, err := s.conn.Begin(ctx) + if err != nil { + return 0, err + } + defer tx.Rollback(ctx) + + const query = ` + WITH due AS ( + SELECT id + FROM users + WHERE deletion_scheduled_at IS NOT NULL + AND deletion_scheduled_at <= CURRENT_TIMESTAMP + ORDER BY deletion_scheduled_at ASC + FOR UPDATE SKIP LOCKED + LIMIT $1 + ), + deleted AS ( + DELETE FROM users u + USING due + WHERE u.id = due.id + RETURNING u.id + ) + SELECT COUNT(*)::BIGINT + FROM deleted; + ` + var deletedCount int64 + if err := tx.QueryRow(ctx, query, limit).Scan(&deletedCount); err != nil { + return 0, err + } + if err := tx.Commit(ctx); err != nil { + return 0, err + } + return deletedCount, nil +} + // CheckPhoneEmailExist checks if phone or email exists in an organization func (s *Store) CheckPhoneEmailExist(ctx context.Context, phone, email string) (phoneExists, emailExists bool, err error) { res, err := s.queries.CheckPhoneEmailExist(ctx, dbgen.CheckPhoneEmailExistParams{ diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index c1bfc08..d0d8f37 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -105,7 +105,10 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "users.update_self", Name: "Update Own Profile", Description: "Update own user profile", GroupName: "Users"}, {Key: "users.update_status", Name: "Update User Status", Description: "Activate/deactivate users", GroupName: "Users"}, {Key: "users.delete", Name: "Delete User", Description: "Delete a user", GroupName: "Users"}, - {Key: "users.delete_self", Name: "Delete Own Account", Description: "Delete own user account", GroupName: "Users"}, + {Key: "users.delete_self", Name: "Request Own Account Deletion", Description: "Request own account deletion with grace period", GroupName: "Users"}, + {Key: "users.cancel_delete_self", Name: "Cancel Own Account Deletion", Description: "Cancel own pending account deletion request", GroupName: "Users"}, + {Key: "users.purge_due_deletions", Name: "Purge Due Account Deletions", Description: "Purge users whose deletion grace period has elapsed", GroupName: "Users"}, + {Key: "users.deletion_requests.list", Name: "List Account Deletion Requests", Description: "List account deletion requests for admin tracking", GroupName: "Users"}, {Key: "users.search", Name: "Search Users", Description: "Search users by name or phone", GroupName: "Users"}, {Key: "users.profile_completed", Name: "Check Profile Completed", Description: "Check if user profile is completed", GroupName: "Users"}, {Key: "users.upload_profile_picture", Name: "Upload Profile Picture", Description: "Upload user profile picture", GroupName: "Users"}, @@ -251,7 +254,7 @@ var DefaultRolePermissions = map[string][]string{ "payments.direct_initiate", "payments.direct_verify_otp", // Users (full access) - "users.list", "users.get", "users.update_self", "users.update_status", "users.delete", "users.delete_self", "users.search", + "users.list", "users.get", "users.update_self", "users.update_status", "users.delete", "users.delete_self", "users.cancel_delete_self", "users.purge_due_deletions", "users.deletion_requests.list", "users.search", "users.profile_completed", "users.upload_profile_picture", "users.admin_profile", "users.user_profile", // Admin management @@ -326,7 +329,7 @@ var DefaultRolePermissions = map[string][]string{ "payments.direct_initiate", "payments.direct_verify_otp", // User (self-service) - "users.update_self", "users.delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile", + "users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile", // Notifications (own) "notifications.ws_connect", "notifications.list_mine", "notifications.list_all", @@ -374,7 +377,7 @@ var DefaultRolePermissions = map[string][]string{ "payments.direct_initiate", "payments.direct_verify_otp", // User (self-service) - "users.update_self", "users.delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile", + "users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile", // Notifications (own) "notifications.ws_connect", "notifications.list_mine", "notifications.list_all", @@ -416,7 +419,7 @@ var DefaultRolePermissions = map[string][]string{ "question_set_personas.list", // Users (view + search for support) - "users.list", "users.get", "users.search", "users.update_self", "users.delete_self", "users.profile_completed", + "users.list", "users.get", "users.search", "users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile", // Notifications (own) diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index e62162d..898fb57 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -47,6 +47,18 @@ func (s *Service) DeleteUser(ctx context.Context, id int64) error { return s.userStore.DeleteUser(ctx, id) } +func (s *Service) RequestUserDeletion(ctx context.Context, userID int64, gracePeriod time.Duration) (time.Time, error) { + return s.userStore.RequestUserDeletion(ctx, userID, gracePeriod) +} + +func (s *Service) CancelUserDeletion(ctx context.Context, userID int64) error { + return s.userStore.CancelUserDeletion(ctx, userID) +} + +func (s *Service) PurgeDueUserDeletions(ctx context.Context, limit int32) (int64, error) { + return s.userStore.PurgeDueUserDeletions(ctx, limit) +} + func (s *Service) GetAllUsers( ctx context.Context, filter domain.UserFilter, @@ -95,6 +107,10 @@ func (s *Service) GetUserSummary(ctx context.Context) (domain.UserSummary, error return s.userStore.GetUserSummary(ctx) } +func (s *Service) ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error) { + return s.userStore.ListAccountDeletionRequests(ctx, filter) +} + func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error { return s.userStore.UpdateUserStatus(ctx, req) } diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 6b6ebff..a7e41fb 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -4,16 +4,16 @@ import ( dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/config" activitylogservice "Yimaru-Backend/internal/services/activity_log" - cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" - ratingsservice "Yimaru-Backend/internal/services/ratings" - rbacservice "Yimaru-Backend/internal/services/rbac" "Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" + cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" "Yimaru-Backend/internal/services/course_management" issuereporting "Yimaru-Backend/internal/services/issue_reporting" notificationservice "Yimaru-Backend/internal/services/notification" "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" @@ -24,8 +24,10 @@ import ( "Yimaru-Backend/internal/services/user" jwtutil "Yimaru-Backend/internal/web_server/jwt" customvalidator "Yimaru-Backend/internal/web_server/validator" + "context" "fmt" "log/slog" + "time" "go.uber.org/zap" @@ -62,6 +64,7 @@ type App struct { mongoLoggerSvc *zap.Logger analyticsDB *dbgen.Queries rbacSvc *rbacservice.Service + stopPurgeWorker context.CancelFunc } func NewApp( @@ -108,13 +111,13 @@ func NewApp( app.Static("/static", "./static") s := &App{ - assessmentSvc: assessmentSvc, - courseSvc: courseSvc, - questionsSvc: questionsSvc, - subscriptionsSvc: subscriptionsSvc, - arifpaySvc: arifpaySvc, - vimeoSvc: vimeoSvc, - teamSvc: teamSvc, + assessmentSvc: assessmentSvc, + courseSvc: courseSvc, + questionsSvc: questionsSvc, + subscriptionsSvc: subscriptionsSvc, + arifpaySvc: arifpaySvc, + vimeoSvc: vimeoSvc, + teamSvc: teamSvc, activityLogSvc: activityLogSvc, cloudConvertSvc: cloudConvertSvc, ratingSvc: ratingSvc, @@ -143,5 +146,69 @@ func NewApp( } func (a *App) Run() error { + a.startAccountDeletionPurgeWorker() + defer a.stopAccountDeletionPurgeWorker() return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) } + +func (a *App) startAccountDeletionPurgeWorker() { + if a.cfg == nil || !a.cfg.AccountDeletionPurgeEnabled { + a.logger.Info("account deletion purge worker disabled") + return + } + + interval := a.cfg.AccountDeletionPurgeInterval + if interval <= 0 { + interval = time.Hour + } + + batchSize := a.cfg.AccountDeletionPurgeBatchSize + if batchSize <= 0 { + batchSize = 100 + } + + ctx, cancel := context.WithCancel(context.Background()) + a.stopPurgeWorker = cancel + + a.logger.Info( + "starting account deletion purge worker", + "interval", interval.String(), + "batch_size", batchSize, + ) + + go func() { + // Run once on startup so stale due rows are cleaned quickly. + a.runAccountDeletionPurgeOnce(ctx, batchSize) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + a.logger.Info("account deletion purge worker stopped") + return + case <-ticker.C: + a.runAccountDeletionPurgeOnce(ctx, batchSize) + } + } + }() +} + +func (a *App) stopAccountDeletionPurgeWorker() { + if a.stopPurgeWorker != nil { + a.stopPurgeWorker() + } +} + +func (a *App) runAccountDeletionPurgeOnce(ctx context.Context, batchSize int32) { + deletedCount, err := a.userSvc.PurgeDueUserDeletions(ctx, batchSize) + if err != nil { + a.logger.Error("account deletion purge run failed", "error", err) + return + } + + if deletedCount > 0 { + a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize) + } +} diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 6ca9206..60da1cd 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -96,8 +96,8 @@ func (h *Handler) CheckProfileCompleted(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Profile completion status fetched successfully", Data: map[string]interface{}{ - "is_profile_completed": status.IsCompleted, - "profile_completion_percentage": status.Percentage, + "is_profile_completed": status.IsCompleted, + "profile_completion_percentage": status.Percentage, }, }) } @@ -572,6 +572,99 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Users fetched successfully", map[string]interface{}{"users": mapped, "total": total}, nil) } +// ListAccountDeletionRequests godoc +// @Summary List account deletion requests +// @Description Returns account deletion requests for admin panel tracking with filtering and pagination +// @Tags user +// @Accept json +// @Produce json +// @Param query query string false "Search in first_name, last_name, email, phone_number" +// @Param role query string false "Role filter" +// @Param status query string false "User status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)" +// @Param state query string false "Deletion state filter (PENDING, DUE, CANCELLED)" +// @Param requested_before query string false "Requested before (RFC3339)" +// @Param requested_after query string false "Requested after (RFC3339)" +// @Param scheduled_before query string false "Scheduled before (RFC3339)" +// @Param scheduled_after query string false "Scheduled after (RFC3339)" +// @Param page query int false "Page number (default 1)" +// @Param page_size query int false "Page size (default 10, max 100)" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /api/v1/admin/users/deletion-requests [get] +func (h *Handler) ListAccountDeletionRequests(c *fiber.Ctx) error { + parseRFC3339 := func(value string) (domain.ValidTime, error) { + if value == "" { + return domain.ValidTime{}, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return domain.ValidTime{}, err + } + return domain.ValidTime{Value: parsed, Valid: true}, nil + } + + requestedBefore, err := parseRFC3339(c.Query("requested_before")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid requested_before format, expected RFC3339") + } + requestedAfter, err := parseRFC3339(c.Query("requested_after")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid requested_after format, expected RFC3339") + } + scheduledBefore, err := parseRFC3339(c.Query("scheduled_before")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid scheduled_before format, expected RFC3339") + } + scheduledAfter, err := parseRFC3339(c.Query("scheduled_after")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid scheduled_after format, expected RFC3339") + } + + page := int64(c.QueryInt("page", 1)) + if page < 1 { + page = 1 + } + pageSize := int64(c.QueryInt("page_size", 10)) + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + filter := domain.AccountDeletionRequestFilter{ + Query: c.Query("query"), + Role: c.Query("role"), + Status: c.Query("status"), + State: c.Query("state"), + RequestedBefore: requestedBefore, + RequestedAfter: requestedAfter, + ScheduledBefore: scheduledBefore, + ScheduledAfter: scheduledAfter, + Page: page - 1, + PageSize: pageSize, + } + + requests, total, err := h.userSvc.ListAccountDeletionRequests(c.Context(), filter) + if err != nil { + h.mongoLoggerSvc.Error("failed to list account deletion requests", + zap.Any("filter", filter), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to list account deletion requests: "+err.Error()) + } + + return response.WriteJSON(c, fiber.StatusOK, "Account deletion requests fetched successfully", map[string]interface{}{ + "items": requests, + "total": total, + "page": page, + "page_size": pageSize, + }, nil) +} + // UpdateUserStatus godoc // @Summary Update user status // @Description Activates, deactivates, or suspends a user account @@ -1714,9 +1807,14 @@ func (h *Handler) DeleteUser(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil) } +const ( + defaultSelfDeleteGracePeriod = 15 * 24 * time.Hour + defaultDeletePurgeBatchSize = int32(100) +) + // DeleteMyUserAccount godoc -// @Summary Delete my user account -// @Description Deletes the authenticated learner's own account +// @Summary Request deletion of my account +// @Description Starts account deletion with grace period before permanent purge // @Tags user // @Produce json // @Success 200 {object} response.APIResponse @@ -1739,23 +1837,113 @@ func (h *Handler) DeleteMyUserAccount(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusForbidden, "Only learners can delete their own account using this endpoint") } - if err := h.userSvc.DeleteUser(c.Context(), userID); err != nil { - h.mongoLoggerSvc.Error("Failed to self-delete user account", + scheduledAt, err := h.userSvc.RequestUserDeletion(c.Context(), userID, defaultSelfDeleteGracePeriod) + if err != nil { + h.mongoLoggerSvc.Error("Failed to request self-deletion for user account", zap.Int64("userID", userID), zap.Int("status_code", fiber.StatusInternalServerError), zap.Error(err), zap.Time("timestamp", time.Now()), ) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete account:"+err.Error()) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to request account deletion: "+err.Error()) } actorRole := string(role) ip := c.IP() ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"deleted_user_id": userID, "self_delete": true}) - go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionUserDeleted, domain.ResourceUser, &userID, fmt.Sprintf("Self-deleted user account ID: %d", userID), meta, &ip, &ua) + meta, _ := json.Marshal(map[string]interface{}{ + "user_id": userID, + "self_delete_requested": true, + "scheduled_purge_at": scheduledAt, + }) + go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &userID, fmt.Sprintf("Self-delete requested for user account ID: %d", userID), meta, &ip, &ua) - return response.WriteJSON(c, fiber.StatusOK, "Account deleted successfully", nil, nil) + return response.WriteJSON(c, fiber.StatusOK, "Account deletion requested successfully", map[string]interface{}{ + "grace_period_days": 15, + "scheduled_purge_at": scheduledAt, + "can_cancel_until_at": scheduledAt, + }, nil) +} + +// CancelMyUserAccountDeletion godoc +// @Summary Cancel my account deletion request +// @Description Cancels a pending self-deletion request during grace period +// @Tags user +// @Produce json +// @Success 200 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 403 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Security Bearer +// @Router /api/v1/user/me/deletion/cancel [post] +func (h *Handler) CancelMyUserAccountDeletion(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok || userID <= 0 { + return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated user") + } + + role, ok := c.Locals("role").(domain.Role) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role") + } + if role != domain.RoleStudent { + return fiber.NewError(fiber.StatusForbidden, "Only learners can cancel their own account deletion using this endpoint") + } + + if err := h.userSvc.CancelUserDeletion(c.Context(), userID); err != nil { + h.mongoLoggerSvc.Error("Failed to cancel self-deletion for user account", + zap.Int64("userID", userID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to cancel account deletion: "+err.Error()) + } + + actorRole := string(role) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{ + "user_id": userID, + "self_delete_cancelled": true, + }) + go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &userID, fmt.Sprintf("Self-delete cancelled for user account ID: %d", userID), meta, &ip, &ua) + + return response.WriteJSON(c, fiber.StatusOK, "Account deletion cancelled successfully", nil, nil) +} + +// PurgeDueDeletedUsers godoc +// @Summary Purge due account deletions +// @Description Worker-safe purge for due self-deletion requests +// @Tags user +// @Produce json +// @Param limit query int false "Max users to purge in one run (default 100, max 1000)" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Security Bearer +// @Router /api/v1/internal/users/purge-due-deletions [post] +func (h *Handler) PurgeDueDeletedUsers(c *fiber.Ctx) error { + limit := int32(c.QueryInt("limit", int(defaultDeletePurgeBatchSize))) + if limit <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "limit must be greater than zero") + } + + deletedCount, err := h.userSvc.PurgeDueUserDeletions(c.Context(), limit) + if err != nil { + h.mongoLoggerSvc.Error("Failed to purge due user deletions", + zap.Int32("limit", limit), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to purge due user deletions: "+err.Error()) + } + + return response.WriteJSON(c, fiber.StatusOK, "Due user deletions purged successfully", map[string]interface{}{ + "deleted_count": deletedCount, + "batch_limit": limit, + }, nil) } type UpdateUserSuspendReq struct { diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 40fdb4b..a4d9ce5 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -52,7 +52,7 @@ func (a *App) initAppRoutes() { // Groups groupV1 := a.fiber.Group("/api/v1") - + // Serve static files (profile pictures, etc.) a.fiber.Static("/static", "./static") @@ -219,6 +219,7 @@ func (a *App) initAppRoutes() { // User Routes groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted) groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers) + groupV1.Get("/admin/users/deletion-requests", a.authMiddleware, a.RequirePermission("users.deletion_requests.list"), h.ListAccountDeletionRequests) groupV1.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary) groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser) groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus) @@ -234,6 +235,8 @@ func (a *App) initAppRoutes() { groupV1.Get("/user/admin-profile", a.authMiddleware, a.RequirePermission("users.admin_profile"), h.AdminProfile) groupV1.Get("/user/user-profile", a.authMiddleware, a.RequirePermission("users.user_profile"), h.GetUserProfile) groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount) + groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion) + groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers) groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID) groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser) groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)