Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.90% covered (success)
97.90%
140 / 143
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
DailyUsageRepository
97.90% covered (success)
97.90%
140 / 143
57.14% covered (warning)
57.14%
4 / 7
20
0.00% covered (danger)
0.00%
0 / 1
 aggregateByMonth
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
3
 getSummaryStats
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
4
 getTopUsersByMetric
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
3
 aggregateByUserMultiField
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getCoachLevelAggregates
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 getDateRange
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 getUsageOverview
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Repositories\Reports;
4
5use App\Http\Models\FlyMsgUserDailyUsage;
6use Carbon\Carbon;
7use Illuminate\Support\Collection;
8
9/**
10 * Repository for FlyMsgUserDailyUsage reporting aggregations.
11 *
12 * All methods accept a pre-built MongoDB $match array (from ReportFilterTrait)
13 * which already includes security scoping (company, group, user filters).
14 * Date range filtering (created_at) should be included in the $match by the caller.
15 */
16class DailyUsageRepository
17{
18    /**
19     * Aggregate a metric by month for chart data.
20     *
21     * Returns a collection of documents with 'month_year' (format: Y-m) and 'total' fields.
22     *
23     * @param  array  $match  MongoDB $match conditions (including created_at range)
24     * @param  string  $property  The daily usage field to sum (e.g., 'cost_savings', 'time_saved')
25     * @param  int  $round  Decimal places for rounding (default: 0)
26     * @return Collection Collection of {month_year, total} documents
27     */
28    public function aggregateByMonth(array $match, string $property, int $round = 0, bool $useAvg = false): Collection
29    {
30        // When averaging (e.g. roleplay_score which is already a daily
31        // mean), exclude zero rows so idle days don't drag the mean down.
32        if ($useAvg) {
33            $match[$property] = ['$gt' => 0];
34        }
35
36        $pipeline = [
37            ['$match' => $match],
38            [
39                '$group' => [
40                    '_id' => ['$dateToString' => ['format' => '%Y-%m', 'date' => '$created_at']],
41                    'totalValue' => $useAvg ? ['$avg' => '$'.$property] : ['$sum' => '$'.$property],
42                ],
43            ],
44            ['$sort' => ['_id' => 1]],
45            [
46                '$project' => [
47                    '_id' => 0,
48                    'month_year' => '$_id',
49                    'total' => ['$round' => ['$totalValue', $round]],
50                ],
51            ],
52        ];
53
54        return collect(FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline)));
55    }
56
57    /**
58     * Get summary statistics: total value and unique user count for a metric.
59     *
60     * @param  array  $match  MongoDB $match conditions (including created_at range)
61     * @param  string  $property  The daily usage field to sum
62     * @return array{total: float, unique_users: int}
63     */
64    public function getSummaryStats(array $match, string $property, bool $useAvg = false): array
65    {
66        if ($useAvg) {
67            $match[$property] = ['$gt' => 0];
68        }
69
70        $userAgg = $useAvg ? ['$avg' => '$'.$property] : ['$sum' => '$'.$property];
71        $globalAgg = $useAvg ? ['$avg' => '$userTotal'] : ['$sum' => '$userTotal'];
72
73        $pipeline = [
74            ['$match' => $match],
75            [
76                '$group' => [
77                    '_id' => '$user_id',
78                    'userTotal' => $userAgg,
79                ],
80            ],
81            [
82                '$group' => [
83                    '_id' => null,
84                    'total' => $globalAgg,
85                    'unique_users' => ['$sum' => 1],
86                ],
87            ],
88        ];
89
90        $result = collect(FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline)))->first();
91
92        return [
93            'total' => $result->total ?? 0,
94            'unique_users' => $result->unique_users ?? 0,
95        ];
96    }
97
98    /**
99     * Get top N users by a metric, sorted descending.
100     *
101     * Returns user_id and the aggregated metric value. Users with zero activity are excluded.
102     *
103     * @param  array  $match  MongoDB $match conditions (including created_at range)
104     * @param  string  $property  The daily usage field to sum
105     * @param  int  $limit  Maximum number of users to return (default: 5)
106     * @return Collection Collection of {user_id, value} documents
107     */
108    public function getTopUsersByMetric(array $match, string $property, int $limit = 5, bool $useAvg = false): Collection
109    {
110        if ($useAvg) {
111            $match[$property] = ['$gt' => 0];
112        }
113
114        $pipeline = [
115            ['$match' => $match],
116            [
117                '$group' => [
118                    '_id' => '$user_id',
119                    'value' => $useAvg ? ['$avg' => '$'.$property] : ['$sum' => '$'.$property],
120                ],
121            ],
122            ['$match' => ['value' => ['$gt' => 0]]],
123            ['$sort' => ['value' => -1]],
124            ['$limit' => $limit],
125            [
126                '$project' => [
127                    '_id' => 0,
128                    'user_id' => '$_id',
129                    'value' => 1,
130                ],
131            ],
132        ];
133
134        return collect(FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline)));
135    }
136
137    /**
138     * Aggregate multiple fields per user (used for exports with date filtering).
139     *
140     * @param  array  $match  MongoDB $match conditions (including created_at range)
141     * @param  array  $fields  Array of daily_field names to sum (e.g., ['cost_savings', 'time_saved'])
142     * @return array Associative array keyed by user_id: [user_id => [field => value, ...]]
143     */
144    public function aggregateByUserMultiField(array $match, array $fields): array
145    {
146        $groupSums = ['_id' => '$user_id'];
147        foreach ($fields as $field) {
148            $groupSums[$field] = ['$sum' => '$'.$field];
149        }
150
151        $pipeline = [
152            ['$match' => $match],
153            ['$group' => $groupSums],
154        ];
155
156        $results = collect(FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline)));
157
158        $aggregates = [];
159        foreach ($results as $row) {
160            $userId = $row['_id'];
161            $aggregates[$userId] = [];
162            foreach ($fields as $field) {
163                $aggregates[$userId][$field] = $row[$field] ?? 0;
164            }
165        }
166
167        return $aggregates;
168    }
169
170    /**
171     * Aggregate characters_typed per user for coach level categorization.
172     *
173     * Replaces the old $lookup approach that required 3GB memory.
174     * This aggregates directly on fly_msg_user_daily_usage.
175     *
176     * @param  array  $match  MongoDB $match conditions (including created_at range)
177     * @return Collection Collection of {user_id, characters_typed} documents
178     */
179    public function getCoachLevelAggregates(array $match): Collection
180    {
181        $pipeline = [
182            ['$match' => $match],
183            [
184                '$group' => [
185                    '_id' => '$user_id',
186                    'characters_typed' => ['$sum' => '$characters_typed'],
187                ],
188            ],
189            [
190                '$project' => [
191                    '_id' => 0,
192                    'user_id' => '$_id',
193                    'characters_typed' => 1,
194                ],
195            ],
196        ];
197
198        return collect(FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline)));
199    }
200
201    /**
202     * Get the min/max date range from the collection for a given match.
203     *
204     * @param  array  $match  MongoDB $match conditions (without created_at)
205     * @param  string  $dateField  The date field to aggregate on (default: 'created_at')
206     * @return array{0: Carbon|null, 1: Carbon|null} [startDate, endDate] or [null, null] if no data
207     */
208    public function getDateRange(array $match, string $dateField = 'created_at'): array
209    {
210        $pipeline = [];
211        if (! empty($match)) {
212            $pipeline[] = ['$match' => $match];
213        }
214        $pipeline[] = [
215            '$group' => [
216                '_id' => null,
217                'minDate' => ['$min' => '$'.$dateField],
218                'maxDate' => ['$max' => '$'.$dateField],
219            ],
220        ];
221
222        $result = collect(FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline)))->first();
223
224        if ($result && $result->minDate) {
225            $startDate = Carbon::createFromTimestampMs($result->minDate->toDateTime()->getTimestamp() * 1000);
226            $endDate = Carbon::createFromTimestampMs($result->maxDate->toDateTime()->getTimestamp() * 1000);
227
228            return [$startDate, $endDate];
229        }
230
231        return [null, null];
232    }
233
234    /**
235     * Get aggregated usage overview (cost, time, chars, unique users).
236     *
237     * @param  array  $match  MongoDB $match conditions (including created_at range)
238     * @return array{cost: float, time: float, chars: int, total_users: int}
239     */
240    public function getUsageOverview(array $match): array
241    {
242        $pipeline = [
243            ['$match' => $match],
244            [
245                '$group' => [
246                    '_id' => null,
247                    'cost' => ['$sum' => '$cost_savings'],
248                    'time' => ['$sum' => '$time_saved'],
249                    'chars' => ['$sum' => '$characters_typed'],
250                    'uniqueUserIds' => ['$addToSet' => '$user_id'],
251                ],
252            ],
253            [
254                '$project' => [
255                    '_id' => 0,
256                    'cost' => 1,
257                    'time' => 1,
258                    'chars' => 1,
259                    'total_users' => ['$size' => '$uniqueUserIds'],
260                ],
261            ],
262        ];
263
264        $result = collect(FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline)))->first();
265
266        return [
267            'cost' => $result->cost ?? 0,
268            'time' => $result->time ?? 0,
269            'chars' => $result->chars ?? 0,
270            'total_users' => $result->total_users ?? 0,
271        ];
272    }
273}