Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.92% covered (success)
97.92%
47 / 48
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
RoleplayLevelsReportRepository
97.92% covered (success)
97.92%
47 / 48
50.00% covered (danger)
50.00%
1 / 2
12
0.00% covered (danger)
0.00%
0 / 1
 getRoleplayUserIds
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 getAverageScoreByUser
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
9
1<?php
2
3namespace App\Http\Repositories\Report;
4
5use App\Http\Models\RolePlayConversations;
6use App\Http\Models\UserAddOns;
7use Carbon\Carbon;
8use Illuminate\Support\Collection;
9use MongoDB\BSON\UTCDateTime;
10
11/**
12 * Data-access layer for the "roleplay levels" admin distribution report.
13 *
14 * This repository owns all direct database access for the report and keeps
15 * the service layer free of query construction details. Queries are kept
16 * database-agnostic where practical; MongoDB-specific aggregation is used
17 * only for the average-score pipeline which has no Eloquent equivalent.
18 */
19class RoleplayLevelsReportRepository
20{
21    /**
22     * Return the list of user IDs that currently hold an active RolePlay
23     * UserAddOn, optionally scoped to a specific company.
24     *
25     * An addon is considered "active" when its `status` field equals
26     * "active". Both individual (Stripe-billed) and company (corporate)
27     * sources are included per product requirements.
28     *
29     * @param  array<int, string>|null  $companyIds  Optional list of company IDs to scope to
30     * @return Collection<int, string> Collection of user ID strings
31     */
32    public function getRoleplayUserIds(?array $companyIds = null): Collection
33    {
34        $addonUserIds = UserAddOns::query()
35            ->where('product', 'RolePlay')
36            ->where('status', 'active')
37            ->pluck('user_id')
38            ->map(fn ($id) => (string) $id)
39            ->unique()
40            ->values();
41
42        if ($companyIds === null || empty($companyIds)) {
43            return $addonUserIds;
44        }
45
46        // Scope via the users' company_id. Done as a secondary query rather
47        // than a join so the repository stays portable across database backends.
48        $scopedIds = \App\Http\Models\Auth\User::query()
49            ->whereIn('company_id', $companyIds)
50            ->get(['_id'])
51            ->map(fn ($u) => (string) $u->_id)
52            ->values()
53            ->all();
54
55        $scopedSet = array_flip($scopedIds);
56
57        return $addonUserIds
58            ->filter(fn (string $id) => isset($scopedSet[$id]))
59            ->values();
60    }
61
62    /**
63     * Compute the average non-failed roleplay score for each of the given
64     * users within the optional date window.
65     *
66     * Users with no qualifying sessions are NOT returned in the map; the
67     * service layer is responsible for defaulting missing users to score 0.
68     *
69     * @param  array<int, string>  $userIds  List of user IDs to aggregate
70     * @param  Carbon|null  $from  Inclusive lower bound on created_at
71     * @param  Carbon|null  $to  Inclusive upper bound on created_at
72     * @return array<string, float> Map of user_id â†’ average score
73     */
74    public function getAverageScoreByUser(array $userIds, ?Carbon $from, ?Carbon $to): array
75    {
76        if (empty($userIds)) {
77            return [];
78        }
79
80        $match = [
81            'user_id' => ['$in' => array_values(array_map('strval', $userIds))],
82            'status' => ['$ne' => 'failed'],
83        ];
84
85        if ($from || $to) {
86            $range = [];
87            if ($from) {
88                $range['$gte'] = new UTCDateTime($from->copy()->startOfDay());
89            }
90            if ($to) {
91                $range['$lte'] = new UTCDateTime($to->copy()->endOfDay());
92            }
93            $match['created_at'] = $range;
94        }
95
96        $pipeline = [
97            ['$match' => $match],
98            ['$group' => [
99                '_id' => '$user_id',
100                'avg_score' => ['$avg' => '$score'],
101            ]],
102        ];
103
104        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
105
106        $map = [];
107        foreach ($results as $row) {
108            $userId = (string) ($row['_id'] ?? '');
109            if ($userId === '') {
110                continue;
111            }
112            $avg = $row['avg_score'] ?? null;
113            $map[$userId] = $avg === null ? 0.0 : (float) $avg;
114        }
115
116        return $map;
117    }
118}