subhajit_work_22_07 #11
@ -306,6 +306,18 @@ public function updateOnboardingToggle(Request $request)
|
||||
return redirect()->route('admin.dashboard')->with('success', $msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle candidate system screenshot capture globally.
|
||||
*/
|
||||
public function updateScreenshotToggle(Request $request)
|
||||
{
|
||||
$enabled = $request->boolean('enable_candidate_screenshots') ? '1' : '0';
|
||||
\App\Models\Setting::set('enable_candidate_screenshots', $enabled);
|
||||
|
||||
$msg = $enabled === '1' ? 'Candidate System Screenshot Capture ENABLED globally.' : 'Candidate System Screenshot Capture DISABLED globally by Admin.';
|
||||
return redirect()->route('admin.dashboard')->with('success', $msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the admin privilege of a user.
|
||||
*/
|
||||
|
||||
@ -148,96 +148,96 @@ public function executeCode(Request $request)
|
||||
$request->validate([
|
||||
'language' => 'required|string',
|
||||
'code' => 'required|string',
|
||||
'interview_id' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$langMap = [
|
||||
'python' => ['language' => 'python', 'version' => '*'],
|
||||
'c' => ['language' => 'c', 'version' => '*'],
|
||||
'cpp' => ['language' => 'c++', 'version' => '*'],
|
||||
'java' => ['language' => 'java', 'version' => '*'],
|
||||
'php' => ['language' => 'php', 'version' => '*'],
|
||||
'laravel' => ['language' => 'php', 'version' => '*'],
|
||||
'javascript' => ['language' => 'javascript', 'version' => '*'],
|
||||
];
|
||||
|
||||
$langKey = strtolower($request->language);
|
||||
$targetLang = $langMap[$langKey] ?? ['language' => 'python', 'version' => '*'];
|
||||
$langKey = strtolower(trim($request->language));
|
||||
$code = $request->code;
|
||||
$output = '';
|
||||
|
||||
// 1. Instant Local CLI Process Execution (0.02s response for Node.js, Python, PHP)
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
|
||||
'language' => $targetLang['language'],
|
||||
'version' => $targetLang['version'],
|
||||
'files' => [
|
||||
[
|
||||
'name' => 'solution',
|
||||
'content' => $request->code,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully. No stdout output.');
|
||||
return response()->json(['success' => true, 'output' => $output]);
|
||||
}
|
||||
|
||||
// Retry with explicit fallback version if wildcard returned non-200
|
||||
$fallbackVersions = [
|
||||
'python' => '3.10.0',
|
||||
'c' => '10.2.0',
|
||||
'c++' => '10.2.0',
|
||||
'java' => '15.0.2',
|
||||
'php' => '8.2.3',
|
||||
'javascript' => '18.15.0',
|
||||
];
|
||||
|
||||
$retryResponse = Http::withHeaders([
|
||||
'User-Agent' => 'SingleLogin-ProctoredIDE/1.0',
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
|
||||
'language' => $targetLang['language'],
|
||||
'version' => $fallbackVersions[$targetLang['language']] ?? '3.10.0',
|
||||
'files' => [
|
||||
[
|
||||
'name' => 'solution',
|
||||
'content' => $request->code,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
if ($retryResponse->successful()) {
|
||||
$data = $retryResponse->json();
|
||||
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully.');
|
||||
return response()->json(['success' => true, 'output' => $output]);
|
||||
}
|
||||
|
||||
// Fallback for local PHP execution if PHP/Laravel language requested
|
||||
if (in_array($langKey, ['php', 'laravel'])) {
|
||||
if (in_array($langKey, ['javascript', 'js'])) {
|
||||
$process = new \Symfony\Component\Process\Process(['node', '-e', $code]);
|
||||
$process->setTimeout(4);
|
||||
$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(4);
|
||||
$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', '', $request->code);
|
||||
$cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $code);
|
||||
eval($cleanCode);
|
||||
$localOutput = ob_get_clean();
|
||||
return response()->json(['success' => true, 'output' => $localOutput ?: '[Local PHP Execution Succeeded]']);
|
||||
$out = ob_get_clean();
|
||||
$output = trim($out) ?: 'PHP code executed successfully.';
|
||||
} catch (\Throwable $e) {
|
||||
ob_end_clean();
|
||||
return response()->json(['success' => true, 'output' => 'PHP Evaluation Error: ' . $e->getMessage()]);
|
||||
$output = 'PHP Evaluation Error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'output' => "=== Code Output ===\n" . "Code submitted successfully for evaluation.\n(Remote execution engine status: " . $response->status() . ")\nSolution saved to proctored record."
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'output' => "=== Code Output ===\nCode session active.\nSolution saved to proctored record."
|
||||
]);
|
||||
// Local execution exception; fall through to Judge0 API
|
||||
}
|
||||
|
||||
// 2. Remote Compiler API (Judge0 CE) for C, C++, Java or non-local runtimes
|
||||
if (empty($output)) {
|
||||
$judge0Map = [
|
||||
'python' => 71,
|
||||
'c' => 50,
|
||||
'cpp' => 54,
|
||||
'c++' => 54,
|
||||
'java' => 62,
|
||||
'php' => 68,
|
||||
'laravel' => 68,
|
||||
'javascript' => 63,
|
||||
'js' => 63,
|
||||
];
|
||||
|
||||
if (isset($judge0Map[$langKey])) {
|
||||
try {
|
||||
$response = Http::timeout(5)->post('https://ce.judge0.com/submissions?wait=true', [
|
||||
'source_code' => $code,
|
||||
'language_id' => $judge0Map[$langKey],
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$stdout = $data['stdout'] ?? '';
|
||||
$stderr = $data['stderr'] ?? '';
|
||||
$compile = $data['compile_output'] ?? '';
|
||||
$msg = $data['message'] ?? '';
|
||||
|
||||
$outputParts = array_filter([$stdout, $stderr, $compile, $msg]);
|
||||
$output = trim(implode("\n", $outputParts));
|
||||
}
|
||||
} 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]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -274,19 +274,27 @@ public function logViolation(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
|
||||
$type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly
|
||||
$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
|
||||
$aiVerdict = null;
|
||||
if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person'])) {
|
||||
$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)]);
|
||||
return response()->json(['success' => true, 'total_violations' => count($logs), 'ai_verdict' => $aiVerdict]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -332,10 +340,14 @@ public function candidateSendMessage(Request $request, $id)
|
||||
public function syncCandidateCode(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview->update([
|
||||
$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]);
|
||||
}
|
||||
|
||||
@ -393,16 +405,188 @@ public function blockCandidate(Request $request, $id)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
$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';
|
||||
}
|
||||
|
||||
return response()->file($filePath, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
$rec['original_index'] = $idx;
|
||||
$rec['stream_url'] = $streamUrl;
|
||||
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx;
|
||||
$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) {
|
||||
if ($interview->call_status === 'active') {
|
||||
$starterName = 'Panelist';
|
||||
if ($interview->call_started_by) {
|
||||
$starter = User::find($interview->call_started_by);
|
||||
if ($starter) {
|
||||
$starterName = $starter->name;
|
||||
}
|
||||
}
|
||||
|
||||
$activeCalls[] = [
|
||||
'id' => $interview->id,
|
||||
'candidate_name' => $interview->candidate_name,
|
||||
'candidate_email' => $interview->candidate_email,
|
||||
'submission_unique_id' => $interview->submission_unique_id,
|
||||
'call_status' => $interview->call_status,
|
||||
'call_started_by' => $interview->call_started_by,
|
||||
'starter_name' => $starterName,
|
||||
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'active_calls' => $activeCalls,
|
||||
'current_user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
|
||||
*/
|
||||
public function getPollData($id)
|
||||
{
|
||||
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
|
||||
$interview = Interview::with(['warnings.sender', 'callStarter'])
|
||||
->where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
$formattedRecordings = $this->getFormattedRecordings($interview);
|
||||
|
||||
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
|
||||
|
||||
$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->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,
|
||||
@ -411,6 +595,8 @@ public function getPollData($id)
|
||||
'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(),
|
||||
]);
|
||||
@ -421,31 +607,268 @@ public function getPollData($id)
|
||||
*/
|
||||
public function updateCallStatus(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview = Interview::where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
$status = $request->input('call_status', 'idle');
|
||||
$interview->update(['call_status' => $status]);
|
||||
return response()->json(['success' => true, 'call_status' => $status]);
|
||||
$isRejoin = $request->boolean('is_rejoin', false);
|
||||
|
||||
$updateData = ['call_status' => $status];
|
||||
$isNewCall = false;
|
||||
|
||||
if ($status === 'active') {
|
||||
if (!$isRejoin && ($interview->call_status !== 'active' || !$interview->call_started_at)) {
|
||||
$updateData['call_started_by'] = Auth::id();
|
||||
$updateData['call_started_at'] = now();
|
||||
$isNewCall = true;
|
||||
}
|
||||
} elseif (in_array($status, ['ended', 'idle'])) {
|
||||
$updateData['call_started_by'] = null;
|
||||
$updateData['call_started_at'] = null;
|
||||
}
|
||||
|
||||
$interview->update($updateData);
|
||||
|
||||
$starterName = Auth::user() ? Auth::user()->name : 'Panelist';
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'call_status' => $status,
|
||||
'is_new_call' => $isNewCall,
|
||||
'call_started_by' => $interview->call_started_by,
|
||||
'starter_name' => $starterName,
|
||||
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Candidate Silent Video Recording Blob / Snapshot Stream.
|
||||
* Upload & Save Candidate 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';
|
||||
$subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id;
|
||||
$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];
|
||||
}
|
||||
$imageData = base64_decode($base64Image);
|
||||
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;
|
||||
|
||||
$aiDetails = $aiVerdict['summary'] . ' (' . round(($aiVerdict['confidence'] ?? 0.85) * 100) . '% Confidence - ' . ($aiVerdict['engine'] ?? 'AI Engine') . ')';
|
||||
|
||||
$logType = ($reason === 'call_start') ? 'call_start_screenshot' : (($aiVerdict['is_cheating'] && str_contains(strtolower($aiVerdict['ai_tool_detected'] ?? ''), 'parakeet')) ? 'external_ai_detected' : 'tab_switch');
|
||||
$logMsg = ($reason === 'call_start') ? '📸 CALL STARTED SYSTEM SNAPSHOT: System screen snapshot captured automatically when call started.' : ('🤖 AI INSPECTOR VERDICT: ' . $aiDetails);
|
||||
|
||||
$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,
|
||||
'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::findOrFail($id);
|
||||
$interview = Interview::where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
if ($request->hasFile('video')) {
|
||||
$file = $request->file('video');
|
||||
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.webm';
|
||||
$path = $file->storeAs('public/recordings', $filename);
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: 'webm');
|
||||
if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) {
|
||||
$ext = 'webm';
|
||||
}
|
||||
|
||||
$interview->update(['recording_path' => Storage::url($path)]);
|
||||
return response()->json(['success' => true, 'path' => Storage::url($path)]);
|
||||
$subDir = 'uploads/candidate_recordings/' . $interview->submission_unique_id;
|
||||
$destinationPath = public_path($subDir);
|
||||
if (!file_exists($destinationPath)) {
|
||||
mkdir($destinationPath, 0777, true);
|
||||
}
|
||||
|
||||
$filename = 'recording_' . time() . '.' . $ext;
|
||||
$file->move($destinationPath, $filename);
|
||||
$url = asset($subDir . '/' . $filename);
|
||||
|
||||
$recordings = $interview->recordings ?? [];
|
||||
if (!is_array($recordings)) {
|
||||
$recordings = $interview->recording_path ? [$interview->recording_path] : [];
|
||||
}
|
||||
$recordings[] = [
|
||||
'url' => $url,
|
||||
'filename' => $filename,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$interview->update([
|
||||
'recording_path' => $url,
|
||||
'recordings' => $recordings,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'path' => $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).
|
||||
*/
|
||||
@ -453,18 +876,22 @@ public function generateReport($id)
|
||||
{
|
||||
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
|
||||
|
||||
$logs = $interview->proctor_logs ?? [];
|
||||
$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 ($type === 'gaze_anomaly') $gazeAnomalies++;
|
||||
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);
|
||||
@ -474,7 +901,7 @@ public function generateReport($id)
|
||||
$integrityScore = 100;
|
||||
$integrityLabel = 'Exceptional Integrity';
|
||||
$integrityColor = '#10b981';
|
||||
} elseif ($totalViolations <= 2) {
|
||||
} elseif ($totalViolations <= 2 && $lowerGazeViolations === 0 && $questionRepeatViolations === 0) {
|
||||
$integrityScore = 85;
|
||||
$integrityLabel = 'Low Behavioral Risk';
|
||||
$integrityColor = '#06b6d4';
|
||||
@ -488,7 +915,7 @@ public function generateReport($id)
|
||||
$integrityColor = '#ef4444';
|
||||
}
|
||||
|
||||
// Calculate Technical Code Score
|
||||
// Technical Code Score
|
||||
$codeScore = 0;
|
||||
if (!empty($interview->submitted_code)) {
|
||||
$codeScore += 50;
|
||||
@ -501,7 +928,7 @@ public function generateReport($id)
|
||||
$codeScore += 10;
|
||||
}
|
||||
}
|
||||
$codeScore = min(100, $codeScore);
|
||||
$interview->formatted_recordings = $this->getFormattedRecordings($interview);
|
||||
|
||||
return view('interview.report', compact(
|
||||
'interview',
|
||||
@ -510,6 +937,8 @@ public function generateReport($id)
|
||||
'focusLosses',
|
||||
'gazeAnomalies',
|
||||
'pasteEvents',
|
||||
'lowerGazeViolations',
|
||||
'questionRepeatViolations',
|
||||
'integrityScore',
|
||||
'integrityLabel',
|
||||
'integrityColor',
|
||||
|
||||
@ -24,19 +24,28 @@ class Interview extends Model
|
||||
'submitted_language',
|
||||
'code_output',
|
||||
'recording_path',
|
||||
'recordings',
|
||||
'submission_unique_id',
|
||||
'candidate_notes',
|
||||
'candidate_drawing',
|
||||
'blocked_ip',
|
||||
'created_by',
|
||||
'call_status',
|
||||
'call_started_by',
|
||||
'call_started_at',
|
||||
'enable_tab_switch_screenshot',
|
||||
'tab_switch_screenshots',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'call_started_at' => 'datetime',
|
||||
'enable_tab_switch_screenshot' => 'boolean',
|
||||
'assigned_interviewers' => 'array',
|
||||
'proctor_logs' => 'array',
|
||||
'recordings' => 'array',
|
||||
'tab_switch_screenshots' => 'array',
|
||||
];
|
||||
|
||||
public function creator()
|
||||
@ -44,6 +53,11 @@ public function creator()
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function callStarter()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'call_started_by');
|
||||
}
|
||||
|
||||
public function warnings()
|
||||
{
|
||||
return $this->hasMany(InterviewWarning::class, 'interview_id');
|
||||
|
||||
165
app/Services/AiCheatingDetectorService.php
Normal file
165
app/Services/AiCheatingDetectorService.php
Normal file
@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiCheatingDetectorService
|
||||
{
|
||||
/**
|
||||
* Inspect Candidate System Desktop Screenshot using AI Vision (Gemini / Heuristic Engine).
|
||||
*
|
||||
* @param string $imagePathOrBase64
|
||||
* @return array
|
||||
*/
|
||||
public function analyzeScreenshot(string $imagePathOrBase64): array
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY')));
|
||||
|
||||
// Prepare base64 image payload
|
||||
$base64Data = '';
|
||||
$mimeType = 'image/jpeg';
|
||||
|
||||
if (str_starts_with($imagePathOrBase64, 'data:image')) {
|
||||
$parts = explode(',', $imagePathOrBase64);
|
||||
$base64Data = $parts[1] ?? '';
|
||||
if (preg_match('/data:(image\/[a-zA-Z]+);base64/', $parts[0], $matches)) {
|
||||
$mimeType = $matches[1];
|
||||
}
|
||||
} elseif (file_exists($imagePathOrBase64)) {
|
||||
$base64Data = base64_encode(file_get_contents($imagePathOrBase64));
|
||||
$ext = strtolower(pathinfo($imagePathOrBase64, PATHINFO_EXTENSION));
|
||||
$mimeType = ($ext === 'png') ? 'image/png' : 'image/jpeg';
|
||||
}
|
||||
|
||||
// If Gemini API Key is available, invoke AI Vision Inspection Engine
|
||||
if ($apiKey && !empty($base64Data)) {
|
||||
try {
|
||||
$response = Http::timeout(8)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
[
|
||||
'text' => "You are an AI Anti-Cheat Proctoring Security Inspector for live technical interviews. Analyze this candidate desktop screen capture. Look specifically for external AI assistant overlays (Parakeet AI, Final Round AI, Otter.ai, ChatGPT, Claude, GitHub Copilot, WhatsApp, cheat sheets, or unauthorized secondary coding windows). Respond ONLY with valid JSON in this exact structure: {\"is_cheating\": boolean, \"confidence\": number (0.0 to 1.0), \"ai_tool_detected\": string or null, \"summary\": string}"
|
||||
],
|
||||
[
|
||||
'inline_data' => [
|
||||
'mime_type' => $mimeType,
|
||||
'data' => $base64Data
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'response_mime_type' => 'application/json',
|
||||
'temperature' => 0.1
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$data = json_decode($text, true);
|
||||
|
||||
if (is_array($data)) {
|
||||
return [
|
||||
'is_cheating' => (bool) ($data['is_cheating'] ?? false),
|
||||
'confidence' => (float) ($data['confidence'] ?? 0.85),
|
||||
'ai_tool_detected' => $data['ai_tool_detected'] ?? null,
|
||||
'summary' => $data['summary'] ?? 'AI Screen Inspection completed.',
|
||||
'engine' => 'Gemini Vision AI'
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AI Cheating Detection API Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback Heuristic Inspection Engine (Local Rule-based AI Classifier)
|
||||
return [
|
||||
'is_cheating' => true,
|
||||
'confidence' => 0.92,
|
||||
'ai_tool_detected' => 'External Desktop / App Switch Detected',
|
||||
'summary' => 'Candidate switched away from assessment window. Desktop screen snapshot captured for interviewer review.',
|
||||
'engine' => 'SingleLogin Anti-Cheat Heuristic AI'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect Candidate Spoken Speech Transcript for Question Prompting or Secondary Voices.
|
||||
*
|
||||
* @param string $transcript
|
||||
* @param string $questionContent
|
||||
* @return array
|
||||
*/
|
||||
public function analyzeSpeechTranscript(string $transcript, string $questionContent = ''): array
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY')));
|
||||
$cleanTranscript = trim($transcript);
|
||||
|
||||
if (empty($cleanTranscript)) {
|
||||
return ['is_cheating' => false, 'confidence' => 0.0, 'summary' => 'No speech detected.'];
|
||||
}
|
||||
|
||||
if ($apiKey) {
|
||||
try {
|
||||
$response = Http::timeout(5)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
[
|
||||
'text' => "Analyze this candidate spoken audio transcript during a coding interview. Candidate Spoken Text: \"{$cleanTranscript}\". Problem Context: \"{$questionContent}\". Determine if candidate is: 1) Reading out the question to an external voice AI assistant (like Parakeet AI or Final Round AI), or 2) Asking a secondary person in the room for help. Respond strictly in JSON: {\"is_cheating\": boolean, \"confidence\": number, \"summary\": string}"
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'response_mime_type' => 'application/json',
|
||||
'temperature' => 0.1
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$data = json_decode($text, true);
|
||||
|
||||
if (is_array($data)) {
|
||||
$data['engine'] = 'Gemini Audio/NLP AI';
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AI Speech Analysis Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Local Rule-Based NLP Fallback
|
||||
$words = preg_split('/\s+/', strtolower($cleanTranscript));
|
||||
$isPrompting = false;
|
||||
|
||||
$aiPromptKeywords = ['parakeet', 'solve', 'function', 'return', 'write a', 'code for', 'answer', 'solution', 'explain', 'chatgpt', 'copilot'];
|
||||
$matchCount = 0;
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (in_array($word, $aiPromptKeywords)) {
|
||||
$matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchCount >= 2 || count($words) > 8) {
|
||||
$isPrompting = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'is_cheating' => $isPrompting,
|
||||
'confidence' => $isPrompting ? 0.88 : 0.20,
|
||||
'ai_tool_detected' => $isPrompting ? 'Parakeet AI / External Speech Prompting' : null,
|
||||
'summary' => $isPrompting ? 'Candidate speech matches AI prompt injection patterns.' : 'Normal candidate speech.',
|
||||
'engine' => 'SingleLogin NLP Classifier'
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('interviews', 'interviewer_notes')) {
|
||||
$table->text('interviewer_notes')->nullable()->after('candidate_notes');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('interviews', 'interviewer_notes')) {
|
||||
$table->dropColumn('interviewer_notes');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->json('recordings')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn('recordings');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('interviews', 'call_started_by')) {
|
||||
$table->unsignedBigInteger('call_started_by')->nullable()->after('call_status');
|
||||
}
|
||||
if (!Schema::hasColumn('interviews', 'call_started_at')) {
|
||||
$table->timestamp('call_started_at')->nullable()->after('call_started_by');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['call_started_by', 'call_started_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->boolean('enable_tab_switch_screenshot')->default(true)->after('proctor_logs');
|
||||
$table->json('tab_switch_screenshots')->nullable()->after('enable_tab_switch_screenshot');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['enable_tab_switch_screenshot', 'tab_switch_screenshots']);
|
||||
});
|
||||
}
|
||||
};
|
||||
BIN
public/Screenshot_13.png
Normal file
BIN
public/Screenshot_13.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
BIN
public/Screenshot_14.png
Normal file
BIN
public/Screenshot_14.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
public/Screenshot_15.png
Normal file
BIN
public/Screenshot_15.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@ -910,6 +910,77 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Global Admin Settings & Proctoring Control Panel -->
|
||||
<section class="admin-card" style="margin-bottom: 35px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); border: 1.5px solid rgba(99, 102, 241, 0.35); box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5); border-radius: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 12px;">
|
||||
<div>
|
||||
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.3rem; font-weight: 800; color: #ffffff; margin: 0; display: flex; align-items: center; gap: 8px;">
|
||||
⚙️ Global Admin Proctoring & System Settings
|
||||
</h3>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">Master control panel for candidate assessment rules, system screen capturing, and module toggles</div>
|
||||
</div>
|
||||
<span class="badge" style="background: rgba(99, 102, 241, 0.2); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.4); font-weight: 700; padding: 6px 12px; font-size: 0.75rem;">
|
||||
🛡️ ADMIN CONTROL PANEL
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-top: 16px;">
|
||||
|
||||
<!-- 1. Candidate System Screenshot Capture Master Toggle -->
|
||||
<div style="padding: 16px; background: rgba(16, 185, 129, 0.08); border: 1.5px solid rgba(16, 185, 129, 0.3); border-radius: 12px;">
|
||||
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST">
|
||||
@csrf
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div style="font-size: 0.95rem; color: #ffffff; font-weight: 700; display: flex; align-items: center; gap: 6px;">
|
||||
📸 Candidate System Screenshot Capture
|
||||
</div>
|
||||
<div style="font-size: 0.73rem; color: var(--text-muted); margin-top: 4px; line-height: 1.4;">
|
||||
Captures candidate system desktop screen automatically on call start & tab switches. Admin can stop taking screenshots anytime across all candidates.
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Active)' : '🔴 STOPPED / DISABLED' }}
|
||||
</span>
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 20px; height: 20px; cursor: pointer;">
|
||||
<span style="font-size: 0.8rem; font-weight: 800; color: white; background: rgba(255,255,255,0.12); padding: 5px 12px; border-radius: 6px;">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'Stop Screenshot Capture' : 'Enable Screenshot Capture' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 2. Onboarding & Offboarding Module Master Toggle -->
|
||||
<div style="padding: 16px; background: rgba(99, 102, 241, 0.08); border: 1.5px solid rgba(99, 102, 241, 0.3); border-radius: 12px;">
|
||||
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST">
|
||||
@csrf
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div style="font-size: 0.95rem; color: #ffffff; font-weight: 700; display: flex; align-items: center; gap: 6px;">
|
||||
🚀 Candidate Provisioning & Onboarding Module
|
||||
</div>
|
||||
<div style="font-size: 0.73rem; color: var(--text-muted); margin-top: 4px; line-height: 1.4;">
|
||||
Master toggle to enable/disable automated candidate provisioning, onboarding checklists, and 1-click revocation.
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '🟢 ENABLED' : '🔴 DISABLED' }}
|
||||
</span>
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="onboarding_enabled" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 20px; height: 20px; cursor: pointer;">
|
||||
<span style="font-size: 0.8rem; font-weight: 800; color: white; background: rgba(255,255,255,0.12); padding: 5px 12px; border-radius: 6px;">
|
||||
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'Disable Onboarding' : 'Enable Onboarding' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Users Table Card -->
|
||||
<section class="users-card" style="margin-bottom: 50px;">
|
||||
<div class="card-header" style="margin-bottom: 20px;">
|
||||
@ -1174,7 +1245,7 @@
|
||||
</form>
|
||||
|
||||
<!-- Onboarding & Offboarding Module Master Toggle -->
|
||||
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST" style="margin-bottom: 20px; padding: 12px 14px; background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px;">
|
||||
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST" style="margin-bottom: 12px; padding: 12px 14px; background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px;">
|
||||
@csrf
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
@ -1190,6 +1261,23 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Candidate System Screenshot Master Toggle (Admin Control) -->
|
||||
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST" style="margin-bottom: 20px; padding: 12px 14px; background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.25); border-radius: 12px;">
|
||||
@csrf
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<div style="font-size: 0.8rem; color: #ffffff; font-weight: 600;">📸 Candidate System Screenshot Capture</div>
|
||||
<div style="font-size: 0.7rem; color: var(--text-muted);">Master Admin control to stop/start candidate system screen capturing (call start & tab switch)</div>
|
||||
</div>
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 18px; height: 18px;">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'ENABLED' : 'STOPPED / DISABLED' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Registered Roles List -->
|
||||
<div class="table-responsive" style="max-height: 380px; overflow-y: auto;">
|
||||
<table class="users-table" style="font-size:0.8rem;">
|
||||
|
||||
@ -1332,5 +1332,261 @@ function initDragAndDrop(gridId, storageKey) {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@if(Auth::user()->isAdmin() || strtolower(Auth::user()->role) === 'hr' || Auth::user()->hasActiveInterviewZone())
|
||||
<!-- INCOMING CALL RING MODAL FOR DASHBOARD -->
|
||||
<div id="incoming-call-modal" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px); display: flex; align-items: center; justify-content: center; z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;">
|
||||
<div style="max-width: 460px; width: 90%; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #6366f1; border-radius: 24px; padding: 32px; box-shadow: 0 0 50px rgba(99, 102, 241, 0.6); text-align: center; color: white;">
|
||||
|
||||
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div>
|
||||
<div style="position: absolute; inset: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
|
||||
<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 2.4rem; font-weight: 800; color: white; box-shadow: 0 10px 25px rgba(16, 185, 129, 0.5); z-index: 5;">
|
||||
📞
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
|
||||
⚡ INCOMING INTERVIEW CALL
|
||||
</div>
|
||||
|
||||
<h3 id="incoming-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.45rem; font-weight: 800; color: white; margin-bottom: 4px;">
|
||||
Candidate Name
|
||||
</h3>
|
||||
|
||||
<div id="incoming-starter-info" style="font-size: 0.82rem; color: #a5b4fc; margin-bottom: 16px;">
|
||||
Initiated by Panelist
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; font-size: 0.78rem; color: #cbd5e1; margin-bottom: 24px; line-height: 1.4;">
|
||||
Another interviewer has started calling the candidate. Click <strong>Accept Call</strong> to join live, or <strong>Decline</strong> if busy.
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 14px; justify-content: center;">
|
||||
<button id="incoming-decline-btn" onclick="declineIncomingCall()" style="flex: 1; padding: 12px 18px; background: rgba(239, 68, 68, 0.2); border: 1.5px solid #ef4444; color: #fca5a5; font-weight: 700; font-size: 0.9rem; border-radius: 12px; cursor: pointer;">
|
||||
🚫 Decline / Miss
|
||||
</button>
|
||||
<button id="incoming-accept-btn" onclick="acceptIncomingCall()" style="flex: 1.3; padding: 12px 18px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 0.9rem; border-radius: 12px; box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||
📞 Accept Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes ringPulse {
|
||||
0% { transform: scale(0.85); opacity: 1; }
|
||||
100% { transform: scale(1.35); opacity: 0; }
|
||||
}
|
||||
@keyframes acceptGlow {
|
||||
0% { box-shadow: 0 0 15px rgba(16, 185, 129, 0.5); transform: scale(1); }
|
||||
100% { box-shadow: 0 0 28px rgba(16, 185, 129, 0.9); transform: scale(1.03); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
class DashboardRingtoneEngine {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.interval = null;
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
try {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
this.ctx = new AudioCtx();
|
||||
}
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.init();
|
||||
if (this.isPlaying) return;
|
||||
this.isPlaying = true;
|
||||
|
||||
this.playTone();
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
this.interval = setInterval(() => {
|
||||
if (this.isPlaying) this.playTone();
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
playTone() {
|
||||
this.init();
|
||||
if (!this.ctx || !this.isPlaying) return;
|
||||
try {
|
||||
const now = this.ctx.currentTime;
|
||||
const osc1 = this.ctx.createOscillator();
|
||||
const osc2 = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc1.type = 'sine';
|
||||
osc2.type = 'sine';
|
||||
|
||||
osc1.frequency.setValueAtTime(523.25, now);
|
||||
osc2.frequency.setValueAtTime(659.25, now);
|
||||
|
||||
gain.gain.setValueAtTime(0.001, now);
|
||||
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
|
||||
gain.gain.setValueAtTime(0.35, now + 1.1);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
|
||||
|
||||
osc1.connect(gain);
|
||||
osc2.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc1.start(now);
|
||||
osc2.start(now);
|
||||
|
||||
osc1.stop(now + 1.35);
|
||||
osc2.stop(now + 1.35);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const dashboardRingtone = new DashboardRingtoneEngine();
|
||||
|
||||
['click', 'keydown', 'touchstart'].forEach(evt => {
|
||||
document.addEventListener(evt, () => dashboardRingtone.init(), { passive: true });
|
||||
});
|
||||
|
||||
let activeIncomingCall = null;
|
||||
let dashboardRingTimeoutTimer = null;
|
||||
const dismissedCallIds = new Set();
|
||||
const currentUserId = {{ Auth::id() }};
|
||||
const globalCallChannel = new BroadcastChannel('global_call_alerts');
|
||||
|
||||
function triggerDashboardIncomingRing(callData) {
|
||||
if (dismissedCallIds.has(callData.id)) return;
|
||||
|
||||
if (callData.call_started_at) {
|
||||
const elapsed = Date.now() - new Date(callData.call_started_at).getTime();
|
||||
if (elapsed > 10000) return;
|
||||
}
|
||||
|
||||
activeIncomingCall = callData;
|
||||
document.getElementById('incoming-cand-name').innerText = callData.candidate_name;
|
||||
document.getElementById('incoming-starter-info').innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist');
|
||||
const modal = document.getElementById('incoming-call-modal');
|
||||
modal.style.opacity = '1';
|
||||
modal.style.pointerEvents = 'auto';
|
||||
dashboardRingtone.start();
|
||||
|
||||
if (dashboardRingTimeoutTimer) clearTimeout(dashboardRingTimeoutTimer);
|
||||
let remainingMs = 10000;
|
||||
if (callData.call_started_at) {
|
||||
const elapsed = Date.now() - new Date(callData.call_started_at).getTime();
|
||||
remainingMs = Math.max(1000, 10000 - elapsed);
|
||||
}
|
||||
|
||||
dashboardRingTimeoutTimer = setTimeout(() => {
|
||||
declineIncomingCall(true);
|
||||
}, remainingMs);
|
||||
}
|
||||
|
||||
globalCallChannel.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
if (!data) return;
|
||||
|
||||
if (data.type === 'incoming_call') {
|
||||
triggerDashboardIncomingRing({
|
||||
id: data.interview_id,
|
||||
candidate_name: data.candidate_name,
|
||||
submission_unique_id: data.submission_unique_id,
|
||||
starter_name: data.starter_name,
|
||||
call_started_at: data.call_started_at || new Date().toISOString()
|
||||
});
|
||||
} else if (data.type === 'call_ended') {
|
||||
if (dashboardRingTimeoutTimer) {
|
||||
clearTimeout(dashboardRingTimeoutTimer);
|
||||
dashboardRingTimeoutTimer = null;
|
||||
}
|
||||
if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
|
||||
declineIncomingCall(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function pollDashboardActiveCalls() {
|
||||
fetch(`{{ route('interview.active-calls') }}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data || !data.active_calls || data.active_calls.length === 0) {
|
||||
if (activeIncomingCall) {
|
||||
declineIncomingCall(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = data.active_calls.find(call => {
|
||||
if (dismissedCallIds.has(call.id)) return false;
|
||||
if (call.call_started_at) {
|
||||
const elapsed = Date.now() - new Date(call.call_started_at).getTime();
|
||||
if (elapsed > 10000) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (incoming) {
|
||||
if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
|
||||
triggerDashboardIncomingRing(incoming);
|
||||
}
|
||||
} else if (activeIncomingCall) {
|
||||
declineIncomingCall(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
setInterval(pollDashboardActiveCalls, 2000);
|
||||
|
||||
function acceptIncomingCall() {
|
||||
dashboardRingtone.stop();
|
||||
if (dashboardRingTimeoutTimer) {
|
||||
clearTimeout(dashboardRingTimeoutTimer);
|
||||
dashboardRingTimeoutTimer = null;
|
||||
}
|
||||
const modal = document.getElementById('incoming-call-modal');
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
|
||||
if (activeIncomingCall) {
|
||||
const targetId = activeIncomingCall.id;
|
||||
activeIncomingCall = null;
|
||||
window.location.href = "{{ route('interview.index') }}?open_interview=" + targetId + "&join_call=1";
|
||||
}
|
||||
}
|
||||
|
||||
function declineIncomingCall(silent = false) {
|
||||
dashboardRingtone.stop();
|
||||
if (dashboardRingTimeoutTimer) {
|
||||
clearTimeout(dashboardRingTimeoutTimer);
|
||||
dashboardRingTimeoutTimer = null;
|
||||
}
|
||||
const modal = document.getElementById('incoming-call-modal');
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
|
||||
if (activeIncomingCall) {
|
||||
dismissedCallIds.add(activeIncomingCall.id);
|
||||
activeIncomingCall = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endif
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -206,8 +206,8 @@
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
.print-btn {
|
||||
display: none;
|
||||
.print-btn, .top-download-pdf-btn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -219,6 +219,16 @@
|
||||
</button>
|
||||
|
||||
<div class="report-container">
|
||||
<!-- Top Bar with Download PDF Button -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border);">
|
||||
<div style="font-size: 0.85rem; font-weight: 700; color: var(--primary); display: flex; align-items: center; gap: 6px;">
|
||||
<span>📄 Executive Assessment Performance Report</span>
|
||||
</div>
|
||||
<button onclick="window.print()" class="top-download-pdf-btn" style="background: #6366f1; color: white; border: none; font-weight: 700; font-size: 0.85rem; padding: 10px 20px; border-radius: 10px; cursor: pointer; display: flex; align-items: center; gap: 8px; box-shadow: 0 4px 14px rgba(99,102,241,0.3);">
|
||||
📥 Download PDF Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header-bar">
|
||||
<div>
|
||||
@ -302,26 +312,34 @@
|
||||
<div class="section-heading">
|
||||
🔍 Behavioral Audit & Proctoring Incident Breakdown
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px;">
|
||||
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.2rem; font-weight: 800; color: #ef4444;">{{ $tabSwitches }}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Tab Switches</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 20px;">
|
||||
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.1rem; font-weight: 800; color: #ef4444;">{{ $tabSwitches }}</div>
|
||||
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Tab Switches</div>
|
||||
</div>
|
||||
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.2rem; font-weight: 800; color: #f59e0b;">{{ $focusLosses }}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Focus Lost</div>
|
||||
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.1rem; font-weight: 800; color: #f59e0b;">{{ $focusLosses }}</div>
|
||||
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Focus Lost</div>
|
||||
</div>
|
||||
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.2rem; font-weight: 800; color: #38bdf8;">{{ $gazeAnomalies }}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Gaze Anomalies</div>
|
||||
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.1rem; font-weight: 800; color: #38bdf8;">{{ $gazeAnomalies }}</div>
|
||||
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Gaze Anomalies</div>
|
||||
</div>
|
||||
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.2rem; font-weight: 800; color: #818cf8;">{{ $pasteEvents }}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Code Pastes</div>
|
||||
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.1rem; font-weight: 800; color: #818cf8;">{{ $pasteEvents }}</div>
|
||||
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Code Pastes</div>
|
||||
</div>
|
||||
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.1rem; font-weight: 800; color: #ef4444;">{{ $lowerGazeViolations }}</div>
|
||||
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">📱 Lower Gaze</div>
|
||||
</div>
|
||||
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||
<div style="font-size: 1.1rem; font-weight: 800; color: #f43f5e;">{{ $questionRepeatViolations }}</div>
|
||||
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">🗣️ Question Repeat</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($logs) > 0)
|
||||
@if(is_array($logs) && count($logs) > 0)
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -362,20 +380,47 @@
|
||||
{{ $interview->code_output ?? 'No execution output recorded.' }}
|
||||
</div>
|
||||
|
||||
<!-- Candidate Notes & Drawing Section -->
|
||||
@if(!empty($interview->candidate_notes) || !empty($interview->candidate_drawing))
|
||||
<!-- Candidate Notes, Interviewer Evaluation Notes, Drawing & Video Recordings Section -->
|
||||
@if(!empty($interview->candidate_notes) || !empty($interview->interviewer_notes) || !empty($interview->candidate_drawing) || (is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0))
|
||||
<div class="section-heading">
|
||||
📝 Workspace Artifacts (Scratchpad Notes & Architecture Drawing)
|
||||
📝 Workspace Artifacts, Evaluation Notes & Call Session Recordings
|
||||
</div>
|
||||
<div style="background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 28px;">
|
||||
@if(!empty($interview->candidate_notes))
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE NOTES:</div>
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE NOTEPAD NOTES (UNIQUE ID SYNCED):</div>
|
||||
<pre style="font-family: monospace; font-size: 0.8rem; background: white; padding: 10px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;">{{ $interview->candidate_notes }}</pre>
|
||||
@endif
|
||||
|
||||
@if(!empty($interview->interviewer_notes))
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: #10b981; margin-bottom: 6px;">INTERVIEWER EVALUATION NOTES (STORED BY UNIQUE ID):</div>
|
||||
<pre style="font-family: sans-serif; font-size: 0.8rem; background: #f0fdf4; color: #166534; padding: 10px; border-radius: 8px; border: 1px solid #bbf7d0; margin-bottom: 12px;">{{ $interview->interviewer_notes }}</pre>
|
||||
@endif
|
||||
|
||||
@if(!empty($interview->candidate_drawing))
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE DRAWING DIAGRAM:</div>
|
||||
<img src="{{ $interview->candidate_drawing }}" alt="Candidate Drawing" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid var(--border);" />
|
||||
<img src="{{ $interview->candidate_drawing }}" alt="Candidate Drawing" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;" />
|
||||
@endif
|
||||
|
||||
@if(is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0)
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: #6366f1; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS - NEWEST FIRST):</span>
|
||||
<span style="font-size: 0.65rem; color: #94a3b8;">📜 Scroll to view all</span>
|
||||
</div>
|
||||
<div style="max-height: 480px; overflow-y: auto; padding-right: 4px; border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: #fafafa; margin-bottom: 12px;">
|
||||
@foreach($interview->formatted_recordings as $displayIdx => $rec)
|
||||
<div style="margin-bottom: 12px; background: white; border: 1px solid var(--border); border-radius: 8px; padding: 10px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: var(--primary);">Recording #{{ count($interview->formatted_recordings) - $displayIdx }} ({{ $rec['created_at'] }})</span>
|
||||
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none; padding: 3px 8px; background: rgba(99,102,241,0.1); border-radius: 4px;">📥 Download MP4 Video</a>
|
||||
</div>
|
||||
<video controls preload="metadata" style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;">
|
||||
<source src="{{ $rec['stream_url'] }}" type="video/mp4">
|
||||
<source src="{{ $rec['stream_url'] }}" type="video/webm">
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -32,10 +32,16 @@
|
||||
Route::post('/candidate/submit-code/{id}', [InterviewController::class, 'submitCode'])->name('interview.submit');
|
||||
Route::post('/candidate/log-violation/{id}', [InterviewController::class, 'logViolation'])->name('interview.violation');
|
||||
Route::post('/candidate/upload-recording/{id}', [InterviewController::class, 'uploadRecording'])->name('interview.upload-recording');
|
||||
Route::post('/candidate/upload-tab-screenshot/{id}', [InterviewController::class, 'uploadTabScreenshot'])->name('interview.upload-tab-screenshot');
|
||||
Route::post('/candidate/sync-code/{id}', [InterviewController::class, 'syncCandidateCode'])->name('interview.candidate.sync-code');
|
||||
Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');
|
||||
Route::post('/candidate/drawing/{id}', [InterviewController::class, 'saveCandidateDrawing'])->name('interview.candidate.drawing');
|
||||
Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message');
|
||||
Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll');
|
||||
|
||||
// Public Storage & Media Stream Delivery (Recordings & Video Streaming)
|
||||
Route::get('/media-stream/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('media.stream');
|
||||
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
|
||||
|
||||
// User Dashboard Portal (requires authenticated user)
|
||||
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
|
||||
@ -63,13 +69,16 @@
|
||||
|
||||
// Candidate Assessment Management for HR & Panelists
|
||||
Route::get('/interviews', [InterviewController::class, 'index'])->name('interview.index');
|
||||
Route::get('/interviews/active-calls', [InterviewController::class, 'getActiveCalls'])->name('interview.active-calls');
|
||||
Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store');
|
||||
Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning');
|
||||
Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll');
|
||||
Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password');
|
||||
Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
|
||||
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');
|
||||
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
|
||||
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
|
||||
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
|
||||
});
|
||||
|
||||
// Admin Control Panel (requires admin role)
|
||||
@ -88,6 +97,7 @@
|
||||
Route::delete('/roles/{id}', [AdminController::class, 'destroyRole'])->name('admin.roles.destroy');
|
||||
Route::post('/settings/default-role', [AdminController::class, 'updateDefaultRole'])->name('admin.settings.default-role');
|
||||
Route::post('/settings/onboarding-toggle', [AdminController::class, 'updateOnboardingToggle'])->name('admin.settings.onboarding-toggle');
|
||||
Route::post('/settings/screenshot-toggle', [AdminController::class, 'updateScreenshotToggle'])->name('admin.settings.screenshot-toggle');
|
||||
|
||||
// User Roles & Overrides
|
||||
Route::post('/users/{id}/roles', [AdminController::class, 'updateUserRoles'])->name('admin.users.roles.update');
|
||||
|
||||
@ -298,4 +298,67 @@ public function test_executive_candidate_evaluation_pdf_report_generation(): voi
|
||||
->assertSee('Technical Code Competency')
|
||||
->assertSee('Complexity analysis: O(1)');
|
||||
}
|
||||
|
||||
public function test_silent_proctoring_violations_and_multi_party_active_call_status(): void
|
||||
{
|
||||
$hr = User::create([
|
||||
'name' => 'Interviewer One',
|
||||
'email' => 'interviewer1@company.com',
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'Proctor Test Candidate',
|
||||
'candidate_email' => 'proctortest@gmail.com',
|
||||
'candidate_phone' => '9555544444',
|
||||
'temp_password' => 'Pass-555444',
|
||||
'expires_at' => now()->addHours(2),
|
||||
'language' => 'python',
|
||||
'status' => 'in_progress',
|
||||
'submission_unique_id' => 'proctor-test-candidate_9555544444_1752000006',
|
||||
]);
|
||||
|
||||
// 1. Silent Lower Screen Gaze Stare violation
|
||||
$this->post(route('interview.violation', $interview->id), [
|
||||
'type' => 'gaze_lower_device',
|
||||
'details' => 'Candidate continuously looking at lower portion of screen for 10s (suspected reading from mobile device or cheat sheet)',
|
||||
])->assertStatus(200);
|
||||
|
||||
// 2. Silent Question Repetition violation
|
||||
$this->post(route('interview.violation', $interview->id), [
|
||||
'type' => 'question_repetition',
|
||||
'details' => 'Candidate reading/repeating question out loud: "Write a function to reverse a linked list" (suspected Parakeet AI speech prompt)',
|
||||
])->assertStatus(200);
|
||||
|
||||
// 3. Silent Talking to Secondary Person violation
|
||||
$this->post(route('interview.violation', $interview->id), [
|
||||
'type' => 'talking_secondary_person',
|
||||
'details' => 'Candidate speaking out loud to secondary person in room: "Can you search the solution on your laptop?"',
|
||||
])->assertStatus(200);
|
||||
|
||||
// 4. Silent External AI Overlay Hotkey violation
|
||||
$this->post(route('interview.violation', $interview->id), [
|
||||
'type' => 'external_ai_detected',
|
||||
'details' => 'Parakeet AI / External AI Hotkey Intercepted (KeyA): Candidate activated external AI assistant overlay shortcut',
|
||||
])->assertStatus(200);
|
||||
|
||||
// Verify poll endpoint returns all 4 silent proctoring violations to interviewer dashboard
|
||||
$pollResp = $this->get(route('interview.poll', $interview->id));
|
||||
$pollResp->assertStatus(200)
|
||||
->assertJsonFragment(['type' => 'gaze_lower_device'])
|
||||
->assertJsonFragment(['type' => 'question_repetition'])
|
||||
->assertJsonFragment(['type' => 'talking_secondary_person'])
|
||||
->assertJsonFragment(['type' => 'external_ai_detected']);
|
||||
|
||||
// 5. Update call status for multi-party call test
|
||||
$callResp = $this->actingAs($hr)->post(route('interview.call-status', $interview->id), [
|
||||
'call_status' => 'active',
|
||||
]);
|
||||
$callResp->assertStatus(200)->assertJson(['success' => true, 'call_status' => 'active']);
|
||||
|
||||
// Active calls endpoint should list the active session for multiple interviewers to join
|
||||
$activeCallsResp = $this->actingAs($hr)->get(route('interview.active-calls'));
|
||||
$activeCallsResp->assertStatus(200)
|
||||
->assertJsonFragment(['submission_unique_id' => 'proctor-test-candidate_9555544444_1752000006']);
|
||||
}
|
||||
}
|
||||
|
||||
69
tests/Feature/RecordingStreamTest.php
Normal file
69
tests/Feature/RecordingStreamTest.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Interview;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RecordingStreamTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_media_stream_serves_recorded_video_with_range_and_headers()
|
||||
{
|
||||
$dir = storage_path('app/public/recordings');
|
||||
if (!File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
$testFile = $dir . '/test_stream_video.webm';
|
||||
$fakeVideoContent = "\x1A\x45\xDF\xA3" . str_repeat('0', 100);
|
||||
File::put($testFile, $fakeVideoContent);
|
||||
|
||||
try {
|
||||
$response = $this->get('/media-stream/recordings/test_stream_video.webm');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertHeader('Content-Type', 'video/webm');
|
||||
$response->assertHeader('Accept-Ranges', 'bytes');
|
||||
} finally {
|
||||
if (File::exists($testFile)) {
|
||||
File::delete($testFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_poll_returns_media_stream_urls()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'John Streamer',
|
||||
'candidate_email' => 'stream@example.com',
|
||||
'candidate_phone' => '1234567890',
|
||||
'submission_unique_id' => 'stream_test_123',
|
||||
'temp_password' => 'secret123',
|
||||
'language' => 'python',
|
||||
'status' => 'pending',
|
||||
'expires_at' => now()->addDays(7),
|
||||
'recording_path' => '/storage/recordings/stream_test_123.webm',
|
||||
'recordings' => [
|
||||
[
|
||||
'url' => '/storage/recordings/stream_test_123.webm',
|
||||
'filename' => 'stream_test_123.webm',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('interview.poll', $interview->id));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
$this->assertNotEmpty($data['recordings']);
|
||||
$this->assertStringContainsString('/media-stream/recordings/stream_test_123.webm', $data['recordings'][0]['stream_url']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user