handleContent($user); // Anonymize snapshots: entries stay, names go. The actor_id // FK nulls itself on forceDelete; actor_type remains so the // log can still say "a deleted staff account". ActivityLog::query() ->where('actor_id', $user->id) ->update(['actor_name' => null]); ActivityLog::query() ->where('subject_type', $user->getMorphClass()) ->where('subject_id', $user->id) ->update(['subject_name' => null]); // Entries that carry the name in context, not in the FK-backed // snapshot columns: the self-deletion (UserDeleted) and denial // records, plus the content-handling records this very erasure // just wrote and any left by an earlier admin deletion of the // same person. Their :target — a still-active inheritor — is a // different account and is deliberately left alone. ActivityLog::query() ->whereIn('action', [ Action::UserDeleted->value, Action::ClientDenied->value, Action::AccountContentCascadeDeleted->value, Action::AccountContentReassigned->value, ]) ->get() ->filter(fn (ActivityLog $entry): bool => ($entry->context['name'] ?? null) === $user->name) ->each(function (ActivityLog $entry): void { $context = $entry->context; $context['name'] = null; $entry->forceFill(['context' => $context])->save(); }); $type = $user->type->value; $user->forceDelete(); $this->activity->logSystem(Action::AccountErased, ['type' => $type]); }); } /** * Reassign the account's content to the configured fallback, or cascade * it away. Reassign needs a fallback that still exists, is active, and is * not the account being erased; when it does not (never chosen, since * deactivated, deleted), the safe choice is to cascade rather than orphan * — so content is never left ownerless whatever the configuration. */ private function handleContent(User $user): void { if ($this->settings->get(Setting::AccountErasureContentAction) === 'reassign') { $fallback = $this->fallbackFor($user); if ($fallback !== null) { $result = $this->content->reassignTo($user, $fallback); $this->activity->logSystem(Action::AccountContentReassigned, ['name' => $user->name, 'target' => $fallback->name, ...$result]); return; } } $result = $this->content->cascadeDelete($user); $this->activity->logSystem(Action::AccountContentCascadeDeleted, ['name' => $user->name, ...$result]); } /** * The account configured to inherit erased content, or null when * there is nobody valid to hand it to — in which case handleContent() * cascades, because orphaning is never the answer. * * **A staff member's content may only go to staff.** The target is one * installation-wide id used for every erasure, and the picker offers * clients on purpose: erasing a client and handing their files to * another client is what the setting is for. Applied to a *staff* * account the same id means something else entirely — a staff library * is usually the whole installation's, and a client named there would * inherit all of it, in one unattended scheduled job. * * The check cannot live in the settings validation, which is where it * would otherwise belong: that runs when the target is chosen, and * whose account will be erased later is not knowable then. So it is * asked here, where both halves are in hand. */ private function fallbackFor(User $user): ?User { $id = (int) $this->settings->get(Setting::AccountErasureReassignTo); if ($id === 0) { return null; } return User::query() ->where('active', true) ->whereKeyNot($user->id) ->when($user->isStaff(), fn ($query) => $query->where('type', UserType::Staff)) ->find($id); } }