From e957eacf800c50f90d83476dc1c408ad1cd7ad5e Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 22 May 2026 09:54:47 -0700 Subject: [PATCH] Add profile field breakdowns to analytics dashboard. Expose user counts by education_level, occupation, learning_goal, and language_challange on GET /api/v1/analytics/dashboard. Co-authored-by: Cursor --- db/query/analytics.sql | 40 +++++ gen/db/analytics.sql.go | 164 ++++++++++++++++++ gen/db/models.go | 11 ++ internal/domain/analytics.go | 14 +- .../web_server/handlers/analytics_handler.go | 46 ++++- .../web_server/handlers/analytics_params.go | 16 +- 6 files changed, 281 insertions(+), 10 deletions(-) diff --git a/db/query/analytics.sql b/db/query/analytics.sql index f632f05..391ebaf 100644 --- a/db/query/analytics.sql +++ b/db/query/analytics.sql @@ -68,6 +68,46 @@ WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.nar GROUP BY u.knowledge_level ORDER BY count DESC; +-- name: AnalyticsUsersByEducationLevel :many +SELECT + COALESCE(NULLIF(TRIM(u.education_level), ''), 'unknown')::text AS education_level, + COUNT(*)::bigint AS count +FROM users u +WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz) + AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz) +GROUP BY COALESCE(NULLIF(TRIM(u.education_level), ''), 'unknown') +ORDER BY count DESC; + +-- name: AnalyticsUsersByOccupation :many +SELECT + COALESCE(NULLIF(TRIM(u.occupation), ''), 'unknown')::text AS occupation, + COUNT(*)::bigint AS count +FROM users u +WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz) + AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz) +GROUP BY COALESCE(NULLIF(TRIM(u.occupation), ''), 'unknown') +ORDER BY count DESC; + +-- name: AnalyticsUsersByLearningGoal :many +SELECT + COALESCE(NULLIF(TRIM(u.learning_goal), ''), 'unknown')::text AS learning_goal, + COUNT(*)::bigint AS count +FROM users u +WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz) + AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz) +GROUP BY COALESCE(NULLIF(TRIM(u.learning_goal), ''), 'unknown') +ORDER BY count DESC; + +-- name: AnalyticsUsersByLanguageChallange :many +SELECT + COALESCE(NULLIF(TRIM(u.language_challange), ''), 'unknown')::text AS language_challange, + COUNT(*)::bigint AS count +FROM users u +WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR u.created_at >= sqlc.narg('range_start')::timestamptz) + AND (sqlc.narg('range_end')::timestamptz IS NULL OR u.created_at < sqlc.narg('range_end')::timestamptz) +GROUP BY COALESCE(NULLIF(TRIM(u.language_challange), ''), 'unknown') +ORDER BY count DESC; + -- name: AnalyticsUsersByRegion :many SELECT COALESCE(u.region, 'unknown') AS region, diff --git a/gen/db/analytics.sql.go b/gen/db/analytics.sql.go index 23c8754..2c1b053 100644 --- a/gen/db/analytics.sql.go +++ b/gen/db/analytics.sql.go @@ -1080,6 +1080,47 @@ func (q *Queries) AnalyticsUsersByAgeGroup(ctx context.Context, arg AnalyticsUse return items, nil } +const AnalyticsUsersByEducationLevel = `-- name: AnalyticsUsersByEducationLevel :many +SELECT + COALESCE(NULLIF(TRIM(u.education_level), ''), 'unknown')::text AS education_level, + COUNT(*)::bigint AS count +FROM users u +WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz) + AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz) +GROUP BY COALESCE(NULLIF(TRIM(u.education_level), ''), 'unknown') +ORDER BY count DESC +` + +type AnalyticsUsersByEducationLevelParams struct { + RangeStart pgtype.Timestamptz `json:"range_start"` + RangeEnd pgtype.Timestamptz `json:"range_end"` +} + +type AnalyticsUsersByEducationLevelRow struct { + EducationLevel string `json:"education_level"` + Count int64 `json:"count"` +} + +func (q *Queries) AnalyticsUsersByEducationLevel(ctx context.Context, arg AnalyticsUsersByEducationLevelParams) ([]AnalyticsUsersByEducationLevelRow, error) { + rows, err := q.db.Query(ctx, AnalyticsUsersByEducationLevel, arg.RangeStart, arg.RangeEnd) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByEducationLevelRow + for rows.Next() { + var i AnalyticsUsersByEducationLevelRow + if err := rows.Scan(&i.EducationLevel, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const AnalyticsUsersByKnowledgeLevel = `-- name: AnalyticsUsersByKnowledgeLevel :many SELECT COALESCE(u.knowledge_level, 'unknown') AS knowledge_level, @@ -1121,6 +1162,129 @@ func (q *Queries) AnalyticsUsersByKnowledgeLevel(ctx context.Context, arg Analyt return items, nil } +const AnalyticsUsersByLanguageChallange = `-- name: AnalyticsUsersByLanguageChallange :many +SELECT + COALESCE(NULLIF(TRIM(u.language_challange), ''), 'unknown')::text AS language_challange, + COUNT(*)::bigint AS count +FROM users u +WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz) + AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz) +GROUP BY COALESCE(NULLIF(TRIM(u.language_challange), ''), 'unknown') +ORDER BY count DESC +` + +type AnalyticsUsersByLanguageChallangeParams struct { + RangeStart pgtype.Timestamptz `json:"range_start"` + RangeEnd pgtype.Timestamptz `json:"range_end"` +} + +type AnalyticsUsersByLanguageChallangeRow struct { + LanguageChallange string `json:"language_challange"` + Count int64 `json:"count"` +} + +func (q *Queries) AnalyticsUsersByLanguageChallange(ctx context.Context, arg AnalyticsUsersByLanguageChallangeParams) ([]AnalyticsUsersByLanguageChallangeRow, error) { + rows, err := q.db.Query(ctx, AnalyticsUsersByLanguageChallange, arg.RangeStart, arg.RangeEnd) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByLanguageChallangeRow + for rows.Next() { + var i AnalyticsUsersByLanguageChallangeRow + if err := rows.Scan(&i.LanguageChallange, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsUsersByLearningGoal = `-- name: AnalyticsUsersByLearningGoal :many +SELECT + COALESCE(NULLIF(TRIM(u.learning_goal), ''), 'unknown')::text AS learning_goal, + COUNT(*)::bigint AS count +FROM users u +WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz) + AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz) +GROUP BY COALESCE(NULLIF(TRIM(u.learning_goal), ''), 'unknown') +ORDER BY count DESC +` + +type AnalyticsUsersByLearningGoalParams struct { + RangeStart pgtype.Timestamptz `json:"range_start"` + RangeEnd pgtype.Timestamptz `json:"range_end"` +} + +type AnalyticsUsersByLearningGoalRow struct { + LearningGoal string `json:"learning_goal"` + Count int64 `json:"count"` +} + +func (q *Queries) AnalyticsUsersByLearningGoal(ctx context.Context, arg AnalyticsUsersByLearningGoalParams) ([]AnalyticsUsersByLearningGoalRow, error) { + rows, err := q.db.Query(ctx, AnalyticsUsersByLearningGoal, arg.RangeStart, arg.RangeEnd) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByLearningGoalRow + for rows.Next() { + var i AnalyticsUsersByLearningGoalRow + if err := rows.Scan(&i.LearningGoal, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsUsersByOccupation = `-- name: AnalyticsUsersByOccupation :many +SELECT + COALESCE(NULLIF(TRIM(u.occupation), ''), 'unknown')::text AS occupation, + COUNT(*)::bigint AS count +FROM users u +WHERE ($1::timestamptz IS NULL OR u.created_at >= $1::timestamptz) + AND ($2::timestamptz IS NULL OR u.created_at < $2::timestamptz) +GROUP BY COALESCE(NULLIF(TRIM(u.occupation), ''), 'unknown') +ORDER BY count DESC +` + +type AnalyticsUsersByOccupationParams struct { + RangeStart pgtype.Timestamptz `json:"range_start"` + RangeEnd pgtype.Timestamptz `json:"range_end"` +} + +type AnalyticsUsersByOccupationRow struct { + Occupation string `json:"occupation"` + Count int64 `json:"count"` +} + +func (q *Queries) AnalyticsUsersByOccupation(ctx context.Context, arg AnalyticsUsersByOccupationParams) ([]AnalyticsUsersByOccupationRow, error) { + rows, err := q.db.Query(ctx, AnalyticsUsersByOccupation, arg.RangeStart, arg.RangeEnd) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsUsersByOccupationRow + for rows.Next() { + var i AnalyticsUsersByOccupationRow + if err := rows.Scan(&i.Occupation, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const AnalyticsUsersByRegion = `-- name: AnalyticsUsersByRegion :many SELECT COALESCE(u.region, 'unknown') AS region, diff --git a/gen/db/models.go b/gen/db/models.go index e827781..011a8b1 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -127,6 +127,17 @@ type Faq struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type FieldOption struct { + ID int64 `json:"id"` + FieldKey string `json:"field_key"` + Code string `json:"code"` + Label string `json:"label"` + DisplayOrder int32 `json:"display_order"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type GlobalSetting struct { Key string `json:"key"` Value string `json:"value"` diff --git a/internal/domain/analytics.go b/internal/domain/analytics.go index bbf78dd..aa9ddea 100644 --- a/internal/domain/analytics.go +++ b/internal/domain/analytics.go @@ -44,11 +44,15 @@ type AnalyticsUsersSection struct { NewWeek int64 `json:"new_week"` NewMonth int64 `json:"new_month"` - ByRole []AnalyticsLabelCount `json:"by_role"` - ByStatus []AnalyticsLabelCount `json:"by_status"` - ByAgeGroup []AnalyticsLabelCount `json:"by_age_group"` - ByKnowledgeLevel []AnalyticsLabelCount `json:"by_knowledge_level"` - ByRegion []AnalyticsLabelCount `json:"by_region"` + ByRole []AnalyticsLabelCount `json:"by_role"` + ByStatus []AnalyticsLabelCount `json:"by_status"` + ByAgeGroup []AnalyticsLabelCount `json:"by_age_group"` + ByEducationLevel []AnalyticsLabelCount `json:"by_education_level"` + ByOccupation []AnalyticsLabelCount `json:"by_occupation"` + ByLearningGoal []AnalyticsLabelCount `json:"by_learning_goal"` + ByLanguageChallange []AnalyticsLabelCount `json:"by_language_challange"` + ByKnowledgeLevel []AnalyticsLabelCount `json:"by_knowledge_level"` + ByRegion []AnalyticsLabelCount `json:"by_region"` RegistrationsLast30Days []AnalyticsTimePoint `json:"registrations_last_30_days"` } diff --git a/internal/web_server/handlers/analytics_handler.go b/internal/web_server/handlers/analytics_handler.go index daa873f..35ebb08 100644 --- a/internal/web_server/handlers/analytics_handler.go +++ b/internal/web_server/handlers/analytics_handler.go @@ -61,6 +61,22 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by age group") } + usersByEducation, err := h.analyticsDB.AnalyticsUsersByEducationLevel(ctx, p.UsersByEducationLevel) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by education level") + } + usersByOccupation, err := h.analyticsDB.AnalyticsUsersByOccupation(ctx, p.UsersByOccupation) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by occupation") + } + usersByLearningGoal, err := h.analyticsDB.AnalyticsUsersByLearningGoal(ctx, p.UsersByLearningGoal) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by learning goal") + } + usersByLanguageChallange, err := h.analyticsDB.AnalyticsUsersByLanguageChallange(ctx, p.UsersByLanguageChallange) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by language challenge") + } usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx, p.UsersByKnowledgeLevel) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by knowledge level") @@ -179,7 +195,11 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { dashboard := domain.AnalyticsDashboard{ GeneratedAt: time.Now().UTC(), DateFilter: filter, - Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs), + Users: mapUsersSection( + usersSummary, usersByRole, usersByStatus, usersByAge, + usersByEducation, usersByOccupation, usersByLearningGoal, usersByLanguageChallange, + usersByKnowledge, usersByRegion, userRegs, + ), Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear), Courses: mapCoursesSection(courseCounts), @@ -225,6 +245,10 @@ func mapUsersSection( byRole []dbgen.AnalyticsUsersByRoleRow, byStatus []dbgen.AnalyticsUsersByStatusRow, byAge []dbgen.AnalyticsUsersByAgeGroupRow, + byEducation []dbgen.AnalyticsUsersByEducationLevelRow, + byOccupation []dbgen.AnalyticsUsersByOccupationRow, + byLearningGoal []dbgen.AnalyticsUsersByLearningGoalRow, + byLanguageChallange []dbgen.AnalyticsUsersByLanguageChallangeRow, byKnowledge []dbgen.AnalyticsUsersByKnowledgeLevelRow, byRegion []dbgen.AnalyticsUsersByRegionRow, regs []dbgen.AnalyticsUserRegistrationsLast30DaysRow, @@ -241,6 +265,22 @@ func mapUsersSection( for i, r := range byAge { ages[i] = domain.AnalyticsLabelCount{Label: r.AgeGroup, Count: r.Count} } + education := make([]domain.AnalyticsLabelCount, len(byEducation)) + for i, r := range byEducation { + education[i] = domain.AnalyticsLabelCount{Label: r.EducationLevel, Count: r.Count} + } + occupations := make([]domain.AnalyticsLabelCount, len(byOccupation)) + for i, r := range byOccupation { + occupations[i] = domain.AnalyticsLabelCount{Label: r.Occupation, Count: r.Count} + } + learningGoals := make([]domain.AnalyticsLabelCount, len(byLearningGoal)) + for i, r := range byLearningGoal { + learningGoals[i] = domain.AnalyticsLabelCount{Label: r.LearningGoal, Count: r.Count} + } + languageChallanges := make([]domain.AnalyticsLabelCount, len(byLanguageChallange)) + for i, r := range byLanguageChallange { + languageChallanges[i] = domain.AnalyticsLabelCount{Label: r.LanguageChallange, Count: r.Count} + } knowledge := make([]domain.AnalyticsLabelCount, len(byKnowledge)) for i, r := range byKnowledge { knowledge[i] = domain.AnalyticsLabelCount{Label: r.KnowledgeLevel, Count: r.Count} @@ -261,6 +301,10 @@ func mapUsersSection( ByRole: roles, ByStatus: statuses, ByAgeGroup: ages, + ByEducationLevel: education, + ByOccupation: occupations, + ByLearningGoal: learningGoals, + ByLanguageChallange: languageChallanges, ByKnowledgeLevel: knowledge, ByRegion: regions, RegistrationsLast30Days: timePoints, diff --git a/internal/web_server/handlers/analytics_params.go b/internal/web_server/handlers/analytics_params.go index 73aefe7..b11ab1a 100644 --- a/internal/web_server/handlers/analytics_params.go +++ b/internal/web_server/handlers/analytics_params.go @@ -12,8 +12,12 @@ type analyticsQueryParams struct { UsersSummary dbgen.AnalyticsUsersSummaryParams UsersByRole dbgen.AnalyticsUsersByRoleParams UsersByStatus dbgen.AnalyticsUsersByStatusParams - UsersByAgeGroup dbgen.AnalyticsUsersByAgeGroupParams - UsersByKnowledgeLevel dbgen.AnalyticsUsersByKnowledgeLevelParams + UsersByAgeGroup dbgen.AnalyticsUsersByAgeGroupParams + UsersByEducationLevel dbgen.AnalyticsUsersByEducationLevelParams + UsersByOccupation dbgen.AnalyticsUsersByOccupationParams + UsersByLearningGoal dbgen.AnalyticsUsersByLearningGoalParams + UsersByLanguageChallange dbgen.AnalyticsUsersByLanguageChallangeParams + UsersByKnowledgeLevel dbgen.AnalyticsUsersByKnowledgeLevelParams UsersByRegion dbgen.AnalyticsUsersByRegionParams UserRegistrationsSeries dbgen.AnalyticsUserRegistrationsLast30DaysParams @@ -58,8 +62,12 @@ func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams UsersSummary: dbgen.AnalyticsUsersSummaryParams{RefDate: ref, RangeStart: rs, RangeEnd: re}, UsersByRole: dbgen.AnalyticsUsersByRoleParams{RangeStart: rs, RangeEnd: re}, UsersByStatus: dbgen.AnalyticsUsersByStatusParams{RangeStart: rs, RangeEnd: re}, - UsersByAgeGroup: dbgen.AnalyticsUsersByAgeGroupParams{RangeStart: rs, RangeEnd: re}, - UsersByKnowledgeLevel: dbgen.AnalyticsUsersByKnowledgeLevelParams{RangeStart: rs, RangeEnd: re}, + UsersByAgeGroup: dbgen.AnalyticsUsersByAgeGroupParams{RangeStart: rs, RangeEnd: re}, + UsersByEducationLevel: dbgen.AnalyticsUsersByEducationLevelParams{RangeStart: rs, RangeEnd: re}, + UsersByOccupation: dbgen.AnalyticsUsersByOccupationParams{RangeStart: rs, RangeEnd: re}, + UsersByLearningGoal: dbgen.AnalyticsUsersByLearningGoalParams{RangeStart: rs, RangeEnd: re}, + UsersByLanguageChallange: dbgen.AnalyticsUsersByLanguageChallangeParams{RangeStart: rs, RangeEnd: re}, + UsersByKnowledgeLevel: dbgen.AnalyticsUsersByKnowledgeLevelParams{RangeStart: rs, RangeEnd: re}, UsersByRegion: dbgen.AnalyticsUsersByRegionParams{RangeStart: rs, RangeEnd: re}, UserRegistrationsSeries: series,