Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
84.62% |
121 / 143 |
|
85.71% |
6 / 7 |
CRAP | |
0.00% |
0 / 1 |
| CompanyRolePlaySessionService | |
84.62% |
121 / 143 |
|
85.71% |
6 / 7 |
45.83 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| sessionsForUserOnCorporatePersona | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
| sessionsForCorporatePersona | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
| userProgressionOnCorporatePersona | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
2 | |||
| aggregatesForUserOnCorporatePersonas | |
54.17% |
26 / 48 |
|
0.00% |
0 / 1 |
40.65 | |||
| applyFilters | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
8 | |||
| buildProgression | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
8 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Services; |
| 4 | |
| 5 | use App\Http\Models\RolePlayConversations; |
| 6 | use App\Http\Models\RolePlayProjects; |
| 7 | use App\Http\Repositories\RolePlayProjectsRepository; |
| 8 | use Carbon\Carbon; |
| 9 | use Illuminate\Contracts\Pagination\LengthAwarePaginator; |
| 10 | use Illuminate\Database\Eloquent\Builder; |
| 11 | use 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 | */ |
| 26 | class 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 | } |