feat: backend service
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user