Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.96% covered (warning)
61.96%
303 / 489
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReportController
61.96% covered (warning)
61.96%
303 / 489
40.00% covered (danger)
40.00%
6 / 15
258.11
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
 availableCharts
39.13% covered (danger)
39.13%
9 / 23
0.00% covered (danger)
0.00%
0 / 1
5.03
 chart
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 data
96.08% covered (success)
96.08%
147 / 153
0.00% covered (danger)
0.00%
0 / 1
12
 company_users_overview
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
12
 company_licenses_overview
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
6
 company_usage_overview
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
2
 export
100.00% covered (success)
100.00%
84 / 84
100.00% covered (success)
100.00%
1 / 1
16
 resolveEffectiveUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildDateFilteredAggregates
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 resolveExportColumns
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 spotlight
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 topUsers
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 coachLevel
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 resolveRound
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2
3namespace App\Http\Controllers\v2\Company;
4
5use App\Http\Controllers\Controller;
6use App\Http\Models\Admin\AdminUserInvitation;
7use App\Http\Models\Admin\CompanyLicenses;
8use App\Http\Models\Auth\Role;
9use App\Http\Models\Auth\User;
10use App\Http\Models\FlyMsgUserDailyUsage;
11use App\Http\Models\Setting;
12use App\Http\Models\UserInfo;
13use App\Http\Requests\v2\Reports\GetReportRequest;
14use App\Http\Services\Admin\Companies\CompanyUsersService;
15use App\Http\Services\Admin\Users\IndividualUsersService;
16use App\Http\Services\CsvExportService;
17use App\Http\Services\Reports\ReportService;
18use App\Traits\ReportFilterTrait;
19use Carbon\Carbon;
20use Illuminate\Http\JsonResponse;
21use Illuminate\Http\Request;
22use Illuminate\Support\Facades\Log;
23use MongoDB\BSON\UTCDateTime;
24use Symfony\Component\HttpFoundation\StreamedResponse;
25
26class ReportController extends Controller
27{
28    use ReportFilterTrait;
29
30    private array $propertyMap = [
31        'time' => 'time_saved',
32        'cost' => 'cost_savings',
33        'shortcuts_created' => 'flycuts_created',
34        'templates' => 'flyplates_added',
35        'chars' => 'characters_typed',
36        'shortcuts_used' => 'flycut_count',
37        'comments_generated' => 'flyengage_count',
38        'posts_generated' => 'flypost_count',
39        'paragraph_rewrite' => 'paragraph_rewrite_count',
40        'grammar' => 'fly_grammar_actions',
41        'issues_accepted' => 'fly_grammar_accepted',
42        'issues_autocorrect' => 'fly_grammar_autocorrect',
43        'issues_autocomplete' => 'fly_grammar_autocomplete',
44        'sentence_rewrite' => 'sentence_rewrite_count',
45        'roleplay_personas' => 'roleplay_personas_created',
46        'roleplay_sessions' => 'roleplay_sessions_practiced',
47        'roleplay_time' => 'roleplay_time_practiced',
48        'roleplay_score' => 'roleplay_daily_avg_score',
49    ];
50
51    /**
52     * Chart ids that should be aggregated as AVG (skipping zero rows)
53     * instead of the default SUM. Currently only `roleplay_score`
54     * (a per-day average already — summing would be nonsensical).
55     *
56     * @var array<int, string>
57     */
58    private array $avgAggregatorCharts = ['roleplay_score'];
59
60    /**
61     * Maps chart IDs (from available-charts endpoint) to export column definitions.
62     * 'field'       → UserInfo collection field (lifetime totals, used when no date filter)
63     * 'daily_field' → FlyMsgUserDailyUsage field (used when from/to date filter is present)
64     * 'round'       → decimal places for rounding
65     */
66    private array $effectivenessColumns = [
67        'cost' => ['header' => 'Cost Savings ($)',   'field' => 'total_cost_savings_by_flymsg_by_user',    'daily_field' => 'cost_savings',           'round' => 5],
68        'time' => ['header' => 'Time Saved (hrs)',   'field' => 'total_time_saved_by_flymsg_by_user',      'daily_field' => 'time_saved',             'round' => 5],
69        'shortcuts_created' => ['header' => 'Shortcuts Created',  'field' => 'number_of_flycuts_created_count',         'daily_field' => 'flycuts_created',        'round' => 5],
70        'templates' => ['header' => 'Templates Added',    'field' => 'number_of_flyplates_in_flycuts_count',    'daily_field' => 'flyplates_added',        'round' => 5],
71    ];
72
73    private array $usageColumns = [
74        'chars' => ['header' => 'Characters Typed',             'field' => 'total___of_characters_typed_by_flymsg_by_user',        'daily_field' => 'characters_typed',       'round' => 0],
75        'grammar' => ['header' => 'AI Grammar Checker Used',     'field' => 'total___of_times_flygrammar_is_used_count',             'daily_field' => 'fly_grammar_actions',    'round' => 0],
76        'shortcuts_used' => ['header' => 'Shortcuts Deployed',          'field' => 'total___of_times_flycut_used__count_',                  'daily_field' => 'flycut_count',           'round' => 0],
77        'comments_generated' => ['header' => 'AI Comment Generator Used',   'field' => 'total___of_times_flyengage_used__count_',               'daily_field' => 'flyengage_count',        'round' => 0],
78        'posts_generated' => ['header' => 'AI Post Generator Used',      'field' => 'total___of_times_flyposts_used__count_',                'daily_field' => 'flypost_count',          'round' => 0],
79        'paragraph_rewrite' => ['header' => 'AI Paragraph Rewrite Used',   'field' => 'total___of_times_paragraph_rewrite_used__count_',       'daily_field' => 'paragraph_rewrite_count', 'round' => 0],
80    ];
81
82    /**
83     * Sales-roleplay chart → CSV column mapping. Kept separate from
84     * $usageColumns so the two sections can render independently in the
85     * frontend but still share the same export/summary machinery.
86     *
87     * @var array<string, array{header: string, field: string, daily_field: string, round: int}>
88     */
89    private array $salesRoleplayColumns = [
90        'roleplay_personas' => ['header' => 'Roleplay Personas Created', 'field' => 'total_roleplay_personas_created__count_', 'daily_field' => 'roleplay_personas_created', 'round' => 0],
91        'roleplay_sessions' => ['header' => 'Roleplay Sessions Practiced', 'field' => 'total___of_times_roleplay_used__count_', 'daily_field' => 'roleplay_sessions_practiced', 'round' => 0],
92        'roleplay_time' => ['header' => 'Roleplay Time Practiced (s)', 'field' => 'total_roleplay_time_practiced__count_', 'daily_field' => 'roleplay_time_practiced', 'round' => 0],
93        'roleplay_score' => ['header' => 'Roleplay Daily Avg Score', 'field' => 'last_roleplay_progression_score', 'daily_field' => 'roleplay_daily_avg_score', 'round' => 1],
94    ];
95
96    public function __construct(
97        private CompanyUsersService $companyUsersService,
98        private IndividualUsersService $individualUsersService,
99        private CsvExportService $csvExportService,
100        private ReportService $reportService
101    ) {}
102
103    public function availableCharts(Request $request, string $type): JsonResponse
104    {
105        if ($type === 'effectiveness') {
106            $charts = [
107                ['value' => 'cost', 'label' => 'Cost Saved ($)'],
108                ['value' => 'time', 'label' => 'Time Saved (hrs)'],
109                ['value' => 'shortcuts_created', 'label' => 'Shortcuts Created'],
110                ['value' => 'templates', 'label' => 'Templates Added'],
111            ];
112        } elseif ($type === 'sales_roleplay') {
113            // Dedicated Sales Roleplay section on the reports page. Each
114            // chart maps to a column on fly_msg_user_daily_usage.
115            $charts = [
116                ['value' => 'roleplay_personas', 'label' => 'Roleplay Personas Created'],
117                ['value' => 'roleplay_sessions', 'label' => 'Roleplay Sessions Practiced'],
118                ['value' => 'roleplay_time', 'label' => 'Roleplay Time Practiced'],
119                ['value' => 'roleplay_score', 'label' => 'Roleplay Daily Avg Score'],
120            ];
121        } else {
122            $charts = [
123                ['value' => 'chars', 'label' => 'Characters Typed'],
124                ['value' => 'grammar', 'label' => 'AI Grammar Checker Used'],
125                ['value' => 'shortcuts_used', 'label' => 'Shortcuts Deployed'],
126                ['value' => 'comments_generated', 'label' => 'AI Comment Generator Used'],
127                ['value' => 'posts_generated', 'label' => 'AI Post Generator Used'],
128                ['value' => 'paragraph_rewrite', 'label' => 'AI Paragraph Rewrite Used'],
129            ];
130        }
131
132        return response()->json(['data' => (array) $charts, 'success' => true], 200);
133    }
134
135    public function chart(GetReportRequest $request, string $type): JsonResponse
136    {
137        $property = $this->propertyMap[$type] ?? $type;
138        $round = $this->resolveRound($type);
139        $useAvg = in_array($type, $this->avgAggregatorCharts, true);
140
141        $baseMatch = $this->buildBaseMatchQuery($request);
142        $dailyMatch = $this->toDailyUsageMatch($baseMatch);
143        [$startDate, $endDate] = $this->getDateRange($request, $dailyMatch, FlyMsgUserDailyUsage::class);
144
145        $finalMatch = $dailyMatch;
146        $finalMatch['created_at'] = ['$gte' => new UTCDateTime($startDate), '$lte' => new UTCDateTime($endDate)];
147
148        // For averaged charts (e.g. roleplay_score), exclude zero-valued
149        // daily rows so a persona that never recorded a score doesn't
150        // drag the average down to 0 for the month.
151        if ($useAvg) {
152            $finalMatch[$property] = ['$gt' => 0];
153        }
154
155        $aggregation = $useAvg
156            ? ['$avg' => '$'.$property]
157            : ['$sum' => '$'.$property];
158
159        $pipeline = [
160            ['$match' => $finalMatch],
161            ['$group' => ['_id' => ['$dateToString' => ['format' => '%Y-%m', 'date' => '$created_at']], 'totalValue' => $aggregation]],
162            ['$sort' => ['_id' => 1]],
163            ['$project' => ['_id' => 0, 'month_year' => '$_id', 'total' => ['$round' => ['$totalValue', $round]]]],
164        ];
165
166        try {
167            $results = FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline));
168            $dbData = [];
169            foreach ($results as $result) {
170                $dbData[$result['month_year']] = $result['total'];
171            }
172            $chart = [];
173            $period = new \DatePeriod($startDate->copy()->startOfMonth(), new \DateInterval('P1M'), $endDate->copy()->endOfMonth());
174            foreach ($period as $date) {
175                $monthKey = $date->format('Y-m');
176                $chart[] = ['period' => $date->format('M Y'), 'value' => $dbData[$monthKey] ?? 0];
177            }
178
179            return response()->json(['data' => ['chart' => $chart], 'success' => true], 200);
180        } catch (\Exception $e) {
181            return response()->json(['success' => false, 'error' => $e->getMessage()], 400);
182        }
183    }
184
185    public function data(GetReportRequest $request, string $type): JsonResponse
186    {
187        $baseMatch = $this->buildBaseMatchQuery($request);
188        $dailyMatch = $this->toDailyUsageMatch($baseMatch);
189
190        // Extract company for wage lookup from the built match query
191        $companyId = $baseMatch['company_id']['$in'][0]
192            ?? ($request->input('company_ids') ? explode(',', $request->input('company_ids'))[0] : $request->user()->company_id);
193
194        $companySetting = Setting::where('company_id', $companyId)->first();
195        $wageValue = round($companySetting?->wage_per_hour ?? 31.75, 2); // phpcs:ignore
196
197        $textMap = [
198            'time' => [
199                'label' => 'Time Saved (hrs)',
200                'title' => 'Time Saved (hrs)',
201                'tooltip' => 'This show the total amount of time users saved over the selected period of time.',
202            ],
203            'cost' => [
204                'label' => 'Cost Saved ($)',
205                'title' => 'Cost Saved ($)',
206                'tooltip' => 'This show the total cost savings in USD using the average US knowledge worker wage of $'.$wageValue.'/over the selected period of time.',
207            ],
208            'shortcuts_created' => [
209                'label' => 'Shortcuts Created',
210                'title' => 'Shortcuts Created',
211                'tooltip' => 'Total number of Shortcuts (FlyCuts) created from users over the selected period of time.',
212            ],
213            'templates' => [
214                'label' => 'Templates Added',
215                'title' => 'Templates Added',
216                'tooltip' => 'Total number of Templates (FlyPlates) added to FlyCuts from users over the selected period of time.',
217            ],
218            'chars' => [
219                'label' => 'Characters Typed',
220                'title' => 'Characters Typed',
221                'tooltip' => 'This shows the total amount of characters users typed over the selected period of time.',
222            ],
223            'shortcuts_used' => [
224                'label' => 'Shortcuts Deployed',
225                'title' => 'Shortcuts Deployed',
226                'tooltip' => 'Total number of Shortcuts (FlyCuts) used by users over the selected period of time.',
227            ],
228            'comments_generated' => [
229                'label' => 'AI Comment Generator Used',
230                'title' => 'AI Comment Generator Used',
231                'tooltip' => 'Total number of times that AI Comment Generator Used (FlyEngage) was used over the selected period of time.',
232            ],
233            'posts_generated' => [
234                'label' => 'AI Post Generator Used',
235                'title' => 'AI Post Generator Used',
236                'tooltip' => 'Total number of times that AI Post Generator Used (FlyPosts) was used over the selected period of time.',
237            ],
238            'paragraph_rewrite' => [
239                'label' => 'AI Paragraph Rewrite Used',
240                'title' => 'AI Paragraph Rewrite Used',
241                'tooltip' => 'This shows the total number of times AI Sentence and Paragraph Rewrite was used over the selected period of time.',
242            ],
243            'grammar' => [
244                'label' => 'AI Grammar Checker Used',
245                'title' => 'AI Grammar Checker Used',
246                'tooltip' => 'Total number of times AI Grammar Checker was used by users over the selected period of time.',
247            ],
248            'issues_accepted' => [
249                'label' => 'AI Grammar Accepted',
250                'title' => 'AI Grammar Accepted',
251                'tooltip' => 'This total represents all grammar suggestions accepted by users over the selected period of time.',
252            ],
253            'issues_autocorrect' => [
254                'label' => 'AI Spelling Autocorrected',
255                'title' => 'AI Spelling Autocorrected',
256                'tooltip' => 'This total represents all spelling auto correction over the selected period of time.',
257            ],
258            'issues_autocomplete' => [
259                'label' => 'AI Sentence Autocompleted',
260                'title' => 'AI Sentence Autocompleted',
261                'tooltip' => 'This total represents all sentences auto completed accepted by users over the selected period of time.',
262            ],
263            'roleplay_personas' => [
264                'label' => 'Roleplay Personas Created',
265                'title' => 'Roleplay Personas Created',
266                'tooltip' => 'Total number of roleplay training personas users created over the selected period of time.',
267            ],
268            'roleplay_sessions' => [
269                'label' => 'Roleplay Sessions Practiced',
270                'title' => 'Roleplay Sessions Practiced',
271                'tooltip' => 'Total number of roleplay training sessions users completed over the selected period of time.',
272            ],
273            'roleplay_time' => [
274                'label' => 'Roleplay Time Practiced',
275                'title' => 'Roleplay Time Practiced',
276                'tooltip' => 'Total time (in seconds) users spent practicing in roleplay training over the selected period of time.',
277            ],
278            'roleplay_score' => [
279                'label' => 'Roleplay Daily Avg Score',
280                'title' => 'Roleplay Daily Avg Score',
281                'tooltip' => 'Average roleplay training score across scored sessions over the selected period of time.',
282            ],
283        ];
284
285        $property = $this->propertyMap[$type] ?? $type;
286        $round = $this->resolveRound($type);
287        $useAvg = in_array($type, $this->avgAggregatorCharts, true);
288        $textData = $textMap[$type] ?? ['label' => ucfirst($type), 'title' => 'Total '.ucfirst($type), 'tooltip' => ''];
289        $topUserLimit = 5;
290
291        [$startDate, $endDate] = $this->getDateRange($request, $dailyMatch, FlyMsgUserDailyUsage::class);
292
293        $finalMatch = $dailyMatch;
294        $finalMatch['created_at'] = ['$gte' => new UTCDateTime($startDate), '$lte' => new UTCDateTime($endDate)];
295
296        // For averaged charts, exclude zero-valued daily rows so empty
297        // days don't pull the mean down.
298        if ($useAvg) {
299            $finalMatch[$property] = ['$gt' => 0];
300        }
301
302        $userAgg = $useAvg ? ['$avg' => '$'.$property] : ['$sum' => '$'.$property];
303        $globalAgg = $useAvg ? ['$avg' => '$userTotal'] : ['$sum' => '$userTotal'];
304
305        try {
306            // --- QUERY 1: Get Summary Statistics (Total and Unique User Count) ---
307            $summaryPipeline = [
308                ['$match' => $finalMatch],
309                ['$group' => ['_id' => '$user_id', 'userTotal' => $userAgg]],
310                ['$group' => ['_id' => null, 'total' => $globalAgg, 'unique_users' => ['$sum' => 1]]],
311            ];
312            $summaryResult = FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($summaryPipeline))->first();
313
314            // --- QUERY 2: Get Top Users ---
315            // First, get per-user aggregates from daily usage within the date range
316            $dailyAggPipeline = [
317                ['$match' => $finalMatch],
318                ['$group' => ['_id' => '$user_id', 'value' => $userAgg]],
319            ];
320            $dailyAggResults = FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($dailyAggPipeline));
321            $userDailyValues = [];
322            foreach ($dailyAggResults as $row) {
323                $userDailyValues[$row['_id']] = $row['value'];
324            }
325
326            // Get all users in scope from UserInfo (respects company/group filters), including those with zero activity
327            $userInfoPipeline = [
328                ['$match' => $baseMatch],
329                ['$project' => ['_id' => 0, 'user_id' => 1, 'full_name' => 1, 'avatar' => 1]],
330            ];
331            $usersInScope = UserInfo::raw(fn ($c) => $c->aggregate($userInfoPipeline));
332
333            // Merge: assign daily value or 0 for each user
334            $allUsers = [];
335            foreach ($usersInScope as $user) {
336                $userId = $user['user_id'] ?? '';
337                $allUsers[] = [
338                    'name' => $user['full_name'] ?? 'Deleted User',
339                    'avatar' => $user['avatar'] ?? null,
340                    'value' => round($userDailyValues[$userId] ?? 0, $round),
341                ];
342            }
343
344            // Sort descending by value, then take top 5
345            usort($allUsers, fn ($a, $b) => $b['value'] <=> $a['value']);
346            $topUsers = array_slice($allUsers, 0, $topUserLimit);
347
348            $total = $summaryResult->total ?? 0;
349            $uniqueUsers = $summaryResult->unique_users ?? 0;
350            // For averaged charts the "total" is already a mean — reuse it
351            // as the "average" too. For summed charts the average is the
352            // classic total ÷ contributing users.
353            $average = $useAvg
354                ? $total
355                : ($uniqueUsers > 0 ? ($total / $uniqueUsers) : 0);
356
357            $summaryData = [
358                'total' => number_format(round($total, $round), $round),
359                'average' => round($average, 2) ? number_format($average, 2) : $average,
360                'top' => $topUsers,
361                'label' => $textData['label'],
362                'title' => $textData['title'],
363                'tooltip' => $textData['tooltip'],
364            ];
365
366            if ($type === 'cost') {
367                $summaryData['valuePrefix'] = '$';
368            }
369
370            return response()->json(['data' => $summaryData, 'success' => true], 200);
371        } catch (\Exception $e) {
372            Log::error('Summary data aggregation error: '.$e->getMessage());
373
374            return response()->json(['success' => false, 'error' => $e->getMessage()], 400);
375        }
376    }
377
378    public function company_users_overview(GetReportRequest $request): JsonResponse
379    {
380        $baseMatch = $this->buildBaseMatchQuery($request);
381        // Get Carbon dates first.
382        [$carbonStartDate, $carbonEndDate] = $this->getDateRange($request, $baseMatch, UserInfo::class, 'status_date');
383
384        $pipeline = [];
385        if (! empty($baseMatch)) {
386            $pipeline[] = ['$match' => $baseMatch];
387        }
388
389        // Prepare date clauses with UTCDateTime objects to prevent driver errors.
390        $statusDateClause = [];
391        $extensionInstallDateClause = [];
392        $extensionUninstallDateClause = [];
393
394        if ($carbonStartDate) {
395            $utcStartDate = new UTCDateTime($carbonStartDate);
396            $utcEndDate = new UTCDateTime($carbonEndDate);
397
398            $statusDateClause = ['status_date' => ['$gte' => $utcStartDate, '$lte' => $utcEndDate]];
399
400            $extensionInstallDateClause = ['$or' => [
401                ['flymsg_chrome_extension_installed__date_' => ['$gte' => $utcStartDate, '$lte' => $utcEndDate]],
402                ['flymsg_edge_extension_installed__date_' => ['$gte' => $utcStartDate, '$lte' => $utcEndDate]],
403            ]];
404
405            $extensionUninstallDateClause = ['$or' => [
406                ['flymsg_chrome_extension_uninstalled__date_' => ['$gte' => $utcStartDate, '$lte' => $utcEndDate]],
407                ['flymsg_edge_extension_uninstalled__date_' => ['$gte' => $utcStartDate, '$lte' => $utcEndDate]],
408            ]];
409        }
410
411        $pipeline[] = [
412            '$facet' => [
413                'activated_users' => [
414                    ['$match' => array_merge(['status' => 'Active'], $statusDateClause)],
415                    ['$count' => 'count'],
416                ],
417                'inactivated_users' => [
418                    ['$match' => array_merge(['status' => ['$nin' => ['Active', 'Invited']]], $statusDateClause)],
419                    ['$count' => 'count'],
420                ],
421                'extensions_installed' => [
422                    ['$match' => array_merge(
423                        ['is_any_extension_installed' => true, 'status' => 'Active'],
424                        $extensionInstallDateClause
425                    )],
426                    ['$count' => 'count'],
427                ],
428                'extensions_uninstalled' => [
429                    ['$match' => array_merge(
430                        [
431                            'is_any_extension_installed' => false,
432                            'is_any_extension_uninstalled' => true,
433                            'status' => ['$nin' => ['Deleted', 'Deactivated']],
434                        ],
435                        $extensionUninstallDateClause
436                    )],
437                    ['$count' => 'count'],
438                ],
439            ],
440        ];
441
442        $pipeline[] = [
443            '$project' => [
444                'activated_users' => ['$ifNull' => [['$arrayElemAt' => ['$activated_users.count', 0]], 0]],
445                'inactivated_users' => ['$ifNull' => [['$arrayElemAt' => ['$inactivated_users.count', 0]], 0]],
446                'extensions_installed' => ['$ifNull' => [['$arrayElemAt' => ['$extensions_installed.count', 0]], 0]],
447                'extensions_uninstalled' => ['$ifNull' => [['$arrayElemAt' => ['$extensions_uninstalled.count', 0]], 0]],
448            ],
449        ];
450
451        $result = UserInfo::raw(fn ($c) => $c->aggregate($pipeline))->first();
452
453        return response()->json([
454            'data' => [
455                ['amount' => round($result->activated_users ?? 0), 'title' => 'Activated Users', 'tooltip' => 'Total number of users activated.'],
456                ['amount' => round($result->extensions_installed ?? 0), 'title' => 'Extension Installed', 'tooltip' => 'Total number of activated users with the extension installed.'],
457                ['amount' => round($result->inactivated_users ?? 0), 'title' => 'Deactivated Users', 'tooltip' => 'Total number of users deactivated.'],
458                ['amount' => round($result->extensions_uninstalled ?? 0), 'title' => 'Extension Uninstalled', 'tooltip' => 'Total number of extensions uninstalled.'],
459            ],
460            'success' => true,
461        ]);
462    }
463
464    public function company_licenses_overview(GetReportRequest $request): JsonResponse
465    {
466        $baseMatch = $this->buildBaseMatchQuery($request);
467        $companyId = $baseMatch['company_id']['$in'][0] ?? $request->user()->company_id;
468
469        $company_license = CompanyLicenses::where('company_id', $companyId)->active()->first();
470        $totalLicenses = $company_license->total_number_of_licenses_available ?? 0;
471        $totalAssignedUserLicenses = User::where('company_id', $companyId)->where('status', 'Active')->count();
472        $totalAssignedInvitationLicenses = AdminUserInvitation::where('company_id', $companyId)->count();
473
474        $pipeline = [];
475
476        if (! empty($baseMatch)) {
477            $pipeline[] = ['$match' => $baseMatch];
478        }
479
480        $pipeline = array_merge($pipeline, [
481            ['$facet' => [
482                'activated_licenses' => [['$match' => ['status' => 'Active']], ['$count' => 'count']],
483                'invited_licenses' => [['$match' => ['status' => 'Invited']], ['$count' => 'count']],
484                'extensions_installed' => [['$match' => ['status' => 'Active', 'is_any_extension_installed' => true]], ['$count' => 'count']],
485            ]],
486            ['$project' => [
487                'activated_licenses' => ['$ifNull' => [['$arrayElemAt' => ['$activated_licenses.count', 0]], 0]],
488                'invited_licenses' => ['$ifNull' => [['$arrayElemAt' => ['$invited_licenses.count', 0]], 0]],
489                'extensions_installed' => ['$ifNull' => [['$arrayElemAt' => ['$extensions_installed.count', 0]], 0]],
490            ]],
491        ]);
492        $result = UserInfo::raw(fn ($c) => $c->aggregate($pipeline))->first();
493        $totalActivatedLicenses = $result->activated_licenses ?? 0;
494        $totalInvitedLicenses = $result->invited_licenses ?? 0;
495
496        return response()->json(['data' => [
497            'licenses' => $totalLicenses,
498            'remaining_licenses' => $totalLicenses - $totalAssignedUserLicenses - $totalAssignedInvitationLicenses,
499            'activated_licenses' => $totalActivatedLicenses,
500            'assigned_licenses' => $totalActivatedLicenses + $totalInvitedLicenses,
501            'invitations' => $totalInvitedLicenses,
502            'extensions_installed' => $result->extensions_installed ?? 0,
503        ]]);
504    }
505
506    public function company_usage_overview(GetReportRequest $request): JsonResponse
507    {
508        $baseMatch = $this->buildBaseMatchQuery($request);
509        $dailyMatch = $this->toDailyUsageMatch($baseMatch);
510        $companyId = $baseMatch['company_id']['$in'][0] ?? $request->user()->company_id;
511        [$startDate, $endDate] = $this->getDateRange($request, $dailyMatch, FlyMsgUserDailyUsage::class);
512
513        $finalMatch = $dailyMatch;
514        $finalMatch['created_at'] = ['$gte' => new UTCDateTime($startDate), '$lte' => new UTCDateTime($endDate)];
515
516        $pipeline = [
517            ['$match' => $finalMatch],
518            ['$group' => [
519                '_id' => null,
520                'cost' => ['$sum' => '$cost_savings'],
521                'time' => ['$sum' => '$time_saved'],
522                'chars' => ['$sum' => '$characters_typed'],
523                'uniqueUserIds' => ['$addToSet' => '$user_id'],
524            ]],
525            ['$project' => [
526                '_id' => 0,
527                'cost' => 1,
528                'time' => 1,
529                'chars' => 1,
530                'total_users' => ['$size' => '$uniqueUserIds'],
531            ]],
532        ];
533
534        $result = FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline))->first();
535        $companySetting = Setting::where('company_id', $companyId)->first();
536        $wageValue = round($companySetting?->wage_per_hour ?? 31.75, 2);
537        $totalUsers = $result->total_users ?? 1;
538
539        return response()->json(['data' => [
540            ['title' => 'ROI Spotlight', 'subtitle' => 'Total Cost Savings ($)', 'tooltip' => 'This shows the total cost savings in USD using the average US knowledge worker wage of $'.$wageValue.'/hour.', 'amount' => number_format(round($result->cost ?? 0, 2), 2), 'average' => ['value' => number_format(round(($result->cost ?? 0) / $totalUsers, 2), 2), 'label' => 'Average per User']],
541            ['title' => 'Productivity Spotlight', 'subtitle' => 'Total Time Saved (hrs)', 'tooltip' => 'This shows the total amount of time activated users saved over the selected period of time.', 'amount' => number_format(round($result->time ?? 0, 2), 2), 'average' => ['value' => number_format(round(($result->time ?? 0) / $totalUsers, 2), 2), 'label' => 'Average per User']],
542            ['title' => 'Total Characters Typed', 'subtitle' => 'Characters Typed', 'tooltip' => 'This shows the total amount of characters users typed over the selected period of time.', 'amount' => number_format(round($result->chars ?? 0, 0), 0), 'average' => ['value' => number_format(round(($result->chars ?? 0) / $totalUsers, 0), 2), 'label' => 'Average per User']],
543        ], 'success' => true]);
544    }
545
546    public function export(GetReportRequest $request, string $type): StreamedResponse|JsonResponse
547    {
548        $baseMatch = $this->buildBaseMatchQuery($request);
549        $fileName = "report_{$type}_".now()->format('Y-m-d').'.csv';
550
551        // In masquerade mode (VENGRESO_ADMIN viewing another company's data via company_ids),
552        // there is no effective end-user to resolve a CSV separator from — default to comma.
553        $effectiveUser = $this->resolveEffectiveUser($request);
554        $delimiter = $this->csvExportService->resolveDelimiter($effectiveUser);
555
556        // Resolve columns BEFORE the streaming callback (where $request is not available)
557        $chartIdsParam = $request->input('chart_ids');
558        $columns = $this->resolveExportColumns($type, $chartIdsParam);
559
560        if (! empty($chartIdsParam) && empty($columns)) {
561            return response()->json([
562                'success' => false,
563                'error' => 'None of the provided chart_ids match the report type.',
564            ], 422);
565        }
566
567        // Always use FlyMsgUserDailyUsage so export and UI display share the same data source.
568        // When no date filter is provided, derive the full available date range from the collection.
569        $dailyMatch = $this->toDailyUsageMatch($baseMatch);
570        [$startDate, $endDate] = $this->getDateRange($request, $dailyMatch, FlyMsgUserDailyUsage::class);
571        $dateFilteredAggregates = $this->buildDateFilteredAggregates($baseMatch, $columns, $startDate, $endDate);
572
573        $callback = function () use ($type, $baseMatch, $delimiter, $columns, $dateFilteredAggregates) {
574            $file = fopen('php://output', 'w');
575            // UTF-8 BOM for Excel compatibility
576            fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
577            // Excel column-delimiter hint — must match the fputcsv delimiter below
578            fprintf($file, 'sep='.$delimiter."\r\n");
579
580            switch ($type) {
581                case 'effectiveness':
582                case 'usage':
583                    $headers = ['User Name', 'Email', 'License', 'Extension Installed'];
584                    foreach ($columns as $col) {
585                        $headers[] = $col['header'];
586                    }
587                    fputcsv($file, $headers, $delimiter);
588
589                    $pipeline = [];
590                    if (! empty($baseMatch)) {
591                        $pipeline[] = ['$match' => $baseMatch];
592                    }
593                    $pipeline[] = ['$project' => [
594                        '_id' => 0, 'user_id' => 1, 'full_name' => 1, 'email' => 1,
595                        'plan_name' => 1, 'is_any_extension_installed' => 1,
596                    ]];
597
598                    $data = UserInfo::raw(fn ($c) => $c->aggregate($pipeline));
599                    foreach ($data as $row) {
600                        $userId = $row['user_id'] ?? '';
601                        $csvRow = [
602                            $row['full_name'],
603                            $row['email'],
604                            $row['plan_name'],
605                            $row['is_any_extension_installed'] ? 'Yes' : 'No',
606                        ];
607                        foreach ($columns as $col) {
608                            $csvRow[] = round($dateFilteredAggregates[$userId][$col['daily_field']] ?? 0, $col['round']);
609                        }
610                        fputcsv($file, $csvRow, $delimiter);
611                    }
612                    break;
613
614                case 'overview':
615                    $headers = [
616                        'User Name', 'Email', 'Account Creation', 'Extension Installed',
617                        'Last Login', 'License', 'Group', 'Subgroup',
618                    ];
619                    foreach ($columns as $col) {
620                        $headers[] = $col['header'];
621                    }
622                    fputcsv($file, $headers, $delimiter);
623
624                    $pipeline = [];
625                    if (! empty($baseMatch)) {
626                        $pipeline[] = ['$match' => $baseMatch];
627                    }
628                    $pipeline[] = ['$project' => [
629                        '_id' => 0, 'user_id' => 1,
630                        'full_name' => 1, 'email' => 1, 'user_created_at' => 1,
631                        'is_any_extension_installed' => 1, 'last_login' => 1,
632                        'plan_name' => 1, 'group_name' => 1, 'subgroup_name' => 1,
633                    ]];
634
635                    $data = UserInfo::raw(fn ($c) => $c->aggregate($pipeline));
636                    foreach ($data as $row) {
637                        $userId = $row['user_id'] ?? '';
638                        $csvRow = [
639                            $row['full_name'],
640                            $row['email'],
641                            $row['user_created_at'],
642                            $row['is_any_extension_installed'] ? 'Yes' : 'No',
643                            $row['last_login'],
644                            $row['plan_name'],
645                            $row['group_name'] ?? 'Not Assigned',
646                            $row['subgroup_name'] ?? 'Not Assigned',
647                        ];
648                        foreach ($columns as $col) {
649                            $csvRow[] = round($dateFilteredAggregates[$userId][$col['daily_field']] ?? 0, $col['round']);
650                        }
651                        fputcsv($file, $csvRow, $delimiter);
652                    }
653                    break;
654            }
655            fclose($file);
656        };
657
658        return new StreamedResponse($callback, 200, [
659            'Content-Type' => 'text/csv; charset=UTF-8',
660            'Content-Disposition' => 'attachment; filename="'.$fileName.'"',
661        ]);
662    }
663
664    /**
665     * Returns the effective user for CSV delimiter resolution.
666     *
667     * VENGRESO_ADMINs viewing another company's data (masquerade context) have no
668     * meaningful per-user separator setting for the target company, so we return
669     * null and let resolveDelimiter() default to comma.
670     */
671    private function resolveEffectiveUser(GetReportRequest $request): ?User
672    {
673        $user = $request->user();
674
675        if (! in_array(Role::VENGRESO_ADMIN, $user->roles())) {
676            return $user;
677        }
678
679        // VENGRESO_ADMIN — always default to comma (no effective target user)
680        return null;
681    }
682
683    /**
684     * Aggregate data from FlyMsgUserDailyUsage for an explicit date range,
685     * grouped by user_id. Returns an associative array keyed by user_id.
686     */
687    private function buildDateFilteredAggregates(array $baseMatch, array $columns, Carbon $startDate, Carbon $endDate): array
688    {
689        $dailyMatch = $this->toDailyUsageMatch($baseMatch);
690        $dailyMatch['created_at'] = [
691            '$gte' => new UTCDateTime($startDate),
692            '$lte' => new UTCDateTime($endDate),
693        ];
694
695        $groupSums = ['_id' => '$user_id'];
696        foreach ($columns as $col) {
697            $dailyField = $col['daily_field'];
698            $groupSums[$dailyField] = ['$sum' => '$'.$dailyField];
699        }
700
701        $pipeline = [
702            ['$match' => $dailyMatch],
703            ['$group' => $groupSums],
704        ];
705
706        $results = FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline));
707
708        $aggregates = [];
709        foreach ($results as $row) {
710            $userId = $row['_id'];
711            $aggregates[$userId] = [];
712            foreach ($columns as $col) {
713                $aggregates[$userId][$col['daily_field']] = $row[$col['daily_field']] ?? 0;
714            }
715        }
716
717        return $aggregates;
718    }
719
720    private function resolveExportColumns(string $type, ?string $chartIdsParam): array
721    {
722        $allColumns = match ($type) {
723            'effectiveness' => $this->effectivenessColumns,
724            'usage' => $this->usageColumns,
725            'overview' => array_merge($this->effectivenessColumns, $this->usageColumns),
726            default => [],
727        };
728
729        if (empty($chartIdsParam)) {
730            return $allColumns; // No filter → export all (backward-compatible)
731        }
732
733        $requestedIds = array_filter(explode(',', $chartIdsParam));
734
735        return array_intersect_key($allColumns, array_flip($requestedIds));
736    }
737
738    /**
739     * Get spotlight data for a specific metric type.
740     *
741     * Returns monthly chart data, total, and average for the requested metric.
742     * Supports all roles via GetReportRequest authorization and ReportFilterTrait scoping.
743     *
744     * @param  GetReportRequest  $request  Validated request with optional filters (from, to, company_ids, group_ids, user_ids, cmc)
745     * @param  string  $type  Metric type key (e.g., 'cost', 'time', 'chars', 'shortcuts_used')
746     *
747     * @response 200 {"data": {"chart": [{"period": "Jan 2026", "value": 100}], "total": "1,000.00", "average": "100.00"}, "success": true}
748     */
749    public function spotlight(GetReportRequest $request, string $type): JsonResponse
750    {
751        $property = $this->propertyMap[$type] ?? null;
752        if (! $property) {
753            return response()->json(['success' => false, 'error' => "Unknown metric type: {$type}"], 422);
754        }
755
756        $round = $this->resolveRound($type);
757        $useAvg = in_array($type, $this->avgAggregatorCharts, true);
758
759        $baseMatch = $this->buildBaseMatchQuery($request);
760        $dailyMatch = $this->toDailyUsageMatch($baseMatch);
761        [$startDate, $endDate] = $this->getDateRange($request, $dailyMatch, FlyMsgUserDailyUsage::class);
762
763        $data = $this->reportService->getSpotlightData($dailyMatch, $property, $startDate, $endDate, $round, $useAvg);
764
765        return response()->json(['data' => $data, 'success' => true]);
766    }
767
768    /**
769     * Get top users for a specific metric type.
770     *
771     * Returns top 5 users ranked by the requested metric, with name and avatar.
772     *
773     * @param  GetReportRequest  $request  Validated request with optional filters
774     * @param  string  $type  Metric type key
775     *
776     * @response 200 {"users": [{"name": "John Doe", "count": "1,234", "image": "https://..."}], "success": true}
777     */
778    public function topUsers(GetReportRequest $request, string $type): JsonResponse
779    {
780        $property = $this->propertyMap[$type] ?? null;
781        if (! $property) {
782            return response()->json(['success' => false, 'error' => "Unknown metric type: {$type}"], 422);
783        }
784
785        $round = $this->resolveRound($type);
786        $useAvg = in_array($type, $this->avgAggregatorCharts, true);
787
788        $baseMatch = $this->buildBaseMatchQuery($request);
789        $dailyMatch = $this->toDailyUsageMatch($baseMatch);
790        [$startDate, $endDate] = $this->getDateRange($request, $dailyMatch, FlyMsgUserDailyUsage::class);
791
792        $users = $this->reportService->getTopUsers($dailyMatch, $property, $startDate, $endDate, $round, 5, $useAvg);
793
794        return response()->json(['users' => $users, 'success' => true]);
795    }
796
797    /**
798     * Get FlyMSG coach level categorization.
799     *
800     * Categorizes users into 5 coach levels based on characters typed.
801     * Optimized: aggregates directly on FlyMsgUserDailyUsage (no $lookup, no 3GB memory).
802     *
803     * @param  GetReportRequest  $request  Validated request with optional filters
804     *
805     * @response 200 {"data": {"chart": [100, 50, 30, 15, 5], "expert_users": 2.5, "beginner_users": 50.0}, "success": true}
806     */
807    public function coachLevel(GetReportRequest $request): JsonResponse
808    {
809        $baseMatch = $this->buildBaseMatchQuery($request);
810        $dailyMatch = $this->toDailyUsageMatch($baseMatch);
811        [$startDate, $endDate] = $this->getDateRange($request, $dailyMatch, FlyMsgUserDailyUsage::class);
812
813        $data = $this->reportService->getCoachLevels($baseMatch, $dailyMatch, $startDate, $endDate);
814
815        return response()->json(['data' => $data, 'success' => true]);
816    }
817
818    /**
819     * Resolve the rounding precision for a metric type.
820     *
821     * @param  string  $type  Metric type key
822     * @return int Decimal places
823     */
824    private function resolveRound(string $type): int
825    {
826        if (in_array($type, ['time', 'cost'], true)) {
827            return 2;
828        }
829        if ($type === 'roleplay_score') {
830            return 1;
831        }
832
833        return 0;
834    }
835}