sls/app/Http/Controllers/InterviewController.php
kushal.saha e4fabef1d3 feat(interview): add icalendar invites, white date pickers, and drawer validation feedback
- integrate spatie/icalendar-generator for candidate & panelist .ics invitations (RSVP & 15m alerts)
    - add StoreInterviewRequest, ScheduleInterviewDto, and invitation/reminder queued actions
    - add migration for job_title, round, description, and reminder_sent_at on interviews table
    - style datetime-local picker indicators to pure white on dark input backgrounds
    - support button loading spinners with :showLoader="true" and loadingText
    - auto-open schedule drawer on validation failure with inline @error alerts and old() bindings
2026-08-27 10:16:46 +00:00

1234 lines
46 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Actions\Interview\FinalizeRecordingAction;
use App\Actions\Interview\ScheduleInterviewAction;
use App\Actions\Interview\StoreRecordingChunkAction;
use App\DTOs\Interview\ScheduleInterviewDto;
use App\Http\Requests\Interview\StoreInterviewRequest;
use App\Http\Requests\Interview\UploadRecordingChunkRequest;
use App\Http\Responses\Interview\RecordingChunkReceivedResponse;
use App\Http\Responses\Interview\RecordingFinalizedResponse;
use App\Models\Interview;
use App\Models\InterviewWarning;
use App\Models\Setting;
use App\Models\User;
use App\Services\AiCheatingDetectorService;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
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();
// Auto-terminate call session if interview timeline has expired
foreach ($interviews as $interview) {
if ($interview->isExpired() && $interview->call_status === 'active') {
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
}
}
$interviewers = User::orderBy('name', 'asc')->get();
return view('interview.index', compact('interviews', 'interviewers'));
}
/**
* Display a specific Candidate Interview Live Review & Call Session.
*/
public function show($id)
{
$user = Auth::user();
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.');
}
$interview = Interview::findOrFail($id);
// Auto-terminate call session if interview timeline has expired
if ($interview->isExpired() && $interview->call_status === 'active') {
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
}
$interviewers = User::orderBy('name', 'asc')->get();
return view('interview.show', compact('interview', 'interviewers'));
}
/**
* Store and schedule a new Candidate Interview session.
*/
public function store(StoreInterviewRequest $request, ScheduleInterviewAction $scheduleAction)
{
$dto = ScheduleInterviewDto::fromRequest($request);
$interview = $scheduleAction->execute($dto, Auth::id());
return redirect()->route('interview.index')->with('success', 'Interview session scheduled and invitations sent for '.$interview->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 Public Judge0 CE Code Execution API (no auth needed)
$judge0LangMap = [
'python' => 71, // Python 3
'py' => 71,
'javascript' => 63, // JavaScript (Node.js)
'js' => 63,
'c' => 50, // C (GCC)
'cpp' => 54, // C++ (GCC)
'c++' => 54,
'java' => 62, // Java (OpenJDK)
'php' => 68, // PHP
'laravel' => 68,
'go' => 60, // Go
'rust' => 73, // Rust
];
if (isset($judge0LangMap[$langKey])) {
try {
$judge0LangId = $judge0LangMap[$langKey];
$response = Http::timeout(6)->post('https://ce.judge0.com/submissions?base64_encoded=false&wait=true', [
'language_id' => $judge0LangId,
'source_code' => $code,
]);
if ($response->successful()) {
$resData = $response->json();
$compileOutput = $resData['compile_output'] ?? '';
$stdout = $resData['stdout'] ?? '';
$stderr = $resData['stderr'] ?? '';
if (! empty($compileOutput)) {
$output = $compileOutput;
} elseif (! empty($stdout)) {
$output = $stdout;
} elseif (! empty($stderr)) {
$output = $stderr;
}
}
} catch (\Exception $e) {
Log::warning('Judge0 Code execute failed.', [$e->getMessage()]);
}
}
// 2. Instant Local Process Runner Fallback
if (empty($output)) {
try {
if (in_array($langKey, ['javascript', 'js'])) {
$process = new 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 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, MediaPipe ML Events, AI Detection).
*/
public function logViolation(Request $request, $id)
{
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
$type = $request->input('type');
$details = $request->input('details', '');
$confidence = $request->input('confidence', null);
$snapshot = $request->input('snapshot', null);
$detailsStr = is_array($details) ? json_encode($details) : (string) $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 AiCheatingDetectorService;
$aiVerdict = $aiDetector->analyzeSpeechTranscript($detailsStr, $interview->candidate_notes ?? '');
}
$logs = $interview->proctor_logs ?? [];
$logEntry = [
'type' => $type,
'details' => $details,
'confidence' => $confidence,
'snapshot' => $snapshot,
'ai_verdict' => $aiVerdict,
'timestamp' => $request->input('timestamp', now()->toIso8601String()),
];
if ($request->has('event_id')) {
$logEntry['event_id'] = $request->input('event_id');
}
$logs[] = $logEntry;
$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;
$rec['duration'] = isset($rec['duration']) ? (int) $rec['duration'] : 0;
$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', 'active_peers' => []]);
$interview->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', 'active_peers' => []]);
}
}
// Clean up stale active_peers (> 30 seconds)
$activePeers = $interview->active_peers ?? [];
$now = now()->timestamp;
$validPeers = array_values(array_filter($activePeers, function ($p) use ($now) {
return ($now - ($p['last_seen'] ?? 0)) < 30;
}));
if (count($validPeers) !== count($activePeers)) {
$interview->update(['active_peers' => $validPeers]);
}
$formattedRecordings = $this->getFormattedRecordings($interview);
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
$globalScreenshotsEnabled = 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,
'active_peers' => $validPeers,
'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(),
]);
}
/**
* Register or update active peer presence heartbeat for WebRTC Mesh call.
*/
public function registerPeerHeartbeat(Request $request, $id)
{
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
$peerId = $request->input('peer_id');
if (! $peerId) {
return response()->json(['error' => 'peer_id is required'], 422);
}
$action = $request->input('action', 'heartbeat'); // heartbeat or leave
$user = Auth::user();
$name = $request->input('name', $user ? $user->name : $interview->candidate_name);
$role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate');
$userId = $user ? $user->id : 0;
$interview->refresh();
$activePeers = $interview->active_peers ?? [];
$now = now()->timestamp;
$updatedPeers = [];
foreach ($activePeers as $p) {
if (($now - ($p['last_seen'] ?? 0)) < 30) {
if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) {
continue;
}
if (($p['peer_id'] ?? '') !== $peerId) {
$updatedPeers[] = $p;
}
}
}
if ($action === 'heartbeat') {
$micOn = $request->has('mic_on') ? filter_var($request->input('mic_on'), FILTER_VALIDATE_BOOLEAN) : true;
$camOn = $request->has('cam_on') ? filter_var($request->input('cam_on'), FILTER_VALIDATE_BOOLEAN) : true;
$updatedPeers[] = [
'peer_id' => $peerId,
'user_id' => $userId,
'name' => $name,
'role' => $role,
'mic_on' => $micOn,
'cam_on' => $camOn,
'last_seen' => $now,
];
}
$interview->update(['active_peers' => array_values($updatedPeers)]);
return response()->json([
'success' => true,
'active_peers' => $updatedPeers,
]);
}
/**
* 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;
$updateData['active_peers'] = [];
}
$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 = 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 (disabled for now)
$aiVerdict = null;
/*
$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)
{
try {
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->first();
if (! $interview) {
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
}
if ($request->hasFile('video')) {
$file = $request->file('video');
$ext = strtolower($file->getClientOriginalExtension() ?: 'webm');
if (! in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) {
$ext = 'webm';
}
$baseFolder = config('interview.recordings.base_folder', 'uploads/candidate_recordings');
$uniqueFolder = $interview->submission_unique_id ?: $interview->id;
$subDir = trim($baseFolder, '/').'/'.$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 (file may exceed server upload size limit).'], 422);
} catch (\Throwable $e) {
\Log::error('Recording upload failed: '.$e->getMessage());
return response()->json(['success' => false, 'message' => 'Server error saving recording: '.$e->getMessage()], 500);
}
}
/**
* Handle live chunked video recording upload from the interviewer proctoring session.
*
* @param int|string $id
* @return JsonResponse|Responsable
*/
public function uploadRecordingChunk(
UploadRecordingChunkRequest $request,
$id,
StoreRecordingChunkAction $storeChunkAction,
FinalizeRecordingAction $finalizeRecordingAction
) {
try {
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->first();
if (! $interview) {
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
}
$validated = $request->validated();
$sessionId = $validated['session_id'];
$chunkIndex = (int) $validated['chunk_index'];
$isFinal = filter_var($validated['is_final'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($request->hasFile('chunk')) {
$file = $request->file('chunk');
$storeChunkAction->execute($interview, $sessionId, $chunkIndex, $file);
}
if ($isFinal) {
$totalChunks = (int) ($validated['total_chunks'] ?? ($chunkIndex + 1));
$duration = isset($validated['duration']) ? (int) $validated['duration'] : null;
$finalizeRecordingAction->execute($interview, $sessionId, $totalChunks, null, null, $duration);
return new RecordingFinalizedResponse($sessionId);
}
return new RecordingChunkReceivedResponse($sessionId, $chunkIndex);
} catch (\Throwable $e) {
\Log::error('Recording chunk upload failed: '.$e->getMessage(), [
'id' => $id,
'session_id' => $request->input('session_id'),
'chunk_index' => $request->input('chunk_index'),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'message' => 'Error processing recording chunk: '.$e->getMessage(),
], 500);
}
}
/**
* 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'
));
}
}