CluPilotCloud/app/Console/Commands/PruneDormantAccounts.php

207 lines
8.0 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Mail\DormantAccountWarningMail;
use App\Models\User;
use App\Models\UserDevice;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* A confirmed account that has never had a package, removed after a year.
*
* The other sweep (PruneUnverifiedAccounts) is about registrations nobody
* confirmed and takes five days. This one is about the opposite case: somebody
* confirmed their address, looked around, never bought anything, and never came
* back. A year later there is a login, an address and a password hash on file
* for no purpose at all — which is a data-protection question, not tidiness.
*
* ## What "no package" means, and why it is drawn this wide
*
* No instance, no contract, no order, no invoice — ever, not merely "not now".
* A customer who cancelled last month has invoices, and invoices have to be kept
* for seven years (§ 132 BAO). Their login is part of how they reach those
* invoices, so nothing with commercial history is touched here. Both links
* between `users` and `customers` count, by id and by address, for the same
* reason as in the other sweep.
*
* ## The clock is reset by signing in
*
* Measured from the LATEST of the registration and the last time any device was
* seen (`user_devices.last_seen_at`) — so a person who signs in once a year
* keeps their account, which is what the terms promise. There is no
* `last_login_at` on users, and the device rows already carry exactly this.
*
* ## Nobody is deleted without being told
*
* A warning goes out a fortnight before, once, and `dormant_warned_at` records
* it. An account is only removed when that warning is on file and old enough —
* so a run that has never warned anybody deletes nobody, and a mail server that
* was down that night delays the deletion instead of skipping the warning.
*/
class PruneDormantAccounts extends Command
{
/** How long an account with no package is kept. */
public const AFTER_DAYS = 365;
/** How long before that the warning goes out. */
public const WARN_DAYS_BEFORE = 14;
protected $signature = 'clupilot:prune-dormant {--dry-run : Only say what would happen}';
protected $description = 'Warn, then delete confirmed accounts that never had a package';
public function handle(): int
{
$dry = (bool) $this->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<User>
*/
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);
}
}