Skip to content

Commit eb0a033

Browse files
committed
fix: soft delete bug + workflow
1 parent 001bb1c commit eb0a033

6 files changed

Lines changed: 254 additions & 3 deletions

File tree

.github/workflows/docker.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ on:
66
tags:
77
- 'v[0-9]+.[0-9]+.[0-9]'
88
- 'v[0-9]+.[0-9]+.[0-9]-**'
9-
- 'v*.*.*-*'
10-
- 'v*'
9+
1110
jobs:
1211
build-and-push-image:
1312
uses: eurofurence/workflows/.github/workflows/docker-buildx-push.yml@main

app/Models/Fursuit/Fursuit.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
use App\Models\Badge\Badge;
66
use App\Models\Event;
7-
use App\Models\EventUser;
87
use App\Models\FCEA\UserCatchLog;
98
use App\Models\Fursuit\States\FursuitStatusState;
109
use App\Models\Species;
@@ -26,6 +25,12 @@ class Fursuit extends Model
2625
{
2726
use HasFactory, HasStates, LogsActivity, SoftDeletes;
2827

28+
/**
29+
* Guard flag set while a fursuit is cascading its own deletion to its badges, so the badge
30+
* delete events don't try to (re-)delete the fursuit. See FursuitObserver / BadgeObserver.
31+
*/
32+
public static bool $isCascadingDelete = false;
33+
2934
protected $guarded = [];
3035

3136
protected $casts = [

app/Observers/BadgeObserver.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,44 @@
33
namespace App\Observers;
44

55
use App\Models\Badge\Badge;
6+
use App\Models\Fursuit\Fursuit;
67

78
class BadgeObserver
89
{
10+
/**
11+
* Keep the soft-delete state consistent between a badge and its connected rows
12+
* (spare copies + the parent fursuit). Fires for soft deletes only — a force delete
13+
* deliberately removes the row, so it is left untouched. See docs/bugfix-04-fix.md.
14+
*/
15+
public function deleted(Badge $badge): void
16+
{
17+
if ($badge->isForceDeleting()) {
18+
return;
19+
}
20+
21+
// When a whole fursuit is cascading, it already soft-deletes every one of its badges
22+
// (main + copies), so skip per-badge cascading to avoid redundant work / recursion.
23+
if (Fursuit::$isCascadingDelete) {
24+
return;
25+
}
26+
27+
// Soft-delete the spare copies of this main badge so they aren't left orphaned.
28+
// newQuery() excludes already soft-deleted copies, so this is idempotent.
29+
if ($badge->extra_copy_of === null) {
30+
$badge->newQuery()
31+
->where('extra_copy_of', $badge->id)
32+
->get()
33+
->each
34+
->delete();
35+
}
36+
37+
// If the parent fursuit no longer has any active badge, soft-delete it too.
38+
$fursuit = $badge->fursuit;
39+
if ($fursuit && ! $fursuit->trashed() && $fursuit->badges()->count() === 0) {
40+
$fursuit->delete();
41+
}
42+
}
43+
944
public function updated(Badge $badge): void
1045
{
1146
// Based on tax_rate, calculate tax and update subtotal

app/Observers/FursuitObserver.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,34 @@
77

88
class FursuitObserver
99
{
10+
/**
11+
* Cascade a fursuit soft-delete to its badges so they aren't left orphaned with a NULL
12+
* deleted_at. See docs/bugfix-04-fix.md.
13+
*/
14+
public function deleting(Fursuit $fursuit): bool
15+
{
16+
// A force delete deliberately removes the row (and the FK cascade removes its badges);
17+
// leave that logic untouched.
18+
if ($fursuit->isForceDeleting()) {
19+
return true;
20+
}
21+
22+
// Re-entrant soft delete on an already-trashed fursuit (e.g. the public controller's
23+
// redundant cleanup after the badge cascade already deleted it): abort cleanly.
24+
if ($fursuit->trashed()) {
25+
return false;
26+
}
27+
28+
Fursuit::$isCascadingDelete = true;
29+
try {
30+
$fursuit->badges()->get()->each->delete();
31+
} finally {
32+
Fursuit::$isCascadingDelete = false;
33+
}
34+
35+
return true;
36+
}
37+
1038
public function created(Fursuit $fursuit): void
1139
{
1240
if ($fursuit->catch_em_all === true) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Support\Facades\DB;
5+
6+
/**
7+
* Backfill for bugfix-04: badge/fursuit soft-deletes were not cascaded, leaving orphans
8+
* (20+ fursuits with no remaining badges, and possibly badges under already-deleted fursuits).
9+
*
10+
* Repairs the existing inconsistency by soft-deleting the orphaned rows. Idempotent: both steps
11+
* are WHERE-guarded UPDATEs that only touch still-inconsistent rows, so re-running converges
12+
* (safe under the ArgoCD PreSync migrate job — see CLAUDE.md "Migrations must be idempotent").
13+
*/
14+
return new class extends Migration
15+
{
16+
public function up(): void
17+
{
18+
$now = now();
19+
20+
// 1) Soft-delete active fursuits that have no remaining (non-trashed) badge.
21+
DB::table('fursuits')
22+
->whereNull('deleted_at')
23+
->whereNotExists(function ($query) {
24+
$query->select(DB::raw(1))
25+
->from('badges')
26+
->whereColumn('badges.fursuit_id', 'fursuits.id')
27+
->whereNull('badges.deleted_at');
28+
})
29+
->update(['deleted_at' => $now, 'updated_at' => $now]);
30+
31+
// 2) Symmetrically, soft-delete active badges whose parent fursuit is already trashed.
32+
DB::table('badges')
33+
->whereNull('deleted_at')
34+
->whereExists(function ($query) {
35+
$query->select(DB::raw(1))
36+
->from('fursuits')
37+
->whereColumn('fursuits.id', 'badges.fursuit_id')
38+
->whereNotNull('fursuits.deleted_at');
39+
})
40+
->update(['deleted_at' => $now, 'updated_at' => $now]);
41+
}
42+
43+
public function down(): void
44+
{
45+
// Data repair only; not reversible.
46+
}
47+
};
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
3+
use App\Models\Badge\Badge;
4+
use App\Models\Event;
5+
use App\Models\EventUser;
6+
use App\Models\Fursuit\Fursuit;
7+
use App\Models\User;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Illuminate\Support\Facades\DB;
10+
11+
use function Pest\Laravel\actingAs;
12+
use function Pest\Laravel\delete;
13+
14+
uses(RefreshDatabase::class);
15+
16+
function badgeDeletedAt(Badge $badge)
17+
{
18+
return DB::table('badges')->where('id', $badge->id)->value('deleted_at');
19+
}
20+
21+
function fursuitDeletedAt(Fursuit $fursuit)
22+
{
23+
return DB::table('fursuits')->where('id', $fursuit->id)->value('deleted_at');
24+
}
25+
26+
test('soft-deleting a fursuit cascades to its badges', function () {
27+
$fursuit = Fursuit::factory()->create();
28+
$b1 = Badge::factory()->for($fursuit)->create(['extra_copy_of' => null]);
29+
$b2 = Badge::factory()->for($fursuit)->create(['extra_copy_of' => null]);
30+
31+
$fursuit->delete();
32+
33+
expect(fursuitDeletedAt($fursuit))->not->toBeNull();
34+
expect(badgeDeletedAt($b1))->not->toBeNull();
35+
expect(badgeDeletedAt($b2))->not->toBeNull();
36+
});
37+
38+
test('soft-deleting the last badge cascades to its fursuit', function () {
39+
$fursuit = Fursuit::factory()->create();
40+
$badge = Badge::factory()->for($fursuit)->create(['extra_copy_of' => null]);
41+
42+
$badge->delete();
43+
44+
expect(badgeDeletedAt($badge))->not->toBeNull();
45+
expect(fursuitDeletedAt($fursuit))->not->toBeNull();
46+
});
47+
48+
test('deleting one badge keeps the fursuit while another active badge remains', function () {
49+
$fursuit = Fursuit::factory()->create();
50+
$b1 = Badge::factory()->for($fursuit)->create(['extra_copy_of' => null]);
51+
$b2 = Badge::factory()->for($fursuit)->create(['extra_copy_of' => null]);
52+
53+
$b1->delete();
54+
55+
expect(badgeDeletedAt($b1))->not->toBeNull();
56+
expect(fursuitDeletedAt($fursuit))->toBeNull();
57+
expect(badgeDeletedAt($b2))->toBeNull();
58+
});
59+
60+
test('soft-deleting a main badge cascades to its spare copies and the empty fursuit', function () {
61+
$fursuit = Fursuit::factory()->create();
62+
$main = Badge::factory()->for($fursuit)->create(['extra_copy_of' => null]);
63+
$copy = Badge::factory()->for($fursuit)->create(['extra_copy_of' => $main->id, 'extra_copy' => true]);
64+
65+
$main->delete();
66+
67+
expect(badgeDeletedAt($main))->not->toBeNull();
68+
expect(badgeDeletedAt($copy))->not->toBeNull();
69+
expect(fursuitDeletedAt($fursuit))->not->toBeNull();
70+
});
71+
72+
test('public badge deletion soft-deletes the badge and its now-empty fursuit', function () {
73+
$event = Event::factory()->create([
74+
'starts_at' => now()->subDays(5),
75+
'ends_at' => now()->addDays(20),
76+
'order_starts_at' => now()->subDays(5),
77+
'order_ends_at' => now()->addDays(20),
78+
]);
79+
$user = User::factory()->create();
80+
EventUser::create([
81+
'user_id' => $user->id,
82+
'event_id' => $event->id,
83+
'attendee_id' => 'TEST-'.$user->id,
84+
'valid_registration' => true,
85+
'prepaid_badges' => 0,
86+
]);
87+
88+
$fursuit = Fursuit::factory()->recycle($user)->recycle($event)->create(['status' => 'approved']);
89+
$badge = Badge::factory()->for($fursuit)->create([
90+
'status_fulfillment' => 'pending',
91+
'extra_copy_of' => null,
92+
]);
93+
94+
actingAs($user);
95+
delete(route('badges.destroy', $badge))->assertRedirect();
96+
97+
expect(badgeDeletedAt($badge))->not->toBeNull();
98+
expect(fursuitDeletedAt($fursuit))->not->toBeNull();
99+
});
100+
101+
test('force-deleting a badge removes the row and does not cascade to the fursuit', function () {
102+
$fursuit = Fursuit::factory()->create();
103+
$badge = Badge::factory()->for($fursuit)->create(['extra_copy_of' => null]);
104+
105+
$badge->forceDelete();
106+
107+
expect(Badge::withTrashed()->find($badge->id))->toBeNull();
108+
// Force delete deliberately removes the row; the soft-delete cascade does not run.
109+
expect(fursuitDeletedAt($fursuit))->toBeNull();
110+
});
111+
112+
test('backfill migration soft-deletes existing orphans and leaves healthy rows alone', function () {
113+
// Orphan A: active fursuit whose only badge was soft-deleted without cascade (legacy state).
114+
$fursuitA = Fursuit::factory()->create();
115+
$badgeA = Badge::factory()->for($fursuitA)->create(['extra_copy_of' => null]);
116+
DB::table('badges')->where('id', $badgeA->id)->update(['deleted_at' => now()]);
117+
118+
// Orphan B: trashed fursuit with a still-active badge (the other direction).
119+
$fursuitB = Fursuit::factory()->create();
120+
$badgeB = Badge::factory()->for($fursuitB)->create(['extra_copy_of' => null]);
121+
DB::table('fursuits')->where('id', $fursuitB->id)->update(['deleted_at' => now()]);
122+
123+
// Healthy: active fursuit with an active badge — must be left untouched.
124+
$healthy = Fursuit::factory()->create();
125+
Badge::factory()->for($healthy)->create(['extra_copy_of' => null]);
126+
127+
expect(fursuitDeletedAt($fursuitA))->toBeNull();
128+
expect(badgeDeletedAt($badgeB))->toBeNull();
129+
130+
$migration = include database_path('migrations/2026_06_14_120000_soft_delete_orphaned_fursuits_and_badges.php');
131+
$migration->up();
132+
$migration->up(); // idempotent — running twice changes nothing further
133+
134+
expect(fursuitDeletedAt($fursuitA))->not->toBeNull();
135+
expect(badgeDeletedAt($badgeB))->not->toBeNull();
136+
expect(fursuitDeletedAt($healthy))->toBeNull();
137+
});

0 commit comments

Comments
 (0)