Skip to content
Merged

m/p #49

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions app/Console/Commands/Feedback/DeleteProjectCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands\Feedback;

use App\Console\Commands\Feedback\Concerns\InteractsWithFeedback;
use Illuminate\Console\Command;

final class DeleteProjectCommand extends Command
{
use InteractsWithFeedback;

protected $signature = 'feedback:project:delete
{key : Public key or ID}
{--force : Delete without asking}
{--by= : Name recorded in the activity log}
{--json : Output as JSON}';

protected $description = 'Delete a feedback project with all its feedback and screenshots';

public function handle(): int
{
$this->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;
}
}
35 changes: 35 additions & 0 deletions tests/Feature/ProjectCommandsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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]);
});
Loading