package service import ( "context" "errors" "strconv" "strings" "time" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" "gorm.io/gorm" appv1 "stream.api/internal/api/proto/app/v1" "stream.api/internal/database/model" "stream.api/internal/dto" "stream.api/internal/middleware" ) func (s *appServices) requireAdmin(ctx context.Context) (*middleware.AuthResult, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } if result.User == nil || result.User.Role == nil || strings.ToUpper(strings.TrimSpace(*result.User.Role)) != "ADMIN" { return nil, status.Error(codes.PermissionDenied, "Admin access required") } return result, nil } func (s *appServices) ensurePlanExists(ctx context.Context, planID *string) error { if planID == nil { return nil } trimmed := strings.TrimSpace(*planID) if trimmed == "" { return nil } var count int64 if err := s.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", trimmed).Count(&count).Error; err != nil { return status.Error(codes.Internal, "Failed to validate plan") } if count == 0 { return status.Error(codes.InvalidArgument, "Plan not found") } return nil } func (s *appServices) saveAdminVideoAdConfig(ctx context.Context, tx *gorm.DB, video *model.Video, userID string, adTemplateID *string) error { if video == nil || adTemplateID == nil { return nil } trimmed := strings.TrimSpace(*adTemplateID) if trimmed == "" { if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil { return err } video.AdID = nil return nil } var template model.AdTemplate if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmed, userID).First(&template).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("Ad template not found") } return err } if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil { return err } video.AdID = &template.ID return nil } func adminPageLimitOffset(pageValue int32, limitValue int32) (int32, int32, int) { page := pageValue if page < 1 { page = 1 } limit := limitValue if limit <= 0 { limit = 20 } if limit > 100 { limit = 100 } offset := int((page - 1) * limit) return page, limit, offset } func buildAdminJob(job *model.Job) *appv1.AdminJob { if job == nil { return nil } agentID := strconv.FormatInt(*job.AgentID, 10) return &appv1.AdminJob{ Id: job.ID, Status: string(*job.Status), Priority: int32(*job.Priority), UserId: *job.UserID, Name: job.ID, TimeLimit: *job.TimeLimit, InputUrl: *job.InputURL, OutputUrl: *job.OutputURL, TotalDuration: *job.TotalDuration, CurrentTime: *job.CurrentTime, Progress: *job.Progress, AgentId: &agentID, Logs: *job.Logs, Config: *job.Config, Cancelled: *job.Cancelled, RetryCount: int32(*job.RetryCount), MaxRetries: int32(*job.MaxRetries), CreatedAt: timestamppb.New(*job.CreatedAt), UpdatedAt: timestamppb.New(*job.UpdatedAt), VideoId: stringPointerOrNil(*job.VideoID), } } func buildAdminAgent(agent *dto.AgentWithStats) *appv1.AdminAgent { if agent == nil || agent.Agent == nil { return nil } return &appv1.AdminAgent{ Id: agent.ID, Name: agent.Name, Platform: agent.Platform, Backend: agent.Backend, Version: agent.Version, Capacity: agent.Capacity, Status: string(agent.Status), Cpu: agent.CPU, Ram: agent.RAM, LastHeartbeat: timestamppb.New(agent.LastHeartbeat), CreatedAt: timestamppb.New(agent.CreatedAt), UpdatedAt: timestamppb.New(agent.UpdatedAt), } } func normalizeAdminRoleValue(value string) string { role := strings.ToUpper(strings.TrimSpace(value)) if role == "" { return "USER" } return role } func isValidAdminRoleValue(role string) bool { switch normalizeAdminRoleValue(role) { case "USER", "ADMIN", "BLOCK": return true default: return false } } func (s *appServices) buildAdminUser(ctx context.Context, user *model.User) (*appv1.AdminUser, error) { if user == nil { return nil, nil } payload := &appv1.AdminUser{ Id: user.ID, Email: user.Email, Username: nullableTrimmedString(user.Username), Avatar: nullableTrimmedString(user.Avatar), Role: nullableTrimmedString(user.Role), PlanId: nullableTrimmedString(user.PlanID), StorageUsed: user.StorageUsed, CreatedAt: timeToProto(user.CreatedAt), UpdatedAt: timestamppb.New(user.UpdatedAt.UTC()), WalletBalance: 0, } videoCount, err := s.loadAdminUserVideoCount(ctx, user.ID) if err != nil { return nil, err } payload.VideoCount = videoCount walletBalance, err := model.GetWalletBalance(ctx, s.db, user.ID) if err != nil { return nil, err } payload.WalletBalance = walletBalance planName, err := s.loadAdminPlanName(ctx, user.PlanID) if err != nil { return nil, err } payload.PlanName = planName return payload, nil } func (s *appServices) buildAdminUserDetail(ctx context.Context, user *model.User, subscription *model.PlanSubscription) (*appv1.AdminUserDetail, error) { payload, err := s.buildAdminUser(ctx, user) if err != nil { return nil, err } referral, err := s.buildAdminUserReferralInfo(ctx, user) if err != nil { return nil, err } return &appv1.AdminUserDetail{ User: payload, Subscription: toProtoPlanSubscription(subscription), Referral: referral, }, nil } func (s *appServices) buildAdminUserReferralInfo(ctx context.Context, user *model.User) (*appv1.AdminUserReferralInfo, error) { if user == nil { return nil, nil } var referrer *appv1.ReferralUserSummary if user.ReferredByUserID != nil && strings.TrimSpace(*user.ReferredByUserID) != "" { loadedReferrer, err := s.loadReferralUserSummary(ctx, strings.TrimSpace(*user.ReferredByUserID)) if err != nil { return nil, err } referrer = loadedReferrer } bps := effectiveReferralRewardBps(user.ReferralRewardBps) referral := &appv1.AdminUserReferralInfo{ Referrer: referrer, ReferralEligible: referralUserEligible(user), EffectiveRewardPercent: referralRewardBpsToPercent(bps), RewardOverridePercent: func() *float64 { if user.ReferralRewardBps == nil { return nil } value := referralRewardBpsToPercent(*user.ReferralRewardBps) return &value }(), ShareLink: s.buildReferralShareLink(user.Username), RewardGranted: referralRewardProcessed(user), RewardGrantedAt: timeToProto(user.ReferralRewardGrantedAt), RewardPaymentId: nullableTrimmedString(user.ReferralRewardPaymentID), RewardAmount: user.ReferralRewardAmount, } return referral, nil } func (s *appServices) buildAdminVideo(ctx context.Context, video *model.Video) (*appv1.AdminVideo, error) { if video == nil { return nil, nil } statusValue := stringValue(video.Status) if statusValue == "" { statusValue = "ready" } jobID, err := s.loadLatestVideoJobID(ctx, video.ID) if err != nil { return nil, err } payload := &appv1.AdminVideo{ Id: video.ID, UserId: video.UserID, Title: video.Title, Description: nullableTrimmedString(video.Description), Url: video.URL, Status: strings.ToLower(statusValue), Size: video.Size, Duration: video.Duration, Format: video.Format, CreatedAt: timeToProto(video.CreatedAt), UpdatedAt: timestamppb.New(video.UpdatedAt.UTC()), ProcessingStatus: nullableTrimmedString(video.ProcessingStatus), JobId: jobID, } ownerEmail, err := s.loadAdminUserEmail(ctx, video.UserID) if err != nil { return nil, err } payload.OwnerEmail = ownerEmail adTemplateID, adTemplateName, err := s.loadAdminVideoAdTemplateDetails(ctx, video) if err != nil { return nil, err } payload.AdTemplateId = adTemplateID payload.AdTemplateName = adTemplateName return payload, nil } func (s *appServices) buildAdminPayment(ctx context.Context, payment *model.Payment) (*appv1.AdminPayment, error) { if payment == nil { return nil, nil } payload := &appv1.AdminPayment{ Id: payment.ID, UserId: payment.UserID, PlanId: nullableTrimmedString(payment.PlanID), Amount: payment.Amount, Currency: normalizeCurrency(payment.Currency), Status: normalizePaymentStatus(payment.Status), Provider: strings.ToUpper(stringValue(payment.Provider)), TransactionId: nullableTrimmedString(payment.TransactionID), InvoiceId: payment.ID, CreatedAt: timeToProto(payment.CreatedAt), UpdatedAt: timestamppb.New(payment.UpdatedAt.UTC()), } userEmail, err := s.loadAdminUserEmail(ctx, payment.UserID) if err != nil { return nil, err } payload.UserEmail = userEmail planName, err := s.loadAdminPlanName(ctx, payment.PlanID) if err != nil { return nil, err } payload.PlanName = planName termMonths, paymentMethod, expiresAt, walletAmount, topupAmount, err := s.loadAdminPaymentSubscriptionDetails(ctx, payment.ID) if err != nil { return nil, err } payload.TermMonths = termMonths payload.PaymentMethod = paymentMethod payload.ExpiresAt = expiresAt payload.WalletAmount = walletAmount payload.TopupAmount = topupAmount return payload, nil } func (s *appServices) loadAdminUserVideoCount(ctx context.Context, userID string) (int64, error) { var videoCount int64 if err := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", userID).Count(&videoCount).Error; err != nil { return 0, err } return videoCount, nil } func (s *appServices) loadAdminUserEmail(ctx context.Context, userID string) (*string, error) { var user model.User if err := s.db.WithContext(ctx).Select("id, email").Where("id = ?", userID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return nullableTrimmedString(&user.Email), nil } func (s *appServices) loadReferralUserSummary(ctx context.Context, userID string) (*appv1.ReferralUserSummary, error) { if strings.TrimSpace(userID) == "" { return nil, nil } var user model.User if err := s.db.WithContext(ctx).Select("id, email, username").Where("id = ?", userID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return &appv1.ReferralUserSummary{ Id: user.ID, Email: user.Email, Username: nullableTrimmedString(user.Username), }, nil } func (s *appServices) loadAdminPlanName(ctx context.Context, planID *string) (*string, error) { if planID == nil || strings.TrimSpace(*planID) == "" { return nil, nil } var plan model.Plan if err := s.db.WithContext(ctx).Select("id, name").Where("id = ?", *planID).First(&plan).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return nullableTrimmedString(&plan.Name), nil } func (s *appServices) loadAdminVideoAdTemplateDetails(ctx context.Context, video *model.Video) (*string, *string, error) { if video == nil { return nil, nil, nil } adTemplateID := nullableTrimmedString(video.AdID) if adTemplateID == nil { return nil, nil, nil } adTemplateName, err := s.loadAdminAdTemplateName(ctx, *adTemplateID) if err != nil { return nil, nil, err } return adTemplateID, adTemplateName, nil } func (s *appServices) loadAdminAdTemplateName(ctx context.Context, adTemplateID string) (*string, error) { var template model.AdTemplate if err := s.db.WithContext(ctx).Select("id, name").Where("id = ?", adTemplateID).First(&template).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return nullableTrimmedString(&template.Name), nil } func (s *appServices) loadLatestVideoJobID(ctx context.Context, videoID string) (*string, error) { videoID = strings.TrimSpace(videoID) if videoID == "" { return nil, nil } var job model.Job if err := s.db.WithContext(ctx). Where("config::jsonb ->> 'video_id' = ?", videoID). Order("created_at DESC"). First(&job).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return stringPointerOrNil(job.ID), nil } func (s *appServices) loadAdminPaymentSubscriptionDetails(ctx context.Context, paymentID string) (*int32, *string, *string, *float64, *float64, error) { var subscription model.PlanSubscription if err := s.db.WithContext(ctx).Where("payment_id = ?", paymentID).Order("created_at DESC").First(&subscription).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, nil, nil, nil } return nil, nil, nil, nil, nil, err } termMonths := subscription.TermMonths paymentMethod := nullableTrimmedString(&subscription.PaymentMethod) expiresAt := subscription.ExpiresAt.UTC().Format(time.RFC3339) walletAmount := subscription.WalletAmount topupAmount := subscription.TopupAmount return &termMonths, paymentMethod, nullableTrimmedString(&expiresAt), &walletAmount, &topupAmount, nil } func (s *appServices) loadAdminPlanUsageCounts(ctx context.Context, planID string) (int64, int64, int64, error) { var userCount int64 if err := s.db.WithContext(ctx).Model(&model.User{}).Where("plan_id = ?", planID).Count(&userCount).Error; err != nil { return 0, 0, 0, err } var paymentCount int64 if err := s.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", planID).Count(&paymentCount).Error; err != nil { return 0, 0, 0, err } var subscriptionCount int64 if err := s.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", planID).Count(&subscriptionCount).Error; err != nil { return 0, 0, 0, err } return userCount, paymentCount, subscriptionCount, nil } func validateAdminPlanInput(name, cycle string, price float64, storageLimit int64, uploadLimit int32) string { if strings.TrimSpace(name) == "" { return "Name is required" } if strings.TrimSpace(cycle) == "" { return "Cycle is required" } if price < 0 { return "Price must be greater than or equal to 0" } if storageLimit <= 0 { return "Storage limit must be greater than 0" } if uploadLimit <= 0 { return "Upload limit must be greater than 0" } return "" } func validateAdminAdTemplateInput(userID, name, vastTagURL, adFormat string, duration *int64) string { if strings.TrimSpace(userID) == "" { return "User ID is required" } if strings.TrimSpace(name) == "" || strings.TrimSpace(vastTagURL) == "" { return "Name and VAST URL are required" } format := normalizeAdFormat(adFormat) if format == "mid-roll" && (duration == nil || *duration <= 0) { return "Duration is required for mid-roll templates" } return "" } func validateAdminPlayerConfigInput(userID, name string) string { if strings.TrimSpace(userID) == "" { return "User ID is required" } if strings.TrimSpace(name) == "" { return "Name is required" } return "" } func (s *appServices) unsetAdminDefaultTemplates(ctx context.Context, tx *gorm.DB, userID, excludeID string) error { query := tx.WithContext(ctx).Model(&model.AdTemplate{}).Where("user_id = ?", userID) if excludeID != "" { query = query.Where("id <> ?", excludeID) } return query.Update("is_default", false).Error } func (s *appServices) unsetAdminDefaultPlayerConfigs(ctx context.Context, tx *gorm.DB, userID, excludeID string) error { query := tx.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", userID) if excludeID != "" { query = query.Where("id <> ?", excludeID) } return query.Update("is_default", false).Error } func (s *appServices) buildAdminPlan(ctx context.Context, plan *model.Plan) (*appv1.AdminPlan, error) { if plan == nil { return nil, nil } userCount, paymentCount, subscriptionCount, err := s.loadAdminPlanUsageCounts(ctx, plan.ID) if err != nil { return nil, err } payload := &appv1.AdminPlan{ Id: plan.ID, Name: plan.Name, Description: nullableTrimmedString(plan.Description), Features: append([]string(nil), plan.Features...), Price: plan.Price, Cycle: plan.Cycle, StorageLimit: plan.StorageLimit, UploadLimit: plan.UploadLimit, DurationLimit: plan.DurationLimit, QualityLimit: plan.QualityLimit, IsActive: boolValue(plan.IsActive), UserCount: userCount, PaymentCount: paymentCount, SubscriptionCount: subscriptionCount, } return payload, nil } func (s *appServices) buildAdminAdTemplate(ctx context.Context, item *model.AdTemplate) (*appv1.AdminAdTemplate, error) { if item == nil { return nil, nil } payload := &appv1.AdminAdTemplate{ Id: item.ID, UserId: item.UserID, Name: item.Name, Description: nullableTrimmedString(item.Description), VastTagUrl: item.VastTagURL, AdFormat: stringValue(item.AdFormat), Duration: item.Duration, IsActive: boolValue(item.IsActive), IsDefault: item.IsDefault, CreatedAt: timeToProto(item.CreatedAt), UpdatedAt: timeToProto(item.UpdatedAt), } ownerEmail, err := s.loadAdminUserEmail(ctx, item.UserID) if err != nil { return nil, err } payload.OwnerEmail = ownerEmail return payload, nil } func (s *appServices) buildAdminPlayerConfig(ctx context.Context, item *model.PlayerConfig) (*appv1.AdminPlayerConfig, error) { if item == nil { return nil, nil } payload := &appv1.AdminPlayerConfig{ Id: item.ID, UserId: item.UserID, Name: item.Name, Description: nullableTrimmedString(item.Description), Autoplay: item.Autoplay, Loop: item.Loop, Muted: item.Muted, ShowControls: boolValue(item.ShowControls), Pip: boolValue(item.Pip), Airplay: boolValue(item.Airplay), Chromecast: boolValue(item.Chromecast), IsActive: boolValue(item.IsActive), IsDefault: item.IsDefault, CreatedAt: timeToProto(item.CreatedAt), UpdatedAt: timeToProto(&item.UpdatedAt), EncrytionM3U8: boolValue(item.EncrytionM3u8), LogoUrl: nullableTrimmedString(item.LogoURL), } ownerEmail, err := s.loadAdminUserEmail(ctx, item.UserID) if err != nil { return nil, err } payload.OwnerEmail = ownerEmail return payload, nil }