diff --git a/app-modules/squads/CONTEXT.md b/app-modules/squads/CONTEXT.md index ac3876b0..eaacbd76 100644 --- a/app-modules/squads/CONTEXT.md +++ b/app-modules/squads/CONTEXT.md @@ -13,7 +13,7 @@ flow it actively conducts. | **Squad** | A crew with a clear objective. Lifecycle `status`: `draft` → `active` → `inactive` → `archived`. Created by a super-admin (the bottom-up proposal/validation happens off-system). | A WhatsApp group (the informal precursor) — the Squad is the formalized record | | **SquadMember** | A row in the `squad_members` pivot: one person's standing in one squad. Carries `role`, `joined_at`, `left_at`. | A community member generally — this is squad-scoped standing | | **Role (in squad)** | The `squad_members.role` enum: `Captain` · `SubCaptain` · `Member` · `ExMember`. Confers power on the platform: Captain/Sub manage their own squad. | A governance role (super-admin) — that is platform-wide, config-driven | -| **Captain / SubCaptain** | The squad's leadership. They conduct their own squad on the platform: approve/reject candidacy, promote a sub, mark an `ExMember`. | The Head dos Squads (an off-system human role; in software it is the super-admin) | +| **Captain / SubCaptain** | The squad's leadership. Both can use general squad-management capabilities; only the Captain or a super-admin can promote or demote a SubCaptain. | The Head dos Squads (an off-system human role; in software it is the super-admin) | | **ExMember** | A person who left (or was removed from) a squad. A role value, not a deletion. Does **not** count toward exclusivity. `left_at` dates the exit. | A `Member` on leave — there is no "paused membership" state | | **Application (candidatura)** | An APTO person's request to join a squad (`squad_applications`, `pending`/`approved`/`rejected`). The captain decides; approval creates the membership in a transaction. | An onboarding (community/program entry) — that is upstream, in `onboarding` | | **Exclusivity** | A person may hold at most one _active_ membership (role in `Captain`/`SubCaptain`/`Member`) across all squads. Enforced on join. | A hard unique DB constraint — `ExMember` rows are allowed to pile up | @@ -21,14 +21,28 @@ flow it actively conducts. | **Super-admin** | Platform governance authority, sourced from `config('he4rt.admins')` via `User::isAdmin()`. Creates squads, sets captains, overrides any squad action. Stands in for the "Head/Gestão" roles. | A Captain — a Captain's power is scoped to their own squad | | **APTO** | The gate this module consumes from `onboarding`: the person completed the `Squads` onboarding. Required to apply or to be in a squad. | An `active` Squad — APTO is about a person, not a squad | +### Leadership capabilities + +| Capability | Captain | SubCaptain | Super-admin | +| ----------------------------------------- | -------------- | -------------- | ----------- | +| Promote `Member` -> `SubCaptain` | Yes, own squad | No | Yes | +| Demote `SubCaptain` -> `Member` | Yes, own squad | No | Yes | +| General `SquadPolicy::canManage()` action | Yes, own squad | Yes, own squad | Yes | +| Assign or replace `Captain` | No | No | Yes | + +More than one `SubCaptain` is allowed. Neither the product requirements nor the schema define a +single-SubCaptain invariant. A vacancy remains the absence of a `Captain` row. `MarkExMember` is the +current implemented path that can vacate the seat, while `PromoteToSubCaptain` never changes a +`Captain` row. + ## What this module records vs. conducts | Flow (P.O. doc) | In this module (model B — record-keeping) | | --------------------------- | -------------------------------------------------------------------------------------------------------------- | | Candidacy to existing squad | **Conducted**: application → captain decides → membership created (exclusivity checked). | | Squad creation (bottom-up) | **Recorded**: super-admin registers the squad (draft→active) with its captain. Proposal/validation off-system. | -| Captain election | **Recorded**: runs off-system; the outcome is registered (set captain/sub via promote). | -| Captain exit | **Recorded**: mark `ExMember` / promote the sub (sub assumes) or leave the seat vacant. | +| Captain election | **Recorded**: runs off-system; the outcome is registered through `AssignCaptain`. | +| Captain exit | **Recorded**: `MarkExMember` vacates the seat; `AssignCaptain` can record a later replacement. | | Captain removal | **Recorded**: runs off-system (moderator→management→Head); outcome registered (mark ExMember). | | Leadership reallocation | **Recorded**: super-admin moves a leader to another squad. | diff --git a/app-modules/squads/src/Actions/PromoteToSubCaptain.php b/app-modules/squads/src/Actions/PromoteToSubCaptain.php new file mode 100644 index 00000000..8227a1c8 --- /dev/null +++ b/app-modules/squads/src/Actions/PromoteToSubCaptain.php @@ -0,0 +1,117 @@ + SubCaptain result and its inverse, + * SubCaptain -> Member, in the membership ledger. + * + * This action never mutates the captain seat. `AssignCaptain` owns captain + * assignment and replacement, while `MarkExMember` currently owns vacancy. + * The governance decision remains off-platform; this action records its result. + */ +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->authorizeSubCaptainManagement($actor, $squad); + + return $this->transition( + squad: $squad, + subject: $subject, + actor: $actor, + expectedRole: SquadRole::Member, + targetRole: SquadRole::SubCaptain, + action: MembershipAction::Promote, + reason: $reason, + ); + } + + public function demote(User $actor, Squad $squad, User $subject, ?string $reason = null): SquadMember + { + $this->squadPolicy->authorizeSubCaptainManagement($actor, $squad); + + return $this->transition( + squad: $squad, + subject: $subject, + actor: $actor, + expectedRole: SquadRole::SubCaptain, + targetRole: SquadRole::Member, + action: MembershipAction::Demote, + reason: $reason, + ); + } + + private function transition( + Squad $squad, + User $subject, + User $actor, + SquadRole $expectedRole, + SquadRole $targetRole, + MembershipAction $action, + ?string $reason, + ): SquadMember { + return DB::transaction(function () use ( + $squad, + $subject, + $actor, + $expectedRole, + $targetRole, + $action, + $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; + + if ($fromRole === $targetRole) { + return $member; + } + + throw_if( + $fromRole !== $expectedRole, + InvalidSquadRoleTransition::between($fromRole, $targetRole) + ); + + $member->update([ + 'role' => $targetRole, + ]); + + $this->recordMembershipEvent->handle( + squad: $squad, + subject: $subject, + action: $action, + fromRole: $fromRole, + toRole: $targetRole, + actor: $actor, + reason: $reason, + ); + + return $member->refresh(); + }); + } +} diff --git a/app-modules/squads/src/Exceptions/InvalidSquadRoleTransition.php b/app-modules/squads/src/Exceptions/InvalidSquadRoleTransition.php new file mode 100644 index 00000000..fda77f62 --- /dev/null +++ b/app-modules/squads/src/Exceptions/InvalidSquadRoleTransition.php @@ -0,0 +1,18 @@ +value, $to->value) + ); + } +} diff --git a/app-modules/squads/src/Policies/SquadPolicy.php b/app-modules/squads/src/Policies/SquadPolicy.php index 44c18d61..aab37f13 100644 --- a/app-modules/squads/src/Policies/SquadPolicy.php +++ b/app-modules/squads/src/Policies/SquadPolicy.php @@ -29,4 +29,22 @@ public function authorize(User $actor, Squad $squad): void { throw_unless($this->canManage($actor, $squad), AuthorizationException::class); } + + public function canManageSubCaptains(User $actor, Squad $squad): bool + { + if ($actor->isAdmin()) { + return true; + } + + return SquadMember::query() + ->where('squad_id', $squad->id) + ->where('user_id', $actor->id) + ->where('role', SquadRole::Captain) + ->exists(); + } + + public function authorizeSubCaptainManagement(User $actor, Squad $squad): void + { + throw_unless($this->canManageSubCaptains($actor, $squad), AuthorizationException::class); + } } diff --git a/app-modules/squads/tests/Feature/PromoteToSubCaptainTest.php b/app-modules/squads/tests/Feature/PromoteToSubCaptainTest.php new file mode 100644 index 00000000..7f28386c --- /dev/null +++ b/app-modules/squads/tests/Feature/PromoteToSubCaptainTest.php @@ -0,0 +1,375 @@ + 'guisaliba']); + + $this->admin = User::factory()->create([ + 'username' => 'guisaliba', + ]); +}); + +test('a captain promotes a member to sub-captain and records the transition', function (): void { + $squad = Squad::factory()->create(); + $captain = User::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::Member, + ]); + + $member = resolve(PromoteToSubCaptain::class)->handle( + actor: $captain, + squad: $squad, + subject: $subject, + reason: 'Elected off-system.', + ); + + expect($member->role)->toBe(SquadRole::SubCaptain); + + $this->assertDatabaseHas('squad_membership_events', [ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'actor_id' => $captain->id, + 'action' => MembershipAction::Promote->value, + 'from_role' => SquadRole::Member->value, + 'to_role' => SquadRole::SubCaptain->value, + 'reason' => 'Elected off-system.', + ]); +}); + +test('a super-admin promotes a member without belonging to the squad', function (): void { + $squad = Squad::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::Member, + ]); + + $member = resolve(PromoteToSubCaptain::class)->handle( + actor: $this->admin, + squad: $squad, + subject: $subject, + ); + + expect($member->role)->toBe(SquadRole::SubCaptain); + + $this->assertDatabaseHas('squad_membership_events', [ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'actor_id' => $this->admin->id, + 'action' => MembershipAction::Promote->value, + 'from_role' => SquadRole::Member->value, + 'to_role' => SquadRole::SubCaptain->value, + ]); +}); + +test('a captain demotes a sub-captain to member and records the transition', function (): void { + $squad = Squad::factory()->create(); + $captain = User::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::SubCaptain, + ]); + + $member = resolve(PromoteToSubCaptain::class)->demote( + actor: $captain, + squad: $squad, + subject: $subject, + ); + + expect($member->role)->toBe(SquadRole::Member); + + $this->assertDatabaseHas('squad_membership_events', [ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'actor_id' => $captain->id, + 'action' => MembershipAction::Demote->value, + 'from_role' => SquadRole::SubCaptain->value, + 'to_role' => SquadRole::Member->value, + ]); +}); + +test('a super-admin demotes a sub-captain without belonging to the squad', function (): void { + $squad = Squad::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::SubCaptain, + ]); + + $member = resolve(PromoteToSubCaptain::class)->demote( + actor: $this->admin, + squad: $squad, + subject: $subject, + ); + + expect($member->role)->toBe(SquadRole::Member); + + $this->assertDatabaseHas('squad_membership_events', [ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'actor_id' => $this->admin->id, + 'action' => MembershipAction::Demote->value, + 'from_role' => SquadRole::SubCaptain->value, + 'to_role' => SquadRole::Member->value, + ]); +}); + +test('a captain cannot become a sub-captain through this action', function (): void { + $squad = Squad::factory()->create(); + $captain = User::factory()->create(); + + $membership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + + expect(fn () => resolve(PromoteToSubCaptain::class)->handle( + actor: $this->admin, + squad: $squad, + subject: $captain, + ))->toThrow(InvalidSquadRoleTransition::class) + ->and($membership->refresh()->role)->toBe(SquadRole::Captain) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +}); + +test('a captain cannot become a member through this action', function (): void { + $squad = Squad::factory()->create(); + $captain = User::factory()->create(); + + $membership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + + expect(fn () => resolve(PromoteToSubCaptain::class)->demote( + actor: $this->admin, + squad: $squad, + subject: $captain, + ))->toThrow(InvalidSquadRoleTransition::class) + ->and($membership->refresh()->role)->toBe(SquadRole::Captain) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +}); + +test('a sub-captain cannot promote a member in their own squad', function (): void { + $squad = Squad::factory()->create(); + $subCaptain = User::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subCaptain->id, + 'role' => SquadRole::SubCaptain, + ]); + $membership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::Member, + ]); + + expect(fn () => resolve(PromoteToSubCaptain::class)->handle( + actor: $subCaptain, + squad: $squad, + subject: $subject, + ))->toThrow(AuthorizationException::class) + ->and($membership->refresh()->role)->toBe(SquadRole::Member) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +}); + +test('a common member cannot change a squad role', function (): void { + $squad = Squad::factory()->create(); + $actor = User::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $actor->id, + 'role' => SquadRole::Member, + ]); + $membership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::Member, + ]); + + expect(fn () => resolve(PromoteToSubCaptain::class)->handle( + actor: $actor, + squad: $squad, + subject: $subject, + ))->toThrow(AuthorizationException::class) + ->and($membership->refresh()->role)->toBe(SquadRole::Member) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +}); + +test('a leader cannot change a different squad', function (): void { + $ownSquad = Squad::factory()->create(); + $otherSquad = Squad::factory()->create(); + $captain = User::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $ownSquad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + $membership = SquadMember::factory()->create([ + 'squad_id' => $otherSquad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::Member, + ]); + + expect(fn () => resolve(PromoteToSubCaptain::class)->handle( + actor: $captain, + squad: $otherSquad, + subject: $subject, + ))->toThrow(AuthorizationException::class) + ->and($membership->refresh()->role)->toBe(SquadRole::Member) + ->and(SquadMembershipEvent::query()->where('squad_id', $otherSquad->id)->count())->toBe(0); +}); + +test('a missing active membership cannot be changed with :method', function (string $method): void { + $squad = Squad::factory()->create(); + $subject = User::factory()->create(); + + expect(fn () => resolve(PromoteToSubCaptain::class)->{$method}( + actor: $this->admin, + squad: $squad, + subject: $subject, + ))->toThrow(NotAnActiveSquadMember::class) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +})->with(['handle', 'demote']); + +test('an ex-member cannot be changed with :method', function (string $method): void { + $squad = Squad::factory()->create(); + $subject = User::factory()->create(); + + $membership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::ExMember, + ]); + + expect(fn () => resolve(PromoteToSubCaptain::class)->{$method}( + actor: $this->admin, + squad: $squad, + subject: $subject, + ))->toThrow(NotAnActiveSquadMember::class) + ->and($membership->refresh()->role)->toBe(SquadRole::ExMember) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +})->with(['handle', 'demote']); + +test('same-state role changes create no event', function (): void { + $squad = Squad::factory()->create(); + $captain = User::factory()->create(); + $subCaptain = User::factory()->create(); + $member = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + $subCaptainMembership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subCaptain->id, + 'role' => SquadRole::SubCaptain, + ]); + $memberMembership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $member->id, + 'role' => SquadRole::Member, + ]); + + resolve(PromoteToSubCaptain::class)->handle( + actor: $captain, + squad: $squad, + subject: $subCaptain, + ); + resolve(PromoteToSubCaptain::class)->demote( + actor: $captain, + squad: $squad, + subject: $member, + ); + + expect($subCaptainMembership->refresh()->role)->toBe(SquadRole::SubCaptain) + ->and($memberMembership->refresh()->role)->toBe(SquadRole::Member) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +}); + +test('a sub-captain cannot demote another sub-captain', function (): void { + $squad = Squad::factory()->create(); + $subCaptain = User::factory()->create(); + $subject = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subCaptain->id, + 'role' => SquadRole::SubCaptain, + ]); + $membership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subject->id, + 'role' => SquadRole::SubCaptain, + ]); + + expect(fn () => resolve(PromoteToSubCaptain::class)->demote( + actor: $subCaptain, + squad: $squad, + subject: $subject, + ))->toThrow(AuthorizationException::class) + ->and($membership->refresh()->role)->toBe(SquadRole::SubCaptain) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +}); + +test('a sub-captain cannot demote themselves', function (): void { + $squad = Squad::factory()->create(); + $subCaptain = User::factory()->create(); + + $membership = SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subCaptain->id, + 'role' => SquadRole::SubCaptain, + ]); + + expect(fn () => resolve(PromoteToSubCaptain::class)->demote( + actor: $subCaptain, + squad: $squad, + subject: $subCaptain, + ))->toThrow(AuthorizationException::class) + ->and($membership->refresh()->role)->toBe(SquadRole::SubCaptain) + ->and(SquadMembershipEvent::query()->where('squad_id', $squad->id)->count())->toBe(0); +}); diff --git a/app-modules/squads/tests/Feature/SquadPolicyTest.php b/app-modules/squads/tests/Feature/SquadPolicyTest.php index fc2f7e26..a140f19b 100644 --- a/app-modules/squads/tests/Feature/SquadPolicyTest.php +++ b/app-modules/squads/tests/Feature/SquadPolicyTest.php @@ -94,3 +94,67 @@ $policy->authorize($member, $squad); })->throws(AuthorizationException::class); }); + +describe('SquadPolicy sub-captain management', static function (): void { + test('a captain can manage sub-captains in their own squad', function (): void { + $squad = Squad::factory()->create(); + $captain = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + + expect(resolve(SquadPolicy::class)->canManageSubCaptains($captain, $squad))->toBeTrue(); + }); + + test('a super-admin can manage sub-captains in any squad', function (): void { + config(['he4rt.admins' => 'guisaliba']); + + $squad = Squad::factory()->create(); + $admin = User::factory()->create(['username' => 'guisaliba']); + + expect(resolve(SquadPolicy::class)->canManageSubCaptains($admin, $squad))->toBeTrue(); + }); + + test('a sub-captain cannot manage sub-captains', function (): void { + $squad = Squad::factory()->create(); + $subCaptain = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $subCaptain->id, + 'role' => SquadRole::SubCaptain, + ]); + + expect(resolve(SquadPolicy::class)->canManageSubCaptains($subCaptain, $squad))->toBeFalse(); + }); + + test('a common member cannot manage sub-captains', function (): void { + $squad = Squad::factory()->create(); + $member = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $squad->id, + 'user_id' => $member->id, + 'role' => SquadRole::Member, + ]); + + expect(resolve(SquadPolicy::class)->canManageSubCaptains($member, $squad))->toBeFalse(); + }); + + test('a captain cannot manage sub-captains in another squad', function (): void { + $ownSquad = Squad::factory()->create(); + $otherSquad = Squad::factory()->create(); + $captain = User::factory()->create(); + + SquadMember::factory()->create([ + 'squad_id' => $ownSquad->id, + 'user_id' => $captain->id, + 'role' => SquadRole::Captain, + ]); + + expect(resolve(SquadPolicy::class)->canManageSubCaptains($captain, $otherSquad))->toBeFalse(); + }); +});