sno-quiz/backend/internal/service/admin_service.go
2025-09-17 22:22:14 +03:00

193 lines
6.3 KiB
Go

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:<type>:<value>`
// 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
}