Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.62% covered (warning)
84.62%
121 / 143
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CompanyRolePlaySessionService
84.62% covered (warning)
84.62%
121 / 143
85.71% covered (warning)
85.71%
6 / 7
45.83
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
 sessionsForUserOnCorporatePersona
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 sessionsForCorporatePersona
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 userProgressionOnCorporatePersona
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 aggregatesForUserOnCorporatePersonas
54.17% covered (warning)
54.17%
26 / 48
0.00% covered (danger)
0.00%
0 / 1
40.65
 applyFilters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 buildProgression
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\RolePlayConversations;
6use App\Http\Models\RolePlayProjects;
7use App\Http\Repositories\RolePlayProjectsRepository;
8use Carbon\Carbon;
9use Illuminate\Contracts\Pagination\LengthAwarePaginator;
10use Illuminate\Database\Eloquent\Builder;
11use Illuminate\Database\Eloquent\Collection;
12
13/**
14 * Session attribution service for corporate personas.
15 *
16 * The Corporate Personas feature splits roleplay sessions into two shapes:
17 *
18 *  - **Direct calls:** `project_id = null`, `company_project_id = X`
19 *  - **Cloned calls:** `project_id = Y` where Y is a user-owned
20 *    {@see RolePlayProjects} whose `company_project_id = X`
21 *
22 * Admin dashboards (and the end-user's own "my sessions on this persona"
23 * view) need to see *both* shapes as a single list. This service owns the
24 * union query so the controllers stay thin.
25 */
26class CompanyRolePlaySessionService
27{
28    public function __construct(
29        private readonly RolePlayProjectsRepository $projectsRepository,
30    ) {}
31
32    /**
33     * Sessions for a specific user on a specific corporate persona.
34     *
35     * @param  string  $userId
36     * @param  string  $companyProjectId
37     * @param  array{status?: string|null, call_type?: string|null, date_from?: string|null, date_to?: string|null, per_page?: int|null, min_score?: float|null, max_score?: float|null}  $filters
38     */
39    public function sessionsForUserOnCorporatePersona(
40        string $userId,
41        string $companyProjectId,
42        array $filters = [],
43    ): LengthAwarePaginator {
44        $cloneIds = $this->projectsRepository->cloneIdsOf($companyProjectId, $userId);
45
46        $query = RolePlayConversations::query()
47            ->where(function (Builder $q) use ($userId, $companyProjectId, $cloneIds) {
48                $q->where(function (Builder $direct) use ($userId, $companyProjectId) {
49                    $direct->whereNull('project_id')
50                        ->where('company_project_id', $companyProjectId)
51                        ->where('user_id', $userId);
52                })->orWhere(function (Builder $clone) use ($userId, $cloneIds) {
53                    if (empty($cloneIds)) {
54                        // ensure the OR clause never matches if there are no clones
55                        $clone->whereRaw(['_id' => ['$in' => []]]);
56
57                        return;
58                    }
59                    $clone->whereIn('project_id', $cloneIds)
60                        ->where('user_id', $userId);
61                });
62            });
63
64        $this->applyFilters($query, $filters);
65
66        return $query->orderBy('created_at', 'desc')
67            ->paginate((int) ($filters['per_page'] ?? 15));
68    }
69
70    /**
71     * Admin view: every session (from any user) tied to a corporate persona,
72     * both direct and via clones.
73     *
74     * @param  string  $companyProjectId
75     * @param  array{status?: string|null, call_type?: string|null, date_from?: string|null, date_to?: string|null, min_score?: float|null, max_score?: float|null, user_id?: string|null, per_page?: int|null}  $filters
76     */
77    public function sessionsForCorporatePersona(
78        string $companyProjectId,
79        array $filters = [],
80    ): LengthAwarePaginator {
81        $cloneIds = $this->projectsRepository->cloneIdsOf($companyProjectId);
82
83        $query = RolePlayConversations::query()
84            ->where(function (Builder $q) use ($companyProjectId, $cloneIds) {
85                $q->where(function (Builder $direct) use ($companyProjectId) {
86                    $direct->whereNull('project_id')
87                        ->where('company_project_id', $companyProjectId);
88                })->orWhere(function (Builder $clone) use ($cloneIds) {
89                    if (empty($cloneIds)) {
90                        $clone->whereRaw(['_id' => ['$in' => []]]);
91
92                        return;
93                    }
94                    $clone->whereIn('project_id', $cloneIds);
95                });
96            });
97
98        if (! empty($filters['user_id'])) {
99            $query->where('user_id', $filters['user_id']);
100        }
101
102        $this->applyFilters($query, $filters);
103
104        return $query->orderBy('created_at', 'desc')
105            ->paginate((int) ($filters['per_page'] ?? 15));
106    }
107
108    /**
109     * Build the per-user progression summary for a corporate persona.
110     *
111     * Returns `avg_score` across scored sessions (done + numeric score) and
112     * a weekly timeseries covering the lookback window. Empty weeks return
113     * `avg_score = 0, sessions = 0` so the frontend can plot a flat line.
114     *
115     * @return array{avg_score: float, timeseries: array<int, array{week: string, avg_score: float, sessions: int}>}
116     */
117    public function userProgressionOnCorporatePersona(
118        string $userId,
119        string $companyProjectId,
120        int $weeks = 12,
121    ): array {
122        $cloneIds = $this->projectsRepository->cloneIdsOf($companyProjectId, $userId);
123        $lookback = Carbon::now()->subWeeks($weeks)->startOfWeek();
124
125        $rows = RolePlayConversations::query()
126            ->where('status', 'done')
127            ->where('created_at', '>=', $lookback)
128            ->where(function (Builder $q) use ($userId, $companyProjectId, $cloneIds) {
129                $q->where(function (Builder $direct) use ($userId, $companyProjectId) {
130                    $direct->whereNull('project_id')
131                        ->where('company_project_id', $companyProjectId)
132                        ->where('user_id', $userId);
133                })->orWhere(function (Builder $clone) use ($userId, $cloneIds) {
134                    if (empty($cloneIds)) {
135                        $clone->whereRaw(['_id' => ['$in' => []]]);
136
137                        return;
138                    }
139                    $clone->whereIn('project_id', $cloneIds)
140                        ->where('user_id', $userId);
141                });
142            })
143            ->get(['score', 'created_at']);
144
145        return $this->buildProgression($rows, $weeks);
146    }
147
148    /**
149     * Per-user aggregates across many corporate personas in a single pass.
150     *
151     * Used to enrich the corporate-personas list view with per-user stats
152     * (sessions count, last practice date, average score) without firing
153     * one query per persona. Direct calls and clone calls are both
154     * counted, mirroring `sessionsForUserOnCorporatePersona` semantics.
155     *
156     * @param  string  $userId
157     * @param  array<int, string>  $companyProjectIds
158     * @return array<string, array{sessions_count: int, last_practiced_at: ?string, avg_score: ?float}>
159     *         Map of companyProjectId â†’ aggregates. Personas with no
160     *         sessions are omitted; the caller should treat absence as
161     *         zero / null.
162     */
163    public function aggregatesForUserOnCorporatePersonas(
164        string $userId,
165        array $companyProjectIds,
166    ): array {
167        if (empty($companyProjectIds)) {
168            return [];
169        }
170
171        // Map every cloned RolePlayProjects.id back to the corporate persona
172        // it was cloned from, so the conversation rows we collect can be
173        // bucketed by source corporate project regardless of whether they
174        // hit `company_project_id` (direct) or `project_id` (clone).
175        $cloneRows = RolePlayProjects::query()
176            ->where('user_id', $userId)
177            ->whereIn('company_project_id', $companyProjectIds)
178            ->get(['_id', 'company_project_id']);
179        $cloneIdToCorporate = [];
180        foreach ($cloneRows as $clone) {
181            $cloneIdToCorporate[(string) $clone->_id] = (string) $clone->company_project_id;
182        }
183        $cloneIds = array_keys($cloneIdToCorporate);
184
185        $rows = RolePlayConversations::query()
186            ->where('user_id', $userId)
187            ->where(function (Builder $q) use ($companyProjectIds, $cloneIds) {
188                $q->where(function (Builder $direct) use ($companyProjectIds) {
189                    $direct->whereNull('project_id')
190                        ->whereIn('company_project_id', $companyProjectIds);
191                })->orWhere(function (Builder $clone) use ($cloneIds) {
192                    if (empty($cloneIds)) {
193                        $clone->whereRaw(['_id' => ['$in' => []]]);
194
195                        return;
196                    }
197                    $clone->whereIn('project_id', $cloneIds);
198                });
199            })
200            ->get(['project_id', 'company_project_id', 'score', 'status', 'created_at']);
201
202        $buckets = [];
203        foreach ($rows as $row) {
204            $corporateId = $row->company_project_id
205                ? (string) $row->company_project_id
206                : ($cloneIdToCorporate[(string) $row->project_id] ?? null);
207            if ($corporateId === null) {
208                continue;
209            }
210
211            if (! isset($buckets[$corporateId])) {
212                $buckets[$corporateId] = ['count' => 0, 'scores' => [], 'last' => null];
213            }
214            $buckets[$corporateId]['count']++;
215            if ($row->status === 'done' && $row->score !== null) {
216                $buckets[$corporateId]['scores'][] = (float) $row->score;
217            }
218            $createdAt = $row->created_at ? Carbon::parse($row->created_at) : null;
219            if ($createdAt && (! $buckets[$corporateId]['last'] || $createdAt->gt($buckets[$corporateId]['last']))) {
220                $buckets[$corporateId]['last'] = $createdAt;
221            }
222        }
223
224        $out = [];
225        foreach ($buckets as $id => $b) {
226            $out[$id] = [
227                'sessions_count' => $b['count'],
228                'last_practiced_at' => $b['last']?->toIso8601String(),
229                'avg_score' => count($b['scores']) > 0
230                    ? round(array_sum($b['scores']) / count($b['scores']), 2)
231                    : null,
232            ];
233        }
234
235        return $out;
236    }
237
238    /**
239     * Shared filter application. Centralised so both admin and user queries
240     * honor the same semantics for scores and date windows.
241     *
242     * @param  Builder  $query
243     * @param  array<string, mixed>  $filters
244     */
245    private function applyFilters(Builder $query, array $filters): void
246    {
247        if (! empty($filters['status'])) {
248            $query->where('status', $filters['status']);
249        }
250        if (isset($filters['min_score']) && $filters['min_score'] !== null) {
251            $query->where('score', '>=', (float) $filters['min_score']);
252        }
253        if (isset($filters['max_score']) && $filters['max_score'] !== null) {
254            $query->where('score', '<=', (float) $filters['max_score']);
255        }
256        if (! empty($filters['date_from'])) {
257            $query->where('created_at', '>=', Carbon::parse($filters['date_from'])->startOfDay());
258        }
259        if (! empty($filters['date_to'])) {
260            $query->where('created_at', '<=', Carbon::parse($filters['date_to'])->endOfDay());
261        }
262    }
263
264    /**
265     * Summarise a collection of scored conversations into avg + weekly series.
266     *
267     * @param  Collection<int, RolePlayConversations>  $rows
268     * @return array{avg_score: float, timeseries: array<int, array{week: string, avg_score: float, sessions: int}>}
269     */
270    public function buildProgression(Collection $rows, int $weeks): array
271    {
272        $scores = $rows->pluck('score')->filter(fn ($s) => $s !== null);
273        $avg = $scores->count() > 0 ? round((float) $scores->avg(), 2) : 0.0;
274
275        // Pre-fill buckets for every week in the window so empty weeks render.
276        $series = [];
277        for ($i = $weeks - 1; $i >= 0; $i--) {
278            $weekStart = Carbon::now()->subWeeks($i)->startOfWeek();
279            $series[$weekStart->format('o-\WW')] = ['scores' => [], 'sessions' => 0];
280        }
281
282        foreach ($rows as $row) {
283            $created = Carbon::parse($row->created_at);
284            $key = $created->format('o-\WW');
285            if (! isset($series[$key])) {
286                // Sample created before the lookback (shouldn't happen given
287                // the caller's filter, but be defensive).
288                continue;
289            }
290            $series[$key]['sessions']++;
291            if ($row->score !== null) {
292                $series[$key]['scores'][] = (float) $row->score;
293            }
294        }
295
296        $timeseries = [];
297        foreach ($series as $week => $bucket) {
298            $weekAvg = count($bucket['scores']) > 0
299                ? round(array_sum($bucket['scores']) / count($bucket['scores']), 2)
300                : 0.0;
301            $timeseries[] = [
302                'week' => $week,
303                'avg_score' => $weekAvg,
304                'sessions' => $bucket['sessions'],
305            ];
306        }
307
308        return [
309            'avg_score' => $avg,
310            'timeseries' => $timeseries,
311        ];
312    }
313}