backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.
The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
520 lines
18 KiB
Go
520 lines
18 KiB
Go
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
|
|
}
|