Replace stub AnalyticsCourseCounts query and expose lms / exam_prep inventory in the courses section. Co-authored-by: Cursor <cursoragent@cursor.com>
397 lines
16 KiB
Go
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,
|
|
}
|
|
}
|