Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.73% covered (success)
99.73%
368 / 369
94.74% covered (success)
94.74%
18 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
AdminCompanyRolePlayController
99.73% covered (success)
99.73%
368 / 369
94.74% covered (success)
94.74%
18 / 19
89
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%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 show
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 store
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 update
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 clone
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 destroy
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 updateStatus
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 updateAssignments
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 generateIcps
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
1 / 1
9
 sessions
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 sessionDetail
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 analytics
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 computeAnalytics
100.00% covered (success)
100.00%
89 / 89
100.00% covered (success)
100.00%
1 / 1
28
 trendTail
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 median
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 conversationBelongsToPersona
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 notFound
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forbidden
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Controllers\v2\Admin;
4
5use App\Http\Controllers\Controller;
6use App\Http\Models\Admin\CompanyGroup;
7use App\Http\Models\AIPrompts;
8use App\Http\Models\Auth\User;
9use App\Http\Models\CompanyRolePlayProject;
10use App\Http\Models\Parameter;
11use App\Http\Models\RolePlayConfig;
12use App\Http\Models\RolePlayConversations;
13use App\Http\Models\RolePlayProjects;
14use App\Http\Requests\CompanyRolePlay\StoreCompanyProjectRequest;
15use App\Http\Requests\CompanyRolePlay\UpdateCompanyProjectRequest;
16use App\Http\Resources\v2\CompanyRolePlayAnalyticsResource;
17use App\Http\Resources\v2\CompanyRolePlayProjectWithAssignmentResource;
18use App\Http\Resources\v2\RolePlayConversationAdminResource;
19use App\Http\Services\CompanyRolePlayService;
20use App\Http\Services\CompanyRolePlaySessionService;
21use App\Http\Services\NodeJsAIBridgeService;
22use Carbon\Carbon;
23use Illuminate\Http\JsonResponse;
24use Illuminate\Http\Request;
25use Illuminate\Validation\Rule;
26
27/**
28 * Admin-side controller for corporate personas (Phase 1).
29 *
30 * Lives under `/admin/role-play/personas`. Enforces company ownership via
31 * {@see \App\Policies\CompanyRolePlayPolicy}. Routes are protected by the
32 * `ensure.company.admin` middleware alias which allows both the company's
33 * own Global Admin and Vengreso super-admins operating through a company
34 * masquerade — see the Phase 1 brief, decision #7.
35 */
36class AdminCompanyRolePlayController extends Controller
37{
38    public function __construct(
39        private readonly NodeJsAIBridgeService $bridge,
40        private readonly CompanyRolePlayService $companyRolePlayService,
41        private readonly CompanyRolePlaySessionService $sessionService,
42    ) {}
43
44    /**
45     * List personas for the admin's company.
46     *
47     * Supports filters: `status`, `call_type`, `group_id`, `per_page`.
48     *
49     * @response 200 {"result": {"data": [...], "meta": {...}}}
50     */
51    public function index(Request $request)
52    {
53        $user = $request->user();
54        $perPage = (int) $request->input('per_page', 15);
55
56        $query = CompanyRolePlayProject::forCompany($user->company_id)
57            ->with('creator')
58            ->orderBy('created_at', 'desc');
59
60        if ($status = $request->input('status')) {
61            $query->where('status', $status);
62        }
63
64        if ($callType = $request->input('call_type')) {
65            $query->where('type', $callType);
66        }
67
68        if ($groupId = $request->input('group_id')) {
69            $query->where('assigned_groups', (string) $groupId);
70        }
71
72        return CompanyRolePlayProjectWithAssignmentResource::collection($query->paginate($perPage));
73    }
74
75    /**
76     * Show a single persona with full assignment metadata.
77     */
78    public function show(Request $request, string $id)
79    {
80        $user = $request->user();
81        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
82
83        if (! $project) {
84            return $this->notFound();
85        }
86
87        if ($request->user()->cannot('view', $project)) {
88            return $this->forbidden();
89        }
90
91        $project->load('creator');
92
93        return new CompanyRolePlayProjectWithAssignmentResource($project);
94    }
95
96    /**
97     * Create a new corporate persona.
98     *
99     * Initialises `allow_clone` and `allow_direct_calls` from the request
100     * (or sane defaults) and stamps `company_id` + `created_by` from the
101     * authenticated user.
102     */
103    public function store(StoreCompanyProjectRequest $request)
104    {
105        $user = $request->user();
106
107        if ($user->cannot('create', CompanyRolePlayProject::class)) {
108            return $this->forbidden();
109        }
110
111        $validated = $request->validated();
112
113        $data = array_merge($validated, [
114            'company_id' => $user->company_id,
115            'created_by' => $user->id,
116        ]);
117
118        $data['status'] = $data['status'] ?? 'active';
119        $data['allow_user_customization'] = $data['allow_user_customization'] ?? true;
120        $data['allow_clone'] = $data['allow_clone'] ?? $data['allow_user_customization'];
121        $data['allow_direct_calls'] = $data['allow_direct_calls'] ?? true;
122        $data['assigned_users'] = $data['assigned_users'] ?? [];
123
124        $data['customer_profiles'] = $this->companyRolePlayService->buildIcpPrompts($data);
125
126        $project = CompanyRolePlayProject::create($data);
127        $project->load('creator');
128
129        return (new CompanyRolePlayProjectWithAssignmentResource($project))
130            ->additional(['status' => 'success'])
131            ->response()
132            ->setStatusCode(201);
133    }
134
135    /**
136     * Update an existing corporate persona.
137     */
138    public function update(UpdateCompanyProjectRequest $request, string $id)
139    {
140        $user = $request->user();
141        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
142
143        if (! $project) {
144            return $this->notFound();
145        }
146
147        if ($user->cannot('update', $project)) {
148            return $this->forbidden();
149        }
150
151        $validated = $request->validated();
152
153        if (isset($validated['customer_profiles'])) {
154            $mergedData = array_merge($project->toArray(), $validated);
155            $validated['customer_profiles'] = $this->companyRolePlayService->buildIcpPrompts($mergedData);
156        }
157
158        $project->update($validated);
159        $project->load('creator');
160
161        return new CompanyRolePlayProjectWithAssignmentResource($project->fresh());
162    }
163
164    /**
165     * Duplicate a corporate persona within the same company.
166     *
167     * Copies every persona attribute EXCEPT identity/timestamps/assignments
168     * and lands the clone in `active` status (the persona default).
169     * Authorization: the source must belong to the caller's company,
170     * otherwise a 404 is returned (no leakage of existence across
171     * companies).
172     *
173     * @response 201 {"result": {"status": "success", "data": {"id": "...", "name": "... (Copy)", "status": "active"}}}
174     * @response 404 {"result": {"status": "error", "message": "Persona not found"}}
175     * @response 403 {"result": {"status": "error", "message": "Forbidden"}}
176     */
177    public function clone(Request $request, string $id)
178    {
179        $user = $request->user();
180        $source = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
181
182        if (! $source) {
183            return $this->notFound();
184        }
185
186        if ($user->cannot('create', CompanyRolePlayProject::class)) {
187            return $this->forbidden();
188        }
189
190        // Copy everything except identity, timestamps, assignments, and status.
191        $excluded = ['_id', 'id', 'created_at', 'updated_at', 'assigned_groups', 'assigned_users', 'status', 'name'];
192        $data = array_diff_key($source->getAttributes(), array_flip($excluded));
193
194        $data['name'] = trim((string) $source->name).' (Copy)';
195        $data['status'] = 'active';
196        $data['assigned_groups'] = [];
197        $data['assigned_users'] = [];
198        $data['company_id'] = $user->company_id;
199        $data['created_by'] = $user->id;
200
201        $clone = CompanyRolePlayProject::create($data);
202        $clone->load('creator');
203
204        return (new CompanyRolePlayProjectWithAssignmentResource($clone))
205            ->additional(['status' => 'success'])
206            ->response()
207            ->setStatusCode(201);
208    }
209
210    /**
211     * Delete a corporate persona.
212     */
213    public function destroy(Request $request, string $id): JsonResponse
214    {
215        $user = $request->user();
216        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
217
218        if (! $project) {
219            return $this->notFound();
220        }
221
222        if ($user->cannot('delete', $project)) {
223            return $this->forbidden();
224        }
225
226        $project->delete();
227
228        return response()->json(['status' => 'success']);
229    }
230
231    /**
232     * Transition the persona between active/inactive/archived.
233     */
234    public function updateStatus(Request $request, string $id)
235    {
236        $request->validate([
237            'status' => ['required', 'string', Rule::in(['active', 'inactive', 'archived'])],
238        ]);
239
240        $user = $request->user();
241        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
242
243        if (! $project) {
244            return $this->notFound();
245        }
246
247        if ($user->cannot('update', $project)) {
248            return $this->forbidden();
249        }
250
251        $project->update(['status' => $request->input('status')]);
252        $project->load('creator');
253
254        return new CompanyRolePlayProjectWithAssignmentResource($project->fresh());
255    }
256
257    /**
258     * Update the assignment surface for a persona in one call:
259     * groups, explicit users, and the clone/direct-call toggles.
260     *
261     * Each field is optional — only supplied keys are written.
262     */
263    public function updateAssignments(Request $request, string $id)
264    {
265        $validated = $request->validate([
266            'assigned_groups' => 'sometimes|array',
267            'assigned_groups.*' => 'string',
268            'assigned_users' => 'sometimes|array',
269            'assigned_users.*' => 'string',
270            'allow_clone' => 'sometimes|boolean',
271            'allow_direct_calls' => 'sometimes|boolean',
272        ]);
273
274        $user = $request->user();
275        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
276
277        if (! $project) {
278            return $this->notFound();
279        }
280
281        if ($user->cannot('update', $project)) {
282            return $this->forbidden();
283        }
284
285        $project->update($validated);
286        $project->load('creator');
287
288        return new CompanyRolePlayProjectWithAssignmentResource($project->fresh());
289    }
290
291    /**
292     * Generate ICP profiles for a persona. Reuses the prompt-resolution
293     * pattern from CompanyRolePlayController::generateIcps so the AI bridge
294     * call honors the `ai_prompts` record's model/temperature/top_p/tokens/
295     * is_grounding configuration.
296     */
297    public function generateIcps(Request $request, string $id): JsonResponse
298    {
299        $request->validate([
300            'type' => ['required', 'string', Rule::in(['cold-call', 'discovery-call'])],
301            'industry' => ['required', 'array', 'min:1'],
302            'industry.*' => ['string', 'max:255'],
303            'product_description' => ['required', 'string'],
304            'key_features' => 'nullable|array',
305            'key_features.*' => 'string|max:255',
306            'difficulty_level' => 'required|numeric|in:1,2,3,4,5',
307        ]);
308
309        $user = $request->user();
310        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
311
312        if (! $project) {
313            return $this->notFound();
314        }
315
316        if ($user->cannot('update', $project)) {
317            return $this->forbidden();
318        }
319
320        $validated = $request->only(['type', 'industry', 'product_description', 'key_features', 'difficulty_level']);
321
322        $promptRecord = AIPrompts::where('product', 'roleplay_generate_icp')
323            ->where('status', 'active')
324            ->first();
325
326        $promptTemplate = $promptRecord
327            ? $promptRecord->context
328            : (Parameter::where('name', 'role_play_generate_icp_prompt')->first()?->value ?? '');
329
330        $config = RolePlayConfig::getGlobal();
331        $personalities = $config
332            ? ($config->personalities ?? [])
333            : (Parameter::where('name', 'role_play_personalities')->first()?->value ?? []);
334
335        $personalitiesForPrompt = array_map(function ($p, $index) {
336            $num = $index + 1;
337            $type = $p['type'] ?? '';
338            $name = $p['name'] ?? '';
339            $description = $p['description'] ?? '';
340            $traits = implode(', ', $p['traits'] ?? []);
341
342            return "{$num}. **{$type} – {$name}** (Traits: {$traits})\n   {$description}";
343        }, $personalities, array_keys($personalities));
344        $personalitiesStr = implode("\n\n", $personalitiesForPrompt);
345
346        $keyFeatures = array_map(fn ($feature) => '- '.trim((string) $feature), $validated['key_features'] ?? []);
347        $keyFeaturesStr = implode("\n", $keyFeatures);
348        $industryStr = implode(', ', $validated['industry']);
349
350        $placeholders = ['{type}', '{productDescription}', '{key_features}', '{targetIndustry}', '{difficulty_level}', '{min_profiles}', '{personalities}'];
351        $replacements = [
352            $validated['type'],
353            $validated['product_description'],
354            $keyFeaturesStr,
355            $industryStr,
356            $validated['difficulty_level'],
357            (string) count($personalities),
358            $personalitiesStr,
359        ];
360
361        $prompt = str_replace($placeholders, $replacements, $promptTemplate);
362
363        $rawModel = $promptRecord?->model;
364        $model = ($rawModel !== null && $rawModel !== '')
365            ? str_replace(':streamGenerateContent', '', $rawModel)
366            : 'gemini-3.1-flash-lite-preview';
367
368        $text = $this->bridge->generate(
369            [
370                'provider' => 'vertex',
371                'model' => $model,
372                'prompt' => $prompt,
373                'config' => [
374                    'maxOutputTokens' => (int) ($promptRecord?->tokens ?? 8000),
375                    'temperature' => (float) ($promptRecord?->temperature ?? 1.0),
376                    'topP' => (float) ($promptRecord?->top_p ?? 1.0),
377                    'thinkingBudget' => 0,
378                    'enableGoogleSearch' => (bool) ($promptRecord?->is_grounding ?? false),
379                ],
380            ],
381            [
382                'feature' => 'persona_generate',
383                'user_id' => $user->id,
384                'company_id' => $user->company_id ?? null,
385                'prompt_id' => $promptRecord?->_id ? (string) $promptRecord->_id : null,
386            ]
387        );
388
389        $result = $this->companyRolePlayService->parseGeminiResponse($text);
390        if (is_array($result)) {
391            $result = $this->companyRolePlayService->assignPersonalityAndAvatar($result);
392        }
393
394        return response()->json([
395            'status' => 'success',
396            'data' => $result,
397        ]);
398    }
399
400    /**
401     * List sessions for a persona (across users, via both direct and clone routes).
402     */
403    public function sessions(Request $request, string $id): JsonResponse
404    {
405        $user = $request->user();
406        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
407
408        if (! $project) {
409            return $this->notFound();
410        }
411
412        if ($user->cannot('manageSessions', $project)) {
413            return $this->forbidden();
414        }
415
416        $filters = $request->only([
417            'status', 'user_id', 'min_score', 'max_score', 'date_from', 'date_to', 'per_page',
418        ]);
419
420        $paginator = $this->sessionService->sessionsForCorporatePersona($id, $filters);
421
422        // Eager-load the user snapshot per row for the admin table.
423        $paginator->getCollection()->load('project');
424        $paginator->getCollection()->each(function ($c) {
425            if ($c->user_id) {
426                $c->setRelation('user', User::find($c->user_id));
427            }
428        });
429
430        return response()->json([
431            'status' => 'success',
432            'data' => RolePlayConversationAdminResource::collection($paginator->getCollection())->toArray($request),
433            'meta' => [
434                'current_page' => $paginator->currentPage(),
435                'per_page' => $paginator->perPage(),
436                'total' => $paginator->total(),
437                'last_page' => $paginator->lastPage(),
438            ],
439        ]);
440    }
441
442    /**
443     * Session detail — admin view with transcript + feedback.
444     */
445    public function sessionDetail(Request $request, string $id, string $sessionId): JsonResponse
446    {
447        $user = $request->user();
448        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
449
450        if (! $project) {
451            return $this->notFound();
452        }
453
454        if ($user->cannot('manageSessions', $project)) {
455            return $this->forbidden();
456        }
457
458        $conversation = RolePlayConversations::find($sessionId);
459        if (! $conversation || ! $this->conversationBelongsToPersona($conversation, $id)) {
460            return $this->notFound('Session not found');
461        }
462
463        if ($conversation->user_id) {
464            $conversation->setRelation('user', User::find($conversation->user_id));
465        }
466
467        return response()->json([
468            'status' => 'success',
469            'data' => (new RolePlayConversationAdminResource($conversation))->toArray($request),
470        ]);
471    }
472
473    /**
474     * Analytics dashboard for a corporate persona. The payload shape is
475     * locked by the Phase 1 brief — keep it stable unless the brief changes.
476     */
477    public function analytics(Request $request, string $id): JsonResponse
478    {
479        $user = $request->user();
480        $project = CompanyRolePlayProject::forCompany($user->company_id)->find($id);
481
482        if (! $project) {
483            return $this->notFound();
484        }
485
486        if ($user->cannot('manageSessions', $project)) {
487            return $this->forbidden();
488        }
489
490        $lookbackWeeks = max(1, (int) $request->input('weeks', 13));
491        $lookbackDays = 90;
492        $lookbackStart = Carbon::now()->subDays($lookbackDays)->startOfDay();
493
494        $cloneIds = RolePlayProjects::where('company_project_id', $id)->pluck('id')->map(fn ($x) => (string) $x)->all();
495
496        $rows = RolePlayConversations::query()
497            ->where(function ($q) use ($id, $cloneIds) {
498                $q->where(function ($direct) use ($id) {
499                    $direct->whereNull('project_id')->where('company_project_id', $id);
500                });
501                if (! empty($cloneIds)) {
502                    $q->orWhere(function ($clone) use ($cloneIds) {
503                        $clone->whereIn('project_id', $cloneIds);
504                    });
505                }
506            })
507            ->where('created_at', '>=', $lookbackStart)
508            ->get();
509
510        $analytics = $this->computeAnalytics($rows, $user->company_id, $lookbackWeeks);
511
512        return response()->json([
513            'status' => 'success',
514            'data' => (new CompanyRolePlayAnalyticsResource($analytics))->toArray($request),
515        ]);
516    }
517
518    /**
519     * Compute the analytics payload in the locked Phase 1 shape.
520     *
521     * @param  \Illuminate\Database\Eloquent\Collection<int, RolePlayConversations>  $rows
522     * @return array<string, mixed>
523     */
524    private function computeAnalytics($rows, string $companyId, int $trendWeeks): array
525    {
526        $scores = $rows->pluck('score')->filter(fn ($s) => $s !== null)->map(fn ($s) => (float) $s)->values();
527        $durations = $rows->pluck('duration')->filter(fn ($d) => $d !== null)->map(fn ($d) => (float) $d)->values();
528
529        $totals = [
530            'sessions' => $rows->count(),
531            'participants' => $rows->pluck('user_id')->unique()->filter()->count(),
532            'avg_score' => $scores->count() > 0 ? round((float) $scores->avg(), 2) : 0,
533            'median_score' => $scores->count() > 0 ? $this->median($scores->all()) : 0,
534            'avg_duration' => $durations->count() > 0 ? round((float) $durations->avg(), 2) : 0,
535        ];
536
537        // Weekly series covering the full trendWeeks window.
538        $series = [];
539        for ($i = $trendWeeks - 1; $i >= 0; $i--) {
540            $weekStart = Carbon::now()->subWeeks($i)->startOfWeek();
541            $series[$weekStart->format('o-\WW')] = ['scores' => [], 'sessions' => 0];
542        }
543        foreach ($rows as $row) {
544            $key = Carbon::parse($row->created_at)->format('o-\WW');
545            if (! isset($series[$key])) {
546                continue;
547            }
548            $series[$key]['sessions']++;
549            if ($row->score !== null) {
550                $series[$key]['scores'][] = (float) $row->score;
551            }
552        }
553
554        $timeseries = [];
555        foreach ($series as $week => $bucket) {
556            $timeseries[] = [
557                'week' => $week,
558                'avg_score' => count($bucket['scores']) > 0 ? round(array_sum($bucket['scores']) / count($bucket['scores']), 2) : 0,
559                'sessions' => $bucket['sessions'],
560            ];
561        }
562
563        // Group breakdown — keyed by the user's company_group_id at time of read.
564        $userIds = $rows->pluck('user_id')->unique()->filter()->values()->all();
565        $users = User::whereIn('_id', $userIds)->get()->keyBy(fn ($u) => (string) $u->id);
566        $groupIds = $users->pluck('company_group_id')->unique()->filter()->values()->all();
567        $groups = CompanyGroup::whereIn('_id', $groupIds)->get()->keyBy(fn ($g) => (string) $g->id);
568
569        $byGroup = [];
570        $groupBuckets = [];
571        foreach ($rows as $row) {
572            $u = $users->get((string) $row->user_id);
573            $gid = $u?->company_group_id ? (string) $u->company_group_id : 'unassigned';
574            if (! isset($groupBuckets[$gid])) {
575                $groupBuckets[$gid] = ['sessions' => 0, 'scores' => [], 'participants' => []];
576            }
577            $groupBuckets[$gid]['sessions']++;
578            if ($row->score !== null) {
579                $groupBuckets[$gid]['scores'][] = (float) $row->score;
580            }
581            $groupBuckets[$gid]['participants'][(string) $row->user_id] = true;
582        }
583        foreach ($groupBuckets as $gid => $bucket) {
584            $byGroup[] = [
585                'group_id' => $gid === 'unassigned' ? null : $gid,
586                'name' => $gid === 'unassigned' ? 'Unassigned' : ($groups->get($gid)?->name ?? 'Unknown'),
587                'participants' => count($bucket['participants']),
588                'sessions' => $bucket['sessions'],
589                'avg_score' => count($bucket['scores']) > 0 ? round(array_sum($bucket['scores']) / count($bucket['scores']), 2) : 0,
590            ];
591        }
592
593        // User breakdown with a trailing-8-week trend per user.
594        $byUser = [];
595        $userBuckets = [];
596        foreach ($rows as $row) {
597            $uid = (string) $row->user_id;
598            if (! isset($userBuckets[$uid])) {
599                $userBuckets[$uid] = [
600                    'sessions' => 0,
601                    'scores' => [],
602                    'last_at' => null,
603                    'by_week' => [],
604                ];
605            }
606            $userBuckets[$uid]['sessions']++;
607            if ($row->score !== null) {
608                $userBuckets[$uid]['scores'][] = (float) $row->score;
609            }
610            $created = Carbon::parse($row->created_at);
611            $ts = $created->timestamp;
612            if ($userBuckets[$uid]['last_at'] === null || $ts > $userBuckets[$uid]['last_at']) {
613                $userBuckets[$uid]['last_at'] = $ts;
614            }
615            $weekKey = $created->format('o-\WW');
616            if (! isset($userBuckets[$uid]['by_week'][$weekKey])) {
617                $userBuckets[$uid]['by_week'][$weekKey] = [];
618            }
619            if ($row->score !== null) {
620                $userBuckets[$uid]['by_week'][$weekKey][] = (float) $row->score;
621            }
622        }
623
624        foreach ($userBuckets as $uid => $bucket) {
625            $u = $users->get($uid);
626            $byUser[] = [
627                'user_id' => $uid,
628                'name' => $u ? trim(($u->first_name ?? '').' '.($u->last_name ?? '')) : 'Unknown',
629                'sessions' => $bucket['sessions'],
630                'avg_score' => count($bucket['scores']) > 0 ? round(array_sum($bucket['scores']) / count($bucket['scores']), 2) : 0,
631                'last_session_at' => $bucket['last_at'] ?? 0,
632                'trend' => $this->trendTail($bucket['by_week'], 8),
633            ];
634        }
635
636        return [
637            'totals' => $totals,
638            'timeseries' => $timeseries,
639            'by_group' => $byGroup,
640            'by_user' => $byUser,
641        ];
642    }
643
644    /**
645     * Build the trailing-N weekly average sequence (oldest→newest, 0 for empty weeks).
646     *
647     * @param  array<string, array<int, float>>  $byWeek
648     * @return array<int, float>
649     */
650    private function trendTail(array $byWeek, int $n): array
651    {
652        $out = [];
653        for ($i = $n - 1; $i >= 0; $i--) {
654            $key = Carbon::now()->subWeeks($i)->startOfWeek()->format('o-\WW');
655            $scores = $byWeek[$key] ?? [];
656            $out[] = count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : 0;
657        }
658
659        return $out;
660    }
661
662    /**
663     * Integer-position median (no interpolation) — keeps the wire shape stable
664     * for small samples.
665     */
666    private function median(array $values): float
667    {
668        sort($values);
669        $n = count($values);
670        if ($n === 0) {
671            return 0;
672        }
673        $mid = (int) floor(($n - 1) / 2);
674        if ($n % 2 === 0) {
675            return round(($values[$mid] + $values[$mid + 1]) / 2, 2);
676        }
677
678        return round((float) $values[$mid], 2);
679    }
680
681    private function conversationBelongsToPersona(RolePlayConversations $conversation, string $personaId): bool
682    {
683        if ((string) $conversation->company_project_id === (string) $personaId) {
684            return true;
685        }
686
687        if ($conversation->project_id) {
688            $project = RolePlayProjects::find($conversation->project_id);
689
690            return $project && (string) $project->company_project_id === (string) $personaId;
691        }
692
693        return false;
694    }
695
696    private function notFound(string $message = 'Persona not found'): JsonResponse
697    {
698        return response()->json(['status' => 'error', 'message' => $message], 404);
699    }
700
701    private function forbidden(string $message = 'Forbidden'): JsonResponse
702    {
703        return response()->json(['status' => 'error', 'message' => $message], 403);
704    }
705}