Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.20% covered (success)
93.20%
96 / 103
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlaySectionAveragesService
93.20% covered (success)
93.20%
96 / 103
50.00% covered (danger)
50.00%
3 / 6
25.20
0.00% covered (danger)
0.00%
0 / 1
 recomputeAll
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
7
 getForViewer
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 averageSectionsAcrossProgressions
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
11.56
 upsertSnapshot
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 fetch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 shapeForResponse
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3namespace App\Http\Services\RolePlay;
4
5use App\Http\Models\Auth\User;
6use App\Http\Models\RolePlaySectionAverage;
7use App\Http\Models\UserRolePlayProgression;
8use Illuminate\Support\Collection;
9
10/**
11 * Computes and serves the pre-aggregated section-balance averages used by
12 * the roleplay dashboard radar. Uses a user-fair mean: each user counts
13 * once per (scope, call_type), regardless of how many sessions they ran.
14 *
15 * Snapshots are refreshed by the `roleplay:recompute-section-averages`
16 * scheduled command (every 6h). The dashboard endpoint reads the cached
17 * rows directly so each page load is a single indexed lookup.
18 */
19class RolePlaySectionAveragesService
20{
21    /**
22     * Recompute every (scope, scope_id, call_type) snapshot from scratch.
23     * Returns the number of rows upserted.
24     */
25    public function recomputeAll(): int
26    {
27        $progressions = UserRolePlayProgression::all();
28        if ($progressions->isEmpty()) {
29            return 0;
30        }
31
32        // Preload users keyed by id to resolve company_id without N+1 queries.
33        $userIds = $progressions->pluck('user_id')->unique()->filter()->values()->all();
34        $usersById = User::whereIn('_id', $userIds)
35            ->get(['_id', 'company_id'])
36            ->keyBy(fn (User $u) => (string) $u->id);
37
38        $now = now();
39        $rowsWritten = 0;
40
41        // Group progressions by call_type so each snapshot is isolated.
42        $byCallType = $progressions->groupBy('call_type');
43
44        foreach ($byCallType as $callType => $progressionsForType) {
45            if (! $callType) {
46                continue;
47            }
48
49            // â”€â”€ Global snapshot â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
50            $globalSections = $this->averageSectionsAcrossProgressions($progressionsForType);
51            $this->upsertSnapshot(
52                RolePlaySectionAverage::SCOPE_GLOBAL,
53                null,
54                (string) $callType,
55                $globalSections,
56                $progressionsForType->count(),
57                $now,
58            );
59            $rowsWritten++;
60
61            // â”€â”€ Per-company snapshots â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
62            $byCompany = $progressionsForType->groupBy(function (UserRolePlayProgression $progression) use ($usersById) {
63                $user = $usersById->get((string) $progression->user_id);
64
65                return $user?->company_id ? (string) $user->company_id : null;
66            });
67
68            foreach ($byCompany as $companyId => $progressionsForCompany) {
69                if (! $companyId) {
70                    continue;
71                }
72
73                $companySections = $this->averageSectionsAcrossProgressions($progressionsForCompany);
74                $this->upsertSnapshot(
75                    RolePlaySectionAverage::SCOPE_COMPANY,
76                    (string) $companyId,
77                    (string) $callType,
78                    $companySections,
79                    $progressionsForCompany->count(),
80                    $now,
81                );
82                $rowsWritten++;
83            }
84        }
85
86        return $rowsWritten;
87    }
88
89    /**
90     * Load the company and global snapshots for the viewing user, shaped
91     * for the dashboard response. Datasets with zero sections are
92     * returned as null so the frontend can hide them.
93     *
94     * @return array{company: ?array{sections: array, user_count: int, computed_at: ?string}, global: ?array{sections: array, user_count: int, computed_at: ?string}}
95     */
96    public function getForViewer(User $viewer, string $callType): array
97    {
98        $global = $this->fetch(RolePlaySectionAverage::SCOPE_GLOBAL, null, $callType);
99
100        $company = null;
101        if ($viewer->company_id) {
102            $company = $this->fetch(
103                RolePlaySectionAverage::SCOPE_COMPANY,
104                (string) $viewer->company_id,
105                $callType,
106            );
107        }
108
109        return [
110            'company' => $this->shapeForResponse($company),
111            'global' => $this->shapeForResponse($global),
112        ];
113    }
114
115    /**
116     * Compute the user-fair mean of current_averages.sections across a
117     * collection of UserRolePlayProgression documents.
118     *
119     * @param  Collection<int, UserRolePlayProgression>  $progressions
120     * @return array<int, array{name: string, score: float}>
121     */
122    private function averageSectionsAcrossProgressions(Collection $progressions): array
123    {
124        $sums = [];
125        $counts = [];
126
127        foreach ($progressions as $progression) {
128            $sections = $progression->current_averages['sections'] ?? null;
129            if (! is_array($sections) || empty($sections)) {
130                continue;
131            }
132
133            foreach ($sections as $name => $score) {
134                if (! is_string($name) || $name === '') {
135                    continue;
136                }
137                $numeric = is_numeric($score) ? (float) $score : null;
138                if ($numeric === null) {
139                    continue;
140                }
141
142                $sums[$name] = ($sums[$name] ?? 0.0) + $numeric;
143                $counts[$name] = ($counts[$name] ?? 0) + 1;
144            }
145        }
146
147        $result = [];
148        foreach ($sums as $name => $sum) {
149            $count = $counts[$name] ?? 0;
150            if ($count === 0) {
151                continue;
152            }
153            $result[] = [
154                'name' => $name,
155                'score' => round($sum / $count, 2),
156            ];
157        }
158
159        return $result;
160    }
161
162    private function upsertSnapshot(
163        string $scope,
164        ?string $scopeId,
165        string $callType,
166        array $sections,
167        int $userCount,
168        \Carbon\Carbon $computedAt,
169    ): void {
170        RolePlaySectionAverage::updateOrCreate(
171            [
172                'scope' => $scope,
173                'scope_id' => $scopeId,
174                'call_type' => $callType,
175            ],
176            [
177                'sections' => $sections,
178                'user_count' => $userCount,
179                'computed_at' => $computedAt,
180            ],
181        );
182    }
183
184    private function fetch(string $scope, ?string $scopeId, string $callType): ?RolePlaySectionAverage
185    {
186        return RolePlaySectionAverage::where('scope', $scope)
187            ->where('scope_id', $scopeId)
188            ->where('call_type', $callType)
189            ->first();
190    }
191
192    /**
193     * @return array{sections: array, user_count: int, computed_at: ?string}|null
194     */
195    private function shapeForResponse(?RolePlaySectionAverage $snapshot): ?array
196    {
197        if (! $snapshot) {
198            return null;
199        }
200
201        $sections = $snapshot->sections ?? [];
202        if (empty($sections)) {
203            return null;
204        }
205
206        return [
207            'sections' => $sections,
208            'user_count' => (int) ($snapshot->user_count ?? 0),
209            'computed_at' => $snapshot->computed_at?->toIso8601String(),
210        ];
211    }
212}