veza/veza-backend-api/internal/services/admin_platform_service.go

521 lines
18 KiB
Go
Raw Normal View History

package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// AdminPlatformService provides platform administration capabilities (F421-F435)
type AdminPlatformService struct {
db *gorm.DB
logger *zap.Logger
}
// NewAdminPlatformService creates a new admin platform service
func NewAdminPlatformService(db *gorm.DB, logger *zap.Logger) *AdminPlatformService {
if logger == nil {
logger = zap.NewNop()
}
return &AdminPlatformService{db: db, logger: logger}
}
// --- F421: Platform Dashboard Metrics ---
// PlatformMetrics contains platform-wide statistics
type PlatformMetrics struct {
TotalUsers int64 `json:"total_users"`
ActiveUsers int64 `json:"active_users"`
NewUsersToday int64 `json:"new_users_today"`
NewUsersWeek int64 `json:"new_users_week"`
TotalTracks int64 `json:"total_tracks"`
TracksToday int64 `json:"tracks_today"`
TotalPlaylists int64 `json:"total_playlists"`
TotalComments int64 `json:"total_comments"`
BannedUsers int64 `json:"banned_users"`
PendingReports int64 `json:"pending_reports"`
StorageUsedMB float64 `json:"storage_used_mb"`
TotalRevenue float64 `json:"total_revenue"`
RevenueThisMonth float64 `json:"revenue_this_month"`
}
// GetPlatformMetrics returns platform-wide metrics (F421)
func (s *AdminPlatformService) GetPlatformMetrics(ctx context.Context) (*PlatformMetrics, error) {
m := &PlatformMetrics{}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
weekAgo := today.AddDate(0, 0, -7)
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
// User counts
s.db.WithContext(ctx).Table("users").Where("deleted_at IS NULL").Count(&m.TotalUsers)
s.db.WithContext(ctx).Table("users").Where("deleted_at IS NULL AND last_login_at > NOW() - INTERVAL '30 days'").Count(&m.ActiveUsers)
s.db.WithContext(ctx).Table("users").Where("deleted_at IS NULL AND created_at >= ?", today).Count(&m.NewUsersToday)
s.db.WithContext(ctx).Table("users").Where("deleted_at IS NULL AND created_at >= ?", weekAgo).Count(&m.NewUsersWeek)
s.db.WithContext(ctx).Table("users").Where("deleted_at IS NULL AND is_banned = TRUE").Count(&m.BannedUsers)
// Content counts
s.db.WithContext(ctx).Table("tracks").Where("deleted_at IS NULL").Count(&m.TotalTracks)
s.db.WithContext(ctx).Table("tracks").Where("deleted_at IS NULL AND created_at >= ?", today).Count(&m.TracksToday)
s.db.WithContext(ctx).Table("playlists").Where("deleted_at IS NULL").Count(&m.TotalPlaylists)
s.db.WithContext(ctx).Table("comments").Where("deleted_at IS NULL").Count(&m.TotalComments)
// Reports
s.db.WithContext(ctx).Table("reports").Where("status = 'pending'").Count(&m.PendingReports)
// Storage (approximate from tracks file sizes)
var storageBytesRaw *int64
s.db.WithContext(ctx).Raw("SELECT SUM(file_size) FROM tracks WHERE deleted_at IS NULL").Scan(&storageBytesRaw)
if storageBytesRaw != nil {
m.StorageUsedMB = float64(*storageBytesRaw) / (1024 * 1024)
}
// Revenue (from orders)
var totalRevRaw *float64
s.db.WithContext(ctx).Raw("SELECT COALESCE(SUM(total_amount), 0) FROM orders WHERE status = 'completed'").Scan(&totalRevRaw)
if totalRevRaw != nil {
m.TotalRevenue = *totalRevRaw
}
var monthRevRaw *float64
s.db.WithContext(ctx).Raw("SELECT COALESCE(SUM(total_amount), 0) FROM orders WHERE status = 'completed' AND created_at >= ?", monthStart).Scan(&monthRevRaw)
if monthRevRaw != nil {
m.RevenueThisMonth = *monthRevRaw
}
return m, nil
}
// --- F422: User Management ---
// AdminUserInfo represents a user as seen by admin
type AdminUserInfo struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
IsActive bool `json:"is_active"`
IsBanned bool `json:"is_banned"`
IsVerified bool `json:"is_verified"`
IsAdmin bool `json:"is_admin"`
TrackCount int64 `json:"track_count"`
LoginCount int `json:"login_count"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ActiveStrikes int64 `json:"active_strikes"`
IsSuspended bool `json:"is_suspended"`
}
// AdminUserSearchParams holds search/filter parameters for user management
type AdminUserSearchParams struct {
Query string // search username/email
Role string
IsBanned *bool
Limit int
Offset int
SortBy string // "created_at", "username", "last_login_at"
}
// SearchUsers searches and lists users for admin management (F422)
func (s *AdminPlatformService) SearchUsers(ctx context.Context, params AdminUserSearchParams) ([]AdminUserInfo, int64, error) {
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
query := s.db.WithContext(ctx).Table("users").Where("users.deleted_at IS NULL")
if params.Query != "" {
like := "%" + params.Query + "%"
query = query.Where("(username ILIKE ? OR email ILIKE ?)", like, like)
}
if params.Role != "" {
query = query.Where("role = ?", params.Role)
}
if params.IsBanned != nil {
query = query.Where("is_banned = ?", *params.IsBanned)
}
var total int64
query.Count(&total)
orderBy := "created_at DESC"
switch params.SortBy {
case "username":
orderBy = "username ASC"
case "last_login_at":
orderBy = "last_login_at DESC NULLS LAST"
}
var rows []struct {
ID uuid.UUID `gorm:"column:id"`
Username string `gorm:"column:username"`
Email string `gorm:"column:email"`
Role string `gorm:"column:role"`
IsActive bool `gorm:"column:is_active"`
IsBanned bool `gorm:"column:is_banned"`
IsVerified bool `gorm:"column:is_verified"`
IsAdmin bool `gorm:"column:is_admin"`
LoginCount int `gorm:"column:login_count"`
LastLoginAt *time.Time `gorm:"column:last_login_at"`
CreatedAt time.Time `gorm:"column:created_at"`
}
if err := query.Select("id, username, email, role, is_active, is_banned, is_verified, is_admin, login_count, last_login_at, created_at").
Order(orderBy).Offset(params.Offset).Limit(params.Limit).Scan(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("failed to search users: %w", err)
}
users := make([]AdminUserInfo, len(rows))
for i, r := range rows {
users[i] = AdminUserInfo{
ID: r.ID.String(),
Username: r.Username,
Email: r.Email,
Role: r.Role,
IsActive: r.IsActive,
IsBanned: r.IsBanned,
IsVerified: r.IsVerified,
IsAdmin: r.IsAdmin,
LoginCount: r.LoginCount,
LastLoginAt: r.LastLoginAt,
CreatedAt: r.CreatedAt,
}
// Track count
s.db.WithContext(ctx).Table("tracks").Where("creator_id = ? AND deleted_at IS NULL", r.ID).Count(&users[i].TrackCount)
// Active strikes
s.db.WithContext(ctx).Table("user_strikes").Where("user_id = ? AND is_active = TRUE", r.ID).Count(&users[i].ActiveStrikes)
// Suspension
var suspCount int64
s.db.WithContext(ctx).Table("user_suspensions").Where("user_id = ? AND is_active = TRUE", r.ID).Count(&suspCount)
users[i].IsSuspended = suspCount > 0
}
return users, total, nil
}
// GetUserDetail returns detailed user info for admin view (F422)
func (s *AdminPlatformService) GetUserDetail(ctx context.Context, userID uuid.UUID) (*AdminUserInfo, error) {
var r struct {
ID uuid.UUID `gorm:"column:id"`
Username string `gorm:"column:username"`
Email string `gorm:"column:email"`
Role string `gorm:"column:role"`
IsActive bool `gorm:"column:is_active"`
IsBanned bool `gorm:"column:is_banned"`
IsVerified bool `gorm:"column:is_verified"`
IsAdmin bool `gorm:"column:is_admin"`
LoginCount int `gorm:"column:login_count"`
LastLoginAt *time.Time `gorm:"column:last_login_at"`
CreatedAt time.Time `gorm:"column:created_at"`
}
if err := s.db.WithContext(ctx).Table("users").Where("id = ? AND deleted_at IS NULL", userID).First(&r).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("failed to get user: %w", err)
}
user := &AdminUserInfo{
ID: r.ID.String(),
Username: r.Username,
Email: r.Email,
Role: r.Role,
IsActive: r.IsActive,
IsBanned: r.IsBanned,
IsVerified: r.IsVerified,
IsAdmin: r.IsAdmin,
LoginCount: r.LoginCount,
LastLoginAt: r.LastLoginAt,
CreatedAt: r.CreatedAt,
}
s.db.WithContext(ctx).Table("tracks").Where("creator_id = ? AND deleted_at IS NULL", userID).Count(&user.TrackCount)
s.db.WithContext(ctx).Table("user_strikes").Where("user_id = ? AND is_active = TRUE", userID).Count(&user.ActiveStrikes)
var suspCount int64
s.db.WithContext(ctx).Table("user_suspensions").Where("user_id = ? AND is_active = TRUE", userID).Count(&suspCount)
user.IsSuspended = suspCount > 0
return user, nil
}
// UpdateUserRole updates a user's role (F422)
func (s *AdminPlatformService) UpdateUserRole(ctx context.Context, userID uuid.UUID, newRole string) error {
validRoles := map[string]bool{"user": true, "creator": true, "premium": true, "admin": true, "artist": true, "producer": true, "label": true}
if !validRoles[newRole] {
return fmt.Errorf("invalid role: %s", newRole)
}
result := s.db.WithContext(ctx).Table("users").Where("id = ? AND deleted_at IS NULL", userID).Update("role", newRole)
if result.Error != nil {
return fmt.Errorf("failed to update role: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("user not found")
}
isAdmin := newRole == "admin"
s.db.WithContext(ctx).Table("users").Where("id = ?", userID).Update("is_admin", isAdmin)
return nil
}
// SuspendUser suspends a user (F422)
func (s *AdminPlatformService) SuspendUser(ctx context.Context, userID uuid.UUID, adminID uuid.UUID, reason string, durationDays *int) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var suspendedUntil *time.Time
if durationDays != nil {
t := time.Now().AddDate(0, 0, *durationDays)
suspendedUntil = &t
}
if err := tx.Exec(`
INSERT INTO user_suspensions (user_id, reason, suspended_by, suspended_until)
VALUES (?, ?, ?, ?)
`, userID, reason, adminID, suspendedUntil).Error; err != nil {
return fmt.Errorf("failed to create suspension: %w", err)
}
if err := tx.Table("users").Where("id = ?", userID).Update("is_banned", true).Error; err != nil {
return fmt.Errorf("failed to ban user: %w", err)
}
// Log moderation action
tx.Exec(`INSERT INTO moderation_actions (moderator_id, target_user_id, action, reason) VALUES (?, ?, 'suspend', ?)`,
adminID, userID, reason)
return nil
})
}
// UnsuspendUser lifts a user's suspension (F422)
func (s *AdminPlatformService) UnsuspendUser(ctx context.Context, userID uuid.UUID, adminID uuid.UUID) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
tx.Exec(`UPDATE user_suspensions SET is_active = FALSE, lifted_by = ?, lifted_at = ? WHERE user_id = ? AND is_active = TRUE`,
adminID, now, userID)
if err := tx.Table("users").Where("id = ?", userID).Update("is_banned", false).Error; err != nil {
return fmt.Errorf("failed to unban user: %w", err)
}
tx.Exec(`INSERT INTO moderation_actions (moderator_id, target_user_id, action, reason) VALUES (?, ?, 'unsuspend', 'Manual unsuspension by admin')`,
adminID, userID)
return nil
})
}
// --- F423: Content Management ---
// AdminContentItem represents a content item for admin management
type AdminContentItem struct {
ID string `json:"id"`
Type string `json:"type"` // "track" or "comment"
Title string `json:"title"`
CreatorID string `json:"creator_id"`
CreatorName string `json:"creator_name"`
Status string `json:"status"`
ReportCount int64 `json:"report_count"`
CreatedAt time.Time `json:"created_at"`
}
// SearchContent searches content for admin management (F423)
func (s *AdminPlatformService) SearchContent(ctx context.Context, contentType, query string, limit, offset int) ([]AdminContentItem, int64, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
var items []AdminContentItem
var total int64
switch contentType {
case "track", "":
baseQuery := s.db.WithContext(ctx).Table("tracks t").
Select(`CAST(t.id AS TEXT) AS id, 'track' AS type, t.title, CAST(t.creator_id AS TEXT) AS creator_id,
u.username AS creator_name,
CASE WHEN t.deleted_at IS NOT NULL THEN 'hidden' ELSE 'active' END AS status,
t.created_at`).
Joins("LEFT JOIN users u ON u.id = t.creator_id")
if query != "" {
like := "%" + query + "%"
baseQuery = baseQuery.Where("t.title ILIKE ?", like)
}
s.db.WithContext(ctx).Table("tracks").Count(&total)
if query != "" {
s.db.WithContext(ctx).Table("tracks").Where("title ILIKE ?", "%"+query+"%").Count(&total)
}
var rows []AdminContentItem
if err := baseQuery.Order("t.created_at DESC").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("failed to search tracks: %w", err)
}
// Add report counts
for i, row := range rows {
var rc int64
s.db.WithContext(ctx).Table("reports").Where("content_type = 'track' AND CAST(content_id AS TEXT) = ? AND status = 'pending'", row.ID).Count(&rc)
rows[i].ReportCount = rc
}
items = rows
case "comment":
baseQuery := s.db.WithContext(ctx).Table("comments c").
Select(`CAST(c.id AS TEXT) AS id, 'comment' AS type, LEFT(c.content, 100) AS title,
CAST(c.user_id AS TEXT) AS creator_id, u.username AS creator_name,
CASE WHEN c.deleted_at IS NOT NULL THEN 'hidden' ELSE 'active' END AS status,
c.created_at`).
Joins("LEFT JOIN users u ON u.id = c.user_id")
if query != "" {
like := "%" + query + "%"
baseQuery = baseQuery.Where("c.content ILIKE ?", like)
}
s.db.WithContext(ctx).Table("comments").Count(&total)
if query != "" {
s.db.WithContext(ctx).Table("comments").Where("content ILIKE ?", "%"+query+"%").Count(&total)
}
if err := baseQuery.Order("c.created_at DESC").Offset(offset).Limit(limit).Scan(&items).Error; err != nil {
return nil, 0, fmt.Errorf("failed to search comments: %w", err)
}
}
return items, total, nil
}
// HideContent hides (soft deletes) content by admin (F423)
func (s *AdminPlatformService) HideContent(ctx context.Context, adminID uuid.UUID, contentType string, contentID uuid.UUID, reason string) error {
now := time.Now()
var err error
switch contentType {
case "track":
err = s.db.WithContext(ctx).Table("tracks").Where("id = ? AND deleted_at IS NULL", contentID).Update("deleted_at", now).Error
case "comment":
err = s.db.WithContext(ctx).Table("comments").Where("id = ? AND deleted_at IS NULL", contentID).Update("deleted_at", now).Error
default:
return fmt.Errorf("invalid content type: %s", contentType)
}
if err != nil {
return fmt.Errorf("failed to hide content: %w", err)
}
// Log moderation action
s.db.WithContext(ctx).Exec(`
INSERT INTO moderation_actions (moderator_id, target_content_type, target_content_id, action, reason)
VALUES (?, ?, ?, 'hide_content', ?)
`, adminID, contentType, contentID, reason)
return nil
}
// RestoreContent restores hidden content (F423)
func (s *AdminPlatformService) RestoreContent(ctx context.Context, adminID uuid.UUID, contentType string, contentID uuid.UUID) error {
var err error
switch contentType {
case "track":
err = s.db.WithContext(ctx).Exec("UPDATE tracks SET deleted_at = NULL WHERE id = ?", contentID).Error
case "comment":
err = s.db.WithContext(ctx).Exec("UPDATE comments SET deleted_at = NULL WHERE id = ?", contentID).Error
default:
return fmt.Errorf("invalid content type: %s", contentType)
}
if err != nil {
return fmt.Errorf("failed to restore content: %w", err)
}
s.db.WithContext(ctx).Exec(`
INSERT INTO moderation_actions (moderator_id, target_content_type, target_content_id, action, reason)
VALUES (?, ?, ?, 'restore_content', 'Admin restored content')
`, adminID, contentType, contentID)
return nil
}
// --- F424: Payment Overview ---
// PaymentOverview contains payment/revenue overview
type PaymentOverview struct {
TotalOrders int64 `json:"total_orders"`
CompletedOrders int64 `json:"completed_orders"`
PendingOrders int64 `json:"pending_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalRevenue float64 `json:"total_revenue"`
TotalRefunded float64 `json:"total_refunded"`
PlatformFees float64 `json:"platform_fees"`
}
// GetPaymentOverview returns payment statistics (F424)
func (s *AdminPlatformService) GetPaymentOverview(ctx context.Context) (*PaymentOverview, error) {
overview := &PaymentOverview{}
s.db.WithContext(ctx).Table("orders").Count(&overview.TotalOrders)
s.db.WithContext(ctx).Table("orders").Where("status = 'completed'").Count(&overview.CompletedOrders)
s.db.WithContext(ctx).Table("orders").Where("status = 'pending'").Count(&overview.PendingOrders)
s.db.WithContext(ctx).Table("orders").Where("status = 'refunded'").Count(&overview.RefundedOrders)
var totalRev *float64
s.db.WithContext(ctx).Raw("SELECT COALESCE(SUM(total_amount), 0) FROM orders WHERE status = 'completed'").Scan(&totalRev)
if totalRev != nil {
overview.TotalRevenue = *totalRev
}
var totalRefund *float64
s.db.WithContext(ctx).Raw("SELECT COALESCE(SUM(total_amount), 0) FROM orders WHERE status = 'refunded'").Scan(&totalRefund)
if totalRefund != nil {
overview.TotalRefunded = *totalRefund
}
var fees *float64
s.db.WithContext(ctx).Raw("SELECT COALESCE(SUM(platform_fee), 0) FROM orders WHERE status = 'completed'").Scan(&fees)
if fees != nil {
overview.PlatformFees = *fees
}
return overview, nil
}
// RefundOrder marks an order as refunded (F424)
// In production this would also call the payment gateway. For now it records the intent.
func (s *AdminPlatformService) RefundOrder(ctx context.Context, adminID uuid.UUID, orderID uuid.UUID, reason string) error {
result := s.db.WithContext(ctx).Table("orders").Where("id = ? AND status = 'completed'", orderID).Updates(map[string]interface{}{
"status": "refunded",
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("failed to refund order: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("order not found or not eligible for refund")
}
// Log the refund action
s.db.WithContext(ctx).Exec(`
INSERT INTO moderation_actions (moderator_id, target_content_type, target_content_id, action, reason)
VALUES (?, 'order', ?, 'refund', ?)
`, adminID, orderID, reason)
return nil
}