Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.05% covered (warning)
87.05%
121 / 139
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlayConversationsController
87.05% covered (warning)
87.05%
121 / 139
62.50% covered (warning)
62.50%
5 / 8
25.25
0.00% covered (danger)
0.00%
0 / 1
 start
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
2
 process
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 end
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 recent
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 conversation
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 history
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
6
 agents
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 destroy
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Controllers\v2\RolePlay;
4
5use App\Http\Controllers\Controller;
6use App\Http\Models\RolePlayConversations;
7use App\Http\Models\RolePlayProjects;
8use App\Http\Models\UserAddOns;
9use App\Http\Resources\v2\UserSessionHistoryResource;
10use App\Http\Requests\v2\RolePlay\DestroyRolePlayConversationRequest;
11use App\Http\Requests\v2\RolePlay\GetRolePlayAgentsRequest;
12use App\Http\Requests\v2\RolePlay\RolePlaySessionEndRequest;
13use App\Http\Requests\v2\RolePlay\RolePlaySessionHistoryRequest;
14use App\Http\Requests\v2\RolePlay\RolePlaySessionProcessRequest;
15use App\Http\Requests\v2\RolePlay\RolePlaySessionStartRequest;
16use App\Http\Services\RolePlay\RolePlayCallTypeSettingsService;
17use App\Http\Services\RolePlay\RolePlaySessionDeletionService;
18use App\Http\Services\RolePlayPromptBuilderService;
19use App\Jobs\ProcessRolePlaySessionAsyncJob;
20use App\Traits\SubscriptionTrait;
21use Carbon\Carbon;
22use Illuminate\Http\JsonResponse;
23use Illuminate\Http\Request;
24use MongoDB\BSON\UTCDateTime;
25
26/**
27 * RolePlay Conversations Controller
28 *
29 * Owns the session lifecycle for the roleplay frontend: start, process
30 * (idempotent re-aggregation), end (dispatch async evaluation), and
31 * history / detail reads. Ownership is enforced by the companion
32 * FormRequests so the controller itself stays thin.
33 */
34class RolePlayConversationsController extends Controller
35{
36    use SubscriptionTrait;
37
38    /**
39     * Start a new RolePlay session for the caller's project.
40     *
41     * Builds the runtime prompt server-side (latest objections + ICP
42     * interpolation), snapshots the resolved call-duration target so
43     * scoring is stable across subsequent config changes, and returns
44     * the created conversation stub plus remaining monthly credit.
45     *
46     * Authorization (ownership + quota) is enforced by
47     * {@see RolePlaySessionStartRequest::authorize()}.
48     *
49     * @response 200 {
50     *   "result": {
51     *     "status": "success",
52     *     "data": {
53     *       "session": {"id": "...", "status": "created"},
54     *       "prompt": "You are ...",
55     *       "seconds_remaining": 2400
56     *     }
57     *   }
58     * }
59     * @response 403 On foreign project_id (IDOR guard) or quota exceeded.
60     * @response 422 When project_id/icp validation fails.
61     */
62    public function start(
63        RolePlaySessionStartRequest $request,
64        RolePlayPromptBuilderService $promptBuilder,
65        RolePlayCallTypeSettingsService $callTypeSettings,
66    ): JsonResponse {
67        $user = $request->user();
68        $project_id = $request->input('project_id');
69        $icpInput = json_decode($request->input('icp'), true);
70        $agent = $request->input('agent');
71
72        // Load fresh persona from DB to get latest objections and data
73        $project = RolePlayProjects::findOrFail($project_id);
74
75        // Build prompt dynamically using latest template + objections
76        $prompt = $promptBuilder->buildPromptForIcp($project->toArray(), $icpInput);
77
78        // Replace {jobTitle} placeholder (previously done on frontend)
79        $prompt = str_replace('{jobTitle}', $icpInput['target_job_title'] ?? '', $prompt);
80
81        // Snapshot the resolved target call duration so the scoring job can
82        // compute duration adherence against the value that was in effect
83        // when the user started the call, even if an admin changes it later.
84        $resolvedTarget = $callTypeSettings->resolve($user, $project->type);
85
86        $conversation = RolePlayConversations::create([
87            'user_id' => $user->id,
88            'project_id' => $project_id,
89            'icp' => json_encode($icpInput),
90            'prompt' => $prompt,
91            'agent' => $agent,
92            'duration' => 0,
93            'status' => 'created',
94            'target_duration_seconds_at_call' => (int) $resolvedTarget['target_duration_seconds'],
95        ]);
96
97        $rolePlayAddOn = UserAddOns::where('user_id', $user->id)
98            ->where('product', 'RolePlay')
99            ->where('status', 'active')
100            ->orderBy('created_at', 'desc')
101            ->first();
102
103        $rolePlayAddOn->load('addOn');
104
105        $monthly_total_time_credits = (int) ($rolePlayAddOn->addOn->features['monthly_total_time_credits'] ?? 0);
106        $usedRolePlayTimeCredits = RolePlayConversations::where('user_id', $user->id)
107            ->where('status', 'done')
108            ->where('created_at', '>=', new UTCDateTime(now()->startOfMonth()->getTimestampMs()))
109            ->sum('duration') ?? 0;
110
111        return response()->json([
112            'status' => 'success',
113            'data' => [
114                'session' => $conversation,
115                'prompt' => $prompt,
116                // -1 is the unlimited sentinel (matches other endpoints like
117                // /role-play/user/info.available_time_seconds). Without this
118                // guard, unlimited plans rendered as "0 seconds remaining".
119                'seconds_remaining' => $monthly_total_time_credits === -1
120                    ? -1
121                    : max(0, $monthly_total_time_credits - $usedRolePlayTimeCredits),
122            ],
123        ]);
124    }
125
126    /**
127     * Re-aggregate or re-queue a session depending on its current status.
128     *
129     * - `failed` â†’ re-dispatch the evaluation job (retry without replay).
130     * - `done`   â†’ idempotent re-aggregation of progression using the stored feedback.
131     * - anything else â†’ no-op; caller should poll `/history/{id}` until status flips.
132     *
133     * @response 200 {"result": {"status": "success", "data": {"id": "...", "status": "...", "feedback": {...}}}}
134     */
135    public function process(RolePlaySessionProcessRequest $request, RolePlayConversations $conversation): JsonResponse
136    {
137        $project = RolePlayProjects::find($conversation->project_id);
138
139        if ($conversation->status === 'failed') {
140            // Failed sessions are re-queued so the user can retry the
141            // evaluation without replaying the call.
142            $conversation->update(['status' => 'processing', 'feedback' => null]);
143            ProcessRolePlaySessionAsyncJob::dispatch(
144                $conversation,
145                $conversation->id,
146                $conversation->vapi_call_id
147            );
148        } elseif ($conversation->status === 'done' && is_array($conversation->feedback)) {
149            // Idempotent re-aggregation when the caller knows the session
150            // has already been scored â€” used by the dashboard to refresh
151            // progression without waiting for a new job.
152            $project?->calculateProgression($conversation->feedback);
153        }
154        // Any other state (most commonly 'processing', right after the
155        // frontend calls /end and the async job hasn't landed yet) is a
156        // no-op: feedback is not ready, progression cannot be computed,
157        // and the caller should poll /history/{id} until status flips.
158
159        $conversation = $conversation->fresh();
160
161        $conversation->load('project');
162
163        return response()->json([
164            'status' => 'success',
165            'data' => $conversation,
166        ]);
167    }
168
169    /**
170     * Close an in-flight session and dispatch the async evaluation job.
171     *
172     * Marks the conversation as `processing`, persists the VAPI call id if
173     * provided, and queues {@see ProcessRolePlaySessionAsyncJob}. Ownership
174     * is enforced inline (owner-only â€” admins do not end other users' calls).
175     *
176     * @response 200 {"result": {"status": "success", "data": {"id": "...", "status": "processing"}}}
177     * @response 403 When the caller is not the conversation owner.
178     */
179    public function end(RolePlaySessionEndRequest $request, RolePlayConversations $conversation): JsonResponse
180    {
181        $user = $request->user();
182
183        if ($conversation->user_id !== $user->id) {
184            return response()->json(['status' => 'error', 'message' => 'Unauthorized'], 403);
185        }
186
187        $vapiCallId = $request->input('vapi_call_id');
188
189        $conversation->status = 'processing';
190        if ($vapiCallId) {
191            $conversation->vapi_call_id = $vapiCallId;
192        }
193        $conversation->save();
194
195        ProcessRolePlaySessionAsyncJob::dispatch($conversation, $conversation->id, $vapiCallId);
196
197        return response()->json([
198            'status' => 'success',
199            'data' => $conversation,
200        ]);
201    }
202
203    /**
204     * Return the caller's five most recent `done` sessions (with project
205     * relation eagerly loaded). Used by the roleplay dashboard's Recent card.
206     *
207     * @response 200 {"result": {"status": "success", "data": [{"id": "...", "icp": {...}, "agent": {...}, "project": {...}}]}}
208     */
209    public function recent(Request $request): JsonResponse
210    {
211        $user = $request->user();
212
213        $conversations = RolePlayConversations::where('user_id', $user->id)
214            ->where('status', 'done')
215            ->latest()
216            ->take(5)
217            ->get()
218            ->load('project')
219            ->map(function ($conversation) {
220                $conversation->icp = is_string($conversation->icp) ? json_decode($conversation->icp, true) : (array) $conversation->icp;
221                $conversation->agent = is_string($conversation->agent) ? json_decode($conversation->agent, true) : (array) $conversation->agent;
222
223                return $conversation;
224            });
225
226        return response()->json([
227            'status' => 'success',
228            'data' => $conversations,
229        ]);
230    }
231
232    /**
233     * Return a single conversation (with project relation) owned by the caller.
234     *
235     * ICP and agent are JSON-decoded from storage for frontend consumption.
236     *
237     * @response 200 {"result": {"status": "success", "data": {"id": "...", "icp": {...}, "agent": {...}, "project": {...}}}}
238     * @response 403 When the caller is not the conversation owner.
239     * @response 404 When the route-bound id does not exist.
240     */
241    public function conversation(Request $request, RolePlayConversations $conversation): JsonResponse
242    {
243        $user = $request->user();
244
245        if ($conversation->user_id !== $user->id) {
246            return response()->json(['status' => 'error', 'message' => 'Unauthorized'], 403);
247        }
248
249        $conversation->load('project');
250        $conversation->icp = is_string($conversation->icp) ? json_decode($conversation->icp, true) : (array) $conversation->icp;
251        $conversation->agent = is_string($conversation->agent) ? json_decode($conversation->agent, true) : (array) $conversation->agent;
252
253        return response()->json([
254            'status' => 'success',
255            'data' => $conversation,
256        ]);
257    }
258
259    /**
260     * List the caller's sessions, optionally filtered by period.
261     *
262     * @param  RolePlaySessionHistoryRequest  $request  Accepts `period` âˆˆ { `this_week`, `this_month`, `all_times` (default) }.
263     *
264     * Includes both user-owned persona sessions (`project_id` set) AND
265     * direct-call sessions on corporate personas (`company_project_id`
266     * set, `project_id` null). Each row carries a unified `persona`
267     * descriptor with `source: 'user' | 'company'` so the FE renders one
268     * list regardless of origin.
269     *
270     * @response 200 {"result": {"status": "success", "data": [{"id": "...", "score": 72, "duration": 240, "persona": {"id": "...", "name": "...", "source": "user"}}]}}
271     */
272    public function history(RolePlaySessionHistoryRequest $request): JsonResponse
273    {
274        $user = $request->user();
275        $period = $request->input('period', 'all_times');
276
277        $startDate = null;
278        $endDate = null;
279
280        switch ($period) {
281            case 'this_week':
282                $startDate = now()->startOfWeek()->subMinute();
283                $endDate = now()->endOfWeek()->addMinute();
284                break;
285            case 'this_month':
286                $startDate = now()->startOfMonth()->subMinute();
287                $endDate = now()->endOfMonth()->addMinute();
288                break;
289        }
290
291        $query = RolePlayConversations::where('user_id', $user->id);
292
293        if ($startDate && $endDate) {
294            $query->whereBetween('created_at', [
295                new UTCDateTime(Carbon::parse($startDate->toDateString())->getTimestampMs()),
296                new UTCDateTime(Carbon::parse($endDate->toDateString())->getTimestampMs()),
297            ]);
298        } elseif ($endDate) {
299            $query->where('created_at', '<', new UTCDateTime(Carbon::parse($endDate->toDateString())->getTimestampMs()));
300        }
301
302        $conversations = $query->select([
303            'id',
304            'project_id',
305            'company_project_id',
306            'score',
307            'status',
308            'duration',
309            'icp',
310            'agent',
311            'created_at',
312        ])->with(['project', 'companyProject'])->latest()->get();
313
314        return response()->json([
315            'status' => 'success',
316            'data' => UserSessionHistoryResource::collection($conversations)->toArray($request),
317        ]);
318    }
319
320    /**
321     * Return the persona's `customer_profiles` array (the "agents" the user
322     * can choose for a call). Ownership is enforced by
323     * {@see GetRolePlayAgentsRequest::authorize()}.
324     *
325     * @param  RolePlayProjects  $persona  Route-model-bound persona/project.
326     *
327     * @response 200 {"result": {"status": "success", "data": [{"name": "...", "voice": "...", "personality": {...}}]}}
328     * @response 403 When the persona does not belong to the caller.
329     * @response 404 When the route-bound id does not exist.
330     */
331    public function agents(GetRolePlayAgentsRequest $request, RolePlayProjects $persona): JsonResponse
332    {
333        return response()->json([
334            'status' => 'success',
335            'data' => $persona->customer_profiles ?? [],
336        ]);
337    }
338
339    /**
340     * Delete a short roleplay session (duration below the CMC-configured
341     * threshold) and recompute downstream aggregates.
342     *
343     * The session is soft-deleted so that historical daily usage metadata
344     * stays intact. UserRolePlayProgression is recomputed by replaying EMA
345     * over the surviving entries; the persona's average_score is updated
346     * from the surviving (not-trashed) "done" conversations.
347     *
348     * Authorization cascade: owner -> company Global Admin -> Vengreso Admin.
349     *
350     * @response 200 {"status": "success", "data": {"persona_average_score": 72.5}}
351     * @response 403 {"status": "error"}
352     */
353    public function destroy(
354        DestroyRolePlayConversationRequest $request,
355        RolePlayConversations $conversation,
356        RolePlaySessionDeletionService $sessionDeletion,
357    ): JsonResponse {
358        $result = $sessionDeletion->delete($request->user(), $conversation);
359
360        return response()->json([
361            'status' => 'success',
362            'data' => [
363                'persona_average_score' => $result['persona_average_score'],
364            ],
365        ]);
366    }
367}