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 Jan–Dec 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") } usersByKnowledge, err := h.analyticsDB.AnalyticsUsersByKnowledgeLevel(ctx, p.UsersByKnowledgeLevel) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch users by knowledge level") } 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, usersByKnowledge, 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, byKnowledge []dbgen.AnalyticsUsersByKnowledgeLevelRow, 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} } knowledge := make([]domain.AnalyticsLabelCount, len(byKnowledge)) for i, r := range byKnowledge { knowledge[i] = domain.AnalyticsLabelCount{Label: r.KnowledgeLevel, 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, ByKnowledgeLevel: knowledge, 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, } }