- current compositor has a 2 pane layout, with candidate video/screenshare on left - other peers are added on right and bellow
1148 lines
44 KiB
PHP
1148 lines
44 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\Log;
|
|
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'));
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
$interviewers = User::orderBy('name', 'asc')->get();
|
|
|
|
return view('interview.show', compact('interview', '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 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 \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, 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 \App\Services\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;
|
|
$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', '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 = \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,
|
|
'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 = \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 (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';
|
|
}
|
|
|
|
$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 (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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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'
|
|
));
|
|
}
|
|
}
|