Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
110 changes: 110 additions & 0 deletions app-modules/squads/src/Actions/PromoteToSubCaptain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?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 He4rt\Squads\Policies\SquadPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;

/**
* Records the outcome of an off-system promotion to sub-captain, together
* with its inverse demotion back to `Member` — one governance concern in
* both directions, mirroring how `AssignCaptain` owns the captain seat.
*
* The name follows the module's action map (`CONTEXT.md`), which records
* outcomes rather than personas: this is not an action "for" a sub-captain;
* it records someone BECOMING one (or leaving that role) after a decision
* made off-platform, appending `promote`/`demote` to the audit trail.
*
* The seated captain is above sub-captain authority: only super-admins or
* the captain themselves may move that row, matching the admin-only rule
* `AssignCaptain` applies when it replaces an incumbent.
*/
final readonly class PromoteToSubCaptain
{
public function __construct(
private SquadPolicy $squadPolicy,
private RecordMembershipEvent $recordMembershipEvent,
) {}

public function handle(User $actor, Squad $squad, User $subject, ?string $reason = null): SquadMember
{
$this->squadPolicy->authorize($actor, $squad);

return $this->transition($squad, $subject, $actor, SquadRole::SubCaptain, $reason);
}

public function demote(User $actor, Squad $squad, User $subject, ?string $reason = null): SquadMember
{
$this->squadPolicy->authorize($actor, $squad);

return $this->transition($squad, $subject, $actor, SquadRole::Member, $reason);
}
Comment thread
guisaliba marked this conversation as resolved.
Comment thread
guisaliba marked this conversation as resolved.

private function transition(
Squad $squad,
User $subject,
User $actor,
SquadRole $targetRole,
?string $reason,
): SquadMember {
return DB::transaction(function () use ($squad, $subject, $actor, $targetRole, $reason): SquadMember {
$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));

$fromRole = $member->role;

// The captain seat is only moved by super-admins (via `AssignCaptain`
// or this action) or by the seated captain stepping down themselves.
// Leadership roles below the seat never outrank it, so they cannot
// record a transition over the current `Captain`.
throw_if(
$fromRole === SquadRole::Captain
&& !$actor->isAdmin()
&& !$actor->is($subject),
AuthorizationException::class,
);

if ($fromRole === $targetRole) {
return $member;
}

$member->update([
'role' => $targetRole,
]);

$this->recordMembershipEvent->handle(
squad: $squad,
subject: $subject,
action: $this->actionFor($fromRole, $targetRole),
fromRole: $fromRole,
toRole: $targetRole,
actor: $actor,
reason: $reason,
);

return $member->refresh();
});
}

private function actionFor(SquadRole $fromRole, SquadRole $targetRole): MembershipAction
{
return $fromRole === SquadRole::Member && $targetRole === SquadRole::SubCaptain
? MembershipAction::Promote
: MembershipAction::Demote;
}
}
Loading