Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.05% |
121 / 139 |
|
62.50% |
5 / 8 |
CRAP | |
0.00% |
0 / 1 |
| RolePlayConversationsController | |
87.05% |
121 / 139 |
|
62.50% |
5 / 8 |
25.25 | |
0.00% |
0 / 1 |
| start | |
100.00% |
38 / 38 |
|
100.00% |
1 / 1 |
2 | |||
| process | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
20 | |||
| end | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
3.00 | |||
| recent | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
3 | |||
| conversation | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| history | |
97.14% |
34 / 35 |
|
0.00% |
0 / 1 |
6 | |||
| agents | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| destroy | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Controllers\v2\RolePlay; |
| 4 | |
| 5 | use App\Http\Controllers\Controller; |
| 6 | use App\Http\Models\RolePlayConversations; |
| 7 | use App\Http\Models\RolePlayProjects; |
| 8 | use App\Http\Models\UserAddOns; |
| 9 | use App\Http\Resources\v2\UserSessionHistoryResource; |
| 10 | use App\Http\Requests\v2\RolePlay\DestroyRolePlayConversationRequest; |
| 11 | use App\Http\Requests\v2\RolePlay\GetRolePlayAgentsRequest; |
| 12 | use App\Http\Requests\v2\RolePlay\RolePlaySessionEndRequest; |
| 13 | use App\Http\Requests\v2\RolePlay\RolePlaySessionHistoryRequest; |
| 14 | use App\Http\Requests\v2\RolePlay\RolePlaySessionProcessRequest; |
| 15 | use App\Http\Requests\v2\RolePlay\RolePlaySessionStartRequest; |
| 16 | use App\Http\Services\RolePlay\RolePlayCallTypeSettingsService; |
| 17 | use App\Http\Services\RolePlay\RolePlaySessionDeletionService; |
| 18 | use App\Http\Services\RolePlayPromptBuilderService; |
| 19 | use App\Jobs\ProcessRolePlaySessionAsyncJob; |
| 20 | use App\Traits\SubscriptionTrait; |
| 21 | use Carbon\Carbon; |
| 22 | use Illuminate\Http\JsonResponse; |
| 23 | use Illuminate\Http\Request; |
| 24 | use 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 | */ |
| 34 | class 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 | } |