diff --git a/db/query/analytics.sql b/db/query/analytics.sql index 5898f2d..f6c9793 100644 --- a/db/query/analytics.sql +++ b/db/query/analytics.sql @@ -209,13 +209,34 @@ ORDER BY d.date; -- ===================== -- Course Analytics -- ===================== +-- LMS: programs -> courses -> modules -> lessons; lms_practices attach to course, module, or lesson. +-- Exam prep: exam_prep.catalog_courses -> units -> unit_modules -> unit_module_lessons; exam_prep.lesson_practices. +-- Legacy dashboard fields map to: programs, courses, modules, and video-bearing lessons (LMS + exam prep). -- name: AnalyticsCourseCounts :one SELECT - 0::bigint AS total_categories, - 0::bigint AS total_courses, - 0::bigint AS total_sub_courses, - 0::bigint AS total_videos; + (SELECT COUNT(*)::bigint FROM programs) AS total_categories, + (SELECT COUNT(*)::bigint FROM courses) AS total_courses, + (SELECT COUNT(*)::bigint FROM modules) AS total_sub_courses, + ( + COALESCE((SELECT COUNT(*)::bigint FROM lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL), 0::bigint) + + COALESCE((SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL), 0::bigint) + )::bigint AS total_videos, + (SELECT COUNT(*)::bigint FROM programs) AS lms_programs, + (SELECT COUNT(*)::bigint FROM courses) AS lms_courses, + (SELECT COUNT(*)::bigint FROM modules) AS lms_modules, + (SELECT COUNT(*)::bigint FROM lessons) AS lms_lessons, + (SELECT COUNT(*)::bigint FROM lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS lms_lessons_with_video, + (SELECT COUNT(*)::bigint FROM lms_practices) AS lms_practices, + (SELECT COUNT(*)::bigint FROM lms_practices WHERE course_id IS NOT NULL) AS lms_practices_at_course, + (SELECT COUNT(*)::bigint FROM lms_practices WHERE module_id IS NOT NULL) AS lms_practices_at_module, + (SELECT COUNT(*)::bigint FROM lms_practices WHERE lesson_id IS NOT NULL) AS lms_practices_at_lesson, + (SELECT COUNT(*)::bigint FROM exam_prep.catalog_courses) AS exam_prep_catalog_courses, + (SELECT COUNT(*)::bigint FROM exam_prep.units) AS exam_prep_units, + (SELECT COUNT(*)::bigint FROM exam_prep.unit_modules) AS exam_prep_unit_modules, + (SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons) AS exam_prep_lessons, + (SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS exam_prep_lessons_with_video, + (SELECT COUNT(*)::bigint FROM exam_prep.lesson_practices) AS exam_prep_lesson_practices; -- ===================== -- Content Analytics diff --git a/docs/docs.go b/docs/docs.go index cb863d9..5447df5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -9638,7 +9638,14 @@ const docTemplate = `{ "domain.AnalyticsCoursesSection": { "type": "object", "properties": { + "exam_prep": { + "$ref": "#/definitions/domain.AnalyticsExamPrepContentCounts" + }, + "lms": { + "$ref": "#/definitions/domain.AnalyticsLMSContentCounts" + }, "total_categories": { + "description": "Top-level keys preserved for existing clients: map to LMS programs, courses, modules, and all video lessons (LMS + exam prep).", "type": "integer" }, "total_courses": { @@ -9722,6 +9729,29 @@ const docTemplate = `{ } } }, + "domain.AnalyticsExamPrepContentCounts": { + "type": "object", + "properties": { + "catalog_courses": { + "type": "integer" + }, + "lesson_practices": { + "type": "integer" + }, + "lessons": { + "type": "integer" + }, + "lessons_with_video": { + "type": "integer" + }, + "unit_modules": { + "type": "integer" + }, + "units": { + "type": "integer" + } + } + }, "domain.AnalyticsIssuesSection": { "type": "object", "properties": { @@ -9748,6 +9778,38 @@ const docTemplate = `{ } } }, + "domain.AnalyticsLMSContentCounts": { + "type": "object", + "properties": { + "courses": { + "type": "integer" + }, + "lessons": { + "type": "integer" + }, + "lessons_with_video": { + "type": "integer" + }, + "modules": { + "type": "integer" + }, + "practices": { + "type": "integer" + }, + "practices_at_course": { + "type": "integer" + }, + "practices_at_lesson": { + "type": "integer" + }, + "practices_at_module": { + "type": "integer" + }, + "programs": { + "type": "integer" + } + } + }, "domain.AnalyticsLabelAmount": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index b38b2ea..da58ff5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -9630,7 +9630,14 @@ "domain.AnalyticsCoursesSection": { "type": "object", "properties": { + "exam_prep": { + "$ref": "#/definitions/domain.AnalyticsExamPrepContentCounts" + }, + "lms": { + "$ref": "#/definitions/domain.AnalyticsLMSContentCounts" + }, "total_categories": { + "description": "Top-level keys preserved for existing clients: map to LMS programs, courses, modules, and all video lessons (LMS + exam prep).", "type": "integer" }, "total_courses": { @@ -9714,6 +9721,29 @@ } } }, + "domain.AnalyticsExamPrepContentCounts": { + "type": "object", + "properties": { + "catalog_courses": { + "type": "integer" + }, + "lesson_practices": { + "type": "integer" + }, + "lessons": { + "type": "integer" + }, + "lessons_with_video": { + "type": "integer" + }, + "unit_modules": { + "type": "integer" + }, + "units": { + "type": "integer" + } + } + }, "domain.AnalyticsIssuesSection": { "type": "object", "properties": { @@ -9740,6 +9770,38 @@ } } }, + "domain.AnalyticsLMSContentCounts": { + "type": "object", + "properties": { + "courses": { + "type": "integer" + }, + "lessons": { + "type": "integer" + }, + "lessons_with_video": { + "type": "integer" + }, + "modules": { + "type": "integer" + }, + "practices": { + "type": "integer" + }, + "practices_at_course": { + "type": "integer" + }, + "practices_at_lesson": { + "type": "integer" + }, + "practices_at_module": { + "type": "integer" + }, + "programs": { + "type": "integer" + } + } + }, "domain.AnalyticsLabelAmount": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7c6ee0f..111f033 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -34,7 +34,13 @@ definitions: type: object domain.AnalyticsCoursesSection: properties: + exam_prep: + $ref: '#/definitions/domain.AnalyticsExamPrepContentCounts' + lms: + $ref: '#/definitions/domain.AnalyticsLMSContentCounts' total_categories: + description: 'Top-level keys preserved for existing clients: map to LMS programs, + courses, modules, and all video lessons (LMS + exam prep).' type: integer total_courses: type: integer @@ -89,6 +95,21 @@ definitions: year: type: integer type: object + domain.AnalyticsExamPrepContentCounts: + properties: + catalog_courses: + type: integer + lesson_practices: + type: integer + lessons: + type: integer + lessons_with_video: + type: integer + unit_modules: + type: integer + units: + type: integer + type: object domain.AnalyticsIssuesSection: properties: by_status: @@ -106,6 +127,27 @@ definitions: total_issues: type: integer type: object + domain.AnalyticsLMSContentCounts: + properties: + courses: + type: integer + lessons: + type: integer + lessons_with_video: + type: integer + modules: + type: integer + practices: + type: integer + practices_at_course: + type: integer + practices_at_lesson: + type: integer + practices_at_module: + type: integer + programs: + type: integer + type: object domain.AnalyticsLabelAmount: properties: amount: diff --git a/gen/db/analytics.sql.go b/gen/db/analytics.sql.go index 3a8bd2a..75aacae 100644 --- a/gen/db/analytics.sql.go +++ b/gen/db/analytics.sql.go @@ -14,22 +14,58 @@ import ( const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one SELECT - 0::bigint AS total_categories, - 0::bigint AS total_courses, - 0::bigint AS total_sub_courses, - 0::bigint AS total_videos + (SELECT COUNT(*)::bigint FROM programs) AS total_categories, + (SELECT COUNT(*)::bigint FROM courses) AS total_courses, + (SELECT COUNT(*)::bigint FROM modules) AS total_sub_courses, + ( + COALESCE((SELECT COUNT(*)::bigint FROM lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL), 0::bigint) + + COALESCE((SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL), 0::bigint) + )::bigint AS total_videos, + (SELECT COUNT(*)::bigint FROM programs) AS lms_programs, + (SELECT COUNT(*)::bigint FROM courses) AS lms_courses, + (SELECT COUNT(*)::bigint FROM modules) AS lms_modules, + (SELECT COUNT(*)::bigint FROM lessons) AS lms_lessons, + (SELECT COUNT(*)::bigint FROM lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS lms_lessons_with_video, + (SELECT COUNT(*)::bigint FROM lms_practices) AS lms_practices, + (SELECT COUNT(*)::bigint FROM lms_practices WHERE course_id IS NOT NULL) AS lms_practices_at_course, + (SELECT COUNT(*)::bigint FROM lms_practices WHERE module_id IS NOT NULL) AS lms_practices_at_module, + (SELECT COUNT(*)::bigint FROM lms_practices WHERE lesson_id IS NOT NULL) AS lms_practices_at_lesson, + (SELECT COUNT(*)::bigint FROM exam_prep.catalog_courses) AS exam_prep_catalog_courses, + (SELECT COUNT(*)::bigint FROM exam_prep.units) AS exam_prep_units, + (SELECT COUNT(*)::bigint FROM exam_prep.unit_modules) AS exam_prep_unit_modules, + (SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons) AS exam_prep_lessons, + (SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS exam_prep_lessons_with_video, + (SELECT COUNT(*)::bigint FROM exam_prep.lesson_practices) AS exam_prep_lesson_practices ` type AnalyticsCourseCountsRow struct { - TotalCategories int64 `json:"total_categories"` - TotalCourses int64 `json:"total_courses"` - TotalSubCourses int64 `json:"total_sub_courses"` - TotalVideos int64 `json:"total_videos"` + TotalCategories int64 `json:"total_categories"` + TotalCourses int64 `json:"total_courses"` + TotalSubCourses int64 `json:"total_sub_courses"` + TotalVideos int64 `json:"total_videos"` + LmsPrograms int64 `json:"lms_programs"` + LmsCourses int64 `json:"lms_courses"` + LmsModules int64 `json:"lms_modules"` + LmsLessons int64 `json:"lms_lessons"` + LmsLessonsWithVideo int64 `json:"lms_lessons_with_video"` + LmsPractices int64 `json:"lms_practices"` + LmsPracticesAtCourse int64 `json:"lms_practices_at_course"` + LmsPracticesAtModule int64 `json:"lms_practices_at_module"` + LmsPracticesAtLesson int64 `json:"lms_practices_at_lesson"` + ExamPrepCatalogCourses int64 `json:"exam_prep_catalog_courses"` + ExamPrepUnits int64 `json:"exam_prep_units"` + ExamPrepUnitModules int64 `json:"exam_prep_unit_modules"` + ExamPrepLessons int64 `json:"exam_prep_lessons"` + ExamPrepLessonsWithVideo int64 `json:"exam_prep_lessons_with_video"` + ExamPrepLessonPractices int64 `json:"exam_prep_lesson_practices"` } // ===================== // Course Analytics // ===================== +// LMS: programs -> courses -> modules -> lessons; lms_practices attach to course, module, or lesson. +// Exam prep: exam_prep.catalog_courses -> units -> unit_modules -> unit_module_lessons; exam_prep.lesson_practices. +// Legacy dashboard fields map to: programs, courses, modules, and video-bearing lessons (LMS + exam prep). func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCountsRow, error) { row := q.db.QueryRow(ctx, AnalyticsCourseCounts) var i AnalyticsCourseCountsRow @@ -38,6 +74,21 @@ func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCou &i.TotalCourses, &i.TotalSubCourses, &i.TotalVideos, + &i.LmsPrograms, + &i.LmsCourses, + &i.LmsModules, + &i.LmsLessons, + &i.LmsLessonsWithVideo, + &i.LmsPractices, + &i.LmsPracticesAtCourse, + &i.LmsPracticesAtModule, + &i.LmsPracticesAtLesson, + &i.ExamPrepCatalogCourses, + &i.ExamPrepUnits, + &i.ExamPrepUnitModules, + &i.ExamPrepLessons, + &i.ExamPrepLessonsWithVideo, + &i.ExamPrepLessonPractices, ) return i, err } diff --git a/internal/domain/analytics.go b/internal/domain/analytics.go index 05b73c8..f7d1793 100644 --- a/internal/domain/analytics.go +++ b/internal/domain/analytics.go @@ -69,11 +69,38 @@ type AnalyticsPaymentsSection struct { RevenueLast30Days []AnalyticsRevenueTimePoint `json:"revenue_last_30_days"` } +// AnalyticsLMSContentCounts reflects the LMS hierarchy (Learn English): programs → courses → modules → lessons. +type AnalyticsLMSContentCounts struct { + Programs int64 `json:"programs"` + Courses int64 `json:"courses"` + Modules int64 `json:"modules"` + Lessons int64 `json:"lessons"` + LessonsWithVideo int64 `json:"lessons_with_video"` + Practices int64 `json:"practices"` + PracticesAtCourse int64 `json:"practices_at_course"` + PracticesAtModule int64 `json:"practices_at_module"` + PracticesAtLesson int64 `json:"practices_at_lesson"` +} + +// AnalyticsExamPrepContentCounts reflects the exam_prep schema: catalog_courses → units → unit_modules → lessons → lesson_practices. +type AnalyticsExamPrepContentCounts struct { + CatalogCourses int64 `json:"catalog_courses"` + Units int64 `json:"units"` + UnitModules int64 `json:"unit_modules"` + Lessons int64 `json:"lessons"` + LessonsWithVideo int64 `json:"lessons_with_video"` + LessonPractices int64 `json:"lesson_practices"` +} + type AnalyticsCoursesSection struct { + // Top-level keys preserved for existing clients: map to LMS programs, courses, modules, and all video lessons (LMS + exam prep). TotalCategories int64 `json:"total_categories"` TotalCourses int64 `json:"total_courses"` TotalSubCourses int64 `json:"total_sub_courses"` TotalVideos int64 `json:"total_videos"` + + LMS AnalyticsLMSContentCounts `json:"lms"` + ExamPrep AnalyticsExamPrepContentCounts `json:"exam_prep"` } type AnalyticsContentSection struct { diff --git a/internal/web_server/handlers/analytics_handler.go b/internal/web_server/handlers/analytics_handler.go index c48483b..71cc69f 100644 --- a/internal/web_server/handlers/analytics_handler.go +++ b/internal/web_server/handlers/analytics_handler.go @@ -17,7 +17,7 @@ func toTime(v interface{}) time.Time { // GetAnalyticsDashboard godoc // @Summary Analytics dashboard -// @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. +// @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. The courses section includes LMS (programs→courses→modules→lessons, lms_practices) and exam_prep (catalog_courses→units→unit_modules→lessons, lesson_practices) inventory counts. // @Tags analytics // @Produce json // @Param year query int false "Calendar year (e.g. 2025)" @@ -166,12 +166,7 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs), Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30), - Courses: domain.AnalyticsCoursesSection{ - TotalCategories: courseCounts.TotalCategories, - TotalCourses: courseCounts.TotalCourses, - TotalSubCourses: courseCounts.TotalSubCourses, - TotalVideos: courseCounts.TotalVideos, - }, + Courses: mapCoursesSection(courseCounts), Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType), Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType), Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType), @@ -181,6 +176,34 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { return c.JSON(dashboard) } +func mapCoursesSection(r dbgen.AnalyticsCourseCountsRow) domain.AnalyticsCoursesSection { + return domain.AnalyticsCoursesSection{ + TotalCategories: r.TotalCategories, + TotalCourses: r.TotalCourses, + TotalSubCourses: r.TotalSubCourses, + TotalVideos: r.TotalVideos, + LMS: domain.AnalyticsLMSContentCounts{ + Programs: r.LmsPrograms, + Courses: r.LmsCourses, + Modules: r.LmsModules, + Lessons: r.LmsLessons, + LessonsWithVideo: r.LmsLessonsWithVideo, + Practices: r.LmsPractices, + PracticesAtCourse: r.LmsPracticesAtCourse, + PracticesAtModule: r.LmsPracticesAtModule, + PracticesAtLesson: r.LmsPracticesAtLesson, + }, + ExamPrep: domain.AnalyticsExamPrepContentCounts{ + CatalogCourses: r.ExamPrepCatalogCourses, + Units: r.ExamPrepUnits, + UnitModules: r.ExamPrepUnitModules, + Lessons: r.ExamPrepLessons, + LessonsWithVideo: r.ExamPrepLessonsWithVideo, + LessonPractices: r.ExamPrepLessonPractices, + }, + } +} + func mapUsersSection( summary dbgen.AnalyticsUsersSummaryRow, byRole []dbgen.AnalyticsUsersByRoleRow,