Fix analytics dashboard course counts for LMS and exam_prep hierarchies.

Replace stub AnalyticsCourseCounts query and expose lms / exam_prep inventory in the courses section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-17 22:34:25 -07:00
parent 7f8ef3373c
commit a1696bf1e0
7 changed files with 307 additions and 19 deletions

View File

@ -209,13 +209,34 @@ ORDER BY d.date;
-- ===================== -- =====================
-- Course Analytics -- 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 -- name: AnalyticsCourseCounts :one
SELECT SELECT
0::bigint AS total_categories, (SELECT COUNT(*)::bigint FROM programs) AS total_categories,
0::bigint AS total_courses, (SELECT COUNT(*)::bigint FROM courses) AS total_courses,
0::bigint AS total_sub_courses, (SELECT COUNT(*)::bigint FROM modules) AS total_sub_courses,
0::bigint AS total_videos; (
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 -- Content Analytics

View File

@ -9638,7 +9638,14 @@ const docTemplate = `{
"domain.AnalyticsCoursesSection": { "domain.AnalyticsCoursesSection": {
"type": "object", "type": "object",
"properties": { "properties": {
"exam_prep": {
"$ref": "#/definitions/domain.AnalyticsExamPrepContentCounts"
},
"lms": {
"$ref": "#/definitions/domain.AnalyticsLMSContentCounts"
},
"total_categories": { "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" "type": "integer"
}, },
"total_courses": { "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": { "domain.AnalyticsIssuesSection": {
"type": "object", "type": "object",
"properties": { "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": { "domain.AnalyticsLabelAmount": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -9630,7 +9630,14 @@
"domain.AnalyticsCoursesSection": { "domain.AnalyticsCoursesSection": {
"type": "object", "type": "object",
"properties": { "properties": {
"exam_prep": {
"$ref": "#/definitions/domain.AnalyticsExamPrepContentCounts"
},
"lms": {
"$ref": "#/definitions/domain.AnalyticsLMSContentCounts"
},
"total_categories": { "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" "type": "integer"
}, },
"total_courses": { "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": { "domain.AnalyticsIssuesSection": {
"type": "object", "type": "object",
"properties": { "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": { "domain.AnalyticsLabelAmount": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -34,7 +34,13 @@ definitions:
type: object type: object
domain.AnalyticsCoursesSection: domain.AnalyticsCoursesSection:
properties: properties:
exam_prep:
$ref: '#/definitions/domain.AnalyticsExamPrepContentCounts'
lms:
$ref: '#/definitions/domain.AnalyticsLMSContentCounts'
total_categories: 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 type: integer
total_courses: total_courses:
type: integer type: integer
@ -89,6 +95,21 @@ definitions:
year: year:
type: integer type: integer
type: object 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: domain.AnalyticsIssuesSection:
properties: properties:
by_status: by_status:
@ -106,6 +127,27 @@ definitions:
total_issues: total_issues:
type: integer type: integer
type: object 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: domain.AnalyticsLabelAmount:
properties: properties:
amount: amount:

View File

@ -14,10 +14,28 @@ import (
const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one
SELECT SELECT
0::bigint AS total_categories, (SELECT COUNT(*)::bigint FROM programs) AS total_categories,
0::bigint AS total_courses, (SELECT COUNT(*)::bigint FROM courses) AS total_courses,
0::bigint AS total_sub_courses, (SELECT COUNT(*)::bigint FROM modules) AS total_sub_courses,
0::bigint AS total_videos (
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 { type AnalyticsCourseCountsRow struct {
@ -25,11 +43,29 @@ type AnalyticsCourseCountsRow struct {
TotalCourses int64 `json:"total_courses"` TotalCourses int64 `json:"total_courses"`
TotalSubCourses int64 `json:"total_sub_courses"` TotalSubCourses int64 `json:"total_sub_courses"`
TotalVideos int64 `json:"total_videos"` 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 // 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) { func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCountsRow, error) {
row := q.db.QueryRow(ctx, AnalyticsCourseCounts) row := q.db.QueryRow(ctx, AnalyticsCourseCounts)
var i AnalyticsCourseCountsRow var i AnalyticsCourseCountsRow
@ -38,6 +74,21 @@ func (q *Queries) AnalyticsCourseCounts(ctx context.Context) (AnalyticsCourseCou
&i.TotalCourses, &i.TotalCourses,
&i.TotalSubCourses, &i.TotalSubCourses,
&i.TotalVideos, &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 return i, err
} }

View File

@ -69,11 +69,38 @@ type AnalyticsPaymentsSection struct {
RevenueLast30Days []AnalyticsRevenueTimePoint `json:"revenue_last_30_days"` 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 { 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"` TotalCategories int64 `json:"total_categories"`
TotalCourses int64 `json:"total_courses"` TotalCourses int64 `json:"total_courses"`
TotalSubCourses int64 `json:"total_sub_courses"` TotalSubCourses int64 `json:"total_sub_courses"`
TotalVideos int64 `json:"total_videos"` TotalVideos int64 `json:"total_videos"`
LMS AnalyticsLMSContentCounts `json:"lms"`
ExamPrep AnalyticsExamPrepContentCounts `json:"exam_prep"`
} }
type AnalyticsContentSection struct { type AnalyticsContentSection struct {

View File

@ -17,7 +17,7 @@ func toTime(v interface{}) time.Time {
// GetAnalyticsDashboard godoc // GetAnalyticsDashboard godoc
// @Summary Analytics dashboard // @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 // @Tags analytics
// @Produce json // @Produce json
// @Param year query int false "Calendar year (e.g. 2025)" // @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), Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs),
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30), Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30),
Courses: domain.AnalyticsCoursesSection{ Courses: mapCoursesSection(courseCounts),
TotalCategories: courseCounts.TotalCategories,
TotalCourses: courseCounts.TotalCourses,
TotalSubCourses: courseCounts.TotalSubCourses,
TotalVideos: courseCounts.TotalVideos,
},
Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType), Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType),
Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType), Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType),
Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType), Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType),
@ -181,6 +176,34 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
return c.JSON(dashboard) 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( func mapUsersSection(
summary dbgen.AnalyticsUsersSummaryRow, summary dbgen.AnalyticsUsersSummaryRow,
byRole []dbgen.AnalyticsUsersByRoleRow, byRole []dbgen.AnalyticsUsersByRoleRow,