Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.25% covered (success)
94.25%
82 / 87
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserInfoReportRepository
94.25% covered (success)
94.25%
82 / 87
75.00% covered (warning)
75.00%
3 / 4
9.02
0.00% covered (danger)
0.00%
0 / 1
 getUsersOverviewFacet
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
4
 getUserIdentities
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 countUsers
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getUsersByMatch
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Http\Repositories\Reports;
4
5use App\Http\Models\UserInfo;
6use Carbon\Carbon;
7use Illuminate\Support\Collection;
8use MongoDB\BSON\UTCDateTime;
9
10/**
11 * Repository for UserInfo reporting aggregations.
12 *
13 * Handles user overview facets, identity lookups, and extension stats.
14 * All methods accept pre-built MongoDB $match arrays from ReportFilterTrait.
15 *
16 * @tech-debt  Uses raw MongoDB aggregation pipeline syntax ($match, $facet,
17 * $project, ->aggregate()). This violates the "database-agnostic code"
18 * policy from CLAUDE.MD that anticipates a future SQL migration. Kept as-is
19 * for now because the performance characteristics of the reporting
20 * dashboard depend on the server-side facet; porting to Eloquent would
21 * require rewriting every caller. Track follow-up: extract an aggregation
22 * abstraction (e.g. a query-builder adapter) so these pipelines can be
23 * replaced with SQL `CASE WHEN` / window functions when we migrate.
24 */
25class UserInfoReportRepository
26{
27    /**
28     * Get a faceted user overview (activated, deactivated, extension stats).
29     *
30     * Runs a single aggregation with $facet to count all categories in one query.
31     *
32     * @param  array  $baseMatch  MongoDB $match conditions targeting UserInfo (from ReportFilterTrait)
33     * @param  Carbon|null  $startDate  Optional start date for status/extension date filtering
34     * @param  Carbon|null  $endDate  Optional end date for status/extension date filtering
35     * @return array{activated_users: int, inactivated_users: int, extensions_installed: int, extensions_uninstalled: int}
36     */
37    public function getUsersOverviewFacet(array $baseMatch, ?Carbon $startDate, ?Carbon $endDate): array
38    {
39        $pipeline = [];
40        if (! empty($baseMatch)) {
41            $pipeline[] = ['$match' => $baseMatch];
42        }
43
44        $statusDateClause = [];
45        $extensionInstallDateClause = [];
46        $extensionUninstallDateClause = [];
47
48        if ($startDate && $endDate) {
49            $utcStart = new UTCDateTime($startDate);
50            $utcEnd = new UTCDateTime($endDate);
51
52            $statusDateClause = ['status_date' => ['$gte' => $utcStart, '$lte' => $utcEnd]];
53
54            $extensionInstallDateClause = ['$or' => [
55                ['flymsg_chrome_extension_installed__date_' => ['$gte' => $utcStart, '$lte' => $utcEnd]],
56                ['flymsg_edge_extension_installed__date_' => ['$gte' => $utcStart, '$lte' => $utcEnd]],
57            ]];
58
59            $extensionUninstallDateClause = ['$or' => [
60                ['flymsg_chrome_extension_uninstalled__date_' => ['$gte' => $utcStart, '$lte' => $utcEnd]],
61                ['flymsg_edge_extension_uninstalled__date_' => ['$gte' => $utcStart, '$lte' => $utcEnd]],
62            ]];
63        }
64
65        $pipeline[] = [
66            '$facet' => [
67                'activated_users' => [
68                    ['$match' => array_merge(['status' => 'Active'], $statusDateClause)],
69                    ['$count' => 'count'],
70                ],
71                'inactivated_users' => [
72                    ['$match' => array_merge(['status' => ['$nin' => ['Active', 'Invited']]], $statusDateClause)],
73                    ['$count' => 'count'],
74                ],
75                'extensions_installed' => [
76                    ['$match' => array_merge(
77                        ['is_any_extension_installed' => true, 'status' => 'Active'],
78                        $extensionInstallDateClause
79                    )],
80                    ['$count' => 'count'],
81                ],
82                'extensions_uninstalled' => [
83                    ['$match' => array_merge(
84                        [
85                            'is_any_extension_installed' => false,
86                            'is_any_extension_uninstalled' => true,
87                            'status' => ['$nin' => ['Deleted', 'Deactivated']],
88                        ],
89                        $extensionUninstallDateClause
90                    )],
91                    ['$count' => 'count'],
92                ],
93            ],
94        ];
95
96        $pipeline[] = [
97            '$project' => [
98                'activated_users' => ['$ifNull' => [['$arrayElemAt' => ['$activated_users.count', 0]], 0]],
99                'inactivated_users' => ['$ifNull' => [['$arrayElemAt' => ['$inactivated_users.count', 0]], 0]],
100                'extensions_installed' => ['$ifNull' => [['$arrayElemAt' => ['$extensions_installed.count', 0]], 0]],
101                'extensions_uninstalled' => ['$ifNull' => [['$arrayElemAt' => ['$extensions_uninstalled.count', 0]], 0]],
102            ],
103        ];
104
105        $result = collect(UserInfo::raw(fn ($c) => $c->aggregate($pipeline)))->first();
106
107        return [
108            'activated_users' => $result->activated_users ?? 0,
109            'inactivated_users' => $result->inactivated_users ?? 0,
110            'extensions_installed' => $result->extensions_installed ?? 0,
111            'extensions_uninstalled' => $result->extensions_uninstalled ?? 0,
112        ];
113    }
114
115    /**
116     * Batch-load user identities (name, avatar) by user IDs.
117     *
118     * Used for enriching top-user results without N+1 queries.
119     *
120     * @param  array  $userIds  Array of user_id strings
121     * @return Collection Keyed by user_id, each containing full_name and avatar
122     */
123    public function getUserIdentities(array $userIds): Collection
124    {
125        $pipeline = [
126            ['$match' => ['user_id' => ['$in' => $userIds]]],
127            [
128                '$project' => [
129                    '_id' => 0,
130                    'user_id' => 1,
131                    'full_name' => 1,
132                    'avatar' => 1,
133                ],
134            ],
135        ];
136
137        $results = collect(UserInfo::raw(fn ($c) => $c->aggregate($pipeline)));
138
139        return $results->keyBy('user_id');
140    }
141
142    /**
143     * Count users matching a filter (for averages and coach level totals).
144     *
145     * @param  array  $baseMatch  MongoDB $match conditions targeting UserInfo
146     * @return int Number of matching users
147     */
148    public function countUsers(array $baseMatch): int
149    {
150        $pipeline = [];
151        if (! empty($baseMatch)) {
152            $pipeline[] = ['$match' => $baseMatch];
153        }
154        $pipeline[] = ['$count' => 'total'];
155
156        $result = collect(UserInfo::raw(fn ($c) => $c->aggregate($pipeline)))->first();
157
158        return $result->total ?? 0;
159    }
160
161    /**
162     * Get users in scope with selected projections (for exports and data endpoints).
163     *
164     * @param  array  $baseMatch  MongoDB $match conditions targeting UserInfo
165     * @param  array  $projection  MongoDB $project fields
166     * @return Collection Collection of user documents
167     */
168    public function getUsersByMatch(array $baseMatch, array $projection): Collection
169    {
170        $pipeline = [];
171        if (! empty($baseMatch)) {
172            $pipeline[] = ['$match' => $baseMatch];
173        }
174        $pipeline[] = ['$project' => $projection];
175
176        return collect(UserInfo::raw(fn ($c) => $c->aggregate($pipeline)));
177    }
178}