Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
CompanyRolePlayFeedbackRepository
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
3 / 3
15
100.00% covered (success)
100.00%
1 / 1
 filtered
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
 resolvePersonaIds
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 resolveDateWindow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace App\Http\Repositories;
4
5use App\Http\Models\CompanyRolePlayProject;
6use App\Http\Models\RolePlayConversations;
7use App\Http\Models\RolePlayProjects;
8use Carbon\Carbon;
9use Illuminate\Contracts\Pagination\LengthAwarePaginator;
10use Illuminate\Database\Eloquent\Builder;
11
12/**
13 * Data access for the cross-persona admin "feedback browser".
14 *
15 * The admin dashboard lets a company Global Admin slice all roleplay
16 * sessions across their corporate personas by group/user/call-type/score
17 * window. This repository owns the composite query that expands corporate
18 * persona ids into their clone ids (so both direct-call and clone-call
19 * sessions surface in one paginator) and honors the Phase 1 brief's
20 * default 30-day lookback.
21 */
22class CompanyRolePlayFeedbackRepository
23{
24    /**
25     * Return a paginator of sessions filtered across all corporate personas
26     * belonging to the given company.
27     *
28     * @param  string  $companyId
29     * @param  array{persona_id?: array<int, string>, group_id?: array<int, string>, user_id?: array<int, string>, call_type?: array<int, string>, date_from?: string|null, date_to?: string|null, min_score?: float|null, max_score?: float|null}  $filters
30     */
31    public function filtered(string $companyId, array $filters, int $perPage = 15): LengthAwarePaginator
32    {
33        $personaIds = $this->resolvePersonaIds($companyId, $filters);
34
35        if (empty($personaIds)) {
36            // Build a query that deterministically returns no rows so we
37            // still get a valid paginator shape.
38            return RolePlayConversations::query()->whereRaw(['_id' => ['$in' => []]])->paginate($perPage);
39        }
40
41        $cloneMap = RolePlayProjects::whereIn('company_project_id', $personaIds)
42            ->pluck('id')
43            ->map(fn ($id) => (string) $id)
44            ->all();
45
46        [$dateFrom, $dateTo] = $this->resolveDateWindow($filters);
47
48        $query = RolePlayConversations::query()
49            ->where(function (Builder $q) use ($personaIds, $cloneMap) {
50                $q->where(function (Builder $direct) use ($personaIds) {
51                    $direct->whereNull('project_id')
52                        ->whereIn('company_project_id', $personaIds);
53                });
54                if (! empty($cloneMap)) {
55                    $q->orWhere(function (Builder $clone) use ($cloneMap) {
56                        $clone->whereIn('project_id', $cloneMap);
57                    });
58                }
59            })
60            ->where('created_at', '>=', $dateFrom)
61            ->where('created_at', '<=', $dateTo);
62
63        if (! empty($filters['user_id'])) {
64            $query->whereIn('user_id', (array) $filters['user_id']);
65        }
66
67        if (isset($filters['min_score']) && $filters['min_score'] !== null) {
68            $query->where('score', '>=', (float) $filters['min_score']);
69        }
70
71        if (isset($filters['max_score']) && $filters['max_score'] !== null) {
72            $query->where('score', '<=', (float) $filters['max_score']);
73        }
74
75        return $query->orderBy('created_at', 'desc')->paginate($perPage);
76    }
77
78    /**
79     * Derive the set of corporate persona ids to query against, honoring
80     * persona/group/call-type filters. Always scoped to the caller's company.
81     *
82     * @param  array<string, mixed>  $filters
83     * @return array<int, string>
84     */
85    private function resolvePersonaIds(string $companyId, array $filters): array
86    {
87        $query = CompanyRolePlayProject::query()->where('company_id', $companyId);
88
89        if (! empty($filters['persona_id'])) {
90            $query->whereIn('_id', (array) $filters['persona_id']);
91        }
92
93        if (! empty($filters['call_type'])) {
94            $query->whereIn('type', (array) $filters['call_type']);
95        }
96
97        if (! empty($filters['group_id'])) {
98            $query->whereIn('assigned_groups', (array) $filters['group_id']);
99        }
100
101        return $query->pluck('id')->map(fn ($id) => (string) $id)->all();
102    }
103
104    /**
105     * Default to the Phase 1 brief's 30-day window when neither bound is set.
106     *
107     * @param  array<string, mixed>  $filters
108     * @return array{0: \Carbon\Carbon, 1: \Carbon\Carbon}
109     */
110    private function resolveDateWindow(array $filters): array
111    {
112        $dateFrom = ! empty($filters['date_from'])
113            ? Carbon::parse($filters['date_from'])->startOfDay()
114            : Carbon::now()->subDays(30)->startOfDay();
115
116        $dateTo = ! empty($filters['date_to'])
117            ? Carbon::parse($filters['date_to'])->endOfDay()
118            : Carbon::now()->endOfDay();
119
120        return [$dateFrom, $dateTo];
121    }
122}