Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
public function up(): void
{
DB::statement(
"CREATE UNIQUE INDEX squad_members_squad_id_captain_unique ON squad_members (squad_id) WHERE role = 'captain'"
);
}

public function down(): void
{
DB::statement('DROP INDEX IF EXISTS squad_members_squad_id_captain_unique');
}
};
96 changes: 96 additions & 0 deletions app-modules/squads/src/Actions/AssignCaptain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace He4rt\Squads\Actions;

use He4rt\Identity\User\Models\User;
use He4rt\Squads\Enums\MembershipAction;
use He4rt\Squads\Enums\SquadRole;
use He4rt\Squads\Exceptions\NotAnActiveSquadMember;
use He4rt\Squads\Models\Squad;
use He4rt\Squads\Models\SquadMember;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;

/**
* Records the outcome of an off-system captain election.
*
* The seat is single: assigning a new captain demotes the incumbent to
* `Member` in the same transaction, so the partial unique index
* `UNIQUE (squad_id) WHERE role = 'captain'` never sees two captains.
*
* Vacating the seat is not this Action's job — a captain who leaves becomes
* an `ExMember` (see `MarkExMember`), which frees the seat by itself.
*/
final readonly class AssignCaptain
{
public function __construct(
private RecordMembershipEvent $recordMembershipEvent,
) {}

public function handle(User $actor, Squad $squad, User $subject, ?string $reason = null): SquadMember
{
throw_unless($actor->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,
);
}
}
1 change: 1 addition & 0 deletions app-modules/squads/src/Enums/MembershipAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ enum MembershipAction: string
case Leave = 'leave';
case Promote = 'promote';
case Demote = 'demote';
case CaptainAssigned = 'captain_assigned';
}
19 changes: 19 additions & 0 deletions app-modules/squads/src/Exceptions/NotAnActiveSquadMember.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace He4rt\Squads\Exceptions;

use Exception;
use He4rt\Identity\User\Models\User;
use He4rt\Squads\Models\Squad;

final class NotAnActiveSquadMember extends Exception
{
public static function for(Squad $squad, User $subject): self
{
return new self(
sprintf('User "%s" holds no active membership in squad "%s".', $subject->id, $squad->id)
);
}
}
14 changes: 14 additions & 0 deletions app-modules/squads/src/Models/Squad.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<SquadMember, $this>
*/
public function captain(): HasOne
{
return $this->hasOne(SquadMember::class)
->where('role', SquadRole::Captain);
}

/**
* @return array<string, string>
*/
Expand Down
163 changes: 163 additions & 0 deletions app-modules/squads/tests/Feature/AssignCaptainTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

use He4rt\Identity\User\Models\User;
use He4rt\Squads\Actions\AssignCaptain;
use He4rt\Squads\Enums\MembershipAction;
use He4rt\Squads\Enums\SquadRole;
use He4rt\Squads\Exceptions\NotAnActiveSquadMember;
use He4rt\Squads\Models\Squad;
use He4rt\Squads\Models\SquadMember;
use He4rt\Squads\Models\SquadMembershipEvent;
use Illuminate\Auth\Access\AuthorizationException;

beforeEach(function (): void {
config(['he4rt.admins' => '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);
14 changes: 14 additions & 0 deletions app-modules/squads/tests/Feature/SquadMemberTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);