package admin import ( "bytes" "context" "encoding/csv" "encoding/json" "errors" "fmt" "log/slog" "strconv" "strings" "time" "github.com/google/uuid" "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"` } type UserFilter struct { Status string Q string } func (s *Service) ListUsers(ctx context.Context, params query.ListParams, filter UserFilter) (UsersList, error) { whereSQL, args := buildUserFilter(filter) var total int64 totalSQL := "SELECT COUNT(*) FROM users" + whereSQL if err := s.db.QueryRow(ctx, totalSQL, args...).Scan(&total); err != nil { return UsersList{}, err } listSQL := ` SELECT id, external_id, email, name, status, invited_at, disabled_at, created_at, updated_at FROM users` + whereSQL + ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2) args = append(args, params.Limit(), params.Offset()) rows, err := s.db.Query(ctx, listSQL, args...) if err != nil { return UsersList{}, err } defer rows.Close() users := make([]map[string]any, 0) for rows.Next() { var id, extID, email, name, status string var invitedAt, disabledAt *time.Time var createdAt, updatedAt time.Time if err := rows.Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt); err != nil { return UsersList{}, err } users = append(users, map[string]any{ "id": id, "external_id": extID, "email": email, "name": name, "status": status, "invited_at": invitedAt, "disabled_at": disabledAt, "created_at": createdAt, "updated_at": updatedAt, }) } if err := rows.Err(); err != nil { return UsersList{}, err } return UsersList{ Users: users, Pagination: params.Meta(&total), }, nil } func buildUserFilter(filter UserFilter) (string, []any) { clauses := make([]string, 0, 2) args := make([]any, 0, 2) if strings.TrimSpace(filter.Status) != "" { args = append(args, strings.TrimSpace(filter.Status)) clauses = append(clauses, "status = $"+strconv.Itoa(len(args))) } if strings.TrimSpace(filter.Q) != "" { q := "%" + strings.ToLower(strings.TrimSpace(filter.Q)) + "%" args = append(args, q) idx := strconv.Itoa(len(args)) clauses = append(clauses, "(LOWER(email) LIKE $"+idx+" OR LOWER(name) LIKE $"+idx+" OR LOWER(external_id) LIKE $"+idx+")") } if len(clauses) == 0 { return "", args } return " WHERE " + strings.Join(clauses, " AND "), args } func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, error) { var id, extID, email, name, status string var invitedAt, disabledAt *time.Time var createdAt, updatedAt time.Time err := s.db.QueryRow(ctx, ` SELECT id, external_id, email, name, status, invited_at, disabled_at, created_at, updated_at FROM users WHERE id = $1 `, userID).Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } var mailCount, mailUsedStorage int64 if err := s.db.QueryRow(ctx, ` SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(COALESCE(m.raw_size, 0)), 0) FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = $1 `, userID).Scan(&mailCount, &mailUsedStorage); err != nil { return nil, err } var mailMax, driveMax, photosMax int64 if err := s.db.QueryRow(ctx, ` SELECT COALESCE((preferences->>'mail_max_storage_bytes')::bigint, 5368709120), COALESCE((preferences->>'drive_max_storage_bytes')::bigint, 5368709120), COALESCE((preferences->>'photos_max_storage_bytes')::bigint, 5368709120) FROM settings WHERE user_id = $1 `, userID).Scan(&mailMax, &driveMax, &photosMax); err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } return map[string]any{ "id": id, "external_id": extID, "email": email, "name": name, "status": status, "invited_at": invitedAt, "disabled_at": disabledAt, "created_at": createdAt, "updated_at": updatedAt, "quota": map[string]any{ "mail": map[string]any{ "count": mailCount, "used_storage_bytes": mailUsedStorage, "max_storage_bytes": mailMax, }, "drive": map[string]any{ "used_storage_bytes": int64(0), "max_storage_bytes": driveMax, }, "photos": map[string]any{ "used_storage_bytes": int64(0), "max_storage_bytes": photosMax, }, }, }, nil } func (s *Service) CreateUser(ctx context.Context, actorSub string, req createUserRequest) (map[string]any, error) { var id string if err := s.db.QueryRow(ctx, ` INSERT INTO users (external_id, email, name, status) VALUES ($1, $2, $3, 'active') RETURNING id `, strings.TrimSpace(req.ExternalID), strings.ToLower(strings.TrimSpace(req.Email)), strings.TrimSpace(req.Name)).Scan(&id); err != nil { return nil, err } s.logAudit(ctx, actorSub, "create_user", map[string]any{ "target_user": id, "external_id": strings.TrimSpace(req.ExternalID), "email": strings.ToLower(strings.TrimSpace(req.Email)), "initial_name": strings.TrimSpace(req.Name), }) return s.GetUser(ctx, id) } func (s *Service) InviteUser(ctx context.Context, actorSub string, req inviteUserRequest) (map[string]any, error) { var id string inviteExternalID := "invite:" + uuid.NewString() if err := s.db.QueryRow(ctx, ` INSERT INTO users (external_id, email, name, status, invited_at) VALUES ($1, $2, $3, 'invited', NOW()) RETURNING id `, inviteExternalID, strings.ToLower(strings.TrimSpace(req.Email)), strings.TrimSpace(req.Name)).Scan(&id); err != nil { return nil, err } s.logAudit(ctx, actorSub, "invite_user", map[string]any{ "target_user": id, "email": strings.ToLower(strings.TrimSpace(req.Email)), }) return s.GetUser(ctx, id) } func (s *Service) UpdateUser(ctx context.Context, actorSub, userID string, req updateUserRequest) (map[string]any, error) { result, err := s.db.Exec(ctx, ` UPDATE users SET email = COALESCE($2, email), name = COALESCE($3, name), updated_at = NOW() WHERE id = $1 `, userID, trimStringPtr(req.Email), trimStringPtr(req.Name)) if err != nil { return nil, err } if result.RowsAffected() == 0 { return nil, ErrNotFound } s.logAudit(ctx, actorSub, "update_user", map[string]any{ "target_user": userID, }) return s.GetUser(ctx, userID) } func trimStringPtr(v *string) *string { if v == nil { return nil } s := strings.TrimSpace(*v) return &s } func (s *Service) SetQuota(ctx context.Context, actorSub, userID string, req setQuotaRequest) error { exists, err := s.userExists(ctx, userID) if err != nil { return err } if !exists { return ErrNotFound } _, err = s.db.Exec(ctx, ` INSERT INTO settings (user_id, preferences) VALUES ( $1, jsonb_strip_nulls(jsonb_build_object( 'mail_max_storage_bytes', $2::text, 'drive_max_storage_bytes', $3::text, 'photos_max_storage_bytes', $4::text )) ) ON CONFLICT (user_id) DO UPDATE SET preferences = settings.preferences || jsonb_strip_nulls(jsonb_build_object( 'mail_max_storage_bytes', $2::text, 'drive_max_storage_bytes', $3::text, 'photos_max_storage_bytes', $4::text )), updated_at = NOW() `, userID, req.MailMaxStorageBytes, req.DriveMaxStorageBytes, req.PhotosMaxStorageBytes) if err != nil { return err } s.logAudit(ctx, actorSub, "set_quota", map[string]any{ "target_user": userID, "mail_max_storage_bytes": req.MailMaxStorageBytes, "drive_max_storage_bytes": req.DriveMaxStorageBytes, "photos_max_storage_bytes": req.PhotosMaxStorageBytes, "multi_service_quota_patch": true, }) return nil } func (s *Service) DisableUser(ctx context.Context, actorSub, userID string) error { result, err := s.db.Exec(ctx, ` UPDATE users SET status = 'disabled', disabled_at = NOW(), updated_at = NOW() WHERE id = $1 `, userID) if err != nil { return err } if result.RowsAffected() == 0 { return ErrNotFound } s.logAudit(ctx, actorSub, "disable_user", map[string]any{"target_user": userID}) return nil } func (s *Service) ReactivateUser(ctx context.Context, actorSub, userID string) error { result, err := s.db.Exec(ctx, ` UPDATE users SET status = 'active', disabled_at = NULL, updated_at = NOW() WHERE id = $1 `, userID) if err != nil { return err } if result.RowsAffected() == 0 { return ErrNotFound } s.logAudit(ctx, actorSub, "reactivate_user", map[string]any{"target_user": userID}) 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 } func (s *Service) userExists(ctx context.Context, userID string) (bool, error) { var exists bool if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists); err != nil { return false, err } return exists, 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 } type AuditExport struct { Content []byte ContentType string FileName string } func (s *Service) ExportAuditLogs(ctx context.Context, format string, limit int) (AuditExport, error) { rows, err := s.db.Query(ctx, ` SELECT id, actor, action, details, created_at FROM audit_logs ORDER BY created_at DESC LIMIT $1 `, limit) if err != nil { return AuditExport{}, err } defer rows.Close() type rowItem struct { ID string Actor string Action string Details []byte CreatedAt time.Time } items := make([]rowItem, 0, limit) for rows.Next() { var item rowItem if err := rows.Scan(&item.ID, &item.Actor, &item.Action, &item.Details, &item.CreatedAt); err != nil { return AuditExport{}, err } items = append(items, item) } if err := rows.Err(); err != nil { return AuditExport{}, err } now := time.Now().UTC().Format("20060102T150405Z") switch format { case "csv": buf := &bytes.Buffer{} w := csv.NewWriter(buf) if err := w.Write([]string{"id", "actor", "action", "details_json", "created_at"}); err != nil { return AuditExport{}, err } for _, item := range items { if err := w.Write([]string{ item.ID, item.Actor, item.Action, string(item.Details), item.CreatedAt.UTC().Format(time.RFC3339), }); err != nil { return AuditExport{}, err } } w.Flush() if err := w.Error(); err != nil { return AuditExport{}, err } return AuditExport{ Content: buf.Bytes(), ContentType: "text/csv; charset=utf-8", FileName: fmt.Sprintf("audit-%s.csv", now), }, nil default: buf := &bytes.Buffer{} enc := json.NewEncoder(buf) for _, item := range items { entry := map[string]any{ "id": item.ID, "actor": item.Actor, "action": item.Action, "details": json.RawMessage(item.Details), "created_at": item.CreatedAt.UTC().Format(time.RFC3339), } if err := enc.Encode(entry); err != nil { return AuditExport{}, err } } return AuditExport{ Content: buf.Bytes(), ContentType: "application/x-ndjson; charset=utf-8", FileName: fmt.Sprintf("audit-%s.ndjson", now), }, nil } } func (s *Service) GetStats(ctx context.Context) (map[string]any, error) { stats := map[string]any{} var totalUsers, activeUsers, disabledUsers, invitedUsers int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&totalUsers); err != nil { return nil, err } if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'active'`).Scan(&activeUsers); err != nil { return nil, err } if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'disabled'`).Scan(&disabledUsers); err != nil { return nil, err } if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'invited'`).Scan(&invitedUsers); err != nil { return nil, err } var mailCount, accountCount, audit24h int64 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 } if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM audit_logs WHERE created_at >= NOW() - INTERVAL '24 hour'`).Scan(&audit24h); err != nil { return nil, err } type topActor struct { Actor string `json:"actor"` Count int64 `json:"count"` } topActors := make([]topActor, 0, 5) rows, err := s.db.Query(ctx, ` SELECT actor, COUNT(*) as c FROM audit_logs WHERE created_at >= NOW() - INTERVAL '7 day' GROUP BY actor ORDER BY c DESC LIMIT 5 `) if err != nil { return nil, err } for rows.Next() { var item topActor if err := rows.Scan(&item.Actor, &item.Count); err != nil { rows.Close() return nil, err } topActors = append(topActors, item) } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() var usersNearMailQuota int64 if err := s.db.QueryRow(ctx, ` SELECT COUNT(*) FROM ( SELECT u.id, COALESCE(SUM(COALESCE(m.raw_size, 0)), 0) AS used_storage, COALESCE((s.preferences->>'mail_max_storage_bytes')::bigint, 5368709120) AS max_storage FROM users u LEFT JOIN mail_accounts ma ON ma.user_id = u.id LEFT JOIN messages m ON m.account_id = ma.id LEFT JOIN settings s ON s.user_id = u.id GROUP BY u.id, s.preferences ) q WHERE q.max_storage > 0 AND q.used_storage::numeric / q.max_storage::numeric >= 0.9 `).Scan(&usersNearMailQuota); err != nil { return nil, err } stats["users"] = map[string]any{ "total": totalUsers, "active": activeUsers, "disabled": disabledUsers, "invited": invitedUsers, } stats["services"] = map[string]any{ "mail_accounts_total": accountCount, "messages_total": mailCount, "audit_events_24h": audit24h, } stats["quotas"] = map[string]any{ "users_near_mail_quota_90pct": usersNearMailQuota, } stats["audit"] = map[string]any{ "top_actors_7d": topActors, } 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, }) }