diff --git a/app-modules/squads/database/migrations/2026_07_30_113028_add_single_captain_unique_to_squad_members.php b/app-modules/squads/database/migrations/2026_07_30_113028_add_single_captain_unique_to_squad_members.php new file mode 100644 index 00000000..6bd761bb --- /dev/null +++ b/app-modules/squads/database/migrations/2026_07_30_113028_add_single_captain_unique_to_squad_members.php @@ -0,0 +1,21 @@ +isAdmin(), AuthorizationException::class); + + return DB::transaction(function () use ($squad, $subject, $actor, $reason): SquadMember { + // Locking the subject's row serializes concurrent assignments of the same + // person: the loser re-reads the incumbent after the winner commits and + // short-circuits below, instead of appending a phantom pair of events. + $member = SquadMember::query() + ->where('squad_id', $squad->id) + ->where('user_id', $subject->id) + ->whereNot('role', SquadRole::ExMember) + ->lockForUpdate() + ->first(); + + throw_if($member === null, NotAnActiveSquadMember::for($squad, $subject)); + + $incumbent = $squad->captain()->first(); + + if ($incumbent?->is($member)) { + return $member; + } + + if ($incumbent !== null) { + $this->demote($actor, $squad, $incumbent, $reason); + } + + $fromRole = $member->role; + $member->update([ + 'role' => SquadRole::Captain, + ]); + + $this->recordMembershipEvent->handle( + squad: $squad, + subject: $subject, + action: MembershipAction::CaptainAssigned, + fromRole: $fromRole, + toRole: SquadRole::Captain, + actor: $actor, + reason: $reason, + ); + + return $member->refresh(); + }); + } + + private function demote(User $actor, Squad $squad, SquadMember $incumbent, ?string $reason): void + { + $fromRole = $incumbent->role; + + $incumbent->update([ + 'role' => SquadRole::Member, + ]); + + $this->recordMembershipEvent->handle( + squad: $squad, + subject: $incumbent->user, + action: MembershipAction::Demote, + fromRole: $fromRole, + toRole: SquadRole::Member, + actor: $actor, + reason: $reason, + ); + } +} diff --git a/app-modules/squads/src/Enums/MembershipAction.php b/app-modules/squads/src/Enums/MembershipAction.php index a38d1e15..6d603dcd 100644 --- a/app-modules/squads/src/Enums/MembershipAction.php +++ b/app-modules/squads/src/Enums/MembershipAction.php @@ -10,4 +10,5 @@ enum MembershipAction: string case Leave = 'leave'; case Promote = 'promote'; case Demote = 'demote'; + case CaptainAssigned = 'captain_assigned'; } diff --git a/app-modules/squads/src/Exceptions/NotAnActiveSquadMember.php b/app-modules/squads/src/Exceptions/NotAnActiveSquadMember.php new file mode 100644 index 00000000..d9a6d21d --- /dev/null +++ b/app-modules/squads/src/Exceptions/NotAnActiveSquadMember.php @@ -0,0 +1,19 @@ +id, $squad->id) + ); + } +} diff --git a/app-modules/squads/src/Models/Squad.php b/app-modules/squads/src/Models/Squad.php index f17a127d..cd45ad04 100644 --- a/app-modules/squads/src/Models/Squad.php +++ b/app-modules/squads/src/Models/Squad.php @@ -6,12 +6,14 @@ use Carbon\CarbonInterface; use He4rt\Squads\Database\Factories\SquadFactory; +use He4rt\Squads\Enums\SquadRole; use He4rt\Squads\Enums\SquadStatus; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Attributes\UseFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasOne; /** * @property string $id @@ -37,6 +39,18 @@ final class Squad extends Model 'status', ]; + /** + * The squad's captain seat, derived from the pivot — there is no + * denormalized `captain_id`. A vacant seat resolves to `null`. + * + * @return HasOne + */ + public function captain(): HasOne + { + return $this->hasOne(SquadMember::class) + ->where('role', SquadRole::Captain); + } + /** * @return array */ diff --git a/app-modules/squads/tests/Feature/AssignCaptainTest.php b/app-modules/squads/tests/Feature/AssignCaptainTest.php new file mode 100644 index 00000000..fa12a023 --- /dev/null +++ b/app-modules/squads/tests/Feature/AssignCaptainTest.php @@ -0,0 +1,163 @@ + 'guisaliba']); + + $this->admin = User::factory()->create([ + 'username' => 'guisaliba', + ]); +}); + +test('super-admin assigns a member as captain and the trail records it', function (): void { + $squad = Squad::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::Member, + ]); + + expect($squad->captain()->first())->toBeNull(); + + $member = resolve(AssignCaptain::class)->handle( + actor: $this->admin, + squad: $squad, + subject: $subject, + reason: 'Elected off-system.', + ); + + expect($member->role)->toBe(SquadRole::Captain) + ->and($squad->captain()->first()->user_id)->toBe($subject->id); + + $this->assertDatabaseHas('squad_membership_events', [ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'actor_id' => $this->admin->id, + 'action' => MembershipAction::CaptainAssigned->value, + 'from_role' => SquadRole::Member->value, + 'to_role' => SquadRole::Captain->value, + 'reason' => 'Elected off-system.', + ]); +}); + +test('assigning a new captain demotes the incumbent and leaves a single captain', function (): void { + $squad = Squad::factory()->create(); + $incumbent = User::factory()->create(); + $successor = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $incumbent->id, + 'role' => SquadRole::Captain, + ]); + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $successor->id, + 'role' => SquadRole::SubCaptain, + ]); + + resolve(AssignCaptain::class)->handle( + actor: $this->admin, + squad: $squad, + subject: $successor, + ); + + $captains = SquadMember::query() + ->where('squad_id', $squad->id) + ->where('role', SquadRole::Captain) + ->get(); + + expect($captains)->toHaveCount(1) + ->and($captains->first()->user_id)->toBe($successor->id) + ->and($squad->captain()->first()->user_id)->toBe($successor->id); + + $this->assertDatabaseHas('squad_members', [ + 'squad_id' => $squad->id, + 'user_id' => $incumbent->id, + 'role' => SquadRole::Member->value, + ]); + + $this->assertDatabaseHas('squad_membership_events', [ + 'squad_id' => $squad->id, + 'user_id' => $incumbent->id, + 'actor_id' => $this->admin->id, + 'action' => MembershipAction::Demote->value, + 'from_role' => SquadRole::Captain->value, + 'to_role' => SquadRole::Member->value, + ]); + + $this->assertDatabaseHas('squad_membership_events', [ + 'squad_id' => $squad->id, + 'user_id' => $successor->id, + 'action' => MembershipAction::CaptainAssigned->value, + 'from_role' => SquadRole::SubCaptain->value, + 'to_role' => SquadRole::Captain->value, + ]); +}); + +test('re-assigning the current captain records nothing', function (): void { + $squad = Squad::factory()->create(); + $captain = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + + resolve(AssignCaptain::class)->handle( + actor: $this->admin, + squad: $squad, + subject: $captain, + ); + + expect(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0) + ->and($squad->captain()->first()->user_id)->toBe($captain->id); +}); + +test('a person without an active membership cannot be assigned captain', function (): void { + $squad = Squad::factory()->create(); + $outsider = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $outsider->id, + 'role' => SquadRole::ExMember, + ]); + + resolve(AssignCaptain::class)->handle( + actor: $this->admin, + squad: $squad, + subject: $outsider, + ); +})->throws(NotAnActiveSquadMember::class); + +test('common user cannot assign a captain', function (): void { + $squad = Squad::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::Member, + ]); + + resolve(AssignCaptain::class)->handle( + actor: User::factory()->create(['username' => 'common-user']), + squad: $squad, + subject: $subject, + ); +})->throws(AuthorizationException::class); diff --git a/app-modules/squads/tests/Feature/SquadMemberTest.php b/app-modules/squads/tests/Feature/SquadMemberTest.php index 77f50f42..dcdb1f9a 100644 --- a/app-modules/squads/tests/Feature/SquadMemberTest.php +++ b/app-modules/squads/tests/Feature/SquadMemberTest.php @@ -42,3 +42,17 @@ 'user_id' => $user->id, ]); })->throws(QueryException::class); + +test('a squad cannot hold two captains', function (): void { + $squad = Squad::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'role' => SquadRole::Captain, + ]); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'role' => SquadRole::Captain, + ]); +})->throws(QueryException::class);