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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-22 09:54:47 -07:00
parent f7d4b5c3fb
commit e957eacf80
6 changed files with 281 additions and 10 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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"`

View File

@ -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"`
}

View File

@ -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,

View File

@ -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,