diff --git a/src/app/Console/Commands/UserForceDelete.php b/src/app/Console/Commands/UserForceDelete.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/UserForceDelete.php @@ -0,0 +1,46 @@ +where('email', $this->argument('user'))->first(); + + if (!$user) { + return 1; + } + + if (!$user->trashed()) { + $this->error('The user is not yet deleted'); + return 1; + } + + DB::beginTransaction(); + $user->forceDelete(); + DB::commit(); + } +} diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -119,6 +119,8 @@ if ($this->entitleable instanceof \App\Domain) { return $this->entitleable->namespace; } + + return null; } /** diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -93,8 +93,19 @@ $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } + /** + * Handle the entitlement "deleting" event. + * + * @param \App\Entitlement $entitlement The entitlement. + * + * @return void + */ public function deleting(Entitlement $entitlement) { + if ($entitlement->trashed()) { + return; + } + // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -4,7 +4,9 @@ use App\Entitlement; use App\Domain; +use App\Transaction; use App\User; +use App\Wallet; use Illuminate\Support\Facades\DB; class UserObserver @@ -105,6 +107,11 @@ */ public function deleting(User $user) { + if ($user->isForceDeleting()) { + $this->forceDeleting($user); + return; + } + // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? // TODO: I think all of this should use database transactions @@ -138,9 +145,8 @@ $users = array_unique($users); $domains = array_unique($domains); - // Note: Domains/users need to be deleted one by one to make sure - // events are fired and observers can do the proper cleanup. - // Entitlements have no delete event handlers as for now. + // Domains/users/entitlements need to be deleted one by one to make sure + // events are fired and observers can do the proper cleanup. if (!empty($users)) { foreach (User::whereIn('id', $users)->get() as $_user) { $_user->delete(); @@ -162,6 +168,69 @@ \App\Jobs\UserDelete::dispatch($user->id); } + /** + * Handle the "deleting" event on forceDelete() call. + * + * @param User $user The user that is being deleted. + * + * @return void + */ + public function forceDeleting(User $user) + { + // TODO: We assume that at this moment all belongings are already soft-deleted. + + // Remove owned users/domains + $wallets = $user->wallets()->pluck('id')->all(); + $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); + $entitlements = []; + $domains = []; + $users = []; + + foreach ($assignments as $entitlement) { + $entitlements[] = $entitlement->id; + + if ($entitlement->entitleable_type == Domain::class) { + $domains[] = $entitlement->entitleable_id; + } elseif ( + $entitlement->entitleable_type == User::class + && $entitlement->entitleable_id != $user->id + ) { + $users[] = $entitlement->entitleable_id; + } + } + + $users = array_unique($users); + $domains = array_unique($domains); + + // Remove the user "direct" entitlements explicitely, if they belong to another + // user's wallet they will not be removed by the wallets foreign key cascade + Entitlement::withTrashed() + ->where('entitleable_id', $user->id) + ->where('entitleable_type', User::class) + ->forceDelete(); + + // Users need to be deleted one by one to make sure observers can do the proper cleanup. + if (!empty($users)) { + foreach (User::withTrashed()->whereIn('id', $users)->get() as $_user) { + $_user->forceDelete(); + } + } + + // Domains can be just removed + if (!empty($domains)) { + Domain::withTrashed()->whereIn('id', $domains)->forceDelete(); + } + + // Remove transactions, they also have no foreign key constraint + Transaction::where('object_type', Entitlement::class) + ->whereIn('object_id', $entitlements) + ->delete(); + + Transaction::where('object_type', Wallet::class) + ->whereIn('object_id', $wallets) + ->delete(); + } + /** * Handle the "retrieving" event. * diff --git a/src/tests/Feature/Console/UserForceDeleteTest.php b/src/tests/Feature/Console/UserForceDeleteTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/UserForceDeleteTest.php @@ -0,0 +1,98 @@ +deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + // Non-existing user + $this->artisan('user:force-delete unknown@unknown.org') + ->assertExitCode(1); + + Queue::fake(); + $user = $this->getTestUser('user@force-delete.com'); + $domain = $this->getTestDomain('force-delete.com', [ + 'status' => \App\Domain::STATUS_NEW, + 'type' => \App\Domain::TYPE_HOSTED, + ]); + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $package_domain = \App\Package::where('title', 'domain-hosting')->first(); + $user->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $user); + $wallet = $user->wallets()->first(); + $entitlements = $wallet->entitlements->pluck('id')->all(); + + $this->assertCount(5, $entitlements); + + // Non-deleted user + $this->artisan('user:force-delete user@force-delete.com') + ->assertExitCode(1); + + $user->delete(); + + $this->assertTrue($user->trashed()); + $this->assertTrue($domain->fresh()->trashed()); + + // Deleted user + $this->artisan('user:force-delete user@force-delete.com') + ->assertExitCode(0); + + $this->assertCount( + 0, + \App\User::withTrashed()->where('email', 'user@force-delete.com')->get() + ); + $this->assertCount( + 0, + \App\Domain::withTrashed()->where('namespace', 'force-delete.com')->get() + ); + $this->assertCount( + 0, + \App\Wallet::where('id', $wallet->id)->get() + ); + $this->assertCount( + 0, + \App\Entitlement::withTrashed()->where('wallet_id', $wallet->id)->get() + ); + $this->assertCount( + 0, + \App\Entitlement::withTrashed()->where('entitleable_id', $user->id)->get() + ); + $this->assertCount( + 0, + \App\Transaction::whereIn('object_id', $entitlements) + ->where('object_type', \App\Entitlement::class) + ->get() + ); + + // TODO: Test that it also deletes users in a group account + } +}