diff --git a/README.md b/README.md index 6449ea2..45e7036 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,7 @@ php artisan feedback:project:list php artisan feedback:project:show {key} php artisan feedback:project:update {key} --add-origin=https://staging.example.com --color=white --deactivate php artisan feedback:project:rotate-key {key} +php artisan feedback:project:delete {key} [--force] [--by="Name"] php artisan feedback:list --open --project={key} php artisan feedback:show {id} @@ -309,7 +310,7 @@ Changing commands accept `--by="Name"`, which is recorded in the activity log. - **IPs**: single addresses or CIDR ranges, IPv4 and IPv6. No IPs means no restriction. A request must match the origins **and** the IPs. - **Visibility**: the widget only appears on pages the API accepts. It asks for the project config first and removes itself on a `403`, so a page outside the origins or IPs never shows a button. If the API cannot be reached, the widget still appears, in black. - **Color**: `black`, `white` or a hex value. Text becomes white or dark blue, whichever has more contrast, and button tones are adjusted until they reach 4.5:1. -- **Daily limit**: `--limit=500` caps the entries per project and day, `--deactivate` / `--activate` switch a project off and on without deleting anything, `rotate-key` replaces a leaked key. +- **Daily limit**: `--limit=500` caps the entries per project and day, `--deactivate` / `--activate` switch a project off and on without deleting anything, `rotate-key` replaces a leaked key, `feedback:project:delete` removes a project with all its feedback and screenshots for good (asks first, `--force` skips the question). ## Feedback pages and share links diff --git a/app/Console/Commands/Feedback/DeleteProjectCommand.php b/app/Console/Commands/Feedback/DeleteProjectCommand.php new file mode 100644 index 0000000..1c2c6ea --- /dev/null +++ b/app/Console/Commands/Feedback/DeleteProjectCommand.php @@ -0,0 +1,51 @@ +recordActor(); + + $project = $this->findProject($this->stringArgument('key')); + + if ($project === null) { + return self::FAILURE; + } + + $entries = $project->entries()->count(); + + if (! $this->option('force') && ! $this->confirm("Delete [{$project->name}] and its {$entries} feedback entries? This cannot be undone.", false)) { + $this->components->error('Not deleted. Confirm or pass --force.'); + + return self::FAILURE; + } + + // Entries and screenshots go with it through ON DELETE CASCADE. + $project->delete(); + + if ($this->option('json')) { + return $this->printJson(['id' => $project->id, 'name' => $project->name, 'public_key' => $project->public_key, 'deleted_entries' => $entries]); + } + + $this->components->info("Project [{$project->name}] deleted with {$entries} feedback entries. Its key no longer works."); + + return self::SUCCESS; + } +} diff --git a/tests/Feature/ProjectCommandsTest.php b/tests/Feature/ProjectCommandsTest.php index a6d29b3..06095c6 100644 --- a/tests/Feature/ProjectCommandsTest.php +++ b/tests/Feature/ProjectCommandsTest.php @@ -4,7 +4,9 @@ use App\Models\FeedbackEntry; use App\Models\FeedbackProject; +use App\Models\FeedbackScreenshot; use Illuminate\Support\Facades\Artisan; +use Spatie\Activitylog\Models\Activity; function artisanJson(string $command, array $parameters = []): array { @@ -179,3 +181,36 @@ function artisanJson(string $command, array $parameters = []): array test()->artisan('feedback:project:rotate-key', ['key' => $old])->assertExitCode(1); test()->post("/v1/projects/{$old}/feedback", feedbackPayload(), ['Origin' => TEST_ORIGIN])->assertNotFound(); }); + +it('deletes a project with its feedback and screenshots', function (): void { + $project = FeedbackProject::factory()->create(['name' => 'Alt']); + $other = FeedbackEntry::factory()->create(); + $entry = FeedbackEntry::factory()->for($project, 'project')->create(); + FeedbackScreenshot::query()->create(['entry_id' => $entry->id, 'mime' => 'image/png', 'width' => 1, 'height' => 1, 'bytes' => 1, 'sha256' => str_repeat('0', 64), 'data' => 'x']); + + test()->artisan('feedback:project:delete', ['key' => $project->public_key]) + ->expectsConfirmation('Delete [Alt] and its 1 feedback entries? This cannot be undone.', 'no') + ->assertExitCode(1); + expect($project->exists())->toBeTrue(); + + test()->artisan('feedback:project:delete', ['key' => $project->public_key, '--force' => true, '--by' => 'Anna']) + ->expectsOutputToContain('Project [Alt] deleted with 1 feedback entries.') + ->assertExitCode(0); + + expect(FeedbackProject::query()->find($project->id))->toBeNull() + ->and(FeedbackEntry::query()->pluck('id')->all())->toBe([$other->id]) + ->and(FeedbackScreenshot::query()->count())->toBe(0) + ->and(Activity::query()->where('subject_id', $project->id)->latest('id')->first()) + ->event->toBe('deleted') + ->properties->toArray()->toMatchArray(['by' => 'Anna']); + + test()->post("/v1/projects/{$project->public_key}/feedback", feedbackPayload(), ['Origin' => TEST_ORIGIN])->assertNotFound(); + test()->artisan('feedback:project:delete', ['key' => $project->id, '--force' => true])->assertExitCode(1); +}); + +it('deletes a project as JSON', function (): void { + $project = FeedbackProject::factory()->create(); + + expect(artisanJson('feedback:project:delete', ['key' => $project->id, '--force' => true])) + ->toMatchArray(['id' => $project->id, 'public_key' => $project->public_key, 'deleted_entries' => 0]); +});