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