ultisuite-backend/internal/api/admin/service.go

225 lines
6.0 KiB
Go

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,
})
}