Ctrl K
ComunidadGuías…

internal/store/postgres/admin.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"

	"telesrv/internal/domain"
	"telesrv/internal/store/postgres/sqlcgen"
)

type AdminStore struct {
	db sqlcgen.DBTX
}

func NewAdminStore(db sqlcgen.DBTX) *AdminStore {
	return &AdminStore{db: db}
}

func (s *AdminStore) BeginCommand(ctx context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error) {
	inserted, err := scanAdminCommand(s.db.QueryRow(ctx, `
INSERT INTO admin_commands (
	command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
	dry_run, reason, request, result, status, error, created_at
) VALUES (
	$1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,'{}'::jsonb,$10,'',$11
)
ON CONFLICT (command_id) DO NOTHING
RETURNING command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
	dry_run, reason, request, result, status, error, created_at, completed_at`,
		cmd.CommandID, cmd.Actor, cmd.Action, cmd.TargetUserID, string(cmd.TargetPeer.Type), cmd.TargetPeer.ID,
		cmd.DryRun, cmd.Reason, string(cmd.RequestJSON), string(cmd.Status), cmd.CreatedAt,
	))
	if err == nil {
		return inserted, true, nil
	}
	if !errors.Is(err, pgx.ErrNoRows) {
		return domain.AdminCommand{}, false, fmt.Errorf("insert admin command: %w", err)
	}
	existing, err := s.commandByID(ctx, cmd.CommandID)
	if err != nil {
		return domain.AdminCommand{}, false, err
	}
	return existing, false, nil
}

func (s *AdminStore) FinishCommand(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) {
	if len(resultJSON) == 0 {
		resultJSON = []byte("{}")
	}
	beginner, ok := s.db.(txBeginner)
	if !ok {
		return s.finishCommandNoTx(ctx, commandID, status, resultJSON, errorText)
	}
	tx, err := beginner.Begin(ctx)
	if err != nil {
		return domain.AdminCommand{}, fmt.Errorf("begin finish admin command tx: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback(ctx)
		}
	}()
	cmd, err := finishAdminCommand(ctx, tx, commandID, status, resultJSON, errorText)
	if err != nil {
		return domain.AdminCommand{}, err
	}
	if err := appendAdminAuditLog(ctx, tx, commandID); err != nil {
		return domain.AdminCommand{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return domain.AdminCommand{}, fmt.Errorf("commit finish admin command tx: %w", err)
	}
	committed = true
	return cmd, nil
}

func (s *AdminStore) finishCommandNoTx(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) {
	cmd, err := finishAdminCommand(ctx, s.db, commandID, status, resultJSON, errorText)
	if err != nil {
		return domain.AdminCommand{}, err
	}
	if err := appendAdminAuditLog(ctx, s.db, commandID); err != nil {
		return domain.AdminCommand{}, err
	}
	return cmd, nil
}

func (s *AdminStore) ListRecentCommands(ctx context.Context, limit int, actor string) ([]domain.AdminCommand, error) {
	if limit <= 0 {
		limit = 50
	}
	rows, err := s.db.Query(ctx, `
SELECT command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
	dry_run, reason, request, result, status, error, created_at, NULL::timestamptz AS completed_at
FROM admin_audit_logs
WHERE ($1 = '' OR actor = $1)
ORDER BY created_at DESC, command_id DESC
LIMIT $2`, strings.TrimSpace(actor), limit)
	if err != nil {
		return nil, fmt.Errorf("list recent admin commands: %w", err)
	}
	defer rows.Close()
	commands := make([]domain.AdminCommand, 0, limit)
	for rows.Next() {
		cmd, err := scanAdminCommand(rows)
		if err != nil {
			return nil, fmt.Errorf("scan admin command: %w", err)
		}
		commands = append(commands, cmd)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("list recent admin commands: %w", err)
	}
	return commands, nil
}

func finishAdminCommand(ctx context.Context, db sqlcgen.DBTX, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) {
	cmd, err := scanAdminCommand(db.QueryRow(ctx, `
UPDATE admin_commands
SET status = $2, result = $3::jsonb, error = $4, completed_at = now()
WHERE command_id = $1
RETURNING command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
	dry_run, reason, request, result, status, error, created_at, completed_at`,
		commandID, string(status), string(resultJSON), errorText,
	))
	if err != nil {
		return domain.AdminCommand{}, fmt.Errorf("finish admin command: %w", err)
	}
	return cmd, nil
}

func appendAdminAuditLog(ctx context.Context, db sqlcgen.DBTX, commandID string) error {
	if _, err := db.Exec(ctx, `
INSERT INTO admin_audit_logs (
	command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
	dry_run, reason, request, result, status, error, created_at
)
SELECT command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
	dry_run, reason, request, result, status, error, now()
FROM admin_commands
WHERE command_id = $1
ON CONFLICT (command_id) DO NOTHING`, commandID); err != nil {
		return fmt.Errorf("append admin audit log: %w", err)
	}
	return nil
}

func (s *AdminStore) commandByID(ctx context.Context, commandID string) (domain.AdminCommand, error) {
	cmd, err := scanAdminCommand(s.db.QueryRow(ctx, `
SELECT command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
	dry_run, reason, request, result, status, error, created_at, completed_at
FROM admin_commands
WHERE command_id = $1`, commandID))
	if err != nil {
		return domain.AdminCommand{}, fmt.Errorf("get admin command: %w", err)
	}
	return cmd, nil
}

func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) {
	var cmd domain.AdminCommand
	var peerType string
	var status string
	var completed pgtype.Timestamptz
	if err := row.Scan(
		&cmd.CommandID,
		&cmd.Actor,
		&cmd.Action,
		&cmd.TargetUserID,
		&peerType,
		&cmd.TargetPeer.ID,
		&cmd.DryRun,
		&cmd.Reason,
		&cmd.RequestJSON,
		&cmd.ResultJSON,
		&status,
		&cmd.Error,
		&cmd.CreatedAt,
		&completed,
	); err != nil {
		return domain.AdminCommand{}, err
	}
	cmd.TargetPeer.Type = domain.PeerType(peerType)
	cmd.Status = domain.AdminCommandStatus(status)
	if completed.Valid {
		t := completed.Time
		cmd.CompletedAt = &t
	}
	return cmd, nil
}

func (s *AdminStore) GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
	row := s.db.QueryRow(ctx, `
SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_restrictions
WHERE user_id = $1`, userID)
	r, err := scanAccountFreeze(row)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.AccountFreeze{}, false, nil
		}
		return domain.AccountFreeze{}, false, fmt.Errorf("get account freeze: %w", err)
	}
	return r, true, nil
}

func (s *AdminStore) GetAccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
	out := make(map[int64]domain.AccountFreeze)
	if s == nil || s.db == nil || len(userIDs) == 0 {
		return out, nil
	}
	rows, err := s.db.Query(ctx, `
SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_restrictions
WHERE user_id = ANY($1::bigint[]) AND frozen = true`, userIDs)
	if err != nil {
		return nil, fmt.Errorf("get account freezes: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		freeze, err := scanAccountFreeze(rows)
		if err != nil {
			return nil, fmt.Errorf("scan account freeze: %w", err)
		}
		out[freeze.UserID] = freeze
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate account freezes: %w", err)
	}
	return out, nil
}

func (s *AdminStore) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
	beginner, ok := s.db.(txBeginner)
	if !ok {
		return domain.AccountFreeze{}, fmt.Errorf("set account freeze requires a transaction")
	}
	tx, err := beginner.Begin(ctx)
	if err != nil {
		return domain.AccountFreeze{}, fmt.Errorf("begin set account freeze: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback(ctx)
		}
	}()
	out, err := setAccountFreezeRow(ctx, tx, freeze)
	if err != nil {
		return domain.AccountFreeze{}, err
	}
	// Freeze and market suspension must commit together.
	if err := syncStarGiftListingsForAccountFreeze(ctx, tx, out.UserID, out.Frozen); err != nil {
		return domain.AccountFreeze{}, err
	}
	if err := enqueueAccountFreezeNotifications(ctx, tx, out); err != nil {
		return domain.AccountFreeze{}, err
	}
	// User visibility participates in the same cache/version invalidation spine
	// as profile and dialog changes. These functions emit cross-instance NOTIFY
	// events only after the surrounding transaction commits.
	if _, err := tx.Exec(ctx, `SELECT telesrv_bump_contact_accounts_for_user($1)`, out.UserID); err != nil {
		return domain.AccountFreeze{}, fmt.Errorf("bump frozen user contact projections: %w", err)
	}
	if _, err := tx.Exec(ctx, `SELECT telesrv_bump_private_dialog_light_for_user($1)`, out.UserID); err != nil {
		return domain.AccountFreeze{}, fmt.Errorf("bump frozen user dialog projections: %w", err)
	}
	if _, err := tx.Exec(ctx, `SELECT telesrv_bump_read_model_version('user_visibility', 0, 'user', $1)`, out.UserID); err != nil {
		return domain.AccountFreeze{}, fmt.Errorf("bump frozen user visibility: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return domain.AccountFreeze{}, fmt.Errorf("commit set account freeze: %w", err)
	}
	committed = true
	return out, nil
}

func setAccountFreezeRow(ctx context.Context, db sqlcgen.DBTX, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
	var since, until any
	if freeze.Frozen {
		since = freeze.Since
		until = freeze.Until
	}
	row := db.QueryRow(ctx, `
INSERT INTO account_restrictions (
	user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,now())
ON CONFLICT (user_id) DO UPDATE SET
	frozen = EXCLUDED.frozen,
	frozen_since = EXCLUDED.frozen_since,
	frozen_until = EXCLUDED.frozen_until,
	appeal_url = EXCLUDED.appeal_url,
	reason = EXCLUDED.reason,
	actor = EXCLUDED.actor,
	command_id = EXCLUDED.command_id,
	version = account_restrictions.version + 1,
	updated_at = now()
RETURNING user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
		freeze.UserID, freeze.Frozen, since, until, freeze.AppealURL, freeze.Reason, freeze.Actor, freeze.CommandID,
	)
	out, err := scanAccountFreeze(row)
	if err != nil {
		return domain.AccountFreeze{}, fmt.Errorf("set account freeze: %w", err)
	}
	return out, nil
}

type accountFreezeScanner interface {
	Scan(dest ...any) error
}

func scanAccountFreeze(row accountFreezeScanner) (domain.AccountFreeze, error) {
	var r domain.AccountFreeze
	var since, until pgtype.Timestamptz
	var updated time.Time
	if err := row.Scan(
		&r.UserID, &r.Frozen, &r.Version, &since, &until, &r.AppealURL,
		&r.Reason, &r.Actor, &r.CommandID, &updated,
	); err != nil {
		return domain.AccountFreeze{}, err
	}
	if since.Valid {
		r.Since = since.Time
	}
	if until.Valid {
		r.Until = until.Time
	}
	r.UpdatedAt = updated
	return r, nil
}

func enqueueAccountFreezeNotifications(ctx context.Context, tx pgx.Tx, freeze domain.AccountFreeze) error {
	const maxAccountFreezeNotificationAudience = 4096
	_, err := tx.Exec(ctx, `
INSERT INTO account_freeze_notifications (target_user_id, frozen_user_id, version, frozen)
SELECT audience.user_id, $1, $2, $3
FROM (
  SELECT user_id
  FROM (
    SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
      FROM contacts WHERE user_id = $1
    UNION ALL
    SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
    UNION ALL
    SELECT peer_id, 1, top_message_date
      FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
    UNION ALL
    SELECT user_id, 1, top_message_date
      FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
  ) candidates
  GROUP BY user_id
  ORDER BY min(priority), max(activity) DESC, user_id
  LIMIT $4
) audience
JOIN users u ON u.id = audience.user_id
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
ON CONFLICT (target_user_id, frozen_user_id) DO UPDATE SET
  version = EXCLUDED.version,
  frozen = EXCLUDED.frozen,
  status = 'pending',
  attempts = 0,
  next_attempt_at = now(),
  lease_until = NULL,
  last_error = '',
  updated_at = now()`, freeze.UserID, freeze.Version, freeze.Frozen, maxAccountFreezeNotificationAudience)
	if err != nil {
		return fmt.Errorf("enqueue account freeze notifications: %w", err)
	}
	return nil
}

func (s *AdminStore) ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) {
	if s == nil || s.db == nil || limit <= 0 || lease <= 0 {
		return nil, nil
	}
	rows, err := s.db.Query(ctx, `
WITH claim AS (
  SELECT id FROM account_freeze_notifications
  WHERE (status = 'pending' AND next_attempt_at <= $1)
     OR (status = 'dispatching' AND lease_until <= $1)
  ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
)
UPDATE account_freeze_notifications n
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
FROM claim WHERE n.id = claim.id
RETURNING n.id, n.target_user_id, n.frozen_user_id, n.version, n.frozen, n.attempts`, now, limit, now.Add(lease))
	if err != nil {
		return nil, fmt.Errorf("claim account freeze notifications: %w", err)
	}
	defer rows.Close()
	out := make([]domain.AccountFreezeNotification, 0)
	for rows.Next() {
		var n domain.AccountFreezeNotification
		if err := rows.Scan(&n.ID, &n.TargetUserID, &n.FrozenUserID, &n.Version, &n.Frozen, &n.Attempts); err != nil {
			return nil, fmt.Errorf("scan account freeze notification: %w", err)
		}
		out = append(out, n)
	}
	return out, rows.Err()
}

func (s *AdminStore) CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error {
	_, err := s.db.Exec(ctx, `
UPDATE account_freeze_notifications
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $3
WHERE id = $1 AND version = $2`, id, version, now)
	if err != nil {
		return fmt.Errorf("complete account freeze notification: %w", err)
	}
	return nil
}