Files
galaxy-game/backend/internal/user/sanction.go
T
2026-05-07 00:58:53 +03:00

138 lines
4.0 KiB
Go

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
}