package user import ( "context" "errors" "fmt" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" ) // SanctionCode values mirror the closed MVP set from // `backend/README.md` and the CHECK constraint on // `sanction_records`. const ( SanctionCodePermanentBlock = "permanent_block" ) // ActiveSanction is the read-side projection of a row in // `sanction_active` joined with the audit columns from the underlying // `sanction_records` row. It mirrors the OpenAPI `ActiveSanction` // schema. type ActiveSanction struct { SanctionCode string Scope string ReasonCode string Actor ActorRef AppliedAt time.Time ExpiresAt *time.Time } // ApplySanctionInput carries the admin-supplied parameters of // `POST /api/v1/admin/users/{user_id}/sanctions`. type ApplySanctionInput struct { UserID uuid.UUID SanctionCode string Scope string ReasonCode string Actor ActorRef ExpiresAt *time.Time } // ApplySanction persists a fresh `sanction_records` row, upserts // `sanction_active`, and — when sanction_code == "permanent_block" — // flips `accounts.permanent_block = true` in the same transaction. // After commit it revokes every active session for the user (if a // SessionRevoker is wired) and fires the lobby on-user-blocked // cascade. // // Errors from the post-commit cascade are joined and logged; they do // not roll back the persisted sanction. func (s *Service) ApplySanction(ctx context.Context, input ApplySanctionInput) (Account, error) { if input.UserID == uuid.Nil { return Account{}, ErrAccountNotFound } if err := validateSanctionCode(input.SanctionCode); err != nil { return Account{}, err } if err := input.Actor.Validate(); err != nil { return Account{}, err } if strings.TrimSpace(input.Scope) == "" { return Account{}, fmt.Errorf("%w: scope must be non-empty", ErrInvalidInput) } if strings.TrimSpace(input.ReasonCode) == "" { return Account{}, fmt.Errorf("%w: reason_code must be non-empty", ErrInvalidInput) } now := s.deps.Now().UTC() expiresAt := input.ExpiresAt if expiresAt != nil { t := expiresAt.UTC() expiresAt = &t } flipPermanent := input.SanctionCode == SanctionCodePermanentBlock if err := s.deps.Store.ApplySanctionTx(ctx, sanctionInsert{ UserID: input.UserID, SanctionCode: input.SanctionCode, Scope: input.Scope, ReasonCode: input.ReasonCode, Actor: input.Actor, AppliedAt: now, ExpiresAt: expiresAt, FlipPermanent: flipPermanent, }); err != nil { if errors.Is(err, ErrAccountNotFound) { return Account{}, err } return Account{}, fmt.Errorf("user apply sanction: %w", err) } if flipPermanent { if err := s.cascadePermanentBlock(ctx, input.UserID, input.Actor, input.ReasonCode); err != nil { s.deps.Logger.Warn("permanent-block cascade returned error", zap.String("user_id", input.UserID.String()), zap.Error(err), ) } } return s.GetAccount(ctx, input.UserID) } func validateSanctionCode(code string) error { switch strings.TrimSpace(code) { case SanctionCodePermanentBlock: return nil default: return fmt.Errorf("%w: %q", ErrInvalidSanctionCode, code) } } // cascadePermanentBlock revokes every active session and fires the // lobby on-user-blocked hook. Both calls are best-effort — they run // after the database commit and only join errors for the caller to // log. func (s *Service) cascadePermanentBlock(ctx context.Context, userID uuid.UUID, actor ActorRef, reasonCode string) error { var joined error if s.deps.SessionRevoker != nil { revokeActor := SessionRevokeActor{ Kind: SessionRevokeActorAdminSanction, ID: actor.ID, Reason: SanctionCodePermanentBlock + ":" + reasonCode, } if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID, revokeActor); err != nil { joined = errors.Join(joined, fmt.Errorf("session revoke: %w", err)) } } if s.deps.Lobby != nil { if err := s.deps.Lobby.OnUserBlocked(ctx, userID); err != nil { joined = errors.Join(joined, fmt.Errorf("lobby on-user-blocked: %w", err)) } } return joined }