From 6600c95584e7acd81da7cf0ca836590323170327 Mon Sep 17 00:00:00 2001 From: subhajit Date: Tue, 28 Jul 2026 15:56:04 +0530 Subject: [PATCH] update --- app/Http/Controllers/InterviewController.php | 201 ++++++- app/Models/Interview.php | 12 + ...all_started_fields_to_interviews_table.php | 33 + ...creenshot_settings_to_interviews_table.php | 29 + resources/views/dashboard.blade.php | 256 ++++++++ .../views/interview/candidate_room.blade.php | 564 ++++++++++++++++-- resources/views/interview/index.blade.php | 506 +++++++++++++++- routes/web.php | 3 + 8 files changed, 1531 insertions(+), 73 deletions(-) create mode 100644 database/migrations/2026_07_27_120000_add_call_started_fields_to_interviews_table.php create mode 100644 database/migrations/2026_07_27_180000_add_tab_switch_screenshot_settings_to_interviews_table.php diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index abd0570..5661ec4 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -505,17 +505,71 @@ private function getFormattedRecordings($interview) return $formatted; } + /** + * Live Polling Endpoint for Candidate Warnings & HR Monitoring. + /** + * Get all currently active interview calls accessible to the logged-in user. + */ + public function getActiveCalls(Request $request) + { + $user = Auth::user(); + if (!$user) { + return response()->json(['active_calls' => [], 'current_user_id' => null]); + } + + $accessible = $user->getAccessibleInterviews(); + $activeCalls = []; + + foreach ($accessible as $interview) { + if ($interview->call_status === 'active') { + $starterName = 'Panelist'; + if ($interview->call_started_by) { + $starter = User::find($interview->call_started_by); + if ($starter) { + $starterName = $starter->name; + } + } + + $activeCalls[] = [ + 'id' => $interview->id, + 'candidate_name' => $interview->candidate_name, + 'candidate_email' => $interview->candidate_email, + 'submission_unique_id' => $interview->submission_unique_id, + 'call_status' => $interview->call_status, + 'call_started_by' => $interview->call_started_by, + 'starter_name' => $starterName, + 'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null, + ]; + } + } + + return response()->json([ + 'active_calls' => $activeCalls, + 'current_user_id' => $user->id, + ]); + } + /** * Live Polling Endpoint for Candidate Warnings & HR Monitoring. */ public function getPollData($id) { - $interview = Interview::with(['warnings.sender'])->findOrFail($id); + $interview = Interview::with(['warnings.sender', 'callStarter']) + ->where('id', $id) + ->orWhere('submission_unique_id', $id) + ->firstOrFail(); $formattedRecordings = $this->getFormattedRecordings($interview); + $starterName = $interview->callStarter ? $interview->callStarter->name : null; + return response()->json([ 'status' => $interview->status, 'call_status' => $interview->call_status ?? 'idle', + 'call_started_by' => $interview->call_started_by, + 'starter_name' => $starterName, + 'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null, + 'enable_tab_switch_screenshot' => (bool) ($interview->enable_tab_switch_screenshot ?? true), + 'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [], 'submitted_code' => $interview->submitted_code, 'submitted_language' => $interview->submitted_language, 'code_output' => $interview->code_output, @@ -536,18 +590,141 @@ public function getPollData($id) */ public function updateCallStatus(Request $request, $id) { - $interview = Interview::findOrFail($id); + $interview = Interview::where('id', $id) + ->orWhere('submission_unique_id', $id) + ->firstOrFail(); $status = $request->input('call_status', 'idle'); - $interview->update(['call_status' => $status]); - return response()->json(['success' => true, 'call_status' => $status]); + $isRejoin = $request->boolean('is_rejoin', false); + + $updateData = ['call_status' => $status]; + $isNewCall = false; + + if ($status === 'active') { + if (!$isRejoin && ($interview->call_status !== 'active' || !$interview->call_started_at)) { + $updateData['call_started_by'] = Auth::id(); + $updateData['call_started_at'] = now(); + $isNewCall = true; + } + } elseif (in_array($status, ['ended', 'idle'])) { + $updateData['call_started_by'] = null; + $updateData['call_started_at'] = null; + } + + $interview->update($updateData); + + $starterName = Auth::user() ? Auth::user()->name : 'Panelist'; + + return response()->json([ + 'success' => true, + 'call_status' => $status, + 'is_new_call' => $isNewCall, + 'call_started_by' => $interview->call_started_by, + 'starter_name' => $starterName, + 'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null, + ]); } /** - * Save Candidate Silent Video Recording Blob / Snapshot Stream. + * Upload & Save Candidate Tab-Switch Screenshot under public/uploads/candidate_screenshots/{unique_id}/. + */ + public function uploadTabScreenshot(Request $request, $id) + { + $interview = Interview::where('id', $id) + ->orWhere('submission_unique_id', $id) + ->firstOrFail(); + + if (isset($interview->enable_tab_switch_screenshot) && !$interview->enable_tab_switch_screenshot) { + return response()->json([ + 'success' => false, + 'message' => 'Tab-switch screenshot capture is disabled by admin setting.' + ]); + } + + $url = null; + $filename = 'screenshot_tab_switch_' . time() . '_' . rand(100, 999) . '.jpg'; + $subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id; + $destinationPath = public_path($subDir); + + if (!file_exists($destinationPath)) { + mkdir($destinationPath, 0777, true); + } + + if ($request->hasFile('screenshot')) { + $file = $request->file('screenshot'); + $file->move($destinationPath, $filename); + $url = asset($subDir . '/' . $filename); + } elseif ($request->input('image')) { + $base64Image = $request->input('image'); + if (str_contains($base64Image, ',')) { + $base64Image = explode(',', $base64Image)[1]; + } + $imageData = base64_decode($base64Image); + file_put_contents($destinationPath . '/' . $filename, $imageData); + $url = asset($subDir . '/' . $filename); + } + + if ($url) { + $relativePath = '/' . $subDir . '/' . $filename; + $screenshots = $interview->tab_switch_screenshots ?? []; + $screenshotEntry = [ + 'url' => $relativePath, + 'full_url' => $url, + 'filename' => $filename, + 'timestamp' => now()->toIso8601String(), + 'reason' => 'tab_switch', + ]; + $screenshots[] = $screenshotEntry; + + $logs = $interview->proctor_logs ?? []; + $logs[] = [ + 'type' => 'tab_switch', + 'details' => 'Candidate switched active browser tab (Screenshot Captured: ' . $filename . ')', + 'screenshot_url' => $relativePath, + 'timestamp' => now()->toIso8601String(), + ]; + + $interview->update([ + 'tab_switch_screenshots' => $screenshots, + 'proctor_logs' => $logs, + ]); + + return response()->json([ + 'success' => true, + 'url' => $relativePath, + 'total_screenshots' => count($screenshots), + ]); + } + + return response()->json(['success' => false, 'message' => 'No image payload received']); + } + + /** + * Admin Toggle Enable/Disable Candidate Tab Switch Screenshot Capture. + */ + public function toggleTabScreenshot(Request $request, $id) + { + $interview = Interview::where('id', $id) + ->orWhere('submission_unique_id', $id) + ->firstOrFail(); + + $enable = $request->boolean('enable', true); + $interview->update(['enable_tab_switch_screenshot' => $enable]); + + return response()->json([ + 'success' => true, + 'enable_tab_switch_screenshot' => $enable, + 'message' => $enable ? 'Tab-switch screenshot capture ENABLED.' : 'Tab-switch screenshot capture DISABLED.' + ]); + } + + /** + * Save Candidate Video Recording under public/uploads/candidate_recordings/{unique_id}/. */ public function uploadRecording(Request $request, $id) { - $interview = Interview::findOrFail($id); + $interview = Interview::where('id', $id) + ->orWhere('submission_unique_id', $id) + ->firstOrFail(); if ($request->hasFile('video')) { $file = $request->file('video'); @@ -556,9 +733,15 @@ public function uploadRecording(Request $request, $id) $ext = 'webm'; } - $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.' . $ext; - $path = $file->storeAs('recordings', $filename, 'public'); - $url = Storage::disk('public')->url($path); + $subDir = 'uploads/candidate_recordings/' . $interview->submission_unique_id; + $destinationPath = public_path($subDir); + if (!file_exists($destinationPath)) { + mkdir($destinationPath, 0777, true); + } + + $filename = 'recording_' . time() . '.' . $ext; + $file->move($destinationPath, $filename); + $url = asset($subDir . '/' . $filename); $recordings = $interview->recordings ?? []; if (!is_array($recordings)) { diff --git a/app/Models/Interview.php b/app/Models/Interview.php index 36d6eb5..b45174b 100644 --- a/app/Models/Interview.php +++ b/app/Models/Interview.php @@ -31,14 +31,21 @@ class Interview extends Model 'blocked_ip', 'created_by', 'call_status', + 'call_started_by', + 'call_started_at', + 'enable_tab_switch_screenshot', + 'tab_switch_screenshots', ]; protected $casts = [ 'scheduled_at' => 'datetime', 'expires_at' => 'datetime', + 'call_started_at' => 'datetime', + 'enable_tab_switch_screenshot' => 'boolean', 'assigned_interviewers' => 'array', 'proctor_logs' => 'array', 'recordings' => 'array', + 'tab_switch_screenshots' => 'array', ]; public function creator() @@ -46,6 +53,11 @@ public function creator() return $this->belongsTo(User::class, 'created_by'); } + public function callStarter() + { + return $this->belongsTo(User::class, 'call_started_by'); + } + public function warnings() { return $this->hasMany(InterviewWarning::class, 'interview_id'); diff --git a/database/migrations/2026_07_27_120000_add_call_started_fields_to_interviews_table.php b/database/migrations/2026_07_27_120000_add_call_started_fields_to_interviews_table.php new file mode 100644 index 0000000..3a5cc1c --- /dev/null +++ b/database/migrations/2026_07_27_120000_add_call_started_fields_to_interviews_table.php @@ -0,0 +1,33 @@ +unsignedBigInteger('call_started_by')->nullable()->after('call_status'); + } + if (!Schema::hasColumn('interviews', 'call_started_at')) { + $table->timestamp('call_started_at')->nullable()->after('call_started_by'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('interviews', function (Blueprint $table) { + $table->dropColumn(['call_started_by', 'call_started_at']); + }); + } +}; diff --git a/database/migrations/2026_07_27_180000_add_tab_switch_screenshot_settings_to_interviews_table.php b/database/migrations/2026_07_27_180000_add_tab_switch_screenshot_settings_to_interviews_table.php new file mode 100644 index 0000000..c398a8d --- /dev/null +++ b/database/migrations/2026_07_27_180000_add_tab_switch_screenshot_settings_to_interviews_table.php @@ -0,0 +1,29 @@ +boolean('enable_tab_switch_screenshot')->default(true)->after('proctor_logs'); + $table->json('tab_switch_screenshots')->nullable()->after('enable_tab_switch_screenshot'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('interviews', function (Blueprint $table) { + $table->dropColumn(['enable_tab_switch_screenshot', 'tab_switch_screenshots']); + }); + } +}; diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 80613fc..b7f266e 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1332,5 +1332,261 @@ function initDragAndDrop(gridId, storageKey) { }); }); + + @if(Auth::user()->isAdmin() || strtolower(Auth::user()->role) === 'hr' || Auth::user()->hasActiveInterviewZone()) + +
+
+ +
+
+
+
+ 📞 +
+
+ +
+ ⚡ INCOMING INTERVIEW CALL +
+ +

+ Candidate Name +

+ +
+ Initiated by Panelist +
+ +
+ Another interviewer has started calling the candidate. Click Accept Call to join live, or Decline if busy. +
+ +
+ + +
+
+
+ + + + + @endif diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 57a0e24..50fd2b6 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -337,6 +337,10 @@ READY + +
+ +
+
+
+
+
+
+ 📞 +
+
+ +
+ ⚡ INCOMING INTERVIEW CALL +
+ +

+ Interviewer Panel Calling... +

+ +
+ The interviewer panel has initiated a live 2-way call. +
+ + +
+
+ diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index eade07b..cf83227 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -280,6 +280,10 @@ COMPLETED + @elseif($inv->call_status === 'active') + + 🟢 📞 CALL IN PROGRESS + @elseif($inv->isActiveTimeline()) ⚡ TIMELINE LIVE @@ -331,9 +335,15 @@ 📄 Report - + @if($inv->call_status === 'active') + + @else + + @endif Open IDE Link @@ -583,17 +593,18 @@ ● Live Synced - +
+
- +
@@ -626,6 +637,22 @@
No video recordings saved for this candidate yet.
+ + +
@@ -683,11 +710,58 @@ + +
+
+ +
+
+
+
+ 📞 +
+
+ +
+ ⚡ INCOMING INTERVIEW CALL +
+ +

+ Candidate Name +

+ +
+ Initiated by Panelist +
+ +
+ Another interviewer has started calling the candidate. Click Accept Call to join live, or Decline if busy (you can join later anytime from the dashboard table). +
+ +
+ + +
+
+
+ diff --git a/routes/web.php b/routes/web.php index a23bf26..e223c02 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,6 +32,7 @@ Route::post('/candidate/submit-code/{id}', [InterviewController::class, 'submitCode'])->name('interview.submit'); Route::post('/candidate/log-violation/{id}', [InterviewController::class, 'logViolation'])->name('interview.violation'); Route::post('/candidate/upload-recording/{id}', [InterviewController::class, 'uploadRecording'])->name('interview.upload-recording'); +Route::post('/candidate/upload-tab-screenshot/{id}', [InterviewController::class, 'uploadTabScreenshot'])->name('interview.upload-tab-screenshot'); Route::post('/candidate/sync-code/{id}', [InterviewController::class, 'syncCandidateCode'])->name('interview.candidate.sync-code'); Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes'); Route::post('/candidate/drawing/{id}', [InterviewController::class, 'saveCandidateDrawing'])->name('interview.candidate.drawing'); @@ -68,6 +69,7 @@ // Candidate Assessment Management for HR & Panelists Route::get('/interviews', [InterviewController::class, 'index'])->name('interview.index'); + Route::get('/interviews/active-calls', [InterviewController::class, 'getActiveCalls'])->name('interview.active-calls'); Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store'); Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning'); Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll'); @@ -76,6 +78,7 @@ Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report'); Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording'); Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status'); + Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot'); }); // Admin Control Panel (requires admin role)