diff --git a/app/Console/Commands/Database/CleanupPlaces.php b/app/Console/Commands/Database/CleanupPlaces.php new file mode 100644 index 0000000..d58ba1b --- /dev/null +++ b/app/Console/Commands/Database/CleanupPlaces.php @@ -0,0 +1,81 @@ +option('force'); + + if ($force && ! $this->confirm('This permanently deletes venues and cities on this database. Continue?', false)) { + $this->warn('Aborted.'); + + return Command::FAILURE; + } + + // Venues first: removing unused venues can make their city deletable in the same run. + $venues = Venue::query() + ->whereDoesntHave('courseEvents') + ->whereDoesntHave('bitcoinEvents') + ->get(); + + $this->deleteAll('venue', $venues, $force); + + $cities = City::query() + ->whereDoesntHave('venues') + ->whereDoesntHave('meetups') + ->get(); + + $this->deleteAll('city', $cities, $force); + + $this->newLine(); + $this->table(['Type', $force ? 'Deleted' : 'To delete'], [ + ['Venues', $venues->count()], + ['Cities', $cities->count()], + ]); + + if (! $force) { + $this->comment('Dry-run only. Re-run with --force to apply.'); + } + + return Command::SUCCESS; + } + + /** + * @param Collection $models + */ + private function deleteAll(string $label, $models, bool $force): void + { + if ($models->isEmpty()) { + $this->info(sprintf('No unused %s entries to clean up.', $label)); + + return; + } + + $this->info(sprintf( + '%s %d unused %s entry/entries.', + $force ? 'Deleting' : '[DRY-RUN] Would delete', + $models->count(), + $label, + )); + + if (! $force) { + return; + } + + // ->delete() per model (not a mass delete) so Venue media is removed via its delete hook. + $this->withProgressBar($models, fn ($model) => $model->delete()); + $this->newLine(); + } +} diff --git a/app/Models/Venue.php b/app/Models/Venue.php index a246246..e6b99ee 100644 --- a/app/Models/Venue.php +++ b/app/Models/Venue.php @@ -102,4 +102,9 @@ class Venue extends Model implements HasMedia { return $this->hasMany(CourseEvent::class); } + + public function bitcoinEvents(): HasMany + { + return $this->hasMany(BitcoinEvent::class); + } } diff --git a/tests/Feature/Console/CleanupPlacesTest.php b/tests/Feature/Console/CleanupPlacesTest.php new file mode 100644 index 0000000..33e3344 --- /dev/null +++ b/tests/Feature/Console/CleanupPlacesTest.php @@ -0,0 +1,50 @@ +create(); + + $this->artisan('places:cleanup')->assertExitCode(0); + + expect(Venue::query()->find($venue->id))->not->toBeNull(); + expect(City::query()->find($venue->city_id))->not->toBeNull(); +}); + +it('deletes unused venues and cities with --force', function () { + $venue = Venue::factory()->create(); + $city = City::query()->find($venue->city_id); + + $this->artisan('places:cleanup', ['--force' => true]) + ->expectsConfirmation('This permanently deletes venues and cities on this database. Continue?', 'yes') + ->assertExitCode(0); + + expect(Venue::query()->find($venue->id))->toBeNull(); + expect(City::query()->find($city->id))->toBeNull(); +}); + +it('keeps venues with course events and bitcoin events', function () { + $withCourse = CourseEvent::factory()->create()->venue; + $withBitcoin = BitcoinEvent::factory()->create()->venue; + + $this->artisan('places:cleanup', ['--force' => true]) + ->expectsConfirmation('This permanently deletes venues and cities on this database. Continue?', 'yes') + ->assertExitCode(0); + + expect(Venue::query()->find($withCourse->id))->not->toBeNull(); + expect(Venue::query()->find($withBitcoin->id))->not->toBeNull(); +}); + +it('keeps cities with meetups even without venues', function () { + $meetup = Meetup::factory()->create(); + + $this->artisan('places:cleanup', ['--force' => true]) + ->expectsConfirmation('This permanently deletes venues and cities on this database. Continue?', 'yes') + ->assertExitCode(0); + + expect(City::query()->find($meetup->city_id))->not->toBeNull(); +});