package service import ( "context" "errors" "fmt" "sno/internal/models" "sno/internal/repository" "sno/internal/types" "github.com/google/uuid" "github.com/redis/go-redis/v9" ) // AdminService defines the interface for admin-related business logic. type AdminService interface { GrantStars(ctx context.Context, userID int64, amount int) error GenerateUniqueQRCodes(ctx context.Context, qrType, qrValue string, count int) ([]string, error) GetUserRole(ctx context.Context, userID int64) (types.UserRole, error) CreateOperator(ctx context.Context, telegramID int64, name string) error DeleteOperator(ctx context.Context, telegramID int64) error GetAnalytics(ctx context.Context) (*models.Analytics, error) } // adminService implements the AdminService interface. type adminService struct { userRepo repository.UserRepository adminRepo repository.AdminRepository redisClient *redis.Client } // NewAdminService creates a new instance of an admin service. func NewAdminService(userRepo repository.UserRepository, adminRepo repository.AdminRepository, redisClient *redis.Client) AdminService { return &adminService{ userRepo: userRepo, adminRepo: adminRepo, redisClient: redisClient, } } // GrantStars adds or removes stars from a user's balance. func (s *adminService) GrantStars(ctx context.Context, userID int64, amount int) error { if amount == 0 { return errors.New("amount cannot be zero") } // We might want to check if the user exists first, but the UPDATE query // will just do nothing if the user doesn't exist, which is safe. return s.userRepo.UpdateStarsBalance(ctx, userID, amount) } // GenerateUniqueQRCodes generates unique QR codes and stores them in Redis. // The format in Redis will be a SET named `unique_qrs::` // Each member of the set will be a UUID. func (s *adminService) GenerateUniqueQRCodes(ctx context.Context, qrType, qrValue string, count int) ([]string, error) { if count <= 0 { return nil, errors.New("count must be positive") } key := fmt.Sprintf("unique_qrs:%s:%s", qrType, qrValue) codes := make([]string, count) members := make([]interface{}, count) for i := 0; i < count; i++ { newUUID := uuid.New().String() codes[i] = newUUID members[i] = newUUID } // Add all generated UUIDs to the Redis set if err := s.redisClient.SAdd(ctx, key, members...).Err(); err != nil { return nil, fmt.Errorf("failed to add unique QR codes to Redis: %w", err) } return codes, nil } // GetUserRole retrieves the user's role from the admins table func (s *adminService) GetUserRole(ctx context.Context, userID int64) (types.UserRole, error) { return s.adminRepo.GetUserRole(ctx, userID) } // CreateOperator creates a new operator with the given telegram ID and name func (s *adminService) CreateOperator(ctx context.Context, telegramID int64, name string) error { // Check if user already exists as admin existingAdmin, err := s.adminRepo.GetByTelegramID(ctx, telegramID) if err != nil { return fmt.Errorf("failed to check existing admin: %w", err) } if existingAdmin != nil { return errors.New("user is already an admin or operator") } // Create new operator admin := &models.Admin{ TelegramID: telegramID, Role: models.RoleOperator, Name: &name, } return s.adminRepo.Create(ctx, admin) } // DeleteOperator deletes an operator by their telegram ID func (s *adminService) DeleteOperator(ctx context.Context, telegramID int64) error { // Check if user exists and is an operator existingAdmin, err := s.adminRepo.GetByTelegramID(ctx, telegramID) if err != nil { return fmt.Errorf("failed to check existing admin: %w", err) } if existingAdmin == nil { return errors.New("operator not found") } if existingAdmin.Role != models.RoleOperator { return errors.New("user is not an operator") } // Delete the operator return s.adminRepo.Delete(ctx, telegramID) } // GetAnalytics retrieves various statistics and metrics for the admin dashboard func (s *adminService) GetAnalytics(ctx context.Context) (*models.Analytics, error) { analytics := &models.Analytics{} // Query all analytics data in a single transaction for consistency tx, err := s.userRepo.GetDB().Begin(ctx) if err != nil { return nil, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) // Total users err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&analytics.TotalUsers) if err != nil { return nil, fmt.Errorf("failed to get total users: %w", err) } // Total quizzes err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM quizzes").Scan(&analytics.TotalQuizzes) if err != nil { return nil, fmt.Errorf("failed to get total quizzes: %w", err) } // Active quizzes err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM quizzes WHERE is_active = true").Scan(&analytics.ActiveQuizzes) if err != nil { return nil, fmt.Errorf("failed to get active quizzes: %w", err) } // Total rewards err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM rewards").Scan(&analytics.TotalRewards) if err != nil { return nil, fmt.Errorf("failed to get total rewards: %w", err) } // Active rewards err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM rewards WHERE is_active = true").Scan(&analytics.ActiveRewards) if err != nil { return nil, fmt.Errorf("failed to get active rewards: %w", err) } // Total quiz attempts err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM quiz_attempts").Scan(&analytics.TotalAttempts) if err != nil { return nil, fmt.Errorf("failed to get total quiz attempts: %w", err) } // Total purchases err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM purchases").Scan(&analytics.TotalPurchases) if err != nil { return nil, fmt.Errorf("failed to get total purchases: %w", err) } // Total QR scans err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM qr_scans").Scan(&analytics.TotalQRScans) if err != nil { return nil, fmt.Errorf("failed to get total QR scans: %w", err) } // Stars distributed (from quiz attempts) err = tx.QueryRow(ctx, "SELECT COALESCE(SUM(stars_earned), 0) FROM quiz_attempts").Scan(&analytics.StarsDistributed) if err != nil { return nil, fmt.Errorf("failed to get stars distributed: %w", err) } // Stars spent (from purchases) err = tx.QueryRow(ctx, "SELECT COALESCE(SUM(stars_spent), 0) FROM purchases").Scan(&analytics.StarsSpent) if err != nil { return nil, fmt.Errorf("failed to get stars spent: %w", err) } return analytics, nil }