134 lines
3.7 KiB
Go
134 lines
3.7 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,
|
|
ActorType: input.Actor.Type,
|
|
ActorID: input.Actor.ID,
|
|
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); 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) error {
|
|
var joined error
|
|
if s.deps.SessionRevoker != nil {
|
|
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID); 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
|
|
}
|