207 lines
8.0 KiB
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);
|
|
}
|
|
}
|