diff --git a/server/internal/tenant/app/kol_dashboard_service.go b/server/internal/tenant/app/kol_dashboard_service.go new file mode 100644 index 0000000..4f10fa3 --- /dev/null +++ b/server/internal/tenant/app/kol_dashboard_service.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type DashboardOverviewResponse struct { + TotalSubscriberTenants int64 `json:"total_subscriber_tenants"` + MonthNewSubscriptions int64 `json:"month_new_subscriptions"` + TotalUsage int64 `json:"total_usage"` + MonthUsage int64 `json:"month_usage"` + FailureCount int64 `json:"failure_count"` + FailureRate float64 `json:"failure_rate"` +} + +type DashboardPackageStat struct { + PackageID int64 `json:"package_id"` + PackageName string `json:"package_name"` + SubscriberTenants int64 `json:"subscriber_tenants"` + UsageCount int64 `json:"usage_count"` + FailureRate float64 `json:"failure_rate"` +} + +type DashboardTrendPoint struct { + Date string `json:"date"` + Subscriptions int64 `json:"subscriptions"` + Usages int64 `json:"usages"` +} + +type KolDashboardService struct { + profileSvc *KolProfileService + pkgRepo repository.KolPackageRepository + subRepo repository.KolSubscriptionRepository + usageRepo repository.KolUsageRepository +} + +func NewKolDashboardService( + profileSvc *KolProfileService, + pkgRepo repository.KolPackageRepository, + subRepo repository.KolSubscriptionRepository, + usageRepo repository.KolUsageRepository, +) *KolDashboardService { + return &KolDashboardService{ + profileSvc: profileSvc, + pkgRepo: pkgRepo, + subRepo: subRepo, + usageRepo: usageRepo, + } +} + +func (s *KolDashboardService) Overview(ctx context.Context, actor auth.Actor) (*DashboardOverviewResponse, error) { + profile, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { + return nil, err + } + + packageIDs, err := s.pkgRepo.ListPackageIDsByProfile(ctx, actor.TenantID, profile.ID) + if err != nil { + return nil, response.ErrInternal(50120, "kol_dashboard_package_query_failed", err.Error()) + } + if len(packageIDs) == 0 { + return &DashboardOverviewResponse{}, nil + } + + overview, err := s.usageRepo.DashboardOverview(ctx, packageIDs) + if err != nil { + return nil, response.ErrInternal(50121, "kol_dashboard_overview_query_failed", err.Error()) + } + + totalUsage := maxDashboardBase(overview.TotalUsage) + return &DashboardOverviewResponse{ + TotalSubscriberTenants: overview.TotalSubs, + MonthNewSubscriptions: overview.MonthSubs, + TotalUsage: overview.TotalUsage, + MonthUsage: overview.MonthUsage, + FailureCount: overview.FailureCount, + FailureRate: float64(overview.FailureCount) / float64(totalUsage), + }, nil +} + +func (s *KolDashboardService) PerPackage(ctx context.Context, actor auth.Actor) ([]DashboardPackageStat, error) { + profile, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { + return nil, err + } + + packages, err := s.pkgRepo.ListByProfile(ctx, actor.TenantID, profile.ID) + if err != nil { + return nil, response.ErrInternal(50120, "kol_dashboard_package_query_failed", err.Error()) + } + if len(packages) == 0 { + return []DashboardPackageStat{}, nil + } + + packageIDs := make([]int64, 0, len(packages)) + for _, pkg := range packages { + packageIDs = append(packageIDs, pkg.ID) + } + + usageStats, err := s.usageRepo.PerPackageStats(ctx, packageIDs) + if err != nil { + return nil, response.ErrInternal(50122, "kol_dashboard_package_stats_query_failed", err.Error()) + } + + usageByPackage := make(map[int64]repository.KolUsagePackageStats, len(usageStats)) + for _, stat := range usageStats { + usageByPackage[stat.PackageID] = stat + } + + result := make([]DashboardPackageStat, 0, len(packages)) + for _, pkg := range packages { + subscribers, err := s.subRepo.ListActiveSubscribersForPackage(ctx, pkg.ID) + if err != nil { + return nil, response.ErrInternal(50123, "kol_dashboard_subscriber_query_failed", err.Error()) + } + + subscriberTenants := uniqueSubscriberTenants(subscribers) + usage := usageByPackage[pkg.ID] + result = append(result, DashboardPackageStat{ + PackageID: pkg.ID, + PackageName: pkg.Name, + SubscriberTenants: subscriberTenants, + UsageCount: usage.Total, + FailureRate: float64(usage.Failed) / float64(maxDashboardBase(usage.Total)), + }) + } + + return result, nil +} + +func (s *KolDashboardService) Trend(ctx context.Context, actor auth.Actor, periodDays int) ([]DashboardTrendPoint, error) { + profile, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { + return nil, err + } + + packageIDs, err := s.pkgRepo.ListPackageIDsByProfile(ctx, actor.TenantID, profile.ID) + if err != nil { + return nil, response.ErrInternal(50120, "kol_dashboard_package_query_failed", err.Error()) + } + if len(packageIDs) == 0 { + return []DashboardTrendPoint{}, nil + } + + points, err := s.usageRepo.DashboardTrend(ctx, packageIDs, periodDays) + if err != nil { + return nil, response.ErrInternal(50124, "kol_dashboard_trend_query_failed", err.Error()) + } + + result := make([]DashboardTrendPoint, 0, len(points)) + for _, point := range points { + result = append(result, DashboardTrendPoint{ + Date: point.Day.Format("2006-01-02"), + Subscriptions: point.Subscriptions, + Usages: point.Usages, + }) + } + return result, nil +} + +func maxDashboardBase(total int64) int64 { + if total <= 0 { + return 1 + } + return total +} + +func uniqueSubscriberTenants(subscribers []repository.KolSubscription) int64 { + if len(subscribers) == 0 { + return 0 + } + + seen := make(map[int64]struct{}, len(subscribers)) + for _, subscriber := range subscribers { + seen[subscriber.TenantID] = struct{}{} + } + return int64(len(seen)) +} + +func ValidateKolDashboardPeriodDays(periodDays int) error { + switch periodDays { + case 7, 30, 90: + return nil + default: + return response.ErrBadRequest(40073, "kol_dashboard_period_invalid", "period_days must be one of 7, 30, 90") + } +} diff --git a/server/internal/tenant/transport/kol_dashboard_handler.go b/server/internal/tenant/transport/kol_dashboard_handler.go new file mode 100644 index 0000000..5a41235 --- /dev/null +++ b/server/internal/tenant/transport/kol_dashboard_handler.go @@ -0,0 +1,90 @@ +package transport + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type KolDashboardHandler struct { + svc *app.KolDashboardService +} + +func NewKolDashboardHandler(a *bootstrap.App) *KolDashboardHandler { + profileSvc := app.NewKolProfileService(a.KolProfiles) + return &KolDashboardHandler{ + svc: app.NewKolDashboardService( + profileSvc, + a.KolPackages, + a.KolSubscriptions, + repository.NewKolUsageRepository(a.DB), + ), + } +} + +func (h *KolDashboardHandler) Overview(c *gin.Context) { + actor, ok := actorFromRequest(c) + if !ok { + return + } + + data, err := h.svc.Overview(c.Request.Context(), actor) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func (h *KolDashboardHandler) Packages(c *gin.Context) { + actor, ok := actorFromRequest(c) + if !ok { + return + } + + data, err := h.svc.PerPackage(c.Request.Context(), actor) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func (h *KolDashboardHandler) Trend(c *gin.Context) { + actor, ok := actorFromRequest(c) + if !ok { + return + } + + periodDays := parseKolDashboardPeriodDays(c.Query("period_days")) + if err := app.ValidateKolDashboardPeriodDays(periodDays); err != nil { + response.Error(c, err) + return + } + + data, err := h.svc.Trend(c.Request.Context(), actor, periodDays) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func parseKolDashboardPeriodDays(value string) int { + if value == "" { + return 7 + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0 + } + return parsed +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 2db74dd..85b4f38 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -74,6 +74,12 @@ func RegisterRoutes(app *bootstrap.App) { kolManage.POST("/assist", kolManageHandler.AssistSubmit) kolManage.GET("/assist/:id", kolManageHandler.AssistGet) + kolDash := protected.Group("/tenant/kol/dashboard") + kolDashboardHandler := NewKolDashboardHandler(app) + kolDash.GET("/overview", kolDashboardHandler.Overview) + kolDash.GET("/packages", kolDashboardHandler.Packages) + kolDash.GET("/trend", kolDashboardHandler.Trend) + kolMarketplace := protected.Group("/tenant/kol/marketplace") kolMarketplaceHandler := NewKolMarketplaceHandler(app) kolMarketplace.GET("/packages", kolMarketplaceHandler.ListPackages)