Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.65% covered (success)
94.65%
283 / 299
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CompanyRolePlayController
94.65% covered (success)
94.65%
283 / 299
76.92% covered (warning)
76.92%
10 / 13
47.34
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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 store
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 show
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 update
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
11.35
 destroy
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 updateStatus
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 assignGroups
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 generateIcps
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
3
 regenerateIcp
97.78% covered (success)
97.78%
44 / 45
0.00% covered (danger)
0.00%
0 / 1
2
 generateAiPersonalities
89.69% covered (warning)
89.69%
87 / 97
0.00% covered (danger)
0.00%
0 / 1
17.32
 chatCompletion
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 parseGeminiResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Controllers\v2\Company;
4
5use App\Http\Controllers\Controller;
6use App\Http\Models\AIPrompts;
7use App\Http\Models\CompanyRolePlayProject;
8use App\Http\Models\Parameter;
9use App\Http\Models\RolePlayConfig;
10use App\Http\Requests\CompanyRolePlay\StoreCompanyProjectRequest;
11use App\Http\Requests\CompanyRolePlay\UpdateCompanyProjectRequest;
12use App\Http\Resources\v2\CompanyRolePlayProjectResource;
13use App\Http\Services\NodeJsAIBridgeService;
14use Illuminate\Http\JsonResponse;
15use Illuminate\Http\Request;
16use Illuminate\Validation\Rule;
17
18/**
19 * Company RolePlay Controller
20 *
21 * Manages company-level roleplay projects that company admins create
22 * for their users to practice with. Projects can be assigned to specific
23 * groups within a company and cloned by end users.
24 */
25class CompanyRolePlayController extends Controller
26{
27    public function __construct(
28        private readonly NodeJsAIBridgeService $bridge
29    ) {}
30
31    /**
32     * List all company roleplay projects for the authenticated user's company.
33     *
34     * Returns a paginated list of projects belonging to the admin's company.
35     *
36     * @param Request $request
37     * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection
38     *
39     * @response 200 {"result": {"data": [...], "meta": {...}}}
40     */
41    public function index(Request $request)
42    {
43        $user = $request->user();
44        $companyId = $user->company_id;
45
46        $perPage = $request->input('per_page', 15);
47
48        $projects = CompanyRolePlayProject::forCompany($companyId)
49            ->with('creator')
50            ->orderBy('created_at', 'desc')
51            ->paginate($perPage);
52
53        return CompanyRolePlayProjectResource::collection($projects);
54    }
55
56    /**
57     * Create a new company roleplay project.
58     *
59     * Sets company_id from the authenticated user's company and
60     * created_by from the authenticated user.
61     *
62     * @param StoreCompanyProjectRequest $request Validated request data
63     * @return \Illuminate\Http\JsonResponse
64     *
65     * @response 201 {"result": {"id": "...", "name": "...", ...}}
66     */
67    public function store(StoreCompanyProjectRequest $request)
68    {
69        $user = $request->user();
70        $validated = $request->validated();
71
72        $data = array_merge($validated, [
73            'company_id' => $user->company_id,
74            'created_by' => $user->id,
75        ]);
76
77        if (! isset($data['status'])) {
78            $data['status'] = 'draft';
79        }
80
81        if (! isset($data['allow_user_customization'])) {
82            $data['allow_user_customization'] = true;
83        }
84
85        $data['training_personalities'] = $this->generateAiPersonalities($data);
86
87        $project = CompanyRolePlayProject::create($data);
88        $project->load('creator');
89
90        return (new CompanyRolePlayProjectResource($project))
91            ->additional(['status' => 'success'])
92            ->response()
93            ->setStatusCode(201);
94    }
95
96    /**
97     * Get a single company roleplay project.
98     *
99     * The project must belong to the authenticated user's company.
100     *
101     * @param string $id The project ID
102     * @return CompanyRolePlayProjectResource|JsonResponse
103     *
104     * @response 200 {"result": {"id": "...", "name": "...", ...}}
105     * @response 404 {"status": "error", "message": "Project not found"}
106     */
107    public function show(Request $request, string $id)
108    {
109        $user = $request->user();
110        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
111
112        if (! $project) {
113            return response()->json([
114                'status' => 'error',
115                'message' => 'Project not found',
116            ], 404);
117        }
118
119        $project->load('creator');
120
121        return new CompanyRolePlayProjectResource($project);
122    }
123
124    /**
125     * Update an existing company roleplay project.
126     *
127     * The project must belong to the authenticated user's company.
128     *
129     * @param UpdateCompanyProjectRequest $request Validated request data
130     * @param string $id The project ID
131     * @return CompanyRolePlayProjectResource|JsonResponse
132     *
133     * @response 200 {"result": {"id": "...", "name": "...", ...}}
134     * @response 404 {"status": "error", "message": "Project not found"}
135     */
136    public function update(UpdateCompanyProjectRequest $request, string $id)
137    {
138        $user = $request->user();
139        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
140
141        if (! $project) {
142            return response()->json([
143                'status' => 'error',
144                'message' => 'Project not found',
145            ], 404);
146        }
147
148        $validated = $request->validated();
149
150        // Regenerate training personalities if customer_profiles changed
151        if (isset($validated['customer_profiles'])) {
152            $maxIcpIndex = 0;
153            foreach ($project->training_personalities ?? [] as $aiPersona) {
154                $icpArray = is_array($aiPersona) ? $aiPersona : (array) $aiPersona;
155                foreach ($icpArray['agents'] ?? [] as $aiAgent) {
156                    $aiAgentArray = is_array($aiAgent) ? $aiAgent : (array) $aiAgent;
157                    if (isset($aiAgentArray['id']) && is_int($aiAgentArray['id']) && $aiAgentArray['id'] > $maxIcpIndex) {
158                        $maxIcpIndex = $aiAgentArray['id'];
159                    }
160                }
161            }
162
163            $mergedData = array_merge($project->toArray(), $validated);
164            $validated['training_personalities'] = $this->generateAiPersonalities($mergedData, $maxIcpIndex);
165        }
166
167        $project->update($validated);
168        $project->load('creator');
169
170        return new CompanyRolePlayProjectResource($project->fresh());
171    }
172
173    /**
174     * Delete a company roleplay project.
175     *
176     * The project must belong to the authenticated user's company.
177     *
178     * @param string $id The project ID
179     * @return JsonResponse
180     *
181     * @response 200 {"status": "success"}
182     * @response 404 {"status": "error", "message": "Project not found"}
183     */
184    public function destroy(Request $request, string $id): JsonResponse
185    {
186        $user = $request->user();
187        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
188
189        if (! $project) {
190            return response()->json([
191                'status' => 'error',
192                'message' => 'Project not found',
193            ], 404);
194        }
195
196        $project->delete();
197
198        return response()->json([
199            'status' => 'success',
200        ]);
201    }
202
203    /**
204     * Update the status of a company roleplay project.
205     *
206     * @param Request $request
207     * @param string $id The project ID
208     * @return CompanyRolePlayProjectResource|JsonResponse
209     *
210     * @response 200 {"result": {"id": "...", "status": "active", ...}}
211     * @response 404 {"status": "error", "message": "Project not found"}
212     */
213    public function updateStatus(Request $request, string $id)
214    {
215        $request->validate([
216            'status' => ['required', 'string', Rule::in(['active', 'inactive', 'archived', 'draft'])],
217        ]);
218
219        $user = $request->user();
220        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
221
222        if (! $project) {
223            return response()->json([
224                'status' => 'error',
225                'message' => 'Project not found',
226            ], 404);
227        }
228
229        $project->update(['status' => $request->input('status')]);
230        $project->load('creator');
231
232        return new CompanyRolePlayProjectResource($project->fresh());
233    }
234
235    /**
236     * Update the assigned groups for a company roleplay project.
237     *
238     * An empty array means the project is available to all users in the company.
239     *
240     * @param Request $request
241     * @param string $id The project ID
242     * @return CompanyRolePlayProjectResource|JsonResponse
243     *
244     * @response 200 {"result": {"id": "...", "assigned_groups": [...], ...}}
245     * @response 404 {"status": "error", "message": "Project not found"}
246     */
247    public function assignGroups(Request $request, string $id)
248    {
249        $request->validate([
250            'assigned_groups' => 'required|array',
251            'assigned_groups.*' => 'string',
252        ]);
253
254        $user = $request->user();
255        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
256
257        if (! $project) {
258            return response()->json([
259                'status' => 'error',
260                'message' => 'Project not found',
261            ], 404);
262        }
263
264        $project->update(['assigned_groups' => $request->input('assigned_groups')]);
265        $project->load('creator');
266
267        return new CompanyRolePlayProjectResource($project->fresh());
268    }
269
270    /**
271     * Generate ICPs (Ideal Customer Profiles) for a company roleplay project.
272     *
273     * Uses AI to generate customer profiles based on project configuration.
274     * Reuses the same prompt and AI logic as user-level ICP generation.
275     *
276     * @param Request $request
277     * @param string $id The project ID
278     * @return JsonResponse
279     *
280     * @response 200 {"status": "success", "data": [...]}
281     * @response 404 {"status": "error", "message": "Project not found"}
282     */
283    public function generateIcps(Request $request, string $id): JsonResponse
284    {
285        $request->validate([
286            'type' => ['required', 'string', Rule::in(['cold-call', 'discovery-call'])],
287            'industry' => ['required', 'string'],
288            'product_description' => ['required', 'string'],
289            'key_features' => 'nullable|array',
290            'key_features.*' => 'string|max:255',
291            'difficulty_level' => 'required|numeric|in:1,2,3,4,5',
292        ]);
293
294        $user = $request->user();
295        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
296
297        if (! $project) {
298            return response()->json([
299                'status' => 'error',
300                'message' => 'Project not found',
301            ], 404);
302        }
303
304        $validated = $request->only(['type', 'industry', 'product_description', 'key_features', 'difficulty_level']);
305
306        // Try ai_prompts first, fallback to parameters
307        $promptRecord = AIPrompts::where('product', 'roleplay_generate_icp')
308            ->where('status', 'active')
309            ->first();
310
311        $promptTemplate = $promptRecord
312            ? $promptRecord->context
313            : (Parameter::where('name', 'role_play_generate_icp_prompt')->first()?->value ?? '');
314
315        $keyFeatures = $validated['key_features'] ?? [];
316        $keyFeatures = array_map(fn ($feature) => '- ' . trim($feature), $keyFeatures);
317        $keyFeaturesStr = implode("\n", $keyFeatures);
318
319        $placeholders = ['{type}', '{productDescription}', '{key_features}', '{targetIndustry}', '{difficulty_level}'];
320        $replacements = [
321            $validated['type'],
322            $validated['product_description'],
323            $keyFeaturesStr,
324            $validated['industry'],
325            $validated['difficulty_level'],
326        ];
327
328        $prompt = str_replace($placeholders, $replacements, $promptTemplate);
329        $result = $this->chatCompletion($prompt, $user);
330
331        return response()->json([
332            'status' => 'success',
333            'data' => $result,
334        ]);
335    }
336
337    /**
338     * Regenerate a single ICP (Ideal Customer Profile).
339     *
340     * Uses AI to regenerate a specific customer profile with more details.
341     *
342     * @param Request $request
343     * @return JsonResponse
344     *
345     * @response 200 {"status": "success", "data": {...}}
346     */
347    public function regenerateIcp(Request $request): JsonResponse
348    {
349        $request->validate([
350            'type' => ['required', 'string', Rule::in(['cold-call', 'discovery-call'])],
351            'company_name' => ['required', 'string'],
352            'company_size' => ['required', 'string'],
353            'budget' => ['required', 'string'],
354            'industry' => ['required', 'string'],
355            'product_description' => ['required', 'string'],
356            'key_features' => 'nullable|array',
357            'key_features.*' => 'string|max:255',
358            'difficulty_level' => 'required|numeric|in:1,2,3,4,5',
359        ]);
360
361        $user = $request->user();
362        $validated = $request->only([
363            'type', 'company_name', 'company_size', 'budget',
364            'industry', 'product_description', 'key_features', 'difficulty_level',
365        ]);
366
367        // Try ai_prompts first, fallback to parameters
368        $promptRecord = AIPrompts::where('product', 'roleplay_regenerate_icp')
369            ->where('status', 'active')
370            ->first();
371
372        $promptTemplate = $promptRecord
373            ? $promptRecord->context
374            : (Parameter::where('name', 'role_play_regenerate_icp_prompt')->first()?->value ?? '');
375
376        $keyFeatures = $validated['key_features'] ?? [];
377        $keyFeatures = array_map(fn ($feature) => '- ' . trim($feature), $keyFeatures);
378        $keyFeaturesStr = implode("\n", $keyFeatures);
379
380        $placeholders = [
381            '{type}', '{productDescription}', '{key_features}', '{targetIndustry}',
382            '{company_name}', '{company_size}', '{budget}', '{difficulty_level}',
383        ];
384        $replacements = [
385            $validated['type'],
386            $validated['product_description'],
387            $keyFeaturesStr,
388            $validated['industry'],
389            $validated['company_name'],
390            $validated['company_size'],
391            $validated['budget'],
392            $validated['difficulty_level'],
393        ];
394
395        $prompt = str_replace($placeholders, $replacements, $promptTemplate);
396        $result = $this->chatCompletion($prompt, $user);
397
398        return response()->json([
399            'status' => 'success',
400            'data' => $result,
401        ]);
402    }
403
404    /**
405     * Generate AI training personalities for customer profiles.
406     *
407     * Creates agent configurations with names, images, voices, and prompts
408     * based on the project's customer profiles and call type.
409     *
410     * @param array $persona The project data array
411     * @param int $currentMaxIndex The current maximum agent ID index
412     * @return array The generated training personalities
413     */
414    private function generateAiPersonalities(array $persona, int $currentMaxIndex = 0): array
415    {
416        // Try RolePlayConfig first, fallback to Parameters
417        $config = RolePlayConfig::getGlobal();
418
419        if ($config) {
420            $maleNames = $config->names['male'] ?? [];
421            $maleImages = $config->images['male'] ?? [];
422            $maleVoices = $config->voices['male'] ?? [];
423            $femaleNames = $config->names['female'] ?? [];
424            $femaleImages = $config->images['female'] ?? [];
425            $femaleVoices = $config->voices['female'] ?? [];
426            $personalities = $config->personalities ?? [];
427        } else {
428            $maleNames = Parameter::where('name', 'role_play_male_names')->first()?->value ?? [];
429            $maleImages = Parameter::where('name', 'role_play_male_images')->first()?->value ?? [];
430            $maleVoices = Parameter::where('name', 'role_play_male_voices')->first()?->value ?? [];
431            $femaleNames = Parameter::where('name', 'role_play_female_names')->first()?->value ?? [];
432            $femaleImages = Parameter::where('name', 'role_play_female_images')->first()?->value ?? [];
433            $femaleVoices = Parameter::where('name', 'role_play_female_voices')->first()?->value ?? [];
434            $personalities = Parameter::where('name', 'role_play_personalities')->first()?->value ?? [];
435        }
436
437        // Try ai_prompts first for call prompts, fallback to parameters
438        $coldCallPromptRecord = AIPrompts::where('product', 'roleplay_cold_call')
439            ->where('status', 'active')
440            ->first();
441        $discoveryCallPromptRecord = AIPrompts::where('product', 'roleplay_discovery_call')
442            ->where('status', 'active')
443            ->first();
444
445        $coldCallPromptValue = $coldCallPromptRecord
446            ? $coldCallPromptRecord->context
447            : (Parameter::where('name', 'role_play_cold_call_prompt')->first()?->value ?? '');
448        $discoveryCallPromptValue = $discoveryCallPromptRecord
449            ? $discoveryCallPromptRecord->context
450            : (Parameter::where('name', 'role_play_discovery_call_prompt')->first()?->value ?? '');
451        $icps = $persona['customer_profiles'] ?? [];
452
453        $baseAgents = [];
454        shuffle($maleImages);
455        shuffle($femaleImages);
456        shuffle($personalities);
457
458        $maleImageCount = count($maleImages);
459        $femaleImageCount = count($femaleImages);
460        $personalityCount = count($personalities);
461        $personalityIndex = 0;
462
463        if ($maleImageCount > 0 && $personalityCount > 0 && ! empty($maleVoices)) {
464            foreach ($maleNames as $index => $name) {
465                $baseAgents[] = [
466                    'name' => $name,
467                    'gender' => 'male',
468                    'image' => $maleImages[$index % $maleImageCount],
469                    'voice' => $maleVoices[array_rand($maleVoices)],
470                    'personality' => $personalities[($personalityIndex++) % $personalityCount],
471                ];
472            }
473        }
474
475        if ($femaleImageCount > 0 && $personalityCount > 0 && ! empty($femaleVoices)) {
476            foreach ($femaleNames as $index => $name) {
477                $baseAgents[] = [
478                    'name' => $name,
479                    'gender' => 'female',
480                    'image' => $femaleImages[$index % $femaleImageCount],
481                    'voice' => $femaleVoices[array_rand($femaleVoices)],
482                    'personality' => $personalities[($personalityIndex++) % $personalityCount],
483                ];
484            }
485        }
486
487        shuffle($baseAgents);
488
489        $responseData = [];
490
491        $promptTemplate = ($persona['type'] === 'cold-call')
492            ? $coldCallPromptValue
493            : $discoveryCallPromptValue;
494
495        $indexForId = $currentMaxIndex + 1;
496
497        foreach ($icps as $icp_index => $icp) {
498            foreach ($baseAgents as $agent_index => $baseAgentId) {
499                $baseAgents[$agent_index]['id'] = $indexForId++;
500            }
501
502            $icpArray = is_array($icp) ? $icp : (array) $icp;
503
504            $person_description = "\n- Company Name: {$icpArray['company_name']}";
505            $person_description .= "\n- Company Size: {$icpArray['company_size']}";
506            $person_description .= "\n- Current Pain Points: {$icpArray['pain_points']}";
507            $person_description .= "\n- Decision-Making Authority: {$icpArray['decision_making']}";
508            $person_description .= "\n- Current Solution: {$icpArray['current_solution']}";
509            $person_description .= "\n- Budget: {$icpArray['budget']}";
510            $person_description .= "\n- Urgency Level: {$icpArray['urgency_level']}";
511            $person_description .= "\n- Openness to New Solutions: {$icpArray['openess_to_new_solutions']}";
512            $person_description .= "\n- Communication Style: {$icpArray['communication_style']}";
513            $person_description .= "\n- Personality: {$icpArray['personality']}\n";
514
515            $allObjections = array_map(function ($objection) {
516                $options = is_array($objection['options']) ? $objection['options'] : [];
517                $optionsString = implode("\n", array_map(fn ($opt) => '- ' . $opt, $options));
518
519                return "{$objection['category']}:\n{$optionsString}";
520            }, $persona['objections'] ?? []);
521            $allObjectionsString = implode("\n\n", $allObjections);
522
523            $agentsWithPrompt = array_map(function ($agent) use ($persona, $person_description, $allObjectionsString, $promptTemplate) {
524                $placeholders = ['{name}', '{targetIndustry}', '{persona_prompt}', '{all_objections}'];
525                $replacements = [
526                    $agent['name'],
527                    $persona['industry'],
528                    trim($person_description),
529                    $allObjectionsString,
530                ];
531
532                $agent['prompt'] = str_replace($placeholders, $replacements, $promptTemplate);
533
534                return $agent;
535            }, $baseAgents);
536
537            $responseData[] = [
538                'icp' => $icp,
539                'agents' => $agentsWithPrompt,
540            ];
541        }
542
543        return $responseData;
544    }
545
546    /**
547     * Execute AI chat completion for ICP generation.
548     *
549     * @param string $prompt The prompt to send to the AI
550     * @param mixed $user The authenticated user
551     * @return mixed The parsed AI response
552     */
553    private function chatCompletion(string $prompt, mixed $user = null): mixed
554    {
555        $text = $this->bridge->generate(
556            [
557                'provider' => 'vertex',
558                'model' => 'gemini-2.5-flash',
559                'prompt' => $prompt,
560                'config' => [
561                    'maxOutputTokens' => 2500,
562                    'temperature' => 1.0,
563                    'topP' => 1.0,
564                    'thinkingBudget' => 0,
565                    'enableGoogleSearch' => false,
566                ],
567            ],
568            [
569                'feature' => 'persona_generate',
570                'user_id' => $user?->id,
571                'company_id' => $user?->company_id ?? null,
572            ]
573        );
574
575        return $this->parseGeminiResponse($text);
576    }
577
578    /**
579     * Parse the raw Gemini AI response into structured data.
580     *
581     * @param string $text Raw AI response text
582     * @return mixed Decoded JSON data
583     */
584    private function parseGeminiResponse(string $text): mixed
585    {
586        $completion = str_replace('```json', '', $text);
587        $completion = str_replace('```', '', $completion);
588        $completion = trim($completion);
589
590        return json_decode($completion, true);
591    }
592}