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

94 lines
3.0 KiB
Go

package user
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SoftDelete marks the account as soft-deleted with an audit trail of
// who initiated the operation, then drives the documented in-process
// cascade across `auth`, `lobby`, `notification`, and `geo`.
//
// The `accounts` row is the canonical state; cascade calls run after
// the database commit and are best-effort. Cascade failures are joined
// into the returned error and logged but never roll back the
// soft-delete: the producer signal is "this user is gone", and
// downstream cleanup is idempotent so a future retry can finish the
// job.
//
// Repeated calls on an already-soft-deleted account are no-ops: the
// store reports `false` for "row changed" and the cascade is skipped.
func (s *Service) SoftDelete(ctx context.Context, userID uuid.UUID, actor ActorRef) error {
if userID == uuid.Nil {
return ErrAccountNotFound
}
if err := actor.Validate(); err != nil {
return err
}
now := s.deps.Now().UTC()
changed, err := s.deps.Store.SoftDeleteAccount(ctx, userID, actor, now)
if err != nil {
return fmt.Errorf("user soft delete: %w", err)
}
if !changed {
s.deps.Logger.Info("user soft delete skipped (already deleted)",
zap.String("user_id", userID.String()),
)
return nil
}
s.deps.Logger.Info("user soft deleted",
zap.String("user_id", userID.String()),
zap.String("actor_type", actor.Type),
)
return s.runSoftDeleteCascade(ctx, userID, actor)
}
// runSoftDeleteCascade fans the soft-delete signal out to dependent
// modules in the documented order: auth → lobby → notification → geo.
// Each call's error is joined; the loop continues even after a
// failure so the remaining modules still get notified.
func (s *Service) runSoftDeleteCascade(ctx context.Context, userID uuid.UUID, actor ActorRef) error {
var joined error
if s.deps.SessionRevoker != nil {
kind := SessionRevokeActorSoftDeleteAdmin
if actor.Type == "user" {
kind = SessionRevokeActorSoftDeleteUser
}
revokeActor := SessionRevokeActor{
Kind: kind,
ID: actor.ID,
Reason: "soft delete",
}
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.OnUserDeleted(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("lobby on-user-deleted: %w", err))
}
}
if s.deps.Notification != nil {
if err := s.deps.Notification.OnUserDeleted(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("notification on-user-deleted: %w", err))
}
}
if s.deps.Geo != nil {
if err := s.deps.Geo.OnUserDeleted(ctx, userID); err != nil {
joined = errors.Join(joined, fmt.Errorf("geo on-user-deleted: %w", err))
}
}
if joined != nil {
s.deps.Logger.Warn("soft-delete cascade returned errors",
zap.String("user_id", userID.String()),
zap.Error(joined),
)
}
return joined
}