Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
AdminRolePlayFeedbackController
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
5 / 5
27
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 show
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 sessionBelongsToCompany
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
9
 normalizeArrayFilters
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
11
1<?php
2
3namespace App\Http\Controllers\v2\Admin;
4
5use App\Http\Controllers\Controller;
6use App\Http\Models\Auth\User;
7use App\Http\Models\RolePlayConversations;
8use App\Http\Models\RolePlayProjects;
9use App\Http\Repositories\CompanyRolePlayFeedbackRepository;
10use App\Http\Resources\v2\RolePlayConversationAdminResource;
11use Illuminate\Http\JsonResponse;
12use Illuminate\Http\Request;
13
14/**
15 * Cross-persona session browser for company admins.
16 *
17 * Powers the "Feedback" page of the corporate admin RolePlay section:
18 * aggregates every roleplay session belonging to the admin's company
19 * (via corporate personas — both direct calls and clone-derived calls)
20 * and lets the admin filter by persona, group, user, call-type, score
21 * window, and date window. Default lookback is 30 days when neither
22 * `date_from` nor `date_to` is provided (enforced by the repository).
23 */
24class AdminRolePlayFeedbackController extends Controller
25{
26    public function __construct(
27        private readonly CompanyRolePlayFeedbackRepository $feedbackRepository,
28    ) {}
29
30    /**
31     * Paginated list of sessions matching the supplied filters.
32     *
33     * Query params: persona_id[], group_id[], user_id[], call_type[],
34     * date_from, date_to, min_score, max_score, per_page.
35     */
36    public function index(Request $request): JsonResponse
37    {
38        $filters = $request->only([
39            'persona_id', 'group_id', 'user_id', 'call_type',
40            'date_from', 'date_to', 'min_score', 'max_score',
41        ]);
42
43        $perPage = (int) $request->input('per_page', 15);
44
45        $paginator = $this->feedbackRepository->filtered(
46            (string) $request->user()->company_id,
47            $this->normalizeArrayFilters($filters),
48            $perPage,
49        );
50
51        $paginator->getCollection()->each(function ($c) {
52            if ($c->user_id) {
53                $c->setRelation('user', User::find($c->user_id));
54            }
55        });
56
57        return response()->json([
58            'status' => 'success',
59            'data' => RolePlayConversationAdminResource::collection($paginator->getCollection())->toArray($request),
60            'meta' => [
61                'current_page' => $paginator->currentPage(),
62                'per_page' => $paginator->perPage(),
63                'total' => $paginator->total(),
64                'last_page' => $paginator->lastPage(),
65            ],
66        ]);
67    }
68
69    /**
70     * Session detail for a single session. The caller must be a company
71     * admin for the company that owns the session's persona.
72     */
73    public function show(Request $request, string $sessionId): JsonResponse
74    {
75        $conversation = RolePlayConversations::find($sessionId);
76        if (! $conversation) {
77            return response()->json(['status' => 'error', 'message' => 'Session not found'], 404);
78        }
79
80        if (! $this->sessionBelongsToCompany($conversation, (string) $request->user()->company_id)) {
81            return response()->json(['status' => 'error', 'message' => 'Session not found'], 404);
82        }
83
84        if ($conversation->user_id) {
85            $conversation->setRelation('user', User::find($conversation->user_id));
86        }
87
88        return response()->json([
89            'status' => 'success',
90            'data' => (new RolePlayConversationAdminResource($conversation))->toArray($request),
91        ]);
92    }
93
94    /**
95     * Guard against cross-company access. A session belongs to a company if:
96     *  - company_id is stamped on the row and matches, OR
97     *  - the row's company_project_id resolves to a persona in the company, OR
98     *  - the row's project_id resolves to a clone whose parent persona is in the company.
99     */
100    private function sessionBelongsToCompany(RolePlayConversations $conversation, string $companyId): bool
101    {
102        if (! empty($conversation->company_id) && (string) $conversation->company_id === $companyId) {
103            return true;
104        }
105
106        if (! empty($conversation->company_project_id)) {
107            $persona = \App\Http\Models\CompanyRolePlayProject::find($conversation->company_project_id);
108
109            return $persona && (string) $persona->company_id === $companyId;
110        }
111
112        if (! empty($conversation->project_id)) {
113            $project = RolePlayProjects::find($conversation->project_id);
114            if ($project && $project->company_project_id) {
115                $persona = \App\Http\Models\CompanyRolePlayProject::find($project->company_project_id);
116
117                return $persona && (string) $persona->company_id === $companyId;
118            }
119        }
120
121        return false;
122    }
123
124    /**
125     * Coerce filter values that can arrive as either a scalar, a repeated
126     * query param (`key[]=a&key[]=b`), or a comma-joined string
127     * (`key=a,b,c`) into a flat array. Drops empty values.
128     *
129     * Admin-fe ships comma-joined strings on some surfaces; the repeated
130     * `key[]` form is also valid. Both must round-trip through the same
131     * normalization so the repository sees a flat `['a','b',...]` shape.
132     *
133     * @param  array<string, mixed>  $filters
134     * @return array<string, mixed>
135     */
136    private function normalizeArrayFilters(array $filters): array
137    {
138        foreach (['persona_id', 'group_id', 'user_id', 'call_type'] as $key) {
139            if (! array_key_exists($key, $filters)) {
140                continue;
141            }
142
143            $raw = is_array($filters[$key]) ? $filters[$key] : [$filters[$key]];
144            $flat = [];
145            foreach ($raw as $v) {
146                if (is_string($v) && str_contains($v, ',')) {
147                    foreach (explode(',', $v) as $part) {
148                        $flat[] = $part;
149                    }
150                } else {
151                    $flat[] = $v;
152                }
153            }
154
155            $filters[$key] = array_values(array_filter(
156                array_map(fn ($v) => is_string($v) ? trim($v) : $v, $flat),
157                fn ($v) => $v !== '' && $v !== null,
158            ));
159
160            if (empty($filters[$key])) {
161                unset($filters[$key]);
162            }
163        }
164
165        return $filters;
166    }
167}