Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.23% covered (success)
98.23%
222 / 226
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
CompanyRolePlayController
98.23% covered (success)
98.23%
222 / 226
66.67% covered (warning)
66.67%
8 / 12
32
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
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 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
98.28% covered (success)
98.28%
57 / 58
0.00% covered (danger)
0.00%
0 / 1
6
 regenerateIcp
98.00% covered (success)
98.00%
49 / 50
0.00% covered (danger)
0.00%
0 / 1
4
 getMinProfileCount
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 chatCompletion
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
4
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\CompanyRolePlayService;
14use App\Http\Services\NodeJsAIBridgeService;
15use Illuminate\Http\JsonResponse;
16use Illuminate\Http\Request;
17use Illuminate\Validation\Rule;
18
19/**
20 * Company RolePlay Controller
21 *
22 * Manages company-level roleplay projects that company admins create
23 * for their users to practice with. Projects can be assigned to specific
24 * groups within a company and cloned by end users.
25 */
26class CompanyRolePlayController extends Controller
27{
28    public function __construct(
29        private readonly NodeJsAIBridgeService $bridge,
30        private readonly CompanyRolePlayService $companyRolePlayService,
31    ) {}
32
33    /**
34     * List all company roleplay projects for the authenticated user's company.
35     *
36     * Returns a paginated list of projects belonging to the admin's company.
37     *
38     * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection
39     *
40     * @response 200 {"result": {"data": [...], "meta": {...}}}
41     */
42    public function index(Request $request)
43    {
44        $user = $request->user();
45        $companyId = $user->company_id;
46
47        $perPage = $request->input('per_page', 15);
48
49        $projects = CompanyRolePlayProject::forCompany($companyId)
50            ->with('creator')
51            ->orderBy('created_at', 'desc')
52            ->paginate($perPage);
53
54        return CompanyRolePlayProjectResource::collection($projects);
55    }
56
57    /**
58     * Create a new company roleplay project.
59     *
60     * Sets company_id from the authenticated user's company and
61     * created_by from the authenticated user.
62     *
63     * @param  StoreCompanyProjectRequest  $request  Validated request data
64     * @return \Illuminate\Http\JsonResponse
65     *
66     * @response 201 {"result": {"id": "...", "name": "...", ...}}
67     */
68    public function store(StoreCompanyProjectRequest $request)
69    {
70        $user = $request->user();
71        $validated = $request->validated();
72
73        $data = array_merge($validated, [
74            'company_id' => $user->company_id,
75            'created_by' => $user->id,
76        ]);
77
78        if (! isset($data['status'])) {
79            $data['status'] = 'active';
80        }
81
82        if (! isset($data['allow_user_customization'])) {
83            $data['allow_user_customization'] = true;
84        }
85
86        $data['customer_profiles'] = $this->companyRolePlayService->buildIcpPrompts($data);
87
88        $project = CompanyRolePlayProject::create($data);
89        $project->load('creator');
90
91        return (new CompanyRolePlayProjectResource($project))
92            ->additional(['status' => 'success'])
93            ->response()
94            ->setStatusCode(201);
95    }
96
97    /**
98     * Get a single company roleplay project.
99     *
100     * The project must belong to the authenticated user's company.
101     *
102     * @param  string  $id  The project ID
103     * @return CompanyRolePlayProjectResource|JsonResponse
104     *
105     * @response 200 {"result": {"id": "...", "name": "...", ...}}
106     * @response 404 {"status": "error", "message": "Project not found"}
107     */
108    public function show(Request $request, string $id)
109    {
110        $user = $request->user();
111        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
112
113        if (! $project) {
114            return response()->json([
115                'status' => 'error',
116                'message' => 'Project not found',
117            ], 404);
118        }
119
120        $project->load('creator');
121
122        return new CompanyRolePlayProjectResource($project);
123    }
124
125    /**
126     * Update an existing company roleplay project.
127     *
128     * The project must belong to the authenticated user's company.
129     *
130     * @param  UpdateCompanyProjectRequest  $request  Validated request data
131     * @param  string  $id  The project ID
132     * @return CompanyRolePlayProjectResource|JsonResponse
133     *
134     * @response 200 {"result": {"id": "...", "name": "...", ...}}
135     * @response 404 {"status": "error", "message": "Project not found"}
136     */
137    public function update(UpdateCompanyProjectRequest $request, string $id)
138    {
139        $user = $request->user();
140        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
141
142        if (! $project) {
143            return response()->json([
144                'status' => 'error',
145                'message' => 'Project not found',
146            ], 404);
147        }
148
149        $validated = $request->validated();
150
151        // Rebuild ICP prompts if customer_profiles changed
152        if (isset($validated['customer_profiles'])) {
153            $mergedData = array_merge($project->toArray(), $validated);
154            $validated['customer_profiles'] = $this->companyRolePlayService->buildIcpPrompts($mergedData);
155        }
156
157        $project->update($validated);
158        $project->load('creator');
159
160        return new CompanyRolePlayProjectResource($project->fresh());
161    }
162
163    /**
164     * Delete a company roleplay project.
165     *
166     * The project must belong to the authenticated user's company.
167     *
168     * @param  string  $id  The project ID
169     *
170     * @response 200 {"status": "success"}
171     * @response 404 {"status": "error", "message": "Project not found"}
172     */
173    public function destroy(Request $request, string $id): JsonResponse
174    {
175        $user = $request->user();
176        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
177
178        if (! $project) {
179            return response()->json([
180                'status' => 'error',
181                'message' => 'Project not found',
182            ], 404);
183        }
184
185        $project->delete();
186
187        return response()->json([
188            'status' => 'success',
189        ]);
190    }
191
192    /**
193     * Update the status of a company roleplay project.
194     *
195     * @param  string  $id  The project ID
196     * @return CompanyRolePlayProjectResource|JsonResponse
197     *
198     * @response 200 {"result": {"id": "...", "status": "active", ...}}
199     * @response 404 {"status": "error", "message": "Project not found"}
200     */
201    public function updateStatus(Request $request, string $id)
202    {
203        $request->validate([
204            'status' => ['required', 'string', Rule::in(['active', 'inactive', 'archived'])],
205        ]);
206
207        $user = $request->user();
208        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
209
210        if (! $project) {
211            return response()->json([
212                'status' => 'error',
213                'message' => 'Project not found',
214            ], 404);
215        }
216
217        $project->update(['status' => $request->input('status')]);
218        $project->load('creator');
219
220        return new CompanyRolePlayProjectResource($project->fresh());
221    }
222
223    /**
224     * Update the assigned groups for a company roleplay project.
225     *
226     * An empty array means the project is available to all users in the company.
227     *
228     * @param  string  $id  The project ID
229     * @return CompanyRolePlayProjectResource|JsonResponse
230     *
231     * @response 200 {"result": {"id": "...", "assigned_groups": [...], ...}}
232     * @response 404 {"status": "error", "message": "Project not found"}
233     */
234    public function assignGroups(Request $request, string $id)
235    {
236        $request->validate([
237            'assigned_groups' => 'required|array',
238            'assigned_groups.*' => 'string',
239        ]);
240
241        $user = $request->user();
242        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
243
244        if (! $project) {
245            return response()->json([
246                'status' => 'error',
247                'message' => 'Project not found',
248            ], 404);
249        }
250
251        $project->update(['assigned_groups' => $request->input('assigned_groups')]);
252        $project->load('creator');
253
254        return new CompanyRolePlayProjectResource($project->fresh());
255    }
256
257    /**
258     * Generate ICPs (Ideal Customer Profiles) for a company roleplay project.
259     *
260     * Uses AI to generate customer profiles based on project configuration.
261     * Reuses the same prompt and AI logic as user-level ICP generation.
262     *
263     * @param  string  $id  The project ID
264     *
265     * @response 200 {"status": "success", "data": [...]}
266     * @response 404 {"status": "error", "message": "Project not found"}
267     */
268    public function generateIcps(Request $request, string $id): JsonResponse
269    {
270        $request->validate([
271            'type' => ['required', 'string', Rule::in(['cold-call', 'discovery-call'])],
272            'industry' => ['required', 'array', 'min:1'],
273            'industry.*' => ['string', 'max:255'],
274            'product_description' => ['required', 'string'],
275            'key_features' => 'nullable|array',
276            'key_features.*' => 'string|max:255',
277            'difficulty_level' => 'required|numeric|in:1,2,3,4,5',
278        ]);
279
280        $user = $request->user();
281        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
282
283        if (! $project) {
284            return response()->json([
285                'status' => 'error',
286                'message' => 'Project not found',
287            ], 404);
288        }
289
290        $validated = $request->only(['type', 'industry', 'product_description', 'key_features', 'difficulty_level']);
291
292        // Try ai_prompts first, fallback to parameters
293        $promptRecord = AIPrompts::where('product', 'roleplay_generate_icp')
294            ->where('status', 'active')
295            ->first();
296
297        $promptTemplate = $promptRecord
298            ? $promptRecord->context
299            : (Parameter::where('name', 'role_play_generate_icp_prompt')->first()?->value ?? '');
300
301        // Fetch personalities for the prompt
302        $config = RolePlayConfig::getGlobal();
303        $personalities = $config
304            ? ($config->personalities ?? [])
305            : (Parameter::where('name', 'role_play_personalities')->first()?->value ?? []);
306
307        $personalitiesForPrompt = array_map(function ($p, $index) {
308            $num = $index + 1;
309            $type = $p['type'] ?? '';
310            $name = $p['name'] ?? '';
311            $description = $p['description'] ?? '';
312            $traits = implode(', ', $p['traits'] ?? []);
313
314            return "{$num}. **{$type} â€“ {$name}** (Traits: {$traits})\n   {$description}";
315        }, $personalities, array_keys($personalities));
316        $personalitiesStr = implode("\n\n", $personalitiesForPrompt);
317
318        $keyFeatures = $validated['key_features'] ?? [];
319        $keyFeatures = array_map(fn ($feature) => '- '.trim($feature), $keyFeatures);
320        $keyFeaturesStr = implode("\n", $keyFeatures);
321
322        $industryStr = is_array($validated['industry'] ?? '') ? implode(', ', $validated['industry']) : ($validated['industry'] ?? '');
323
324        $placeholders = ['{type}', '{productDescription}', '{key_features}', '{targetIndustry}', '{difficulty_level}', '{min_profiles}', '{personalities}'];
325        $replacements = [
326            $validated['type'],
327            $validated['product_description'],
328            $keyFeaturesStr,
329            $industryStr,
330            $validated['difficulty_level'],
331            (string) count($personalities),
332            $personalitiesStr,
333        ];
334
335        $prompt = str_replace($placeholders, $replacements, $promptTemplate);
336        $result = $this->chatCompletion($prompt, $user, $promptRecord);
337
338        if (is_array($result)) {
339            $result = $this->companyRolePlayService->assignPersonalityAndAvatar($result);
340        }
341
342        return response()->json([
343            'status' => 'success',
344            'data' => $result,
345        ]);
346    }
347
348    /**
349     * Regenerate a single ICP (Ideal Customer Profile).
350     *
351     * Uses AI to regenerate a specific customer profile with more details.
352     *
353     *
354     * @response 200 {"status": "success", "data": {...}}
355     */
356    public function regenerateIcp(Request $request): JsonResponse
357    {
358        $request->validate([
359            'type' => ['required', 'string', Rule::in(['cold-call', 'discovery-call'])],
360            'company_name' => ['required', 'string'],
361            'company_size' => ['required', 'string'],
362            'budget' => ['required', 'string'],
363            'industry' => ['required', 'array', 'min:1'],
364            'industry.*' => ['string', 'max:255'],
365            'product_description' => ['required', 'string'],
366            'key_features' => 'nullable|array',
367            'key_features.*' => 'string|max:255',
368            'difficulty_level' => 'required|numeric|in:1,2,3,4,5',
369        ]);
370
371        $user = $request->user();
372        $validated = $request->only([
373            'type', 'company_name', 'company_size', 'budget',
374            'industry', 'product_description', 'key_features', 'difficulty_level',
375        ]);
376
377        // Try ai_prompts first, fallback to parameters
378        $promptRecord = AIPrompts::where('product', 'roleplay_regenerate_icp')
379            ->where('status', 'active')
380            ->first();
381
382        $promptTemplate = $promptRecord
383            ? $promptRecord->context
384            : (Parameter::where('name', 'role_play_regenerate_icp_prompt')->first()?->value ?? '');
385
386        $keyFeatures = $validated['key_features'] ?? [];
387        $keyFeatures = array_map(fn ($feature) => '- '.trim($feature), $keyFeatures);
388        $keyFeaturesStr = implode("\n", $keyFeatures);
389
390        $industryStr = is_array($validated['industry'] ?? '') ? implode(', ', $validated['industry']) : ($validated['industry'] ?? '');
391
392        $placeholders = [
393            '{type}', '{productDescription}', '{key_features}', '{targetIndustry}',
394            '{company_name}', '{company_size}', '{budget}', '{difficulty_level}', '{min_profiles}',
395        ];
396        $replacements = [
397            $validated['type'],
398            $validated['product_description'],
399            $keyFeaturesStr,
400            $industryStr,
401            $validated['company_name'],
402            $validated['company_size'],
403            $validated['budget'],
404            $validated['difficulty_level'],
405            (string) $this->getMinProfileCount(),
406        ];
407
408        $prompt = str_replace($placeholders, $replacements, $promptTemplate);
409        $result = $this->chatCompletion($prompt, $user, $promptRecord);
410
411        if (is_array($result)) {
412            $result = $this->companyRolePlayService->assignPersonalityAndAvatar($result);
413        }
414
415        return response()->json([
416            'status' => 'success',
417            'data' => $result,
418        ]);
419    }
420
421    /**
422     * Get the minimum number of customer profiles to generate (one per personality type).
423     */
424    private function getMinProfileCount(): int
425    {
426        $config = RolePlayConfig::getGlobal();
427
428        if ($config) {
429            return count($config->personalities ?? []);
430        }
431
432        $personalities = Parameter::where('name', 'role_play_personalities')->first()?->value ?? [];
433
434        return count($personalities);
435    }
436
437    /**
438     * Execute AI chat completion for ICP generation.
439     *
440     * Honors the model/temperature/top_p/tokens/is_grounding configured on
441     * the supplied ai_prompts record. Fallback defaults only apply when a
442     * field is missing from the record (e.g. legacy / misseeded prompts).
443     * The returned text is handed to
444     * {@see CompanyRolePlayService::parseGeminiResponse()} for fence-stripping
445     * and JSON decode.
446     *
447     * @param  string  $prompt  The prompt to send to the AI
448     * @param  mixed  $user  The authenticated user
449     * @param  \App\Http\Models\AIPrompts|null  $promptRecord  Record whose config drives the call
450     * @return mixed The parsed AI response
451     */
452    private function chatCompletion(string $prompt, mixed $user = null, ?AIPrompts $promptRecord = null): mixed
453    {
454        $rawModel = $promptRecord?->model;
455        $model = ($rawModel !== null && $rawModel !== '')
456            ? str_replace(':streamGenerateContent', '', $rawModel)
457            : 'gemini-3.1-flash-lite-preview';
458
459        $promptId = $promptRecord?->_id;
460
461        $text = $this->bridge->generate(
462            [
463                'provider' => 'vertex',
464                'model' => $model,
465                'prompt' => $prompt,
466                'config' => [
467                    'maxOutputTokens' => (int) ($promptRecord?->tokens ?? 8000),
468                    'temperature' => (float) ($promptRecord?->temperature ?? 1.0),
469                    'topP' => (float) ($promptRecord?->top_p ?? 1.0),
470                    'thinkingBudget' => 0,
471                    'enableGoogleSearch' => (bool) ($promptRecord?->is_grounding ?? false),
472                ],
473            ],
474            [
475                'feature' => 'persona_generate',
476                'user_id' => $user?->id,
477                'company_id' => $user?->company_id ?? null,
478                'prompt_id' => $promptId !== null ? (string) $promptId : null,
479            ]
480        );
481
482        return $this->companyRolePlayService->parseGeminiResponse($text);
483    }
484}