feat(encryption): report count of users still holding encrypted data (#687)

## What

`encryption:notify-removal` now prints, on every run, the total number
of **non-deleted** users who still hold legacy browser-encrypted data:

```
4 non-deleted user(s) still have encrypted data.
```

The count is taken before the billing exclusion, so it reflects the full
remaining scope of browser-encrypted data. Soft-deleted users are
excluded (model default scope), as are users whose data is already
plaintext.

## Why

It's the signal for deciding when the browser-side encryption code can
finally be removed: once this reaches zero, nothing in the app depends
on client-side encrypted columns anymore.

## Not changed

Email-sending logic is untouched. Subscribed users are still never
warned — the recipient set continues to go through
`excludeBilledUsers()` exactly as before. This PR only adds a reporting
line.

## Testing

- New test asserts the count spans everyone with encrypted data
(subscribed included), while excluding soft-deleted and plaintext users,
and that sending still skips the subscribed user.
- Full `NotifyEncryptedDataRemovalCommandTest` suite green.
This commit is contained in:
Víctor Falcón 2026-07-17 11:29:33 +02:00 committed by GitHub
parent 808d41eee2
commit 4cd7619791
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 43 additions and 4 deletions

View File

@ -39,16 +39,22 @@ class NotifyEncryptedDataRemovalCommand extends Command
*/
public function handle(): int
{
$users = $this->excludeBilledUsers($this->usersWithLegacyEncryption()->get());
$encryptedUsers = $this->usersWithLegacyEncryption()->get();
// Always report the total scope of legacy encrypted data (soft-deleted users are
// excluded by the model's default scope). This is the signal for deciding whether
// the browser-side encryption code can finally be removed, independent of who we
// actually email below.
$this->info("{$encryptedUsers->count()} non-deleted user(s) still have encrypted data.");
$users = $this->excludeBilledUsers($encryptedUsers);
if ($users->isEmpty()) {
$this->info('No users with encrypted data found.');
$this->info('No users to warn.');
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.');

View File

@ -45,6 +45,39 @@ test('it never warns a subscribed user', function () {
Queue::assertNotPushed(SendUpdateEmailJob::class);
});
test('it always reports the total count of non-deleted encrypted users, including subscribed ones', function () {
config(['subscriptions.enabled' => true]);
$subscribed = User::factory()->create();
Transaction::factory()->for($subscribed)->create();
$subscribed->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
$unsubscribed = User::factory()->create();
Transaction::factory()->for($unsubscribed)->create();
$deleted = User::factory()->create();
Transaction::factory()->for($deleted)->create();
$deleted->delete();
$clean = User::factory()->create();
Transaction::factory()->for($clean)->plaintext()->create();
// The count spans everyone still holding encrypted data (subscribed included); the
// soft-deleted and plaintext users are excluded.
artisan('encryption:notify-removal', ['--force' => true])
->expectsOutputToContain('2 non-deleted user(s) still have encrypted data.')
->assertSuccessful();
// Sending is unchanged: the subscribed user is counted but never emailed.
Queue::assertPushed(SendUpdateEmailJob::class, 1);
Queue::assertPushed(SendUpdateEmailJob::class, fn (SendUpdateEmailJob $job) => $job->user->is($unsubscribed));
});
test('it queues on the emails queue', function () {
$user = User::factory()->create();
Transaction::factory()->for($user)->create();