Yimaru-BackEnd/internal/web_server/handlers/analytics_handler.go
Yared Yemane 56089fa8fd Add users by country to analytics dashboard.
Expose by_country breakdown on GET /api/v1/analytics/dashboard from users.country.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 01:47:41 -07:00

492 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"time"
"github.com/gofiber/fiber/v2"
)
// Short month labels for analytics monthly charts (aligned with UTC calendar months).
var analyticsShortMonthLabels = []string{
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
}
func toTime(v interface{}) time.Time {
if t, ok := v.(time.Time); ok {
return t
}
return 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. When year is set, payments.revenue_monthly returns JanDec SUCCESS revenue totals (UTC) per currency for that calendar year — use for yearly revenue charts. Daily series remains in revenue_last_30_days (see date_filter.series_*). Courses section counts LMS + exam_prep inventory.
// @Tags analytics
// @Produce json
// @Param year query int false "Calendar year (e.g. 2025)"
// @Param month query int false "Calendar month 1-12 (requires year)"
// @Param from query string false "Custom range start (YYYY-MM-DD or RFC3339)"
// @Param to query string false "Custom range end (YYYY-MM-DD or RFC3339, inclusive)"
// @Success 200 {object} domain.AnalyticsDashboard
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/analytics/dashboard [get]
func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
filter, err := domain.ParseAnalyticsDateFilter(c)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid date filter",
Error: err.Error(),
})
}
ctx := c.Context()
p := newAnalyticsQueryParams(filter)
usersSummary, err := h.analyticsDB.AnalyticsUsersSummary(ctx, p.UsersSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user analytics")
}
usersByRole, err := h.analyticsDB.AnalyticsUsersByRole(ctx, p.UsersByRole)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by role")
}
usersByStatus, err := h.analyticsDB.AnalyticsUsersByStatus(ctx, p.UsersByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by status")
}
usersByAge, err := h.analyticsDB.AnalyticsUsersByAgeGroup(ctx, p.UsersByAgeGroup)
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")
}
usersByCountry, err := h.analyticsDB.AnalyticsUsersByCountry(ctx, p.UsersByCountry)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by country")
}
usersByRegion, err := h.analyticsDB.AnalyticsUsersByRegion(ctx, p.UsersByRegion)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by region")
}
userRegs, err := h.analyticsDB.AnalyticsUserRegistrationsLast30Days(ctx, p.UserRegistrationsSeries)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user registrations")
}
subsSummary, err := h.analyticsDB.AnalyticsSubscriptionsSummary(ctx, p.SubscriptionsSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscription analytics")
}
subsByStatus, err := h.analyticsDB.AnalyticsSubscriptionsByStatus(ctx, p.SubscriptionsByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch subscriptions by status")
}
revenueByPlan, err := h.analyticsDB.AnalyticsRevenueByPlan(ctx, p.RevenueByPlan)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue by plan")
}
newSubs30, err := h.analyticsDB.AnalyticsNewSubscriptionsLast30Days(ctx, p.NewSubscriptionsSeries)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch new subscriptions time series")
}
paymentsSummary, err := h.analyticsDB.AnalyticsPaymentsSummary(ctx, p.PaymentsSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payment analytics")
}
paymentsByStatus, err := h.analyticsDB.AnalyticsPaymentsByStatus(ctx, p.PaymentsByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by status")
}
paymentsByMethod, err := h.analyticsDB.AnalyticsPaymentsByMethod(ctx, p.PaymentsByMethod)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch payments by method")
}
revenue30, err := h.analyticsDB.AnalyticsRevenueLast30Days(ctx, p.RevenueSeries)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue time series")
}
var revenueMonthlyRows []dbgen.AnalyticsRevenueMonthlyByYearRow
var monthlyRevenueYear *int
if filter.Year != nil {
rowsMonthly, err := h.analyticsDB.AnalyticsRevenueMonthlyByYear(ctx, int32(*filter.Year))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch monthly revenue series")
}
revenueMonthlyRows = rowsMonthly
monthlyRevenueYear = filter.Year
}
courseCounts, err := h.analyticsDB.AnalyticsCourseCounts(ctx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch course analytics")
}
questionsCounts, err := h.analyticsDB.AnalyticsQuestionsCounts(ctx, p.QuestionsCounts)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch content analytics")
}
questionsByType, err := h.analyticsDB.AnalyticsQuestionsByType(ctx, p.QuestionsByType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch questions by type")
}
questionSetsByType, err := h.analyticsDB.AnalyticsQuestionSetsByType(ctx, p.QuestionSetsByType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch question sets by type")
}
notifSummary, err := h.analyticsDB.AnalyticsNotificationsSummary(ctx, p.NotificationsSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notification analytics")
}
notifByChannel, err := h.analyticsDB.AnalyticsNotificationsByChannel(ctx, p.NotificationsChannel)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by channel")
}
notifByType, err := h.analyticsDB.AnalyticsNotificationsByType(ctx, p.NotificationsType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications by type")
}
issuesSummary, err := h.analyticsDB.AnalyticsIssuesSummary(ctx, p.IssuesSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issue analytics")
}
issuesByStatus, err := h.analyticsDB.AnalyticsIssuesByStatus(ctx, p.IssuesByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by status")
}
issuesByType, err := h.analyticsDB.AnalyticsIssuesByType(ctx, p.IssuesByType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch issues by type")
}
teamTotal, err := h.analyticsDB.AnalyticsTeamSummary(ctx, p.TeamSummary)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team analytics")
}
teamByRole, err := h.analyticsDB.AnalyticsTeamByRole(ctx, p.TeamByRole)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by role")
}
teamByStatus, err := h.analyticsDB.AnalyticsTeamByStatus(ctx, p.TeamByStatus)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status")
}
dashboard := domain.AnalyticsDashboard{
GeneratedAt: time.Now().UTC(),
DateFilter: filter,
Users: mapUsersSection(
usersSummary, usersByRole, usersByStatus, usersByAge,
usersByEducation, usersByOccupation, usersByLearningGoal, usersByLanguageChallange,
usersByKnowledge, usersByCountry, usersByRegion, userRegs,
),
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear),
Courses: mapCoursesSection(courseCounts),
Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType),
Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType),
Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType),
Team: mapTeamSection(teamTotal, teamByRole, teamByStatus),
}
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,
byStatus []dbgen.AnalyticsUsersByStatusRow,
byAge []dbgen.AnalyticsUsersByAgeGroupRow,
byEducation []dbgen.AnalyticsUsersByEducationLevelRow,
byOccupation []dbgen.AnalyticsUsersByOccupationRow,
byLearningGoal []dbgen.AnalyticsUsersByLearningGoalRow,
byLanguageChallange []dbgen.AnalyticsUsersByLanguageChallangeRow,
byKnowledge []dbgen.AnalyticsUsersByKnowledgeLevelRow,
byCountry []dbgen.AnalyticsUsersByCountryRow,
byRegion []dbgen.AnalyticsUsersByRegionRow,
regs []dbgen.AnalyticsUserRegistrationsLast30DaysRow,
) domain.AnalyticsUsersSection {
roles := make([]domain.AnalyticsLabelCount, len(byRole))
for i, r := range byRole {
roles[i] = domain.AnalyticsLabelCount{Label: r.Role, Count: r.Count}
}
statuses := make([]domain.AnalyticsLabelCount, len(byStatus))
for i, r := range byStatus {
statuses[i] = domain.AnalyticsLabelCount{Label: r.Status, Count: r.Count}
}
ages := make([]domain.AnalyticsLabelCount, len(byAge))
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}
}
countries := make([]domain.AnalyticsLabelCount, len(byCountry))
for i, r := range byCountry {
countries[i] = domain.AnalyticsLabelCount{Label: r.Country, Count: r.Count}
}
regions := make([]domain.AnalyticsLabelCount, len(byRegion))
for i, r := range byRegion {
regions[i] = domain.AnalyticsLabelCount{Label: r.Region, Count: r.Count}
}
timePoints := make([]domain.AnalyticsTimePoint, len(regs))
for i, r := range regs {
timePoints[i] = domain.AnalyticsTimePoint{Date: toTime(r.Date), Count: r.Count}
}
return domain.AnalyticsUsersSection{
TotalUsers: summary.Total,
NewToday: summary.NewToday,
NewWeek: summary.NewThisWeek,
NewMonth: summary.NewThisMonth,
ByRole: roles,
ByStatus: statuses,
ByAgeGroup: ages,
ByEducationLevel: education,
ByOccupation: occupations,
ByLearningGoal: learningGoals,
ByLanguageChallange: languageChallanges,
ByKnowledgeLevel: knowledge,
ByCountry: countries,
ByRegion: regions,
RegistrationsLast30Days: timePoints,
}
}
func mapSubscriptionsSection(
summary dbgen.AnalyticsSubscriptionsSummaryRow,
byStatus []dbgen.AnalyticsSubscriptionsByStatusRow,
byPlan []dbgen.AnalyticsRevenueByPlanRow,
newSubs []dbgen.AnalyticsNewSubscriptionsLast30DaysRow,
) domain.AnalyticsSubscriptionsSection {
statuses := make([]domain.AnalyticsLabelCount, len(byStatus))
for i, r := range byStatus {
statuses[i] = domain.AnalyticsLabelCount{Label: r.Status, Count: r.Count}
}
plans := make([]domain.AnalyticsRevenueByPlan, len(byPlan))
for i, r := range byPlan {
plans[i] = domain.AnalyticsRevenueByPlan{PlanName: r.PlanName, Currency: r.Currency, Revenue: r.TotalRevenue}
}
timePoints := make([]domain.AnalyticsTimePoint, len(newSubs))
for i, r := range newSubs {
timePoints[i] = domain.AnalyticsTimePoint{Date: toTime(r.Date), Count: r.Count}
}
return domain.AnalyticsSubscriptionsSection{
TotalSubscriptions: summary.Total,
ActiveSubscriptions: summary.Active,
NewToday: summary.NewToday,
NewWeek: summary.NewThisWeek,
NewMonth: summary.NewThisMonth,
ByStatus: statuses,
RevenueByPlan: plans,
NewSubscriptionsLast30Days: timePoints,
}
}
func mapPaymentsSection(
summary dbgen.AnalyticsPaymentsSummaryRow,
byStatus []dbgen.AnalyticsPaymentsByStatusRow,
byMethod []dbgen.AnalyticsPaymentsByMethodRow,
revenue []dbgen.AnalyticsRevenueLast30DaysRow,
revenueMonthly []dbgen.AnalyticsRevenueMonthlyByYearRow,
monthlyYear *int,
) domain.AnalyticsPaymentsSection {
statuses := make([]domain.AnalyticsLabelAmount, len(byStatus))
for i, r := range byStatus {
statuses[i] = domain.AnalyticsLabelAmount{Label: r.Status, Count: r.Count, Amount: r.TotalAmount}
}
methods := make([]domain.AnalyticsLabelAmount, len(byMethod))
for i, r := range byMethod {
methods[i] = domain.AnalyticsLabelAmount{Label: r.PaymentMethod, Count: r.Count, Amount: r.TotalAmount}
}
timePoints := make([]domain.AnalyticsRevenueTimePoint, len(revenue))
for i, r := range revenue {
timePoints[i] = domain.AnalyticsRevenueTimePoint{Date: toTime(r.Date), Revenue: r.TotalRevenue}
}
monthlyPoints := make([]domain.AnalyticsMonthlyRevenuePoint, 0, len(revenueMonthly))
for _, r := range revenueMonthly {
m := int(r.Month)
label := ""
if m >= 1 && m <= 12 {
label = analyticsShortMonthLabels[m-1]
}
ms := time.Time{}
if r.MonthStart.Valid {
t := r.MonthStart.Time.UTC()
ms = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
monthlyPoints = append(monthlyPoints, domain.AnalyticsMonthlyRevenuePoint{
Month: m,
MonthStart: ms,
Label: label,
Currency: r.Currency,
Revenue: r.TotalRevenue,
})
}
return domain.AnalyticsPaymentsSection{
TotalRevenue: summary.TotalRevenue,
AvgTransactionValue: summary.AvgValue,
TotalPayments: summary.TotalPayments,
SuccessfulPayments: summary.SuccessfulPayments,
ByStatus: statuses,
ByMethod: methods,
RevenueLast30Days: timePoints,
RevenueMonthly: monthlyPoints,
MonthlyRevenueYear: monthlyYear,
}
}
func mapContentSection(
counts dbgen.AnalyticsQuestionsCountsRow,
byType []dbgen.AnalyticsQuestionsByTypeRow,
setsByType []dbgen.AnalyticsQuestionSetsByTypeRow,
) domain.AnalyticsContentSection {
qTypes := make([]domain.AnalyticsLabelCount, len(byType))
for i, r := range byType {
qTypes[i] = domain.AnalyticsLabelCount{Label: r.QuestionType, Count: r.Count}
}
sTypes := make([]domain.AnalyticsLabelCount, len(setsByType))
for i, r := range setsByType {
sTypes[i] = domain.AnalyticsLabelCount{Label: r.SetType, Count: r.Count}
}
return domain.AnalyticsContentSection{
TotalQuestions: counts.TotalQuestions,
TotalQuestionSets: counts.TotalQuestionSets,
QuestionsByType: qTypes,
QuestionSetsByType: sTypes,
}
}
func mapNotificationsSection(
summary dbgen.AnalyticsNotificationsSummaryRow,
byChannel []dbgen.AnalyticsNotificationsByChannelRow,
byType []dbgen.AnalyticsNotificationsByTypeRow,
) domain.AnalyticsNotificationsSection {
channels := make([]domain.AnalyticsLabelCount, len(byChannel))
for i, r := range byChannel {
channels[i] = domain.AnalyticsLabelCount{Label: r.Channel, Count: r.Count}
}
types := make([]domain.AnalyticsLabelCount, len(byType))
for i, r := range byType {
types[i] = domain.AnalyticsLabelCount{Label: r.Type, Count: r.Count}
}
return domain.AnalyticsNotificationsSection{
TotalSent: summary.Total,
ReadCount: summary.Read,
UnreadCount: summary.Unread,
ByChannel: channels,
ByType: types,
}
}
func mapIssuesSection(
summary dbgen.AnalyticsIssuesSummaryRow,
byStatus []dbgen.AnalyticsIssuesByStatusRow,
byType []dbgen.AnalyticsIssuesByTypeRow,
) domain.AnalyticsIssuesSection {
statuses := make([]domain.AnalyticsLabelCount, len(byStatus))
for i, r := range byStatus {
statuses[i] = domain.AnalyticsLabelCount{Label: r.Status, Count: r.Count}
}
types := make([]domain.AnalyticsLabelCount, len(byType))
for i, r := range byType {
types[i] = domain.AnalyticsLabelCount{Label: r.IssueType, Count: r.Count}
}
return domain.AnalyticsIssuesSection{
TotalIssues: summary.Total,
ResolvedIssues: summary.Resolved,
ResolutionRate: summary.ResolutionRate,
ByStatus: statuses,
ByType: types,
}
}
func mapTeamSection(
totalMembers int64,
byRole []dbgen.AnalyticsTeamByRoleRow,
byStatus []dbgen.AnalyticsTeamByStatusRow,
) domain.AnalyticsTeamSection {
roles := make([]domain.AnalyticsLabelCount, len(byRole))
for i, r := range byRole {
roles[i] = domain.AnalyticsLabelCount{Label: r.TeamRole, Count: r.Count}
}
statuses := make([]domain.AnalyticsLabelCount, len(byStatus))
for i, r := range byStatus {
statuses[i] = domain.AnalyticsLabelCount{Label: r.Status, Count: r.Count}
}
return domain.AnalyticsTeamSection{
TotalMembers: totalMembers,
ByRole: roles,
ByStatus: statuses,
}
}