Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.19% covered (warning)
86.19%
206 / 239
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlayPersonasController
86.19% covered (warning)
86.19%
206 / 239
66.67% covered (warning)
66.67%
10 / 15
33.53
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
 index
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 userState
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 store
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
4
 destroy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generate_icps
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 regenerate_icps
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 autoPopulatePersonaDetails
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 autoPopulateProductDetails
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 autoPopulateIcps
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 companyProjects
87.10% covered (warning)
87.10%
27 / 31
0.00% covered (danger)
0.00%
0 / 1
5.05
 cloneCompanyProject
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
4
 backfillVoices
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
1<?php
2
3namespace App\Http\Controllers\v2\RolePlay;
4
5use App\Http\Controllers\Controller;
6use App\Http\Models\Admin\CompanyGroup;
7use App\Http\Models\CompanyRolePlayProject;
8use App\Http\Models\Parameter;
9use App\Http\Models\RolePlayConversations;
10use App\Http\Models\RolePlayProjects;
11use App\Http\Repositories\interfaces\IRolePlayProjectsRepository;
12use App\Http\Requests\v2\RolePlay\AutoPopulateIcpsRequest;
13use App\Http\Requests\v2\RolePlay\AutoPopulatePersonaDetailsRequest;
14use App\Http\Requests\v2\RolePlay\AutoPopulateProductDetailsRequest;
15use App\Http\Requests\v2\RolePlay\DestroyRolePlayProjectRequest;
16use App\Http\Requests\v2\RolePlay\GenerateICPRolePlayProjectRequest;
17use App\Http\Requests\v2\RolePlay\GetRolePlayProjectRequest;
18use App\Http\Requests\v2\RolePlay\IndexRolePlayPersonasRequest;
19use App\Http\Requests\v2\RolePlay\RegenerateICPRolePlayProjectRequest;
20use App\Http\Requests\v2\RolePlay\StoreRolePlayProjectRequest;
21use App\Http\Requests\v2\RolePlay\UpdateRolePlayProjectRequest;
22use App\Http\Resources\v2\CompanyRolePlayProjectResource;
23use App\Http\Services\CompanyRolePlaySessionService;
24use App\Http\Services\RolePlay\PersonaFieldLockingService;
25use App\Http\Services\RolePlay\RolePlayPersonaDeletionService;
26use App\Http\Services\RolePlayAutoPopulateService;
27use App\Traits\SubscriptionTrait;
28use Illuminate\Http\JsonResponse;
29use Illuminate\Http\Request;
30
31/**
32 * RolePlay Personas Controller
33 *
34 * CRUD + AI-assisted authoring for the caller's RolePlay personas (aka
35 * projects). Surfaces auto-populate endpoints that fill persona details,
36 * product details, and ICP lists via LLM, and honours the persona field-
37 * locking rules that freeze critical fields once a persona has sessions.
38 * Owner-only on user-scoped routes; a separate endpoint pair handles the
39 * company-project listing and clone-into-user-project flow used on app boot.
40 */
41class RolePlayPersonasController extends Controller
42{
43    use SubscriptionTrait;
44
45    public function __construct(
46        private readonly RolePlayAutoPopulateService $autoPopulate,
47        private readonly RolePlayPersonaDeletionService $personaDeletion,
48        private readonly PersonaFieldLockingService $fieldLocking,
49        private readonly IRolePlayProjectsRepository $projectsRepository,
50    ) {}
51
52    /**
53     * List the caller's RolePlay personas (projects), with last-practiced
54     * timestamp and session count annotated per persona.
55     *
56     * Supports filtering by `type` (cold-call | discovery-call) and by
57     * `lastPracticed` (days since last session). Soft-deleted ICPs are
58     * hidden unless `include_deleted=true` is passed in the query string.
59     *
60     * @response 200 {"result": {"status": "success", "data": [{"id": "...", "name": "...", "sessions_count": 3, "last_practiced_at": 1700000000}]}}
61     */
62    public function index(IndexRolePlayPersonasRequest $request): JsonResponse
63    {
64        $user = $request->user();
65        $validated = $request->validated();
66
67        $projects = $this->projectsRepository->listForUser($user, [
68            'type' => $validated['type'] ?? null,
69            'last_practiced_days' => $validated['lastPracticed'] ?? null,
70        ]);
71
72        $includeDeleted = filter_var($request->query('include_deleted', false), FILTER_VALIDATE_BOOLEAN);
73
74        $projects->transform(function ($project) use ($includeDeleted) {
75            $lastConversation = $project->conversations()->latest()->first();
76            $project->last_practiced_at = $lastConversation ? $lastConversation->created_at : null;
77            $project->sessions_count = $project->conversations()->count();
78
79            // Filter soft-deleted ICPs from response
80            $profiles = $project->customer_profiles ?? [];
81            $project->customer_profiles = $this->fieldLocking->filterDeletedIcps($profiles, $includeDeleted);
82
83            return $project;
84        });
85
86        return response()->json([
87            'status' => 'success',
88            'data' => $projects,
89        ]);
90    }
91
92    /**
93     * Lightweight onboarding signal: reports whether the caller has ever
94     * authored a persona (active or soft-deleted) and whether any roleplay
95     * session has been recorded against their account. Used by the frontend
96     * to decide whether to show the "brand new user" banner and which guided
97     * tour to auto-start on the dashboard and persona list.
98     *
99     * The persona check intentionally includes soft-deleted rows so a user
100     * who created and then deleted every persona is still treated as
101     * "returning" â€” we assume they've already seen the blank dashboard.
102     *
103     * @response 200 {"result": {"status": "success", "data": {"has_any_persona": false, "has_any_session": false}}}
104     */
105    public function userState(Request $request): JsonResponse
106    {
107        $userId = $request->user()->id;
108
109        $hasAnyPersona = RolePlayProjects::withTrashed()
110            ->where('user_id', $userId)
111            ->exists();
112
113        $hasAnySession = RolePlayConversations::where('user_id', $userId)->exists();
114
115        return response()->json([
116            'status' => 'success',
117            'data' => [
118                'has_any_persona' => $hasAnyPersona,
119                'has_any_session' => $hasAnySession,
120            ],
121        ]);
122    }
123
124    /**
125     * Return a single persona (owned by the caller) with conversations,
126     * owner user, and per-ICP session counts / deletion state annotated.
127     *
128     * Ownership is enforced by {@see GetRolePlayProjectRequest}.
129     *
130     * @param  RolePlayProjects  $persona  Route-model-bound persona.
131     *
132     * @response 200 {"result": {"status": "success", "data": {"id": "...", "customer_profiles": [{"id": 1, "sessions_count": 2}]}}}
133     */
134    public function get(GetRolePlayProjectRequest $request, RolePlayProjects $persona): JsonResponse
135    {
136        $persona->load('conversations');
137        $persona->load('user');
138
139        $includeDeleted = filter_var($request->query('include_deleted', false), FILTER_VALIDATE_BOOLEAN);
140
141        // Annotate project-level has_sessions
142        $persona->sessions_count = $persona->conversations()->count();
143
144        // Annotate per-ICP has_sessions + sessions_count
145        $icpSessionCounts = $this->fieldLocking->getIcpSessionCounts($persona->id);
146        $profiles = $persona->customer_profiles ?? [];
147        $profiles = $this->fieldLocking->annotateIcpsWithSessions($profiles, $icpSessionCounts);
148        $profiles = $this->fieldLocking->filterDeletedIcps($profiles, $includeDeleted);
149        $persona->customer_profiles = $profiles;
150
151        return response()->json([
152            'status' => 'success',
153            'data' => $persona,
154        ]);
155    }
156
157    /**
158     * Create a new persona (RolePlayProjects) owned by the caller.
159     *
160     * Voices missing on inbound ICPs are backfilled from the global config
161     * (see `backfillVoices`). A signature over persona + product inputs is
162     * stored so the frontend can detect future drift that would require an
163     * ICP regeneration.
164     *
165     * @response 201 {"result": {"status": "success", "data": {"id": "...", "name": "...", "customer_profiles_signature": "sha256:..."}}}
166     */
167    public function store(StoreRolePlayProjectRequest $request): JsonResponse
168    {
169        $user = $request->user();
170        $validated = $request->validated();
171
172        $data = array_merge($validated, ['user_id' => $user->id]);
173        $data['customer_profiles'] = $this->backfillVoices($data['customer_profiles'] ?? []);
174        // Stamp signature so the FE can detect future drift on the persona+product fields.
175        $data['customer_profiles_signature'] = $this->autoPopulate->computeIcpInputsSignature($data);
176
177        $project = RolePlayProjects::create($data);
178
179        return response()->json([
180            'status' => 'success',
181            'data' => $project,
182        ], 201);
183    }
184
185    /**
186     * Update a persona owned by the caller.
187     *
188     * If the persona has any existing sessions, locked fields (enforced by
189     * {@see PersonaFieldLockingService}) cannot be modified â€” the endpoint
190     * returns 422 with the offending field list. Locked ICPs cannot be
191     * removed either; the service must be used to manage locks.
192     *
193     * @param  RolePlayProjects  $persona  Route-model-bound persona.
194     *
195     * @response 200 {"result": {"status": "success", "data": {"id": "...", ...}}}
196     * @response 422 {"result": {"error": "Cannot modify locked fields on a persona with existing sessions.", "locked_fields": ["type"]}}
197     */
198    public function update(UpdateRolePlayProjectRequest $request, RolePlayProjects $persona): JsonResponse
199    {
200        $validated = $request->validated();
201
202        // --- Field locking: reject changes to locked fields when sessions exist ---
203        $hasConversations = $this->fieldLocking->hasConversations($persona->id);
204
205        if ($hasConversations) {
206            $changedLockedFields = $this->fieldLocking->getChangedLockedFields($persona, $validated);
207
208            if (! empty($changedLockedFields)) {
209                return response()->json([
210                    'error' => 'Cannot modify locked fields on a persona with existing sessions.',
211                    'locked_fields' => $changedLockedFields,
212                ], 422);
213            }
214        }
215
216        // --- ICP soft-delete and modification validation ---
217        $icpIdsWithSessions = $this->fieldLocking->getIcpIdsWithSessions($persona->id);
218        $existingProfiles = $persona->customer_profiles ?? [];
219        $incomingProfiles = $validated['customer_profiles'] ?? [];
220
221        $icpResult = $this->fieldLocking->processIcpUpdates(
222            $existingProfiles,
223            $incomingProfiles,
224            $icpIdsWithSessions
225        );
226
227        if ($icpResult['error']) {
228            return response()->json([
229                'error' => $icpResult['error'],
230            ], $icpResult['status']);
231        }
232
233        $validated['customer_profiles'] = $this->backfillVoices($icpResult['customer_profiles']);
234        $validated['customer_profiles_signature'] = $this->autoPopulate->computeIcpInputsSignature($validated);
235
236        $persona->update($validated);
237
238        // Return response with soft-deleted ICPs filtered out
239        $fresh = $persona->fresh();
240        $fresh->customer_profiles = $this->fieldLocking->filterDeletedIcps($fresh->customer_profiles ?? []);
241
242        return response()->json([
243            'status' => 'success',
244            'data' => $fresh,
245        ]);
246    }
247
248    /**
249     * Delete a persona and cascade to its sessions and progression.
250     *
251     * Soft-deletes the persona and all of its conversations so that
252     * historical daily usage reporting remains intact, then recomputes
253     * the owning user's roleplay progression from the surviving entries.
254     *
255     * Authorization cascade: owner -> company Global Admin -> Vengreso Admin.
256     *
257     * @response 200 {"status": "success"}
258     * @response 403 {"status": "error"}
259     */
260    public function destroy(DestroyRolePlayProjectRequest $request, RolePlayProjects $persona): JsonResponse
261    {
262        $this->personaDeletion->delete($request->user(), $persona);
263
264        return response()->json([
265            'status' => 'success',
266        ]);
267    }
268
269    /**
270     * Generate a fresh ICP set for the persona being built.
271     *
272     * Honors call type, difficulty, target industries, product description,
273     * key features, AND the persona's selected `company_sizes` so that the
274     * generated profiles match the user's targeting.
275     *
276     * @response 200 {"status": "success", "data": [{"id": 1, "name": "...", "company_size": "Small (10-99 employees)", ...}]}
277     */
278    public function generate_icps(GenerateICPRolePlayProjectRequest $request): JsonResponse
279    {
280        $user = $request->user();
281        $validated = $request->validated();
282
283        $icps = $this->autoPopulate->generateIcps([
284            'type' => $validated['type'],
285            'difficulty_level' => $validated['difficulty_level'],
286            'industry' => $validated['industry'],
287            'product_description' => $validated['product_description'],
288            'key_features' => $validated['key_features'] ?? [],
289            'company_sizes' => $validated['company_sizes'],
290        ], $user);
291
292        return response()->json([
293            'status' => 'success',
294            'data' => $icps,
295        ]);
296    }
297
298    /**
299     * Regenerate a single ICP, preserving identity fields (name, gender,
300     * image, company, industry, target job title, personality).
301     *
302     * @response 200 {"status": "success", "data": {"id": 1, "name": "...", ...}}
303     */
304    public function regenerate_icps(RegenerateICPRolePlayProjectRequest $request): JsonResponse
305    {
306        $user = $request->user();
307        $validated = $request->validated();
308
309        $icp = $this->autoPopulate->regenerateIcp(
310            preserve: [
311                'id' => $validated['id'] ?? null,
312                'name' => $validated['name'],
313                'gender' => $validated['gender'],
314                'image' => $validated['image'] ?? '',
315                'personality' => $validated['personality'] ?? null,
316                'company_name' => $validated['company_name'],
317                'company_size' => $validated['company_size'],
318                'industry' => $validated['icp_industry'],
319                'target_job_title' => $validated['target_job_title'],
320                'budget' => $validated['budget'] ?? null,
321            ],
322            persona: [
323                'type' => $validated['type'],
324                'difficulty_level' => $validated['difficulty_level'],
325                'industry' => $validated['industry'],
326                'product_description' => $validated['product_description'],
327                'key_features' => $validated['key_features'] ?? [],
328            ],
329            user: $user,
330        );
331
332        return response()->json([
333            'status' => 'success',
334            'data' => $icp,
335        ]);
336    }
337
338    /**
339     * Auto-populate the Persona Details section from a website URL.
340     *
341     * Returns name, type, difficulty_level, industry, target_job_titles,
342     * and company_sizes derived from the website. The frontend patches the
343     * Persona Details tab without touching Product Details or ICPs.
344     *
345     * @response 200 {"status": "success", "data": {"name": "...", "type": "cold-call", "company_sizes": ["small","medium"], ...}}
346     */
347    public function autoPopulatePersonaDetails(AutoPopulatePersonaDetailsRequest $request): JsonResponse
348    {
349        $user = $request->user();
350        $validated = $request->validated();
351
352        $data = $this->autoPopulate->generatePersonaDetails($validated['website_url'], $user);
353
354        return response()->json([
355            'status' => 'success',
356            'data' => $data,
357        ]);
358    }
359
360    /**
361     * Auto-populate the Product Details section from a website URL.
362     *
363     * Returns description and key_features derived from the website.
364     *
365     * @response 200 {"status": "success", "data": {"description": "...", "key_features": ["..."]}}
366     */
367    public function autoPopulateProductDetails(AutoPopulateProductDetailsRequest $request): JsonResponse
368    {
369        $user = $request->user();
370        $validated = $request->validated();
371
372        $data = $this->autoPopulate->generateProductDetails($validated['website_url'], $user);
373
374        return response()->json([
375            'status' => 'success',
376            'data' => $data,
377        ]);
378    }
379
380    /**
381     * Auto-populate the Customer Profiles (ICPs) section.
382     *
383     * Behaves identically to `generate_icps` but exists as a distinct
384     * endpoint to make the section-scoped intent explicit on the FE.
385     *
386     * @response 200 {"status": "success", "data": [{"id": 1, "name": "...", ...}]}
387     */
388    public function autoPopulateIcps(AutoPopulateIcpsRequest $request): JsonResponse
389    {
390        $user = $request->user();
391        $validated = $request->validated();
392
393        $icps = $this->autoPopulate->generateIcps([
394            'type' => $validated['type'],
395            'difficulty_level' => $validated['difficulty_level'],
396            'industry' => $validated['industry'],
397            'product_description' => $validated['product_description'],
398            'key_features' => $validated['key_features'] ?? [],
399            'company_sizes' => $validated['company_sizes'],
400        ], $user);
401
402        return response()->json([
403            'status' => 'success',
404            'data' => $icps,
405        ]);
406    }
407
408    /**
409     * List company roleplay projects visible to the current user.
410     *
411     * Returns active company projects that are either assigned to
412     * the user's group (or parent group) or have no group restrictions.
413     *
414     * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection|\Illuminate\Http\JsonResponse
415     *
416     * @response 200 {"result": {"data": [...]}}
417     */
418    public function companyProjects(Request $request)
419    {
420        $user = $request->user();
421        $companyId = $user->company_id;
422
423        if (! $companyId) {
424            return response()->json([
425                'status' => 'success',
426                'data' => [],
427            ]);
428        }
429
430        // Collect the user's group IDs (direct group + parent group)
431        $userGroupIds = [];
432        if ($user->company_group_id) {
433            $userGroupIds[] = (string) $user->company_group_id;
434
435            $group = CompanyGroup::find($user->company_group_id);
436            if ($group && $group->parent_id) {
437                $userGroupIds[] = (string) $group->parent_id;
438            }
439        }
440
441        $projects = CompanyRolePlayProject::forCompany($companyId)
442            ->active()
443            ->forGroups($userGroupIds)
444            ->with('creator')
445            ->orderBy('created_at', 'desc')
446            ->get();
447
448        // Enrich each persona with the caller's per-persona aggregates so
449        // the unified Personas page can render the same stats (sessions /
450        // last practiced / avg score) on company cards as on user cards.
451        $aggregates = app(CompanyRolePlaySessionService::class)
452            ->aggregatesForUserOnCorporatePersonas(
453                (string) $user->id,
454                $projects->pluck('_id')->map(fn ($id) => (string) $id)->all(),
455            );
456        $projects->each(function ($project) use ($aggregates) {
457            $project->setAttribute(
458                'my_aggregates',
459                $aggregates[(string) $project->_id] ?? null,
460            );
461        });
462
463        return CompanyRolePlayProjectResource::collection($projects);
464    }
465
466    /**
467     * Clone a company roleplay project to the user's own projects.
468     *
469     * Creates a new RolePlayProjects record with data copied from the
470     * company project. Only works if the company project has
471     * allow_user_customization enabled.
472     *
473     * @param  string  $id  The company project ID to clone
474     *
475     * @response 201 {"status": "success", "data": {...}}
476     * @response 403 {"status": "error", "message": "This project does not allow user customization"}
477     * @response 404 {"status": "error", "message": "Company project not found"}
478     */
479    public function cloneCompanyProject(Request $request, string $id): JsonResponse
480    {
481        $user = $request->user();
482        $companyId = $user->company_id;
483
484        $companyProject = CompanyRolePlayProject::forCompany($companyId)
485            ->active()
486            ->find($id);
487
488        if (! $companyProject) {
489            return response()->json([
490                'status' => 'error',
491                'message' => 'Company project not found',
492            ], 404);
493        }
494
495        // Phase 1 Corporate Personas: `allow_clone` is the new, explicit
496        // toggle; fall back to the legacy `allow_user_customization` for
497        // records that haven't been backfilled yet.
498        $allowClone = $companyProject->allow_clone;
499        if ($allowClone === null) {
500            $allowClone = (bool) $companyProject->allow_user_customization;
501        }
502
503        if (! $allowClone) {
504            return response()->json([
505                'status' => 'error',
506                'message' => 'This project does not allow user customization',
507            ], 403);
508        }
509
510        $clonedProject = RolePlayProjects::create([
511            'name' => $companyProject->name,
512            'user_id' => $user->id,
513            'type' => $companyProject->type,
514            'description' => $companyProject->description,
515            'difficulty_level' => $companyProject->difficulty_level,
516            'key_features' => $companyProject->key_features ?? [],
517            'industry' => $companyProject->industry,
518            'target_job_titles' => $companyProject->target_job_titles ?? [],
519            'company_sizes' => $companyProject->company_sizes ?? RolePlayProjects::COMPANY_SIZE_KEYS,
520            'customer_profiles' => $companyProject->customer_profiles ?? [],
521            'objections' => $companyProject->objections ?? [],
522            'company_project_id' => $companyProject->id,
523            'is_clone' => true,
524            'source' => 'company',
525        ]);
526
527        return response()->json([
528            'status' => 'success',
529            'data' => $clonedProject,
530        ], 201);
531    }
532
533    /**
534     * Backfill missing `voice` fields on customer profiles using gender-matched voice pools.
535     *
536     * @param  array<int, array<string, mixed>>  $icps  The customer_profiles array
537     * @return array<int, array<string, mixed>> The ICPs with voices filled in where missing
538     */
539    /** @param  array<int, array<string, mixed>|object>  $icps */
540    private function backfillVoices(array $icps): array
541    {
542        $maleVoices = Parameter::where('name', 'role_play_male_voices')->first()?->value ?? [];
543        $femaleVoices = Parameter::where('name', 'role_play_female_voices')->first()?->value ?? [];
544
545        return array_map(function ($icp) use ($maleVoices, $femaleVoices) {
546            $icpArray = is_array($icp) ? $icp : (array) $icp;
547
548            if (empty($icpArray['voice'])) {
549                $gender = $icpArray['gender'] ?? 'male';
550                if ($gender === 'female') {
551                    $icpArray['voice'] = ! empty($femaleVoices) ? $femaleVoices[array_rand($femaleVoices)] : '';
552                } else {
553                    $icpArray['voice'] = ! empty($maleVoices) ? $maleVoices[array_rand($maleVoices)] : '';
554                }
555            }
556
557            return $icpArray;
558        }, $icps);
559    }
560}