isAdmin() && strtolower($user->role) !== 'hr' && !$user->hasActiveInterviewZone()) { return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.'); } $interviews = $user->getAccessibleInterviews(); $interviewers = User::orderBy('name', 'asc')->get(); return view('interview.index', compact('interviews', 'interviewers')); } /** * Store a new Candidate Interview session created by HR. */ public function store(Request $request) { $request->validate([ 'candidate_name' => 'required|string|max:255', 'candidate_email' => 'required|email|max:255', 'candidate_phone' => 'required|string|max:50', 'scheduled_at' => 'nullable|date', 'valid_hours' => 'required|integer|min:1|max:72', 'language' => 'required|string', 'assigned_interviewers' => 'nullable|array', ]); $tempPassword = 'Pass-' . rand(100000, 999999); $cleanPhone = preg_replace('/[^0-9]/', '', $request->candidate_phone); $cleanName = Str::slug($request->candidate_name); $submissionUniqueId = $cleanName . '_' . $cleanPhone . '_' . time(); $scheduledAt = $request->scheduled_at ? \Carbon\Carbon::parse($request->scheduled_at) : now(); $expiresAt = $scheduledAt->copy()->addHours((int)$request->valid_hours); $interview = Interview::create([ 'candidate_name' => $request->candidate_name, 'candidate_email' => strtolower(trim($request->candidate_email)), 'candidate_phone' => $request->candidate_phone, 'temp_password' => $tempPassword, 'scheduled_at' => $scheduledAt, 'expires_at' => $expiresAt, 'language' => $request->language, 'status' => 'scheduled', 'assigned_interviewers' => $request->assigned_interviewers ?? [], 'proctor_logs' => [], 'submission_unique_id' => $submissionUniqueId, 'created_by' => Auth::id(), ]); return redirect()->route('interview.index')->with('success', 'Interview session created for ' . $request->candidate_name . '. Temporary login credentials generated!'); } /** * Show Candidate Login Screen. */ public function showCandidateLogin() { return view('interview.candidate_login'); } /** * Handle Candidate Temporary Credentials Authentication. */ public function loginCandidate(Request $request) { $request->validate([ 'identifier' => 'required|string', // Email or Phone 'temp_password' => 'required|string', ]); $identifier = trim($request->identifier); $interview = Interview::where(function ($q) use ($identifier) { $q->where('candidate_email', strtolower($identifier)) ->orWhere('candidate_phone', $identifier) ->orWhere('submission_unique_id', $identifier); })->where('temp_password', $request->temp_password)->first(); if (!$interview) { return back()->with('error', 'Invalid candidate credentials or temporary password.'); } if ($interview->status === 'blocked' || $interview->blocked_ip === $request->ip()) { return back()->with('error', 'Access Denied: Candidate email and IP address have been blocked by HR/Interviewer.'); } if ($interview->isExpired()) { $interview->update(['status' => 'expired']); return back()->with('error', 'This candidate interview access window has expired. Please contact HR.'); } // Set candidate session session(['candidate_interview_id' => $interview->id]); if ($interview->status === 'scheduled') { $interview->update(['status' => 'in_progress']); } return redirect()->route('interview.room', $interview->submission_unique_id); } /** * Show Candidate Live IDE Proctored Room. */ public function showCandidateRoom($uniqueId) { $interview = Interview::where('submission_unique_id', $uniqueId)->firstOrFail(); if ($interview->status === 'blocked') { return response()->view('interview.expired', compact('interview'), 403); } if ($interview->isExpired()) { $interview->update(['status' => 'expired']); return response()->view('interview.expired', compact('interview'), 403); } return view('interview.candidate_room', compact('interview')); } /** * Proxy Live Code Execution to Piston Multi-Language API. */ public function executeCode(Request $request) { $request->validate([ 'language' => 'required|string', 'code' => 'required|string', 'interview_id' => 'nullable|string', ]); $langKey = strtolower(trim($request->language)); $code = $request->code; $output = ''; // 1. Ultra-Fast Piston Code Execution API (sub-second for C, C++, Java, JS, Python, PHP, Go, Rust) $pistonLangMap = [ 'python' => 'python', 'py' => 'python', 'javascript' => 'javascript', 'js' => 'javascript', 'c' => 'c', 'cpp' => 'c++', 'c++' => 'c++', 'java' => 'java', 'php' => 'php', 'laravel' => 'php', 'go' => 'go', 'rust' => 'rust', ]; if (isset($pistonLangMap[$langKey])) { try { $pistonLang = $pistonLangMap[$langKey]; $response = Http::timeout(4)->post('https://emkc.org/api/v2/piston/execute', [ 'language' => $pistonLang, 'version' => '*', 'files' => [ ['content' => $code] ] ]); if ($response->successful()) { $resData = $response->json(); $output = $resData['run']['output'] ?? ($resData['run']['stdout'] ?? ''); if (empty($output) && !empty($resData['run']['stderr'])) { $output = $resData['run']['stderr']; } } } catch (\Exception $e) {} } // 2. Instant Local Process Runner Fallback if (empty($output)) { try { if (in_array($langKey, ['javascript', 'js'])) { $process = new \Symfony\Component\Process\Process(['node', '-e', $code]); $process->setTimeout(3); $process->run(); $out = $process->getOutput() ?: $process->getErrorOutput(); $output = trim($out) ?: 'JavaScript executed successfully (no stdout).'; } elseif ($langKey === 'python') { $process = new \Symfony\Component\Process\Process(['python', '-c', $code]); $process->setTimeout(3); $process->run(); $out = $process->getOutput() ?: $process->getErrorOutput(); $output = trim($out) ?: 'Python executed successfully (no stdout).'; } elseif (in_array($langKey, ['php', 'laravel'])) { ob_start(); try { $cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $code); eval($cleanCode); $out = ob_get_clean(); $output = trim($out) ?: 'PHP code executed successfully.'; } catch (\Throwable $e) { ob_end_clean(); $output = 'PHP Evaluation Error: ' . $e->getMessage(); } } } catch (\Exception $e) {} } if (empty($output)) { $output = "=== Code Execution Status ===\nSolution executed.\nLanguage: " . strtoupper($langKey); } // Auto-persist candidate code + execution output directly to interview model $interviewId = $request->input('interview_id'); if ($interviewId) { $interview = Interview::where('id', $interviewId)->orWhere('submission_unique_id', $interviewId)->first(); if ($interview) { $interview->update([ 'submitted_code' => $code, 'submitted_language' => $langKey, 'code_output' => $output, ]); } } return response()->json(['success' => true, 'output' => $output]); } /** * Save Candidate Submitted Code & Execution Results. */ public function submitCode(Request $request, $id) { $interview = Interview::findOrFail($id); $request->validate([ 'code' => 'required|string', 'language' => 'required|string', 'output' => 'nullable|string', ]); $interview->update([ 'submitted_code' => $request->code, 'submitted_language' => $request->language, 'code_output' => $request->output, 'status' => 'completed', ]); return response()->json([ 'success' => true, 'message' => 'Code submitted successfully under unique ID: ' . $interview->submission_unique_id, 'unique_id' => $interview->submission_unique_id ]); } /** * Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, Gaze Anomaly). */ public function logViolation(Request $request, $id) { $interview = Interview::findOrFail($id); $type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly, question_repetition, external_ai_detected $details = $request->input('details', ''); // Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call $aiVerdict = null; if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) { $aiDetector = new \App\Services\AiCheatingDetectorService(); $aiVerdict = $aiDetector->analyzeSpeechTranscript($details, $interview->candidate_notes ?? ''); } $logs = $interview->proctor_logs ?? []; $logs[] = [ 'type' => $type, 'details' => $details, 'ai_verdict' => $aiVerdict, 'timestamp' => now()->toIso8601String(), ]; $interview->update(['proctor_logs' => $logs]); return response()->json(['success' => true, 'total_violations' => count($logs), 'ai_verdict' => $aiVerdict]); } /** * Interviewer / HR sends a Silent Warning to Candidate. */ public function sendWarning(Request $request, $id) { $interview = Interview::findOrFail($id); $request->validate([ 'message' => 'required|string|max:500', ]); $warning = InterviewWarning::create([ 'interview_id' => $interview->id, 'message' => $request->message, 'sent_by' => Auth::id(), ]); return response()->json(['success' => true, 'warning' => $warning]); } /** * Send Real-Time Chat Message from Candidate to Interviewer/HR. */ public function candidateSendMessage(Request $request, $id) { $interview = Interview::findOrFail($id); $request->validate(['message' => 'required|string|max:500']); $warning = InterviewWarning::create([ 'interview_id' => $interview->id, 'message' => $request->message, 'sent_by' => null, // null sent_by indicates candidate sender ]); return response()->json(['success' => true, 'warning' => $warning]); } /** * Live Sync Candidate Code (before submission). */ public function syncCandidateCode(Request $request, $id) { $interview = Interview::findOrFail($id); $updateData = [ 'submitted_code' => $request->input('code'), 'submitted_language' => $request->input('language', $interview->language), ]; if ($request->has('output')) { $updateData['code_output'] = $request->input('output'); } $interview->update($updateData); return response()->json(['success' => true]); } /** * Save Candidate Live Notepad Content. */ public function saveCandidateNotes(Request $request, $id) { $interview = Interview::findOrFail($id); $interview->update(['candidate_notes' => $request->input('notes')]); return response()->json(['success' => true]); } /** * Save Candidate Live Canvas Drawing Data URL. */ public function saveCandidateDrawing(Request $request, $id) { $interview = Interview::findOrFail($id); $interview->update(['candidate_drawing' => $request->input('drawing')]); return response()->json(['success' => true]); } /** * Regenerate Temporary Candidate Password (by Interviewer/HR). */ public function regeneratePassword($id) { $interview = Interview::findOrFail($id); $newPass = 'Pass-' . rand(100000, 999999); $interview->update(['temp_password' => $newPass]); return response()->json(['success' => true, 'new_password' => $newPass]); } /** * Block Candidate Session, Email & Client IP (by Interviewer/HR). */ public function blockCandidate(Request $request, $id) { $interview = Interview::findOrFail($id); $clientIp = $request->ip(); $interview->update([ 'status' => 'blocked', 'blocked_ip' => $clientIp, ]); // Also block user record if exists User::where('email', strtolower($interview->candidate_email)) ->update(['is_blocked' => true]); return response()->json([ 'success' => true, 'message' => 'Candidate ' . $interview->candidate_name . ' (' . $interview->candidate_email . ') and IP ' . $clientIp . ' have been blocked successfully.' ]); } /** * Serve Storage File (Recordings & Public Assets) directly with Range Support for Video Streaming. */ public function serveStorageFile($path) { $sanitizedPath = ltrim(str_replace('..', '', $path), '/'); $candidatePaths = [ storage_path('app/public/' . $sanitizedPath), storage_path('app/private/public/' . $sanitizedPath), storage_path('app/private/' . $sanitizedPath), storage_path('app/' . $sanitizedPath), public_path('storage/' . $sanitizedPath), public_path($sanitizedPath), ]; // Also check with alternate extensions (.webm / .mp4) if requested file extension differs $baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath); if ($baseNoExt !== $sanitizedPath) { $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = public_path($baseNoExt . '.webm'); $candidatePaths[] = public_path($baseNoExt . '.mp4'); } $filePath = null; foreach ($candidatePaths as $cp) { if (file_exists($cp) && is_file($cp)) { $filePath = $cp; break; } } if (!$filePath) { abort(404, 'Storage file not found.'); } // Read first 12 bytes to accurately detect container format (WebM vs MP4 vs Image) $handle = @fopen($filePath, 'rb'); $header = $handle ? fread($handle, 12) : ''; if ($handle) fclose($handle); $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $isWebmHeader = str_starts_with($header, "\x1A\x45\xDF\xA3"); if ($isWebmHeader || $extension === 'webm') { $mimeType = 'video/webm'; } elseif (str_contains($header, 'ftyp') || in_array($extension, ['mp4', 'm4v'])) { $mimeType = 'video/mp4'; } elseif ($extension === 'png') { $mimeType = 'image/png'; } elseif (in_array($extension, ['jpg', 'jpeg'])) { $mimeType = 'image/jpeg'; } else { $mimeType = @mime_content_type($filePath) ?: 'application/octet-stream'; } $fileSize = filesize($filePath); $headers = [ 'Content-Type' => $mimeType, 'Accept-Ranges' => 'bytes', 'Cache-Control' => 'public, max-age=86400', ]; // Enable HTTP 206 Partial Content Range support required for HTML5 video playback/seeking $range = request()->header('Range'); if ($range && preg_match('/bytes=(\d+)-(\d+)?/', $range, $matches)) { $start = (int) $matches[1]; $end = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : ($fileSize - 1); if ($start > $end || $start >= $fileSize) { return response('', 416, ['Content-Range' => "bytes */{$fileSize}"]); } $end = min($end, $fileSize - 1); $length = $end - $start + 1; $headers['Content-Range'] = "bytes {$start}-{$end}/{$fileSize}"; $headers['Content-Length'] = (string) $length; return response()->stream(function () use ($filePath, $start, $length) { $file = fopen($filePath, 'rb'); if ($file) { fseek($file, $start); $bufferSize = 1024 * 64; $bytesLeft = $length; while ($bytesLeft > 0 && !feof($file)) { $readSize = min($bufferSize, $bytesLeft); $data = fread($file, $readSize); if ($data === false) break; echo $data; flush(); $bytesLeft -= strlen($data); } fclose($file); } }, 206, $headers); } $headers['Content-Length'] = (string) $fileSize; return response()->file($filePath, $headers); } /** * Get normalized recordings list sorted in descending order of creation timestamp (newest first). */ private function getFormattedRecordings($interview) { $rawRecordings = $interview->recordings ?? []; if (!is_array($rawRecordings) || empty($rawRecordings)) { if (!empty($interview->recording_path)) { $rawRecordings = [[ 'url' => $interview->recording_path, 'filename' => basename($interview->recording_path), 'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(), ]]; } else { $rawRecordings = []; } } $formatted = []; foreach ($rawRecordings as $idx => $rec) { if (is_string($rec)) { $rec = [ 'url' => $rec, 'filename' => basename($rec), 'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(), ]; } $url = $rec['url'] ?? ''; $urlPath = parse_url($url, PHP_URL_PATH) ?? $url; $cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); $streamUrl = url('/media-stream/' . $cleanPath); $ext = strtolower(pathinfo($cleanPath, PATHINFO_EXTENSION)); $mimeType = in_array($ext, ['mp4', 'm4v']) ? 'video/mp4' : 'video/webm'; $rec['original_index'] = $idx; $rec['stream_url'] = $streamUrl; $rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx; $rec['mime_type'] = $mimeType; $formatted[] = $rec; } // Sort descending by created_at timestamp (newest first) usort($formatted, function ($a, $b) { $timeA = isset($a['created_at']) ? strtotime($a['created_at']) : 0; $timeB = isset($b['created_at']) ? strtotime($b['created_at']) : 0; return $timeB <=> $timeA; }); 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) { // Auto-terminate call session if interview timeline has expired if ($interview->isExpired()) { if ($interview->call_status === 'active') { $interview->update(['call_status' => 'ended']); } continue; } 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', 'callStarter']) ->where('id', $id) ->orWhere('submission_unique_id', $id) ->firstOrFail(); // Enforce expiration: ended call status if timeline is expired if ($interview->isExpired()) { if ($interview->call_status === 'active') { $interview->update(['call_status' => 'ended']); } } $formattedRecordings = $this->getFormattedRecordings($interview); $starterName = $interview->callStarter ? $interview->callStarter->name : null; $globalScreenshotsEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1'; $interviewScreenshotsEnabled = (bool) ($interview->enable_tab_switch_screenshot ?? true); $effectiveScreenshotEnabled = $globalScreenshotsEnabled && $interviewScreenshotsEnabled; return response()->json([ 'status' => $interview->status, 'call_status' => $interview->isExpired() ? 'ended' : ($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' => $effectiveScreenshotEnabled, 'global_screenshot_enabled' => $globalScreenshotsEnabled, 'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [], 'submitted_code' => $interview->submitted_code, 'submitted_language' => $interview->submitted_language, 'code_output' => $interview->code_output, 'candidate_notes' => $interview->candidate_notes, 'candidate_drawing' => $interview->candidate_drawing, 'temp_password' => $interview->temp_password, 'blocked_ip' => $interview->blocked_ip, 'proctor_logs' => $interview->proctor_logs ?? [], 'recording_path' => $interview->recording_path, 'recordings' => $formattedRecordings, 'warnings' => $interview->warnings, 'is_expired' => $interview->isExpired(), ]); } /** * Update Interview Call Session Status (active, idle, ended). */ public function updateCallStatus(Request $request, $id) { $interview = Interview::where('id', $id) ->orWhere('submission_unique_id', $id) ->firstOrFail(); $status = $request->input('call_status', 'idle'); $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, ]); } /** * Upload & Save Candidate System Screen 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(); $globalEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1'; $interviewEnabled = !isset($interview->enable_tab_switch_screenshot) || $interview->enable_tab_switch_screenshot; if (!$globalEnabled || !$interviewEnabled) { return response()->json([ 'success' => false, 'message' => 'Candidate system screenshot capture is disabled by admin setting.' ]); } $reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch' $filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg'; $uniqueFolder = $interview->submission_unique_id ?: $interview->id; $subDir = 'uploads/candidate_screenshots/' . $uniqueFolder; $destinationPath = public_path($subDir); if (!file_exists($destinationPath)) { mkdir($destinationPath, 0777, true); } $url = null; 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]; } $base64Image = str_replace(' ', '+', $base64Image); $imageData = base64_decode($base64Image); if ($imageData !== false && strlen($imageData) > 0) { file_put_contents($destinationPath . '/' . $filename, $imageData); $url = asset($subDir . '/' . $filename); } } if ($url) { $relativePath = '/' . $subDir . '/' . $filename; $fullFilePath = $destinationPath . '/' . $filename; // Automatically analyze candidate system screen capture using AI Vision Engine $aiDetector = new \App\Services\AiCheatingDetectorService(); $aiVerdict = $aiDetector->analyzeScreenshot($fullFilePath); $screenshotEntry = [ 'url' => $relativePath, 'full_url' => $url, 'filename' => $filename, 'ai_verdict' => $aiVerdict, 'timestamp' => now()->toIso8601String(), 'reason' => $reason, ]; $screenshots = $interview->tab_switch_screenshots ?? []; $screenshots[] = $screenshotEntry; $hasAiTool = !empty($aiVerdict['is_cheating']) && !empty($aiVerdict['ai_tool_detected']); if ($reason === 'call_start') { $logType = 'call_start_screenshot'; $logMsg = '📸 CALL STARTED SYSTEM SNAPSHOT: System screen snapshot captured automatically when call started.'; } elseif ($hasAiTool) { $logType = 'external_ai_detected'; $toolName = $aiVerdict['ai_tool_detected']; $logMsg = "🤖 candidate might use external ai ({$toolName}): " . $aiVerdict['summary']; } else { $logType = 'tab_switch'; $logMsg = '📸 Candidate switched browser tab or window: System screen screenshot captured for interviewer & admin review.'; } $logs = $interview->proctor_logs ?? []; $logs[] = [ 'type' => $logType, 'details' => $logMsg, 'ai_verdict' => $aiVerdict, 'screenshot_url' => $relativePath, 'timestamp' => now()->toIso8601String(), ]; $interview->update([ 'tab_switch_screenshots' => $screenshots, 'proctor_logs' => $logs, ]); return response()->json([ 'success' => true, 'url' => $relativePath, 'full_url' => $url, 'ai_verdict' => $aiVerdict, '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::where('id', $id) ->orWhere('submission_unique_id', $id) ->firstOrFail(); if ($request->hasFile('video')) { $file = $request->file('video'); $ext = strtolower($file->getClientOriginalExtension() ?: 'webm'); if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) { $ext = 'webm'; } $uniqueFolder = $interview->submission_unique_id ?: $interview->id; $subDir = 'uploads/candidate_recordings/' . $uniqueFolder; $destinationPath = public_path($subDir); if (!file_exists($destinationPath)) { mkdir($destinationPath, 0777, true); } $filename = 'recording_' . time() . '.' . $ext; $file->move($destinationPath, $filename); $url = asset($subDir . '/' . $filename); $relativePath = '/' . $subDir . '/' . $filename; $recordings = $interview->recordings ?? []; if (!is_array($recordings)) { $recordings = $interview->recording_path ? [$interview->recording_path] : []; } $recordings[] = [ 'url' => $relativePath, 'full_url' => $url, 'filename' => $filename, 'created_at' => now()->toIso8601String(), ]; $interview->update([ 'recording_path' => $relativePath, 'recordings' => $recordings, ]); return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]); } return response()->json(['success' => false, 'message' => 'No video payload received']); } /** * Download Candidate Video Recording as File. */ public function downloadRecording(Request $request, $id) { $interview = Interview::findOrFail($id); $index = (int) $request->input('index', 0); $recordings = $interview->recordings ?? []; $targetUrl = null; if (!empty($recordings) && isset($recordings[$index])) { $rec = $recordings[$index]; $targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec; } if (!$targetUrl) { $targetUrl = $interview->recording_path; } if (!$targetUrl) { return back()->with('error', 'No video recording found for this candidate profile.'); } $urlPath = parse_url($targetUrl, PHP_URL_PATH) ?? $targetUrl; $sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); $candidatePaths = [ storage_path('app/public/' . $sanitizedPath), storage_path('app/private/public/' . $sanitizedPath), storage_path('app/private/' . $sanitizedPath), storage_path('app/' . $sanitizedPath), public_path('storage/' . $sanitizedPath), public_path($sanitizedPath), ]; $baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath); if ($baseNoExt !== $sanitizedPath) { $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4'); } $filePath = null; foreach ($candidatePaths as $cp) { if (file_exists($cp) && is_file($cp)) { $filePath = $cp; break; } } if ($filePath && file_exists($filePath)) { $handle = @fopen($filePath, 'rb'); $header = $handle ? fread($handle, 12) : ''; if ($handle) fclose($handle); $isWebm = str_starts_with($header, "\x1A\x45\xDF\xA3") || str_ends_with($filePath, '.webm'); $ext = $isWebm ? 'webm' : 'mp4'; $mimeType = $isWebm ? 'video/webm' : 'video/mp4'; $downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.' . $ext; return response()->download($filePath, $downloadFilename, [ 'Content-Type' => $mimeType, ]); } return redirect($targetUrl); } /** * Generate Comprehensive Executive Candidate Evaluation & Behavioral Report (PDF View). */ public function generateReport($id) { $interview = Interview::with(['warnings.sender'])->findOrFail($id); $logs = is_array($interview->proctor_logs) ? $interview->proctor_logs : []; $tabSwitches = 0; $focusLosses = 0; $gazeAnomalies = 0; $pasteEvents = 0; $lowerGazeViolations = 0; $questionRepeatViolations = 0; foreach ($logs as $l) { $type = $l['type'] ?? ''; if ($type === 'tab_switch') $tabSwitches++; if ($type === 'focus_lost') $focusLosses++; if (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) $gazeAnomalies++; if ($type === 'paste_event') $pasteEvents++; if ($type === 'gaze_lower_device') $lowerGazeViolations++; if (in_array($type, ['question_repeat_lower', 'question_repetition'])) $questionRepeatViolations++; } $totalViolations = count($logs); // Calculate Integrity Score % if ($totalViolations === 0) { $integrityScore = 100; $integrityLabel = 'Exceptional Integrity'; $integrityColor = '#10b981'; } elseif ($totalViolations <= 2 && $lowerGazeViolations === 0 && $questionRepeatViolations === 0) { $integrityScore = 85; $integrityLabel = 'Low Behavioral Risk'; $integrityColor = '#06b6d4'; } elseif ($totalViolations <= 5) { $integrityScore = 60; $integrityLabel = 'Moderate Integrity Concern'; $integrityColor = '#f59e0b'; } else { $integrityScore = 25; $integrityLabel = 'High Malpractice Risk'; $integrityColor = '#ef4444'; } // Technical Code Score $codeScore = 0; if (!empty($interview->submitted_code)) { $codeScore += 50; if (!empty($interview->code_output) && !str_contains(strtolower($interview->code_output), 'error')) { $codeScore += 40; } else { $codeScore += 20; } if (!empty($interview->candidate_notes) || !empty($interview->candidate_drawing)) { $codeScore += 10; } } $interview->formatted_recordings = $this->getFormattedRecordings($interview); return view('interview.report', compact( 'interview', 'logs', 'tabSwitches', 'focusLosses', 'gazeAnomalies', 'pasteEvents', 'lowerGazeViolations', 'questionRepeatViolations', 'integrityScore', 'integrityLabel', 'integrityColor', 'codeScore' )); } }