Yimaru-BackEnd/internal/web_server/handlers/analytics_handler.go
Yared Yemane a1696bf1e0 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>
2026-05-17 22:34:25 -07:00

397 lines
16 KiB
Go

package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"time"
"github.com/gofiber/fiber/v2"
)
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. 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
// @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")
}
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),
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,
) 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}
}
return domain.AnalyticsPaymentsSection{
TotalRevenue: summary.TotalRevenue,
AvgTransactionValue: summary.AvgValue,
TotalPayments: summary.TotalPayments,
SuccessfulPayments: summary.SuccessfulPayments,
ByStatus: statuses,
ByMethod: methods,
RevenueLast30Days: timePoints,
}
}
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,
}
}