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 }