Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
CorporatePersonaEditAuthorizationService
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
3 / 3
13
100.00% covered (success)
100.00%
1 / 1
 canEdit
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 managedGroupIds
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 safeHasRole
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\Auth\Role;
6use App\Http\Models\Auth\User;
7use App\Http\Models\CompanyRolePlayProject;
8
9/**
10 * Authorization rule for "may this user edit this corporate persona?".
11 *
12 * The end-user roleplay app needs to surface an Edit button that deep-links
13 * back to admin-fe — but only for people who actually have edit rights on
14 * the persona. Computing that on the BE keeps the rule in one place and
15 * avoids exposing per-group admin membership data to the client.
16 *
17 * Rule (matches the product spec):
18 *  - Vengreso Admin **of the persona's company** → editable
19 *    (Vengreso staff is treated as Global Admin of its own company)
20 *  - Global Admin **of the persona's company** → editable
21 *  - Group Admin → editable iff
22 *      • the persona is in the same company as the user, AND
23 *      • `assigned_groups` has **exactly one** group, AND
24 *      • that one group is in the user's managed groups
25 *      (`sales_pro_team_manager.groups`)
26 *  - Otherwise → not editable
27 *
28 * Notes:
29 *  - "Company-wide" personas (empty `assigned_groups`) are only editable by
30 *    Vengreso / Global Admin — Group Admins do not qualify.
31 *  - `assigned_users` does not grant edit rights; the rule is group-based.
32 *  - Masquerade is not handled here; the caller passes whichever User
33 *    represents the current acting identity, which is the masqueraded user
34 *    when masquerade is active.
35 */
36class CorporatePersonaEditAuthorizationService
37{
38    public function canEdit(?User $user, CompanyRolePlayProject $project): bool
39    {
40        if (! $user) {
41            return false;
42        }
43
44        // Cross-company users never edit. The end-user companyProjects scope
45        // already enforces this, but we re-check defensively so a misuse on
46        // some future caller does not silently grant rights.
47        if ((string) $user->company_id !== (string) $project->company_id) {
48            return false;
49        }
50
51        if ($this->safeHasRole($user, Role::VENGRESO_ADMIN) || $this->safeHasRole($user, Role::GLOBAL_ADMIN)) {
52            return true;
53        }
54
55        if (! $this->safeHasRole($user, Role::GROUP_ADMIN)) {
56            return false;
57        }
58
59        $assignedGroupIds = array_values(array_filter(
60            (array) ($project->assigned_groups ?? []),
61            fn ($id) => $id !== null && $id !== ''
62        ));
63
64        // Group Admin only qualifies for single-group assignment.
65        if (count($assignedGroupIds) !== 1) {
66            return false;
67        }
68
69        $managedGroupIds = $this->managedGroupIds($user);
70        if (empty($managedGroupIds)) {
71            return false;
72        }
73
74        return in_array((string) $assignedGroupIds[0], $managedGroupIds, true);
75    }
76
77    /**
78     * Collect the IDs of every CompanyGroup the user manages as Group Admin.
79     *
80     * Backed by `SalesProTeamManager.groups` (a many-to-many link populated
81     * when a user is assigned the Group Admin role). Falls back to an empty
82     * array when the user has never been wired up as a Group Admin manager.
83     *
84     * @return array<int, string>
85     */
86    private function managedGroupIds(User $user): array
87    {
88        // `User::sales_pro_team_manager()` is a hasOne relation; access via
89        // getRelationValue() so PHPStan can resolve the return type without
90        // a property annotation on the User model.
91        $manager = $user->getRelationValue('sales_pro_team_manager');
92        if (! $manager) {
93            return [];
94        }
95        return $manager->groups()
96            ->pluck('_id')
97            ->map(fn ($id) => (string) $id)
98            ->all();
99    }
100
101    /**
102     * {@see CustomHasRoles::hasRole()} throws "Unsupported role." when the
103     * Role row hasn't been seeded yet (fresh test DB, partial deploy, etc.)
104     * — so we treat that as "the user does not hold the role" rather than
105     * letting the request 500. Matches the pattern already used in
106     * {@see \App\Http\Services\RolePlayPersonaTemplateService::assertCanWriteVisibility()}.
107     */
108    private function safeHasRole(User $user, string $role): bool
109    {
110        try {
111            return (bool) $user->hasRole($role);
112        } catch (\Throwable) {
113            return false;
114        }
115    }
116}