sls/app/Http/Controllers/InterviewController.php
2026-08-06 20:31:53 +05:30

1029 lines
39 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Interview;
use App\Models\InterviewWarning;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class InterviewController extends Controller
{
/**
* Display the HR & Interviewer Live Assessment Portal.
*/
public function index()
{
$user = Auth::user();
// Check if user is Admin, HR, or has active assigned interview zone timeline
if (!$user->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'
));
}
}