From afdd07d65dabf55b5cbf2e638b9ebf7ba6a0eacc Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 26 May 2026 03:27:54 -0700 Subject: [PATCH] Update learner progress to use practice completions only. Remove lesson completion from learner progress percentages, access completion snapshots, and LMS rollups while keeping generated SQLC and Swagger artifacts in sync. Co-authored-by: Cursor --- db/query/lms_progress.sql | 115 +- docs/docs.go | 1670 ++++++++++++++++- docs/swagger.json | 1670 ++++++++++++++++- docs/swagger.yaml | 1106 ++++++++++- gen/db/lms_progress.sql.go | 154 +- gen/db/models.go | 14 + internal/domain/lms_access.go | 21 +- internal/repository/lms_access.go | 60 +- internal/repository/lms_progress_tx.go | 28 +- .../repository/lms_user_progress_snapshot.go | 21 +- internal/services/lmsprogress/service.go | 58 +- internal/services/lmsprogress/service_test.go | 97 + .../handlers/lms_progress_handler.go | 2 +- 13 files changed, 4816 insertions(+), 200 deletions(-) create mode 100644 internal/services/lmsprogress/service_test.go diff --git a/db/query/lms_progress.sql b/db/query/lms_progress.sql index 72bcd90..8dd8d68 100644 --- a/db/query/lms_progress.sql +++ b/db/query/lms_progress.sql @@ -117,6 +117,33 @@ INSERT INTO lms_user_program_progress (user_id, program_id) ON CONFLICT (user_id, program_id) DO NOTHING; +-- name: CountPublishedPracticesInLesson :one +SELECT + count(*)::int AS n +FROM + lms_practices lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id +WHERE + lp.lesson_id = $1 + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED'; + +-- name: CountUserCompletedPublishedPracticesInLesson :one +SELECT + count(*)::int AS n +FROM + lms_practices lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id +WHERE + lp.lesson_id = $1 + AND upp.user_id = $2 + AND upp.completed_at IS NOT NULL + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED'; + -- name: CountLessonsInModule :one SELECT count(*)::int AS n @@ -175,47 +202,95 @@ WHERE -- name: ListLMSCompletedLessonIDsByUser :many SELECT - ulp.lesson_id + lp.lesson_id FROM - lms_user_lesson_progress AS ulp + lms_practices AS lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id + AND upp.user_id = $1 + AND upp.completed_at IS NOT NULL WHERE - ulp.user_id = $1 + lp.lesson_id IS NOT NULL + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +GROUP BY + lp.lesson_id +HAVING + count(DISTINCT lp.question_set_id) > 0 + AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id) ORDER BY - ulp.completed_at ASC, - ulp.lesson_id ASC; + max(upp.completed_at) ASC, + lp.lesson_id ASC; -- name: ListLMSCompletedModuleIDsByUser :many SELECT - ump.module_id + lp.module_id FROM - lms_user_module_progress AS ump + lms_practices AS lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id + AND upp.user_id = $1 + AND upp.completed_at IS NOT NULL WHERE - ump.user_id = $1 + lp.module_id IS NOT NULL + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +GROUP BY + lp.module_id +HAVING + count(DISTINCT lp.question_set_id) > 0 + AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id) ORDER BY - ump.completed_at ASC, - ump.module_id ASC; + max(upp.completed_at) ASC, + lp.module_id ASC; -- name: ListLMSCompletedCourseIDsByUser :many SELECT - ucp.course_id + lp.course_id FROM - lms_user_course_progress AS ucp + lms_practices AS lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id + AND upp.user_id = $1 + AND upp.completed_at IS NOT NULL WHERE - ucp.user_id = $1 + lp.course_id IS NOT NULL + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +GROUP BY + lp.course_id +HAVING + count(DISTINCT lp.question_set_id) > 0 + AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id) ORDER BY - ucp.completed_at ASC, - ucp.course_id ASC; + max(upp.completed_at) ASC, + lp.course_id ASC; -- name: ListLMSCompletedProgramIDsByUser :many SELECT - upp.program_id + c.program_id FROM - lms_user_program_progress AS upp + lms_practices AS lp + INNER JOIN courses c ON c.id = lp.course_id + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id + AND upp.user_id = $1 + AND upp.completed_at IS NOT NULL WHERE - upp.user_id = $1 + qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +GROUP BY + c.program_id +HAVING + count(DISTINCT lp.question_set_id) > 0 + AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id) ORDER BY - upp.completed_at ASC, - upp.program_id ASC; + max(upp.completed_at) ASC, + c.program_id ASC; -- Lesson-based progress within a course (all modules). -- name: CountLessonsInCourse :one diff --git a/docs/docs.go b/docs/docs.go index 213846d..28a7274 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -254,6 +254,693 @@ const docTemplate = `{ } } }, + "/api/v1/admin/app-versions": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "List mobile app versions (admin)", + "parameters": [ + { + "type": "string", + "description": "Filter by ANDROID or IOS", + "name": "platform", + "in": "query" + }, + { + "type": "string", + "description": "Filter by ACTIVE or INACTIVE", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Create mobile app version (admin)", + "parameters": [ + { + "description": "App version payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createMobileAppVersionReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/app-versions/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Get mobile app version by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "App version ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Update mobile app version (admin)", + "parameters": [ + { + "type": "integer", + "description": "App version ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "App version payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateMobileAppVersionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Delete mobile app version (admin)", + "parameters": [ + { + "type": "integer", + "description": "App version ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates": { + "get": { + "description": "Returns email templates for admin management", + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "List email templates (admin)", + "parameters": [ + { + "type": "string", + "description": "ACTIVE or INACTIVE", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Search by slug, name, or subject", + "name": "query", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new custom email template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Create email template", + "parameters": [ + { + "description": "Create email template payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createEmailTemplateReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates/slug/{slug}": { + "get": { + "description": "Returns one email template by slug regardless of status", + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Get email template by slug (admin)", + "parameters": [ + { + "type": "string", + "description": "Email template slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates/slug/{slug}/preview": { + "post": { + "description": "Renders an email template with sample variables without sending", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Preview email template by slug", + "parameters": [ + { + "type": "string", + "description": "Email template slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "Preview variables", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.previewEmailTemplateReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates/{id}": { + "get": { + "description": "Returns one email template regardless of status", + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Get email template by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "Email template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates an existing email template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Update email template", + "parameters": [ + { + "type": "integer", + "description": "Email template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update email template payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateEmailTemplateReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes a custom email template", + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Delete email template", + "parameters": [ + { + "type": "integer", + "description": "Email template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates/{id}/preview": { + "post": { + "description": "Renders an email template with sample variables without sending", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Preview email template by ID", + "parameters": [ + { + "type": "integer", + "description": "Email template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Preview variables", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.previewEmailTemplateReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin/faqs": { "get": { "description": "Returns FAQs for admin management with status/category filtering", @@ -505,6 +1192,174 @@ const docTemplate = `{ } } }, + "/api/v1/admin/field-options": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "List field options (admin)", + "parameters": [ + { + "type": "string", + "description": "Filter by field key", + "name": "field_key", + "in": "query" + }, + { + "type": "string", + "description": "ACTIVE or INACTIVE", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "Create field option (admin)", + "parameters": [ + { + "description": "Create option", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createFieldOptionReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/admin/field-options/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "Get field option by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "Option ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "Update field option (admin)", + "parameters": [ + { + "type": "integer", + "description": "Option ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update option", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateFieldOptionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "Delete field option (admin)", + "parameters": [ + { + "type": "integer", + "description": "Option ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/admin/roles/{role}/bulk-deactivate": { "post": { "security": [ @@ -1008,6 +1863,54 @@ const docTemplate = `{ } } }, + "/api/v1/app/version/check": { + "get": { + "description": "Public endpoint for mobile clients to determine if an app update is available (force or optional)", + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Check mobile app version", + "parameters": [ + { + "type": "string", + "description": "Platform: ANDROID or IOS", + "name": "platform", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Client build number (Android versionCode / iOS build number)", + "name": "version_code", + "in": "query", + "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/assessment/questions": { "get": { "description": "Returns all active assessment questions from the initial assessment set", @@ -2258,6 +3161,62 @@ const docTemplate = `{ } } }, + "/api/v1/field-options": { + "get": { + "description": "Returns active options grouped by field_key (e.g. education_level, country)", + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "List field options for client dropdowns", + "parameters": [ + { + "type": "string", + "description": "Filter by field key", + "name": "field_key", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/field-options/fields": { + "get": { + "description": "Returns field_key values that have options (e.g. education_level, country)", + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "List distinct field keys", + "parameters": [ + { + "type": "boolean", + "description": "If true, only keys with active options", + "name": "active_only", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/files/audio": { "post": { "consumes": [ @@ -2948,7 +3907,7 @@ const docTemplate = `{ }, "/api/v1/lms/progress": { "get": { - "description": "Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).", + "description": "Returns practice-based completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).", "produces": [ "application/json" ], @@ -3634,6 +4593,46 @@ const docTemplate = `{ } } }, + "/api/v1/payments/chapa/callback": { + "get": { + "description": "Verifies payment after Chapa redirects to callback_url", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Chapa payment callback", + "parameters": [ + { + "type": "string", + "description": "Transaction reference", + "name": "trx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa reference ID", + "name": "ref_id", + "in": "query" + }, + { + "type": "string", + "description": "Payment status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/payments/direct": { "post": { "description": "Creates a payment session and initiates direct payment (OTP-based)", @@ -3748,14 +4747,14 @@ const docTemplate = `{ }, "/api/v1/payments/methods": { "get": { - "description": "Returns list of supported ArifPay payment methods", + "description": "Returns payment methods available on Chapa checkout", "produces": [ "application/json" ], "tags": [ "payments" ], - "summary": "Get available payment methods", + "summary": "Get Chapa payment methods", "responses": { "200": { "description": "OK", @@ -3855,7 +4854,7 @@ const docTemplate = `{ }, "/api/v1/payments/webhook": { "post": { - "description": "Processes payment notifications from ArifPay", + "description": "Processes payment notifications from Chapa (charge.success, etc.)", "consumes": [ "application/json" ], @@ -3865,18 +4864,7 @@ const docTemplate = `{ "tags": [ "payments" ], - "summary": "Handle ArifPay webhook", - "parameters": [ - { - "description": "Webhook payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.WebhookRequest" - } - } - ], + "summary": "Handle Chapa webhook", "responses": { "200": { "description": "OK", @@ -7345,6 +8333,138 @@ const docTemplate = `{ } } }, + "/api/v1/team/invitations": { + "get": { + "description": "Lists team member invitations with optional status filter", + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "List team invitations", + "parameters": [ + { + "type": "string", + "description": "pending, accepted, expired, or revoked", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/team/invitations/accept": { + "post": { + "description": "Public endpoint to set password and profile details after following the invite link", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Accept team invitation and complete account setup", + "parameters": [ + { + "description": "Accept invitation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.AcceptTeamInvitationReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/team/invitations/verify": { + "get": { + "description": "Public endpoint used by the admin panel accept-invite page", + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Verify team invitation token", + "parameters": [ + { + "type": "string", + "description": "Invitation token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/team/invitations/{id}/revoke": { + "post": { + "description": "Revokes the invitation and removes the pending team member if not yet accepted", + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Revoke a pending team invitation", + "parameters": [ + { + "type": "integer", + "description": "Invitation ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/team/login": { "post": { "description": "Authenticate a team member (internal staff) with email and password", @@ -7648,6 +8768,52 @@ const docTemplate = `{ } } }, + "/api/v1/team/members/invite": { + "post": { + "description": "Creates a pending team member (email + team_role only) and sends an invitation email; profile is completed on accept", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Invite a team member by email", + "parameters": [ + { + "description": "Invite payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.InviteTeamMemberReq" + } + } + ], + "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/team/members/{id}": { "get": { "security": [ @@ -7930,6 +9096,47 @@ const docTemplate = `{ } } }, + "/api/v1/team/members/{id}/resend-invite": { + "post": { + "description": "Revokes the current pending invite and sends a new invitation email", + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Resend team invitation", + "parameters": [ + { + "type": "integer", + "description": "Team member ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/team/members/{id}/status": { "patch": { "security": [ @@ -8942,6 +10149,70 @@ const docTemplate = `{ } } }, + "/api/v1/videos/engagement/heartbeat": { + "post": { + "description": "Records playback position for analytics (completion, replay, and drop-off). Send periodic heartbeats while watching; set ended=true when the viewer leaves. A new session starts after 30 minutes of inactivity or when ended=true on the prior session.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "videos" + ], + "summary": "Report video playback progress", + "parameters": [ + { + "description": "Playback heartbeat", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.VideoEngagementHeartbeatInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.VideoWatchSessionResponse" + } + } + } + ] + } + }, + "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" + } + } + } + } + }, "/api/v1/vimeo/oembed": { "get": { "description": "Fetches oEmbed metadata for a Vimeo video URL", @@ -9974,6 +11245,58 @@ const docTemplate = `{ } }, "definitions": { + "domain.AcceptTeamInvitationReq": { + "type": "object", + "required": [ + "first_name", + "last_name", + "password", + "token" + ], + "properties": { + "bio": { + "type": "string" + }, + "department": { + "type": "string" + }, + "emergency_contact": { + "type": "string" + }, + "employment_type": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "hire_date": { + "description": "YYYY-MM-DD", + "type": "string" + }, + "job_title": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + }, + "phone_number": { + "type": "string" + }, + "profile_picture_url": { + "type": "string" + }, + "token": { + "type": "string" + }, + "work_phone": { + "type": "string" + } + } + }, "domain.AgeGroup": { "type": "string", "enum": [ @@ -10074,6 +11397,9 @@ const docTemplate = `{ }, "users": { "$ref": "#/definitions/domain.AnalyticsUsersSection" + }, + "videos": { + "$ref": "#/definitions/domain.AnalyticsVideosSection" } } }, @@ -10417,12 +11743,42 @@ const docTemplate = `{ "$ref": "#/definitions/domain.AnalyticsLabelCount" } }, + "by_country": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, + "by_education_level": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, "by_knowledge_level": { "type": "array", "items": { "$ref": "#/definitions/domain.AnalyticsLabelCount" } }, + "by_language_challange": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, + "by_learning_goal": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, + "by_occupation": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, "by_region": { "type": "array", "items": { @@ -10461,6 +11817,55 @@ const docTemplate = `{ } } }, + "domain.AnalyticsVideoDropOffPoint": { + "type": "object", + "properties": { + "checkpoint_percent": { + "type": "integer" + }, + "drop_off_rate": { + "type": "number" + }, + "total_sessions": { + "type": "integer" + }, + "viewers_reached": { + "type": "integer" + } + } + }, + "domain.AnalyticsVideosSection": { + "type": "object", + "properties": { + "completed_sessions": { + "type": "integer" + }, + "completion_rate": { + "type": "number" + }, + "drop_off_by_checkpoint": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsVideoDropOffPoint" + } + }, + "replay_rate": { + "type": "number" + }, + "replay_sessions": { + "type": "integer" + }, + "total_watch_sessions": { + "type": "integer" + }, + "unique_video_starts": { + "type": "integer" + }, + "users_who_replayed": { + "type": "integer" + } + } + }, "domain.BulkAccountsByRoleRequest": { "type": "object", "properties": { @@ -10918,6 +12323,21 @@ const docTemplate = `{ } } }, + "domain.InviteTeamMemberReq": { + "type": "object", + "required": [ + "email", + "team_role" + ], + "properties": { + "email": { + "type": "string" + }, + "team_role": { + "type": "string" + } + } + }, "domain.LogEntry": { "type": "object", "properties": { @@ -11950,6 +13370,53 @@ const docTemplate = `{ } } }, + "domain.VideoEngagementHeartbeatInput": { + "type": "object", + "required": [ + "content_id", + "content_kind" + ], + "properties": { + "content_id": { + "type": "integer" + }, + "content_kind": { + "type": "string", + "enum": [ + "lms_lesson", + "exam_prep_lesson" + ] + }, + "duration_sec": { + "type": "integer", + "minimum": 0 + }, + "ended": { + "type": "boolean" + }, + "position_sec": { + "type": "integer", + "minimum": 0 + } + } + }, + "domain.VideoWatchSessionResponse": { + "type": "object", + "properties": { + "completed": { + "type": "boolean" + }, + "max_position_sec": { + "type": "integer" + }, + "session_id": { + "type": "integer" + }, + "session_number": { + "type": "integer" + } + } + }, "domain.WebhookRequest": { "type": "object", "properties": { @@ -12422,6 +13889,42 @@ const docTemplate = `{ } } }, + "handlers.createEmailTemplateReq": { + "type": "object", + "required": [ + "body_html", + "body_text", + "name", + "slug", + "subject" + ], + "properties": { + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handlers.createFAQReq": { "type": "object", "required": [ @@ -12446,6 +13949,31 @@ const docTemplate = `{ } } }, + "handlers.createFieldOptionReq": { + "type": "object", + "required": [ + "code", + "field_key", + "label" + ], + "properties": { + "code": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "field_key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "handlers.createIssueReq": { "type": "object", "required": [ @@ -12469,6 +13997,41 @@ const docTemplate = `{ } } }, + "handlers.createMobileAppVersionReq": { + "type": "object", + "required": [ + "platform", + "version_code", + "version_name" + ], + "properties": { + "min_supported_version_code": { + "type": "integer" + }, + "platform": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "status": { + "type": "string" + }, + "store_url": { + "type": "string" + }, + "update_type": { + "type": "string" + }, + "version_code": { + "type": "integer", + "minimum": 1 + }, + "version_name": { + "type": "string" + } + } + }, "handlers.createPlanReq": { "type": "object", "required": [ @@ -12809,6 +14372,15 @@ const docTemplate = `{ } } }, + "handlers.previewEmailTemplateReq": { + "type": "object", + "properties": { + "variables": { + "type": "object", + "additionalProperties": {} + } + } + }, "handlers.refreshFileURLReq": { "type": "object", "properties": { @@ -12956,6 +14528,32 @@ const docTemplate = `{ } } }, + "handlers.updateEmailTemplateReq": { + "type": "object", + "properties": { + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handlers.updateFAQReq": { "type": "object", "properties": { @@ -12976,6 +14574,20 @@ const docTemplate = `{ } } }, + "handlers.updateFieldOptionReq": { + "type": "object", + "properties": { + "display_order": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "handlers.updateIssueStatusReq": { "type": "object", "required": [ @@ -12993,6 +14605,32 @@ const docTemplate = `{ } } }, + "handlers.updateMobileAppVersionReq": { + "type": "object", + "properties": { + "min_supported_version_code": { + "type": "integer" + }, + "release_notes": { + "type": "string" + }, + "status": { + "type": "string" + }, + "store_url": { + "type": "string" + }, + "update_type": { + "type": "string" + }, + "version_code": { + "type": "integer" + }, + "version_name": { + "type": "string" + } + } + }, "handlers.updatePlanReq": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 651e3b9..c084cbf 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -246,6 +246,693 @@ } } }, + "/api/v1/admin/app-versions": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "List mobile app versions (admin)", + "parameters": [ + { + "type": "string", + "description": "Filter by ANDROID or IOS", + "name": "platform", + "in": "query" + }, + { + "type": "string", + "description": "Filter by ACTIVE or INACTIVE", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Create mobile app version (admin)", + "parameters": [ + { + "description": "App version payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createMobileAppVersionReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/app-versions/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Get mobile app version by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "App version ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Update mobile app version (admin)", + "parameters": [ + { + "type": "integer", + "description": "App version ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "App version payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateMobileAppVersionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Delete mobile app version (admin)", + "parameters": [ + { + "type": "integer", + "description": "App version ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates": { + "get": { + "description": "Returns email templates for admin management", + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "List email templates (admin)", + "parameters": [ + { + "type": "string", + "description": "ACTIVE or INACTIVE", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Search by slug, name, or subject", + "name": "query", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new custom email template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Create email template", + "parameters": [ + { + "description": "Create email template payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createEmailTemplateReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates/slug/{slug}": { + "get": { + "description": "Returns one email template by slug regardless of status", + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Get email template by slug (admin)", + "parameters": [ + { + "type": "string", + "description": "Email template slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates/slug/{slug}/preview": { + "post": { + "description": "Renders an email template with sample variables without sending", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Preview email template by slug", + "parameters": [ + { + "type": "string", + "description": "Email template slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "Preview variables", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.previewEmailTemplateReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates/{id}": { + "get": { + "description": "Returns one email template regardless of status", + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Get email template by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "Email template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates an existing email template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Update email template", + "parameters": [ + { + "type": "integer", + "description": "Email template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update email template payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateEmailTemplateReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes a custom email template", + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Delete email template", + "parameters": [ + { + "type": "integer", + "description": "Email template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/email-templates/{id}/preview": { + "post": { + "description": "Renders an email template with sample variables without sending", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "email-templates" + ], + "summary": "Preview email template by ID", + "parameters": [ + { + "type": "integer", + "description": "Email template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Preview variables", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.previewEmailTemplateReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin/faqs": { "get": { "description": "Returns FAQs for admin management with status/category filtering", @@ -497,6 +1184,174 @@ } } }, + "/api/v1/admin/field-options": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "List field options (admin)", + "parameters": [ + { + "type": "string", + "description": "Filter by field key", + "name": "field_key", + "in": "query" + }, + { + "type": "string", + "description": "ACTIVE or INACTIVE", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "Create field option (admin)", + "parameters": [ + { + "description": "Create option", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createFieldOptionReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/admin/field-options/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "Get field option by ID (admin)", + "parameters": [ + { + "type": "integer", + "description": "Option ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "Update field option (admin)", + "parameters": [ + { + "type": "integer", + "description": "Option ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update option", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateFieldOptionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "Delete field option (admin)", + "parameters": [ + { + "type": "integer", + "description": "Option ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/admin/roles/{role}/bulk-deactivate": { "post": { "security": [ @@ -1000,6 +1855,54 @@ } } }, + "/api/v1/app/version/check": { + "get": { + "description": "Public endpoint for mobile clients to determine if an app update is available (force or optional)", + "produces": [ + "application/json" + ], + "tags": [ + "app-versions" + ], + "summary": "Check mobile app version", + "parameters": [ + { + "type": "string", + "description": "Platform: ANDROID or IOS", + "name": "platform", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Client build number (Android versionCode / iOS build number)", + "name": "version_code", + "in": "query", + "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/assessment/questions": { "get": { "description": "Returns all active assessment questions from the initial assessment set", @@ -2250,6 +3153,62 @@ } } }, + "/api/v1/field-options": { + "get": { + "description": "Returns active options grouped by field_key (e.g. education_level, country)", + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "List field options for client dropdowns", + "parameters": [ + { + "type": "string", + "description": "Filter by field key", + "name": "field_key", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/field-options/fields": { + "get": { + "description": "Returns field_key values that have options (e.g. education_level, country)", + "produces": [ + "application/json" + ], + "tags": [ + "field-options" + ], + "summary": "List distinct field keys", + "parameters": [ + { + "type": "boolean", + "description": "If true, only keys with active options", + "name": "active_only", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/files/audio": { "post": { "consumes": [ @@ -2940,7 +3899,7 @@ }, "/api/v1/lms/progress": { "get": { - "description": "Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).", + "description": "Returns practice-based completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).", "produces": [ "application/json" ], @@ -3626,6 +4585,46 @@ } } }, + "/api/v1/payments/chapa/callback": { + "get": { + "description": "Verifies payment after Chapa redirects to callback_url", + "produces": [ + "application/json" + ], + "tags": [ + "payments" + ], + "summary": "Chapa payment callback", + "parameters": [ + { + "type": "string", + "description": "Transaction reference", + "name": "trx_ref", + "in": "query" + }, + { + "type": "string", + "description": "Chapa reference ID", + "name": "ref_id", + "in": "query" + }, + { + "type": "string", + "description": "Payment status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/payments/direct": { "post": { "description": "Creates a payment session and initiates direct payment (OTP-based)", @@ -3740,14 +4739,14 @@ }, "/api/v1/payments/methods": { "get": { - "description": "Returns list of supported ArifPay payment methods", + "description": "Returns payment methods available on Chapa checkout", "produces": [ "application/json" ], "tags": [ "payments" ], - "summary": "Get available payment methods", + "summary": "Get Chapa payment methods", "responses": { "200": { "description": "OK", @@ -3847,7 +4846,7 @@ }, "/api/v1/payments/webhook": { "post": { - "description": "Processes payment notifications from ArifPay", + "description": "Processes payment notifications from Chapa (charge.success, etc.)", "consumes": [ "application/json" ], @@ -3857,18 +4856,7 @@ "tags": [ "payments" ], - "summary": "Handle ArifPay webhook", - "parameters": [ - { - "description": "Webhook payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.WebhookRequest" - } - } - ], + "summary": "Handle Chapa webhook", "responses": { "200": { "description": "OK", @@ -7337,6 +8325,138 @@ } } }, + "/api/v1/team/invitations": { + "get": { + "description": "Lists team member invitations with optional status filter", + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "List team invitations", + "parameters": [ + { + "type": "string", + "description": "pending, accepted, expired, or revoked", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/team/invitations/accept": { + "post": { + "description": "Public endpoint to set password and profile details after following the invite link", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Accept team invitation and complete account setup", + "parameters": [ + { + "description": "Accept invitation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.AcceptTeamInvitationReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/team/invitations/verify": { + "get": { + "description": "Public endpoint used by the admin panel accept-invite page", + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Verify team invitation token", + "parameters": [ + { + "type": "string", + "description": "Invitation token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, + "/api/v1/team/invitations/{id}/revoke": { + "post": { + "description": "Revokes the invitation and removes the pending team member if not yet accepted", + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Revoke a pending team invitation", + "parameters": [ + { + "type": "integer", + "description": "Invitation ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/team/login": { "post": { "description": "Authenticate a team member (internal staff) with email and password", @@ -7640,6 +8760,52 @@ } } }, + "/api/v1/team/members/invite": { + "post": { + "description": "Creates a pending team member (email + team_role only) and sends an invitation email; profile is completed on accept", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Invite a team member by email", + "parameters": [ + { + "description": "Invite payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.InviteTeamMemberReq" + } + } + ], + "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/team/members/{id}": { "get": { "security": [ @@ -7922,6 +9088,47 @@ } } }, + "/api/v1/team/members/{id}/resend-invite": { + "post": { + "description": "Revokes the current pending invite and sends a new invitation email", + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Resend team invitation", + "parameters": [ + { + "type": "integer", + "description": "Team member ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/team/members/{id}/status": { "patch": { "security": [ @@ -8934,6 +10141,70 @@ } } }, + "/api/v1/videos/engagement/heartbeat": { + "post": { + "description": "Records playback position for analytics (completion, replay, and drop-off). Send periodic heartbeats while watching; set ended=true when the viewer leaves. A new session starts after 30 minutes of inactivity or when ended=true on the prior session.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "videos" + ], + "summary": "Report video playback progress", + "parameters": [ + { + "description": "Playback heartbeat", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.VideoEngagementHeartbeatInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.VideoWatchSessionResponse" + } + } + } + ] + } + }, + "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" + } + } + } + } + }, "/api/v1/vimeo/oembed": { "get": { "description": "Fetches oEmbed metadata for a Vimeo video URL", @@ -9966,6 +11237,58 @@ } }, "definitions": { + "domain.AcceptTeamInvitationReq": { + "type": "object", + "required": [ + "first_name", + "last_name", + "password", + "token" + ], + "properties": { + "bio": { + "type": "string" + }, + "department": { + "type": "string" + }, + "emergency_contact": { + "type": "string" + }, + "employment_type": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "hire_date": { + "description": "YYYY-MM-DD", + "type": "string" + }, + "job_title": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + }, + "phone_number": { + "type": "string" + }, + "profile_picture_url": { + "type": "string" + }, + "token": { + "type": "string" + }, + "work_phone": { + "type": "string" + } + } + }, "domain.AgeGroup": { "type": "string", "enum": [ @@ -10066,6 +11389,9 @@ }, "users": { "$ref": "#/definitions/domain.AnalyticsUsersSection" + }, + "videos": { + "$ref": "#/definitions/domain.AnalyticsVideosSection" } } }, @@ -10409,12 +11735,42 @@ "$ref": "#/definitions/domain.AnalyticsLabelCount" } }, + "by_country": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, + "by_education_level": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, "by_knowledge_level": { "type": "array", "items": { "$ref": "#/definitions/domain.AnalyticsLabelCount" } }, + "by_language_challange": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, + "by_learning_goal": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, + "by_occupation": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsLabelCount" + } + }, "by_region": { "type": "array", "items": { @@ -10453,6 +11809,55 @@ } } }, + "domain.AnalyticsVideoDropOffPoint": { + "type": "object", + "properties": { + "checkpoint_percent": { + "type": "integer" + }, + "drop_off_rate": { + "type": "number" + }, + "total_sessions": { + "type": "integer" + }, + "viewers_reached": { + "type": "integer" + } + } + }, + "domain.AnalyticsVideosSection": { + "type": "object", + "properties": { + "completed_sessions": { + "type": "integer" + }, + "completion_rate": { + "type": "number" + }, + "drop_off_by_checkpoint": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsVideoDropOffPoint" + } + }, + "replay_rate": { + "type": "number" + }, + "replay_sessions": { + "type": "integer" + }, + "total_watch_sessions": { + "type": "integer" + }, + "unique_video_starts": { + "type": "integer" + }, + "users_who_replayed": { + "type": "integer" + } + } + }, "domain.BulkAccountsByRoleRequest": { "type": "object", "properties": { @@ -10910,6 +12315,21 @@ } } }, + "domain.InviteTeamMemberReq": { + "type": "object", + "required": [ + "email", + "team_role" + ], + "properties": { + "email": { + "type": "string" + }, + "team_role": { + "type": "string" + } + } + }, "domain.LogEntry": { "type": "object", "properties": { @@ -11942,6 +13362,53 @@ } } }, + "domain.VideoEngagementHeartbeatInput": { + "type": "object", + "required": [ + "content_id", + "content_kind" + ], + "properties": { + "content_id": { + "type": "integer" + }, + "content_kind": { + "type": "string", + "enum": [ + "lms_lesson", + "exam_prep_lesson" + ] + }, + "duration_sec": { + "type": "integer", + "minimum": 0 + }, + "ended": { + "type": "boolean" + }, + "position_sec": { + "type": "integer", + "minimum": 0 + } + } + }, + "domain.VideoWatchSessionResponse": { + "type": "object", + "properties": { + "completed": { + "type": "boolean" + }, + "max_position_sec": { + "type": "integer" + }, + "session_id": { + "type": "integer" + }, + "session_number": { + "type": "integer" + } + } + }, "domain.WebhookRequest": { "type": "object", "properties": { @@ -12414,6 +13881,42 @@ } } }, + "handlers.createEmailTemplateReq": { + "type": "object", + "required": [ + "body_html", + "body_text", + "name", + "slug", + "subject" + ], + "properties": { + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handlers.createFAQReq": { "type": "object", "required": [ @@ -12438,6 +13941,31 @@ } } }, + "handlers.createFieldOptionReq": { + "type": "object", + "required": [ + "code", + "field_key", + "label" + ], + "properties": { + "code": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "field_key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "handlers.createIssueReq": { "type": "object", "required": [ @@ -12461,6 +13989,41 @@ } } }, + "handlers.createMobileAppVersionReq": { + "type": "object", + "required": [ + "platform", + "version_code", + "version_name" + ], + "properties": { + "min_supported_version_code": { + "type": "integer" + }, + "platform": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "status": { + "type": "string" + }, + "store_url": { + "type": "string" + }, + "update_type": { + "type": "string" + }, + "version_code": { + "type": "integer", + "minimum": 1 + }, + "version_name": { + "type": "string" + } + } + }, "handlers.createPlanReq": { "type": "object", "required": [ @@ -12801,6 +14364,15 @@ } } }, + "handlers.previewEmailTemplateReq": { + "type": "object", + "properties": { + "variables": { + "type": "object", + "additionalProperties": {} + } + } + }, "handlers.refreshFileURLReq": { "type": "object", "properties": { @@ -12948,6 +14520,32 @@ } } }, + "handlers.updateEmailTemplateReq": { + "type": "object", + "properties": { + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handlers.updateFAQReq": { "type": "object", "properties": { @@ -12968,6 +14566,20 @@ } } }, + "handlers.updateFieldOptionReq": { + "type": "object", + "properties": { + "display_order": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "handlers.updateIssueStatusReq": { "type": "object", "required": [ @@ -12985,6 +14597,32 @@ } } }, + "handlers.updateMobileAppVersionReq": { + "type": "object", + "properties": { + "min_supported_version_code": { + "type": "integer" + }, + "release_notes": { + "type": "string" + }, + "status": { + "type": "string" + }, + "store_url": { + "type": "string" + }, + "update_type": { + "type": "string" + }, + "version_code": { + "type": "integer" + }, + "version_name": { + "type": "string" + } + } + }, "handlers.updatePlanReq": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index cb356d9..fafa6a2 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,40 @@ definitions: + domain.AcceptTeamInvitationReq: + properties: + bio: + type: string + department: + type: string + emergency_contact: + type: string + employment_type: + type: string + first_name: + type: string + hire_date: + description: YYYY-MM-DD + type: string + job_title: + type: string + last_name: + type: string + password: + minLength: 8 + type: string + phone_number: + type: string + profile_picture_url: + type: string + token: + type: string + work_phone: + type: string + required: + - first_name + - last_name + - password + - token + type: object domain.AgeGroup: enum: - UNDER_13 @@ -71,6 +107,8 @@ definitions: $ref: '#/definitions/domain.AnalyticsTeamSection' users: $ref: '#/definitions/domain.AnalyticsUsersSection' + videos: + $ref: '#/definitions/domain.AnalyticsVideosSection' type: object domain.AnalyticsDateFilter: properties: @@ -298,10 +336,30 @@ definitions: items: $ref: '#/definitions/domain.AnalyticsLabelCount' type: array + by_country: + items: + $ref: '#/definitions/domain.AnalyticsLabelCount' + type: array + by_education_level: + items: + $ref: '#/definitions/domain.AnalyticsLabelCount' + type: array by_knowledge_level: items: $ref: '#/definitions/domain.AnalyticsLabelCount' type: array + by_language_challange: + items: + $ref: '#/definitions/domain.AnalyticsLabelCount' + type: array + by_learning_goal: + items: + $ref: '#/definitions/domain.AnalyticsLabelCount' + type: array + by_occupation: + items: + $ref: '#/definitions/domain.AnalyticsLabelCount' + type: array by_region: items: $ref: '#/definitions/domain.AnalyticsLabelCount' @@ -327,6 +385,38 @@ definitions: total_users: type: integer type: object + domain.AnalyticsVideoDropOffPoint: + properties: + checkpoint_percent: + type: integer + drop_off_rate: + type: number + total_sessions: + type: integer + viewers_reached: + type: integer + type: object + domain.AnalyticsVideosSection: + properties: + completed_sessions: + type: integer + completion_rate: + type: number + drop_off_by_checkpoint: + items: + $ref: '#/definitions/domain.AnalyticsVideoDropOffPoint' + type: array + replay_rate: + type: number + replay_sessions: + type: integer + total_watch_sessions: + type: integer + unique_video_starts: + type: integer + users_who_replayed: + type: integer + type: object domain.BulkAccountsByRoleRequest: properties: exclude_team_member_id: @@ -645,6 +735,16 @@ definitions: message: type: string type: object + domain.InviteTeamMemberReq: + properties: + email: + type: string + team_role: + type: string + required: + - email + - team_role + type: object domain.LogEntry: properties: caller: @@ -1345,6 +1445,38 @@ definitions: required: - otp type: object + domain.VideoEngagementHeartbeatInput: + properties: + content_id: + type: integer + content_kind: + enum: + - lms_lesson + - exam_prep_lesson + type: string + duration_sec: + minimum: 0 + type: integer + ended: + type: boolean + position_sec: + minimum: 0 + type: integer + required: + - content_id + - content_kind + type: object + domain.VideoWatchSessionResponse: + properties: + completed: + type: boolean + max_position_sec: + type: integer + session_id: + type: integer + session_number: + type: integer + type: object domain.WebhookRequest: properties: nonce: @@ -1661,6 +1793,31 @@ definitions: - current_password - new_password type: object + handlers.createEmailTemplateReq: + properties: + body_html: + type: string + body_text: + type: string + name: + type: string + slug: + type: string + status: + type: string + subject: + type: string + variables: + items: + type: string + type: array + required: + - body_html + - body_text + - name + - slug + - subject + type: object handlers.createFAQReq: properties: answer: @@ -1677,6 +1834,23 @@ definitions: - answer - question type: object + handlers.createFieldOptionReq: + properties: + code: + type: string + display_order: + type: integer + field_key: + type: string + label: + type: string + status: + type: string + required: + - code + - field_key + - label + type: object handlers.createIssueReq: properties: description: @@ -1693,6 +1867,30 @@ definitions: - issue_type - subject type: object + handlers.createMobileAppVersionReq: + properties: + min_supported_version_code: + type: integer + platform: + type: string + release_notes: + type: string + status: + type: string + store_url: + type: string + update_type: + type: string + version_code: + minimum: 1 + type: integer + version_name: + type: string + required: + - platform + - version_code + - version_name + type: object handlers.createPlanReq: properties: currency: @@ -1924,6 +2122,12 @@ definitions: required: - option_text type: object + handlers.previewEmailTemplateReq: + properties: + variables: + additionalProperties: {} + type: object + type: object handlers.refreshFileURLReq: properties: reference: @@ -2024,6 +2228,23 @@ definitions: example: false type: boolean type: object + handlers.updateEmailTemplateReq: + properties: + body_html: + type: string + body_text: + type: string + name: + type: string + status: + type: string + subject: + type: string + variables: + items: + type: string + type: array + type: object handlers.updateFAQReq: properties: answer: @@ -2037,6 +2258,15 @@ definitions: status: type: string type: object + handlers.updateFieldOptionReq: + properties: + display_order: + type: integer + label: + type: string + status: + type: string + type: object handlers.updateIssueStatusReq: properties: status: @@ -2049,6 +2279,23 @@ definitions: required: - status type: object + handlers.updateMobileAppVersionReq: + properties: + min_supported_version_code: + type: integer + release_notes: + type: string + status: + type: string + store_url: + type: string + update_type: + type: string + version_code: + type: integer + version_name: + type: string + type: object handlers.updatePlanReq: properties: currency: @@ -2806,6 +3053,452 @@ paths: summary: Update Admin tags: - admin + /api/v1/admin/app-versions: + get: + parameters: + - description: Filter by ANDROID or IOS + in: query + name: platform + type: string + - description: Filter by ACTIVE or INACTIVE + in: query + name: status + type: string + - description: Limit (default 20) + in: query + name: limit + type: integer + - description: Offset (default 0) + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: List mobile app versions (admin) + tags: + - app-versions + post: + consumes: + - application/json + parameters: + - description: App version payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createMobileAppVersionReq' + 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' + security: + - Bearer: [] + summary: Create mobile app version (admin) + tags: + - app-versions + /api/v1/admin/app-versions/{id}: + delete: + parameters: + - description: App version ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Delete mobile app version (admin) + tags: + - app-versions + get: + parameters: + - description: App version ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Get mobile app version by ID (admin) + tags: + - app-versions + put: + consumes: + - application/json + parameters: + - description: App version ID + in: path + name: id + required: true + type: integer + - description: App version payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateMobileAppVersionReq' + 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' + security: + - Bearer: [] + summary: Update mobile app version (admin) + tags: + - app-versions + /api/v1/admin/email-templates: + get: + description: Returns email templates for admin management + parameters: + - description: ACTIVE or INACTIVE + in: query + name: status + type: string + - description: Search by slug, name, or subject + in: query + name: query + type: string + - description: Limit (default 20) + in: query + name: limit + type: integer + - description: Offset (default 0) + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List email templates (admin) + tags: + - email-templates + post: + consumes: + - application/json + description: Creates a new custom email template + parameters: + - description: Create email template payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createEmailTemplateReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create email template + tags: + - email-templates + /api/v1/admin/email-templates/{id}: + delete: + description: Deletes a custom email template + parameters: + - description: Email template ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete email template + tags: + - email-templates + get: + description: Returns one email template regardless of status + parameters: + - description: Email template ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get email template by ID (admin) + tags: + - email-templates + put: + consumes: + - application/json + description: Updates an existing email template + parameters: + - description: Email template ID + in: path + name: id + required: true + type: integer + - description: Update email template payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateEmailTemplateReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update email template + tags: + - email-templates + /api/v1/admin/email-templates/{id}/preview: + post: + consumes: + - application/json + description: Renders an email template with sample variables without sending + parameters: + - description: Email template ID + in: path + name: id + required: true + type: integer + - description: Preview variables + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.previewEmailTemplateReq' + 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: Preview email template by ID + tags: + - email-templates + /api/v1/admin/email-templates/slug/{slug}: + get: + description: Returns one email template by slug regardless of status + parameters: + - description: Email template slug + in: path + name: slug + required: true + type: string + 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 email template by slug (admin) + tags: + - email-templates + /api/v1/admin/email-templates/slug/{slug}/preview: + post: + consumes: + - application/json + description: Renders an email template with sample variables without sending + parameters: + - description: Email template slug + in: path + name: slug + required: true + type: string + - description: Preview variables + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.previewEmailTemplateReq' + 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: Preview email template by slug + tags: + - email-templates /api/v1/admin/faqs: get: description: Returns FAQs for admin management with status/category filtering @@ -2972,6 +3665,115 @@ paths: summary: Update FAQ tags: - faqs + /api/v1/admin/field-options: + get: + parameters: + - description: Filter by field key + in: query + name: field_key + type: string + - description: ACTIVE or INACTIVE + in: query + name: status + type: string + - description: Limit + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: List field options (admin) + tags: + - field-options + post: + consumes: + - application/json + parameters: + - description: Create option + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createFieldOptionReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + summary: Create field option (admin) + tags: + - field-options + /api/v1/admin/field-options/{id}: + delete: + parameters: + - description: Option ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Delete field option (admin) + tags: + - field-options + get: + parameters: + - description: Option ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Get field option by ID (admin) + tags: + - field-options + put: + consumes: + - application/json + parameters: + - description: Option ID + in: path + name: id + required: true + type: integer + - description: Update option + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateFieldOptionReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Update field option (admin) + tags: + - field-options /api/v1/admin/roles/{role}/bulk-deactivate: post: consumes: @@ -3259,6 +4061,39 @@ paths: summary: Analytics dashboard tags: - analytics + /api/v1/app/version/check: + get: + description: Public endpoint for mobile clients to determine if an app update + is available (force or optional) + parameters: + - description: 'Platform: ANDROID or IOS' + in: query + name: platform + required: true + type: string + - description: Client build number (Android versionCode / iOS build number) + in: query + name: version_code + 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 mobile app version + tags: + - app-versions /api/v1/assessment/questions: get: description: Returns all active assessment questions from the initial assessment @@ -4095,6 +4930,44 @@ paths: summary: Get published FAQ by ID tags: - faqs + /api/v1/field-options: + get: + description: Returns active options grouped by field_key (e.g. education_level, + country) + parameters: + - description: Filter by field key + in: query + name: field_key + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: List field options for client dropdowns + tags: + - field-options + /api/v1/field-options/fields: + get: + description: Returns field_key values that have options (e.g. education_level, + country) + parameters: + - description: If true, only keys with active options + in: query + name: active_only + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: List distinct field keys + tags: + - field-options /api/v1/files/audio: post: consumes: @@ -4527,8 +5400,8 @@ paths: - practices /api/v1/lms/progress: get: - description: Returns completed lesson, module, course, and program IDs for the - authenticated user (ordered by completion time, then id). + description: Returns practice-based completed lesson, module, course, and program + IDs for the authenticated user (ordered by completion time, then id). produces: - application/json responses: @@ -5034,6 +5907,32 @@ paths: summary: Cancel a pending payment tags: - payments + /api/v1/payments/chapa/callback: + get: + description: Verifies payment after Chapa redirects to callback_url + parameters: + - description: Transaction reference + in: query + name: trx_ref + type: string + - description: Chapa reference ID + in: query + name: ref_id + type: string + - description: Payment status + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Chapa payment callback + tags: + - payments /api/v1/payments/direct: post: consumes: @@ -5110,7 +6009,7 @@ paths: - payments /api/v1/payments/methods: get: - description: Returns list of supported ArifPay payment methods + description: Returns payment methods available on Chapa checkout produces: - application/json responses: @@ -5118,7 +6017,7 @@ paths: description: OK schema: $ref: '#/definitions/domain.Response' - summary: Get available payment methods + summary: Get Chapa payment methods tags: - payments /api/v1/payments/subscribe: @@ -5182,14 +6081,7 @@ paths: post: consumes: - application/json - description: Processes payment notifications from ArifPay - parameters: - - description: Webhook payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/domain.WebhookRequest' + description: Processes payment notifications from Chapa (charge.success, etc.) produces: - application/json responses: @@ -5201,7 +6093,7 @@ paths: description: Bad Request schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Handle ArifPay webhook + summary: Handle Chapa webhook tags: - payments /api/v1/personas: @@ -7430,6 +8322,94 @@ paths: summary: Update Admin tags: - admin + /api/v1/team/invitations: + get: + description: Lists team member invitations with optional status filter + parameters: + - description: pending, accepted, expired, or revoked + in: query + name: status + type: string + - description: Limit (default 20) + in: query + name: limit + type: integer + - description: Offset (default 0) + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: List team invitations + tags: + - team + /api/v1/team/invitations/{id}/revoke: + post: + description: Revokes the invitation and removes the pending team member if not + yet accepted + parameters: + - description: Invitation ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Revoke a pending team invitation + tags: + - team + /api/v1/team/invitations/accept: + post: + consumes: + - application/json + description: Public endpoint to set password and profile details after following + the invite link + parameters: + - description: Accept invitation payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.AcceptTeamInvitationReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Accept team invitation and complete account setup + tags: + - team + /api/v1/team/invitations/verify: + get: + description: Public endpoint used by the admin panel accept-invite page + parameters: + - description: Invitation token + in: query + name: token + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Verify team invitation token + tags: + - team /api/v1/team/login: post: consumes: @@ -7794,6 +8774,33 @@ paths: summary: Change team member password tags: - team + /api/v1/team/members/{id}/resend-invite: + post: + description: Revokes the current pending invite and sends a new invitation email + parameters: + - description: Team member ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Resend team invitation + tags: + - team /api/v1/team/members/{id}/status: patch: consumes: @@ -7843,6 +8850,37 @@ paths: summary: Update team member status tags: - team + /api/v1/team/members/invite: + post: + consumes: + - application/json + description: Creates a pending team member (email + team_role only) and sends + an invitation email; profile is completed on accept + parameters: + - description: Invite payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.InviteTeamMemberReq' + 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: Invite a team member by email + tags: + - team /api/v1/team/refresh: post: consumes: @@ -8447,6 +9485,48 @@ paths: summary: Get user summary statistics tags: - user + /api/v1/videos/engagement/heartbeat: + post: + consumes: + - application/json + description: Records playback position for analytics (completion, replay, and + drop-off). Send periodic heartbeats while watching; set ended=true when the + viewer leaves. A new session starts after 30 minutes of inactivity or when + ended=true on the prior session. + parameters: + - description: Playback heartbeat + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.VideoEngagementHeartbeatInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/domain.VideoWatchSessionResponse' + type: object + "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' + summary: Report video playback progress + tags: + - videos /api/v1/vimeo/oembed: get: consumes: diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go index 106eec8..52e420d 100644 --- a/gen/db/lms_progress.sql.go +++ b/gen/db/lms_progress.sql.go @@ -119,6 +119,26 @@ func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID return n, err } +const CountPublishedPracticesInLesson = `-- name: CountPublishedPracticesInLesson :one +SELECT + count(*)::int AS n +FROM + lms_practices lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id +WHERE + lp.lesson_id = $1 + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +` + +func (q *Queries) CountPublishedPracticesInLesson(ctx context.Context, lessonID pgtype.Int8) (int32, error) { + row := q.db.QueryRow(ctx, CountPublishedPracticesInLesson, lessonID) + var n int32 + err := row.Scan(&n) + return n, err +} + const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one SELECT count(*)::int AS n @@ -309,6 +329,34 @@ func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Conte return n, err } +const CountUserCompletedPublishedPracticesInLesson = `-- name: CountUserCompletedPublishedPracticesInLesson :one +SELECT + count(*)::int AS n +FROM + lms_practices lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id +WHERE + lp.lesson_id = $1 + AND upp.user_id = $2 + AND upp.completed_at IS NOT NULL + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +` + +type CountUserCompletedPublishedPracticesInLessonParams struct { + LessonID pgtype.Int8 `json:"lesson_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) CountUserCompletedPublishedPracticesInLesson(ctx context.Context, arg CountUserCompletedPublishedPracticesInLessonParams) (int32, error) { + row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInLesson, arg.LessonID, arg.UserID) + var n int32 + err := row.Scan(&n) + return n, err +} + const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :one SELECT count(*)::int AS n @@ -591,25 +639,37 @@ func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserP const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many SELECT - ucp.course_id + lp.course_id FROM - lms_user_course_progress AS ucp + lms_practices AS lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id + AND upp.user_id = $1 + AND upp.completed_at IS NOT NULL WHERE - ucp.user_id = $1 + lp.course_id IS NOT NULL + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +GROUP BY + lp.course_id +HAVING + count(DISTINCT lp.question_set_id) > 0 + AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id) ORDER BY - ucp.completed_at ASC, - ucp.course_id ASC + max(upp.completed_at) ASC, + lp.course_id ASC ` -func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]int64, error) { +func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) { rows, err := q.db.Query(ctx, ListLMSCompletedCourseIDsByUser, userID) if err != nil { return nil, err } defer rows.Close() - var items []int64 + var items []pgtype.Int8 for rows.Next() { - var course_id int64 + var course_id pgtype.Int8 if err := rows.Scan(&course_id); err != nil { return nil, err } @@ -623,25 +683,37 @@ func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID in const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many SELECT - ulp.lesson_id + lp.lesson_id FROM - lms_user_lesson_progress AS ulp + lms_practices AS lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id + AND upp.user_id = $1 + AND upp.completed_at IS NOT NULL WHERE - ulp.user_id = $1 + lp.lesson_id IS NOT NULL + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +GROUP BY + lp.lesson_id +HAVING + count(DISTINCT lp.question_set_id) > 0 + AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id) ORDER BY - ulp.completed_at ASC, - ulp.lesson_id ASC + max(upp.completed_at) ASC, + lp.lesson_id ASC ` -func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]int64, error) { +func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) { rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID) if err != nil { return nil, err } defer rows.Close() - var items []int64 + var items []pgtype.Int8 for rows.Next() { - var lesson_id int64 + var lesson_id pgtype.Int8 if err := rows.Scan(&lesson_id); err != nil { return nil, err } @@ -655,25 +727,37 @@ func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID in const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many SELECT - ump.module_id + lp.module_id FROM - lms_user_module_progress AS ump + lms_practices AS lp + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id + AND upp.user_id = $1 + AND upp.completed_at IS NOT NULL WHERE - ump.user_id = $1 + lp.module_id IS NOT NULL + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +GROUP BY + lp.module_id +HAVING + count(DISTINCT lp.question_set_id) > 0 + AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id) ORDER BY - ump.completed_at ASC, - ump.module_id ASC + max(upp.completed_at) ASC, + lp.module_id ASC ` -func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]int64, error) { +func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) { rows, err := q.db.Query(ctx, ListLMSCompletedModuleIDsByUser, userID) if err != nil { return nil, err } defer rows.Close() - var items []int64 + var items []pgtype.Int8 for rows.Next() { - var module_id int64 + var module_id pgtype.Int8 if err := rows.Scan(&module_id); err != nil { return nil, err } @@ -687,14 +771,26 @@ func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID in const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many SELECT - upp.program_id + c.program_id FROM - lms_user_program_progress AS upp + lms_practices AS lp + INNER JOIN courses c ON c.id = lp.course_id + INNER JOIN question_sets qs ON qs.id = lp.question_set_id + LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id + AND upp.user_id = $1 + AND upp.completed_at IS NOT NULL WHERE - upp.user_id = $1 + qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' +GROUP BY + c.program_id +HAVING + count(DISTINCT lp.question_set_id) > 0 + AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id) ORDER BY - upp.completed_at ASC, - upp.program_id ASC + max(upp.completed_at) ASC, + c.program_id ASC ` func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) { diff --git a/gen/db/models.go b/gen/db/models.go index e8f9cc1..d897574 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -214,6 +214,20 @@ type LmsUserProgramProgress struct { CompletedAt pgtype.Timestamptz `json:"completed_at"` } +type MobileAppVersion struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + VersionName string `json:"version_name"` + VersionCode int32 `json:"version_code"` + UpdateType string `json:"update_type"` + ReleaseNotes pgtype.Text `json:"release_notes"` + StoreUrl pgtype.Text `json:"store_url"` + MinSupportedVersionCode pgtype.Int4 `json:"min_supported_version_code"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Module struct { ID int64 `json:"id"` ProgramID int64 `json:"program_id"` diff --git a/internal/domain/lms_access.go b/internal/domain/lms_access.go index 888d053..cc74a79 100644 --- a/internal/domain/lms_access.go +++ b/internal/domain/lms_access.go @@ -3,18 +3,21 @@ package domain // LMSEntityAccess describes learner gating for a program, course, module, or lesson. // Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses. // OPEN_LEARNER always has is_accessible true; STUDENT may be false when prerequisites are unmet. -// Progress fields count completed lessons vs total lessons in that entity’s scope (lesson: 0 or 1 of 1). +// Progress fields count completed published practices vs total published practices in the +// entity's scope. progress_percent keeps the legacy whole-number value; use +// progress_percent_precise for decimal precision in learner UIs. type LMSEntityAccess struct { - IsAccessible bool `json:"is_accessible"` - IsCompleted bool `json:"is_completed"` - Reason string `json:"reason,omitempty"` - CompletedCount int `json:"completed_count"` - TotalCount int `json:"total_count"` - ProgressPercent int `json:"progress_percent"` + IsAccessible bool `json:"is_accessible"` + IsCompleted bool `json:"is_completed"` + Reason string `json:"reason,omitempty"` + CompletedCount int `json:"completed_count"` + TotalCount int `json:"total_count"` + ProgressPercent int `json:"progress_percent"` + ProgressPercentPrecise float64 `json:"progress_percent_precise"` } -// LMSUserProgress lists entity IDs the authenticated user has fully completed -// (lessons as marked complete; module/course/program when rollup conditions were met). +// LMSUserProgress lists entity IDs the authenticated user has fully completed based on +// published practice completion in each LMS scope. type LMSUserProgress struct { LessonIDs []int64 `json:"lesson_ids"` ModuleIDs []int64 `json:"module_ids"` diff --git a/internal/repository/lms_access.go b/internal/repository/lms_access.go index 8db1f57..75c9ce4 100644 --- a/internal/repository/lms_access.go +++ b/internal/repository/lms_access.go @@ -38,89 +38,67 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID}) } -// LmsUserLessonProgressInModule returns combined completed/total counts for lessons + published practices in a module. -func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) { - lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID) +// LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson. +func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) { + lessonIDPG := toPgInt8(&lessonID) + total, err = s.queries.CountPublishedPracticesInLesson(ctx, lessonIDPG) if err != nil { return 0, 0, err } - lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{ - ModuleID: moduleID, + completed, err = s.queries.CountUserCompletedPublishedPracticesInLesson(ctx, dbgen.CountUserCompletedPublishedPracticesInLessonParams{ + LessonID: lessonIDPG, UserID: userID, }) if err != nil { return 0, 0, err } - practiceTotal, err := s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID)) + return completed, total, nil +} + +// LmsUserLessonProgressInModule returns published practice completion counts in a module. +func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) { + total, err = s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID)) if err != nil { return 0, 0, err } - practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{ + completed, err = s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{ ModuleID: toPgInt8(&moduleID), UserID: userID, }) if err != nil { return 0, 0, err } - total = lessonTotal + practiceTotal - completed = lessonCompleted + practiceCompleted return completed, total, nil } -// LmsUserLessonProgressInCourse returns combined completed/total counts for lessons + published practices in a course. +// LmsUserLessonProgressInCourse returns published practice completion counts in a course. func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) { - lessonTotal, err := s.queries.CountLessonsInCourse(ctx, courseID) + total, err = s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID)) if err != nil { return 0, 0, err } - lessonCompleted, err := s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{ - CourseID: courseID, - UserID: userID, - }) - if err != nil { - return 0, 0, err - } - practiceTotal, err := s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID)) - if err != nil { - return 0, 0, err - } - practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{ + completed, err = s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{ CourseID: toPgInt8(&courseID), UserID: userID, }) if err != nil { return 0, 0, err } - total = lessonTotal + practiceTotal - completed = lessonCompleted + practiceCompleted return completed, total, nil } -// LmsUserLessonProgressInProgram returns combined completed/total counts for lessons + published practices in a program. +// LmsUserLessonProgressInProgram returns published practice completion counts in a program. func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) { - lessonTotal, err := s.queries.CountLessonsInProgram(ctx, programID) + total, err = s.queries.CountPublishedPracticesInProgram(ctx, programID) if err != nil { return 0, 0, err } - lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{ + completed, err = s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{ ProgramID: programID, UserID: userID, }) if err != nil { return 0, 0, err } - practiceTotal, err := s.queries.CountPublishedPracticesInProgram(ctx, programID) - if err != nil { - return 0, 0, err - } - practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{ - ProgramID: programID, - UserID: userID, - }) - if err != nil { - return 0, 0, err - } - total = lessonTotal + practiceTotal - completed = lessonCompleted + practiceCompleted return completed, total, nil } diff --git a/internal/repository/lms_progress_tx.go b/internal/repository/lms_progress_tx.go index 906da8c..2a582df 100644 --- a/internal/repository/lms_progress_tx.go +++ b/internal/repository/lms_progress_tx.go @@ -7,8 +7,8 @@ import ( dbgen "Yimaru-Backend/gen/db" ) -// CompleteLessonForUser records lesson completion and cascades completion upward when -// both lesson and related practice requirements are satisfied. +// CompleteLessonForUser records lesson completion for sequential lesson gating and +// re-evaluates higher-level practice-based rollups. func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error { q, tx, err := s.BeginTx(ctx) if err != nil { @@ -42,8 +42,8 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6 return nil } -// CompletePracticeForUser records practice completion and cascades completion upward when -// both lesson and related practice requirements are satisfied. +// CompletePracticeForUser records practice completion and cascades practice-based +// completion upward when all published practices in scope are complete. func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error { q, tx, err := s.BeginTx(ctx) if err != nil { @@ -110,17 +110,6 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error { if moduleID != nil { - moduleLessonsTotal, err := q.CountLessonsInModule(ctx, *moduleID) - if err != nil { - return err - } - moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{ - ModuleID: *moduleID, - UserID: userID, - }) - if err != nil { - return err - } modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID)) if err != nil { return err @@ -133,9 +122,8 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user return err } - moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal - modulePracticesComplete := modulePracticesDone >= modulePracticesTotal - if !moduleLessonsComplete || !modulePracticesComplete { + modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal + if !modulePracticesComplete { return nil } @@ -169,7 +157,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user } courseModulesComplete := nMods > 0 && nDoneMods >= nMods - coursePracticesComplete := coursePracticesDone >= coursePracticesTotal + coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal if !courseModulesComplete || !coursePracticesComplete { return nil } @@ -203,7 +191,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user } programCoursesComplete := nCr > 0 && nCrDone >= nCr - programPracticesComplete := programPracticesDone >= programPracticesTotal + programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal if !programCoursesComplete || !programPracticesComplete { return nil } diff --git a/internal/repository/lms_user_progress_snapshot.go b/internal/repository/lms_user_progress_snapshot.go index 9d82393..d255b6e 100644 --- a/internal/repository/lms_user_progress_snapshot.go +++ b/internal/repository/lms_user_progress_snapshot.go @@ -5,9 +5,11 @@ import ( dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" ) -// GetLMSUserProgressSnapshot returns all completed lesson, module, course, and program IDs for a user. +// GetLMSUserProgressSnapshot returns practice-based completed lesson, module, course, +// and program IDs for a user. func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (domain.LMSUserProgress, error) { lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID) if err != nil { @@ -26,13 +28,24 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d return domain.LMSUserProgress{}, err } return domain.LMSUserProgress{ - LessonIDs: lessons, - ModuleIDs: mods, - CourseIDs: courses, + LessonIDs: pgInt8IDsToInt64(lessons), + ModuleIDs: pgInt8IDsToInt64(mods), + CourseIDs: pgInt8IDsToInt64(courses), ProgramIDs: programs, }, nil } +func pgInt8IDsToInt64(items []pgtype.Int8) []int64 { + out := make([]int64, 0, len(items)) + for _, item := range items { + if !item.Valid { + continue + } + out = append(out, item.Int64) + } + return out +} + // ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions). func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) { return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID) diff --git a/internal/services/lmsprogress/service.go b/internal/services/lmsprogress/service.go index c41f7bc..039a2f2 100644 --- a/internal/services/lmsprogress/service.go +++ b/internal/services/lmsprogress/service.go @@ -3,6 +3,7 @@ package lmsprogress import ( "context" "errors" + "math" "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/repository" @@ -151,14 +152,11 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user p.Access = nil return nil } - done, err := s.store.LmsUserHasProgramProgress(ctx, userID, p.ID) - if err != nil { - return err - } comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID) if err != nil { return err } + done := lmsProgressComplete(comp, tot) ok, reason := true, "" if role.UsesLMSSequentialGating() { ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID) @@ -176,14 +174,11 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI c.Access = nil return nil } - done, err := s.store.LmsUserHasCourseProgress(ctx, userID, c.ID) - if err != nil { - return err - } comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID) if err != nil { return err } + done := lmsProgressComplete(comp, tot) ok, reason := true, "" if role.UsesLMSSequentialGating() { ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID) @@ -201,14 +196,11 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI m.Access = nil return nil } - done, err := s.store.LmsUserHasModuleProgress(ctx, userID, m.ID) - if err != nil { - return err - } comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID) if err != nil { return err } + done := lmsProgressComplete(comp, tot) ok, reason := true, "" if role.UsesLMSSequentialGating() { ok, reason, err = s.CanAccessModule(ctx, userID, m.ID) @@ -226,16 +218,11 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI les.Access = nil return nil } - done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID) + comp, tot, err := s.store.LmsUserPracticeProgressInLesson(ctx, userID, les.ID) if err != nil { return err } - var comp, tot int32 - if done { - comp, tot = 1, 1 - } else { - comp, tot = 0, 1 - } + done := lmsProgressComplete(comp, tot) ok, reason := true, "" if role.UsesLMSSequentialGating() { ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID) @@ -247,21 +234,26 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI return nil } +func lmsProgressComplete(completed, total int32) bool { + return total > 0 && completed >= total +} + func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess { - c, t, pct := lmsProgressCounts(completed, total, done) + c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done) return &domain.LMSEntityAccess{ - IsAccessible: ok, - IsCompleted: done, - Reason: reasonIf(ok, reason), - CompletedCount: c, - TotalCount: t, - ProgressPercent: pct, + IsAccessible: ok, + IsCompleted: done, + Reason: reasonIf(ok, reason), + CompletedCount: c, + TotalCount: t, + ProgressPercent: pct, + ProgressPercentPrecise: pctPrecise, } } // lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0–100; completed // and total are aligned with isCompleted when the entity is fully done. -func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) { +func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int, pctPrecise float64) { c, t = int(completed), int(total) if t < 0 { t = 0 @@ -271,18 +263,22 @@ func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) } if isCompleted { if t > 0 { - return t, t, 100 + return t, t, 100, 100 } - return c, t, 100 + return c, t, 100, 100 } if t == 0 { - return 0, 0, 0 + return 0, 0, 0, 0 } pct = (c * 100) / t if pct > 100 { pct = 100 } - return c, t, pct + pctPrecise = math.Round((float64(c)*10000)/float64(t)) / 100 + if pctPrecise > 100 { + pctPrecise = 100 + } + return c, t, pct, pctPrecise } func reasonIf(ok bool, r string) string { diff --git a/internal/services/lmsprogress/service_test.go b/internal/services/lmsprogress/service_test.go new file mode 100644 index 0000000..860cb8a --- /dev/null +++ b/internal/services/lmsprogress/service_test.go @@ -0,0 +1,97 @@ +package lmsprogress + +import "testing" + +func TestLMSProgressCounts(t *testing.T) { + tests := []struct { + name string + completed int32 + total int32 + isCompleted bool + wantCompleted int + wantTotal int + wantPercent int + wantPercentFloat float64 + }{ + { + name: "fractional progress rounds to two decimals", + completed: 1, + total: 3, + wantCompleted: 1, + wantTotal: 3, + wantPercent: 33, + wantPercentFloat: 33.33, + }, + { + name: "larger fraction rounds precisely", + completed: 2, + total: 3, + wantCompleted: 2, + wantTotal: 3, + wantPercent: 66, + wantPercentFloat: 66.67, + }, + { + name: "completed forces full progress", + completed: 2, + total: 3, + isCompleted: true, + wantCompleted: 3, + wantTotal: 3, + wantPercent: 100, + wantPercentFloat: 100, + }, + { + name: "empty scope stays zeroed", + wantCompleted: 0, + wantTotal: 0, + wantPercent: 0, + wantPercentFloat: 0, + }, + { + name: "negative counts are sanitized", + completed: -2, + total: -5, + wantCompleted: 0, + wantTotal: 0, + wantPercent: 0, + wantPercentFloat: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotCompleted, gotTotal, gotPercent, gotPercentFloat := lmsProgressCounts(tt.completed, tt.total, tt.isCompleted) + if gotCompleted != tt.wantCompleted || gotTotal != tt.wantTotal { + t.Fatalf("counts=(%d,%d), want (%d,%d)", gotCompleted, gotTotal, tt.wantCompleted, tt.wantTotal) + } + if gotPercent != tt.wantPercent { + t.Fatalf("progress_percent=%d, want %d", gotPercent, tt.wantPercent) + } + if gotPercentFloat != tt.wantPercentFloat { + t.Fatalf("progress_percent_precise=%v, want %v", gotPercentFloat, tt.wantPercentFloat) + } + }) + } +} + +func TestLMSProgressComplete(t *testing.T) { + tests := []struct { + name string + completed int32 + total int32 + want bool + }{ + {name: "complete when all practices done", completed: 3, total: 3, want: true}, + {name: "incomplete when practices remain", completed: 2, total: 3, want: false}, + {name: "zero total is not completed", completed: 0, total: 0, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := lmsProgressComplete(tt.completed, tt.total); got != tt.want { + t.Fatalf("lmsProgressComplete(%d, %d)=%v, want %v", tt.completed, tt.total, got, tt.want) + } + }) + } +} diff --git a/internal/web_server/handlers/lms_progress_handler.go b/internal/web_server/handlers/lms_progress_handler.go index de69170..e0522a0 100644 --- a/internal/web_server/handlers/lms_progress_handler.go +++ b/internal/web_server/handlers/lms_progress_handler.go @@ -11,7 +11,7 @@ import ( // GetMyLMSProgress godoc // @Summary Get my LMS completion history -// @Description Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id). +// @Description Returns practice-based completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id). // @Tags lms // @Produce json // @Success 200 {object} domain.Response