feat(encryption): add commands to warn and remove inactive encrypted-data accounts
Add two artisan commands to retire legacy client-side encrypted data: - encryption:notify-removal queues a re-engagement/deletion warning to every user who still has an encrypted transaction or account (non-null *_iv columns), reusing SendUpdateEmailJob. - encryption:delete-accounts soft-deletes and anonymizes those users when they have not signed in within the grace window (--days, default 7), sparing anyone who came back and never touching the demo account. Both support --dry-run and --force and share the target query via the FindsUsersWithLegacyEncryption concern.
This commit is contained in:
parent
c370abcd6d
commit
ece5cd5d27
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait FindsUsersWithLegacyEncryption
|
||||
{
|
||||
/**
|
||||
* Users who still have at least one client-side encrypted transaction or account
|
||||
* (a non-null `*_iv` column is the source of truth, not the stale `accounts.encrypted` flag).
|
||||
*
|
||||
* @return Builder<User>
|
||||
*/
|
||||
protected function usersWithLegacyEncryption(): Builder
|
||||
{
|
||||
return User::query()->where(function (Builder $query): void {
|
||||
$query->whereHas('transactions', function (Builder $transactions): void {
|
||||
$transactions->whereNotNull('description_iv')
|
||||
->orWhereNotNull('notes_iv');
|
||||
})->orWhereHas('accounts', function (Builder $accounts): void {
|
||||
$accounts->whereNotNull('name_iv');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DeleteEncryptedDataAccountsCommand extends Command
|
||||
{
|
||||
use FindsUsersWithLegacyEncryption;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'encryption:delete-accounts
|
||||
{--days=7 : Spare users active within the last N days}
|
||||
{--dry-run : List the accounts that would be deleted without touching them}
|
||||
{--force : Skip the confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Soft-delete and anonymize users who still have legacy encrypted data and did not sign in within the grace window';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$users = $this->usersWithLegacyEncryption()
|
||||
->where('email', '!=', config('app.demo.email'))
|
||||
->where(function (Builder $query) use ($cutoff): void {
|
||||
$query->whereNull('last_active_at')
|
||||
->orWhere('last_active_at', '<', $cutoff);
|
||||
})
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No accounts to delete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$users->count()} account(s) with encrypted data and no activity in the last {$days} day(s).");
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->table(['Email', 'Last active'], $users->map(fn (User $user) => [
|
||||
$user->email,
|
||||
$user->last_active_at?->toDateTimeString() ?? 'never',
|
||||
])->all());
|
||||
$this->info('[dry-run] No accounts deleted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm("Soft-delete and anonymize {$users->count()} account(s)?", false)) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$progressBar = $this->output->createProgressBar($users->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user->markAsDeleted();
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Deleted {$users->count()} account(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
|
||||
use App\Jobs\SendUpdateEmailJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NotifyEncryptedDataRemovalCommand extends Command
|
||||
{
|
||||
use FindsUsersWithLegacyEncryption;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'encryption:notify-removal
|
||||
{--dry-run : List affected users without sending anything}
|
||||
{--force : Skip the confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Warn users with legacy encrypted data that their account will be deleted unless they sign in within 7 days';
|
||||
|
||||
private const VIEW = 'encrypted-data-removal';
|
||||
|
||||
private const IDENTIFIER = 'encrypted-data-removal';
|
||||
|
||||
private const SUBJECT = 'Action required: sign in to keep your Whisper Money account';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$users = $this->usersWithLegacyEncryption()->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No users with encrypted data found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$users->count()} user(s) with encrypted data.");
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->renderTable($users);
|
||||
$this->info('[dry-run] No emails sent.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm("Send the deletion warning to {$users->count()} user(s)?", true)) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$progressBar = $this->output->createProgressBar($users->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($users->values() as $index => $user) {
|
||||
// Spread over the 'emails' queue at 50/day to match the existing bulk-email convention.
|
||||
SendUpdateEmailJob::dispatch($user, self::VIEW, self::IDENTIFIER, self::SUBJECT)
|
||||
->delay(now()->addDays((int) floor($index / 50)));
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Queued {$users->count()} warning email(s) to the 'emails' queue (50/day).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, User> $users
|
||||
*/
|
||||
private function renderTable($users): void
|
||||
{
|
||||
$this->table(['Email', 'Last active'], $users->map(fn (User $user) => [
|
||||
$user->email,
|
||||
$user->last_active_at?->toDateTimeString() ?? 'never',
|
||||
])->all());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<x-mail::message>
|
||||
# We've missed you at Whisper Money
|
||||
|
||||
Hi {{ $user->name }},
|
||||
|
||||
We noticed you haven't signed in to Whisper Money for a while. To keep things tidy, **accounts that stay inactive will be removed** — and yours is on that list.
|
||||
|
||||
If you'd like to keep your account and your data, just **sign in within the next 7 days**:
|
||||
|
||||
<x-mail::button :url="route('login')">
|
||||
Sign in to Whisper Money
|
||||
</x-mail::button>
|
||||
|
||||
## A lot has changed since you were last here
|
||||
|
||||
We've been busy adding features to make managing your money effortless:
|
||||
|
||||
- **AI-powered insights** that categorise your transactions automatically.
|
||||
- **Bank integrations** that import your transactions for you — no more manual entry.
|
||||
- And plenty of smaller improvements across the app.
|
||||
|
||||
It's a great time to come back and take a look.
|
||||
|
||||
If you don't sign in before then, your account and all of its data will be permanently deleted.
|
||||
|
||||
If you have any questions, **just reply to this email** — we read every message personally.
|
||||
|
||||
Thanks,<br>
|
||||
The Whisper Money team
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\artisan;
|
||||
|
||||
test('it soft-deletes users with encrypted data who did not sign in within the grace window', function () {
|
||||
$inactive = User::factory()->create(['last_active_at' => now()->subDays(30)]);
|
||||
Transaction::factory()->for($inactive)->create();
|
||||
|
||||
$neverActive = User::factory()->create(['last_active_at' => null]);
|
||||
Account::factory()->for($neverActive)->create(['name_iv' => 'abcdef0123456789']);
|
||||
|
||||
$recentlyActive = User::factory()->create(['last_active_at' => now()->subDay()]);
|
||||
Transaction::factory()->for($recentlyActive)->create();
|
||||
|
||||
$clean = User::factory()->create(['last_active_at' => now()->subDays(30)]);
|
||||
Transaction::factory()->for($clean)->plaintext()->create();
|
||||
|
||||
artisan('encryption:delete-accounts', ['--force' => true])->assertSuccessful();
|
||||
|
||||
// The default soft-delete scope hides trashed users, so a missing row means it was deleted.
|
||||
expect(User::whereKey($inactive->id)->exists())->toBeFalse();
|
||||
expect(User::whereKey($neverActive->id)->exists())->toBeFalse();
|
||||
expect(User::whereKey($recentlyActive->id)->exists())->toBeTrue();
|
||||
expect(User::whereKey($clean->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('it never deletes the demo account', function () {
|
||||
$demo = User::factory()->create([
|
||||
'email' => config('app.demo.email'),
|
||||
'last_active_at' => now()->subDays(30),
|
||||
]);
|
||||
Transaction::factory()->for($demo)->create();
|
||||
|
||||
artisan('encryption:delete-accounts', ['--force' => true])->assertSuccessful();
|
||||
|
||||
expect(User::whereKey($demo->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('dry run deletes nothing', function () {
|
||||
$user = User::factory()->create(['last_active_at' => null]);
|
||||
Transaction::factory()->for($user)->create();
|
||||
|
||||
artisan('encryption:delete-accounts', ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
expect(User::whereKey($user->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\SendUpdateEmailJob;
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
use function Pest\Laravel\artisan;
|
||||
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
test('it queues a warning email for users with encrypted transactions or accounts', function () {
|
||||
$encryptedTransaction = User::factory()->create();
|
||||
Transaction::factory()->for($encryptedTransaction)->create(); // description_iv set by default
|
||||
|
||||
$encryptedAccount = User::factory()->create();
|
||||
Account::factory()->for($encryptedAccount)->create(['name_iv' => 'abcdef0123456789']);
|
||||
|
||||
$clean = User::factory()->create();
|
||||
Transaction::factory()->for($clean)->plaintext()->create();
|
||||
|
||||
artisan('encryption:notify-removal', ['--force' => true])->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(SendUpdateEmailJob::class, 2);
|
||||
Queue::assertPushed(SendUpdateEmailJob::class, fn (SendUpdateEmailJob $job) => $job->user->is($encryptedTransaction));
|
||||
Queue::assertPushed(SendUpdateEmailJob::class, fn (SendUpdateEmailJob $job) => $job->user->is($encryptedAccount));
|
||||
Queue::assertNotPushed(SendUpdateEmailJob::class, fn (SendUpdateEmailJob $job) => $job->user->is($clean));
|
||||
});
|
||||
|
||||
test('it queues on the emails queue', function () {
|
||||
$user = User::factory()->create();
|
||||
Transaction::factory()->for($user)->create();
|
||||
|
||||
artisan('encryption:notify-removal', ['--force' => true])->assertSuccessful();
|
||||
|
||||
Queue::assertPushedOn('emails', SendUpdateEmailJob::class);
|
||||
});
|
||||
|
||||
test('dry run sends nothing', function () {
|
||||
$user = User::factory()->create();
|
||||
Transaction::factory()->for($user)->create();
|
||||
|
||||
artisan('encryption:notify-removal', ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
Queue::assertNotPushed(SendUpdateEmailJob::class);
|
||||
});
|
||||
Loading…
Reference in New Issue