Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.36% covered (success)
96.36%
53 / 55
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlayPersonaTemplateService
96.36% covered (success)
96.36%
53 / 55
77.78% covered (warning)
77.78%
7 / 9
23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listVisibleTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 update
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 materialize
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 assertCanRead
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 assertCanWrite
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 assertCanWriteVisibility
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\Auth\Role;
6use App\Http\Models\Auth\User;
7use App\Http\Models\RolePlayPersonaTemplate;
8use App\Http\Models\RolePlayProjects;
9use App\Http\Repositories\RolePlayPersonaTemplateRepository;
10use Illuminate\Auth\Access\AuthorizationException;
11use Illuminate\Support\Collection;
12
13/**
14 * Business logic for managing {@see RolePlayPersonaTemplate} records.
15 *
16 * Templates encapsulate the reusable parts of a persona — Persona Details
17 * (name, type, difficulty, industries, target job titles, company sizes)
18 * and Product Details (description, key features) — so users can spin up
19 * new personas without copy-pasting between scenarios.
20 *
21 * Authorization rules:
22 *  - Personal templates: only the owner can read, update, delete.
23 *  - Company templates: any user in the same company can read and use,
24 *    but ONLY users with the {@see Role::GLOBAL_ADMIN} role can create,
25 *    update, or delete them.
26 */
27class RolePlayPersonaTemplateService
28{
29    public function __construct(
30        private readonly RolePlayPersonaTemplateRepository $repository,
31    ) {}
32
33    /**
34     * List all templates the supplied user is allowed to see.
35     *
36     * @return Collection<int, RolePlayPersonaTemplate>
37     */
38    public function listVisibleTo(User $user): Collection
39    {
40        return $this->repository->listVisibleTo((string) $user->id, $user->company_id ?? null);
41    }
42
43    /**
44     * Create a new template for the user. Validates company-visibility
45     * write permissions before persisting.
46     *
47     * @param  array<string, mixed>  $attributes  Validated request payload
48     *
49     * @throws AuthorizationException If a non-admin tries to create a company template.
50     */
51    public function create(User $user, array $attributes): RolePlayPersonaTemplate
52    {
53        $visibility = $attributes['visibility'] ?? RolePlayPersonaTemplate::VISIBILITY_PERSONAL;
54        $this->assertCanWriteVisibility($user, $visibility);
55
56        $attributes['user_id'] = (string) $user->id;
57        $attributes['company_id'] = $visibility === RolePlayPersonaTemplate::VISIBILITY_COMPANY
58            ? ($user->company_id ?? null)
59            : null;
60
61        return $this->repository->create($attributes);
62    }
63
64    /**
65     * Update an existing template, enforcing read+write authorization.
66     *
67     * @param  array<string, mixed>  $attributes
68     *
69     * @throws AuthorizationException
70     */
71    public function update(User $user, RolePlayPersonaTemplate $template, array $attributes): RolePlayPersonaTemplate
72    {
73        $this->assertCanRead($user, $template);
74        $this->assertCanWrite($user, $template);
75
76        // If the user is changing visibility, re-validate against the new value.
77        if (isset($attributes['visibility']) && $attributes['visibility'] !== $template->visibility) {
78            $this->assertCanWriteVisibility($user, $attributes['visibility']);
79            $attributes['company_id'] = $attributes['visibility'] === RolePlayPersonaTemplate::VISIBILITY_COMPANY
80                ? ($user->company_id ?? null)
81                : null;
82        }
83
84        return $this->repository->update($template, $attributes);
85    }
86
87    /**
88     * Delete a template.
89     *
90     * @throws AuthorizationException
91     */
92    public function delete(User $user, RolePlayPersonaTemplate $template): void
93    {
94        $this->assertCanRead($user, $template);
95        $this->assertCanWrite($user, $template);
96
97        $this->repository->delete($template);
98    }
99
100    /**
101     * Build a draft persona payload from a template — never persists.
102     *
103     * The frontend uses this payload to seed a new Persona form. The
104     * resulting persona will need fresh ICPs (templates intentionally
105     * don't store customer_profiles).
106     *
107     * @return array<string, mixed>
108     *
109     * @throws AuthorizationException
110     */
111    public function materialize(User $user, RolePlayPersonaTemplate $template): array
112    {
113        $this->assertCanRead($user, $template);
114
115        return [
116            'name' => $template->name,
117            'description' => $template->description,
118            'type' => $template->type,
119            'difficulty_level' => $template->difficulty_level,
120            'industry' => $template->industry ?? [],
121            'target_job_titles' => $template->target_job_titles ?? [],
122            'company_sizes' => $template->company_sizes ?? RolePlayProjects::COMPANY_SIZE_KEYS,
123            'key_features' => $template->key_features ?? [],
124            'product_description' => $template->product_description,
125            'objections' => $template->objections ?? [],
126            // Customer profiles intentionally NOT included — user regenerates fresh ICPs.
127            'customer_profiles' => [],
128            'template_id' => (string) $template->id,
129        ];
130    }
131
132    /**
133     * Authorize the actor to read a given template.
134     *
135     * @throws AuthorizationException
136     */
137    public function assertCanRead(User $user, RolePlayPersonaTemplate $template): void
138    {
139        if ($template->visibility === RolePlayPersonaTemplate::VISIBILITY_PERSONAL) {
140            if ((string) $template->user_id === (string) $user->id) {
141                return;
142            }
143            throw new AuthorizationException('You do not have access to this template.');
144        }
145
146        if ($template->visibility === RolePlayPersonaTemplate::VISIBILITY_COMPANY) {
147            if ($user->company_id && (string) $template->company_id === (string) $user->company_id) {
148                return;
149            }
150            throw new AuthorizationException('You do not have access to this company template.');
151        }
152
153        throw new AuthorizationException('Unknown template visibility.');
154    }
155
156    /**
157     * Authorize the actor to mutate a template (update/delete).
158     *
159     * @throws AuthorizationException
160     */
161    public function assertCanWrite(User $user, RolePlayPersonaTemplate $template): void
162    {
163        $this->assertCanWriteVisibility($user, $template->visibility);
164
165        // Personal templates: must be the owner.
166        if ($template->visibility === RolePlayPersonaTemplate::VISIBILITY_PERSONAL
167            && (string) $template->user_id !== (string) $user->id) {
168            throw new AuthorizationException('You can only modify your own personal templates.');
169        }
170    }
171
172    /**
173     * Guard against creating/updating a template with a visibility the
174     * user is not allowed to manage.
175     *
176     * @throws AuthorizationException
177     */
178    private function assertCanWriteVisibility(User $user, string $visibility): void
179    {
180        if ($visibility !== RolePlayPersonaTemplate::VISIBILITY_COMPANY) {
181            return;
182        }
183
184        // hasRole() throws \Error if the role row does not exist; treat
185        // that case as "not an admin" rather than letting it bubble out
186        // as a 500 — the user is not authorized either way.
187        try {
188            $isAdmin = $user->hasRole(Role::GLOBAL_ADMIN);
189        } catch (\Throwable) {
190            $isAdmin = false;
191        }
192
193        if (! $isAdmin) {
194            throw new AuthorizationException('Only company Global Admins can manage company-visibility templates.');
195        }
196    }
197}