package admin import ( "context" "encoding/json" "errors" "log/slog" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/securityaudit" ) var ErrNotFound = errors.New("not found") type Service struct { db *pgxpool.Pool audit *securityaudit.Logger logger *slog.Logger } func NewService(db *pgxpool.Pool, audit *securityaudit.Logger) *Service { return &Service{ db: db, audit: audit, logger: slog.Default().With("component", "admin-service"), } } type UsersList struct { Users []map[string]any `json:"users"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } func (s *Service) ListUsers(ctx context.Context, params query.ListParams) (UsersList, error) { var total int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil { return UsersList{}, err } rows, err := s.db.Query(ctx, ` SELECT id, external_id, email, name, created_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2 `, params.Limit(), params.Offset()) if err != nil { return UsersList{}, err } defer rows.Close() users := make([]map[string]any, 0) for rows.Next() { var id, extID, email, name string var createdAt time.Time if err := rows.Scan(&id, &extID, &email, &name, &createdAt); err != nil { return UsersList{}, err } users = append(users, map[string]any{ "id": id, "external_id": extID, "email": email, "name": name, "created_at": createdAt, }) } if err := rows.Err(); err != nil { return UsersList{}, err } return UsersList{ Users: users, Pagination: params.Meta(&total), }, nil } func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, error) { var id, extID, email, name string var createdAt time.Time err := s.db.QueryRow(ctx, ` SELECT id, external_id, email, name, created_at FROM users WHERE id = $1 `, userID).Scan(&id, &extID, &email, &name, &createdAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } var mailCount int64 if err := s.db.QueryRow(ctx, ` SELECT COALESCE(COUNT(*), 0) FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = $1 `, userID).Scan(&mailCount); err != nil { return nil, err } var maxStorage int64 if err := s.db.QueryRow(ctx, ` SELECT COALESCE((preferences->>'max_storage')::bigint, 5368709120) FROM settings WHERE user_id = $1 `, userID).Scan(&maxStorage); err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } return map[string]any{ "id": id, "external_id": extID, "email": email, "name": name, "created_at": createdAt, "quota": map[string]any{ "mail_count": mailCount, "storage_used_bytes": int64(0), "max_storage_bytes": maxStorage, }, }, nil } func (s *Service) SetQuota(ctx context.Context, actorSub, userID string, maxStorageBytes int64) error { _, err := s.db.Exec(ctx, ` INSERT INTO settings (user_id, preferences) VALUES ($1, jsonb_build_object('max_storage', $2::text)) ON CONFLICT (user_id) DO UPDATE SET preferences = settings.preferences || jsonb_build_object('max_storage', $2::text), updated_at = NOW() `, userID, maxStorageBytes) if err != nil { return err } s.logAudit(ctx, actorSub, "set_quota", map[string]any{ "target_user": userID, "max_storage_bytes": maxStorageBytes, }) return nil } func (s *Service) DeleteUser(ctx context.Context, actorSub, userID string) error { result, err := s.db.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) if err != nil { return err } if result.RowsAffected() == 0 { return ErrNotFound } s.logAudit(ctx, actorSub, "delete_user", map[string]any{"target_user": userID}) return nil } type AuditLogsList struct { Logs []map[string]any `json:"logs"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } func (s *Service) ListAuditLogs(ctx context.Context, params query.ListParams) (AuditLogsList, error) { var total int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM audit_logs`).Scan(&total); err != nil { return AuditLogsList{}, err } rows, err := s.db.Query(ctx, ` SELECT id, actor, action, details, created_at FROM audit_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2 `, params.Limit(), params.Offset()) if err != nil { return AuditLogsList{}, err } defer rows.Close() logs := make([]map[string]any, 0) for rows.Next() { var id, actor, action string var details []byte var createdAt time.Time if err := rows.Scan(&id, &actor, &action, &details, &createdAt); err != nil { return AuditLogsList{}, err } logs = append(logs, map[string]any{ "id": id, "actor": actor, "action": action, "details": json.RawMessage(details), "created_at": createdAt, }) } if err := rows.Err(); err != nil { return AuditLogsList{}, err } return AuditLogsList{ Logs: logs, Pagination: params.Meta(&total), }, nil } func (s *Service) GetStats(ctx context.Context) (map[string]any, error) { stats := map[string]any{} var userCount, mailCount, accountCount int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { return nil, err } if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM messages`).Scan(&mailCount); err != nil { return nil, err } if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM mail_accounts`).Scan(&accountCount); err != nil { return nil, err } stats["total_users"] = userCount stats["total_messages"] = mailCount stats["total_accounts"] = accountCount return stats, nil } func (s *Service) logAudit(ctx context.Context, actor, action string, details map[string]any) { if s.audit == nil { return } if action == "delete_user" { s.audit.Log(ctx, actor, securityaudit.ActionCriticalDeletion, map[string]any{ "target": "user", "target_user": details["target_user"], "admin_action": true, }) return } s.audit.Log(ctx, actor, securityaudit.ActionAdminAction, map[string]any{ "action": action, "details": details, }) }