option('dry-run'); $warned = $this->sendWarnings($dry); $removed = $this->remove($dry); $this->info($dry ? "would warn {$warned}, would remove {$removed}." : "{$warned} warned, {$removed} removed."); return self::SUCCESS; } /** * The fortnight's notice. * * Not called warn(): Command::warn() is the framework's "print a warning * line" and overriding it with something private breaks the class outright. */ private function sendWarnings(bool $dry): int { $due = $this->dormant() ->whereNull('dormant_warned_at') ->get() ->filter(fn (User $user) => $this->dormantSince($user) ->lte(now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE))); foreach ($due as $user) { if ($dry) { $this->line('would warn: '.$user->email); continue; } try { Mail::to($user->email)->queue(new DormantAccountWarningMail($user, self::WARN_DAYS_BEFORE)); } catch (Throwable $e) { // Not stamped: the stamp is what permits the deletion, and // stamping a mail that never went out would delete an account // whose owner was never told. Log::warning('Could not warn a dormant account', [ 'email' => $user->email, 'exception' => $e->getMessage(), ]); continue; } $user->forceFill(['dormant_warned_at' => now()])->save(); } return $due->count(); } /** The deletion itself, a fortnight after the warning at the earliest. */ private function remove(bool $dry): int { $due = $this->dormant() ->whereNotNull('dormant_warned_at') ->where('dormant_warned_at', '<=', now()->subDays(self::WARN_DAYS_BEFORE)) ->get() ->filter(fn (User $user) => $this->dormantSince($user) ->lte(now()->subDays(self::AFTER_DAYS))); foreach ($due as $user) { if ($dry) { $this->line('would remove: '.$user->email.' (dormant since '.$this->dormantSince($user)->toDateString().')'); continue; } // Logged individually rather than as a count: an address that // disappears is the kind of thing somebody asks about later, and a // number in a log answers nothing. Log::info('Removing a dormant account', [ 'email' => $user->email, 'registered_at' => $user->created_at?->toIso8601String(), 'warned_at' => $user->dormant_warned_at?->toIso8601String(), ]); $user->delete(); } return $due->count(); } /** * Confirmed, and with no customer record behind it at all. * * Drawn at the customer record rather than at "has no live package on * record", and deliberately wider than it has to be: * * - A customer record means somebody in the business knows this person — * an operator entered them, or a checkout created them. Removing the * login of a record we keep is half a deletion. * - Anything commercial (an order, a contract, an instance, an invoice) * hangs off a customer record, and invoices have to be kept for seven * years (§ 132 BAO). Skipping every customer record skips all of that * without having to enumerate it and without a new table sneaking past * the enumeration later. * * What is left is exactly the case this exists for: somebody registered, * confirmed their address, never bought anything, and never came back. * Registration alone creates no customer record. * * Both links count, by id and by address — see Customer::emailTaken for why * one can exist without the other. * * @return \Illuminate\Database\Eloquent\Builder */ private function dormant() { return User::query() ->whereNotNull('email_verified_at') // Old enough to be worth looking at. The precise cut-off is applied // per user afterwards, because it depends on the devices — and this // one is <=, not <: a timestamp exactly at the threshold is stored to // the second, and a stricter pre-filter would drop the very account // the per-user check was about to catch. ->where('created_at', '<=', now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE)) ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers') ->whereColumn('customers.user_id', 'users.id')) ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers') ->whereColumn('customers.email', 'users.email')); } /** * Since when this account has been doing nothing. * * The later of the registration and the last time any of its devices was * seen: signing in resets the clock, which is what the terms say. */ private function dormantSince(User $user): Carbon { // Scoped by guard as well as by id: operator 1 is not customer 1 (R21), // and there is no devices() relation on User to hide that behind. $lastSeen = UserDevice::query() ->where('guard', 'web') ->where('authenticatable_id', $user->id) ->max('last_seen_at'); $since = $user->created_at ?? now(); return $lastSeen === null ? $since : Carbon::parse($lastSeen)->max($since); } }