- Add resume file upload to schedule drawer with instant preview and validation - Create resume lightbox modal viewer with background scroll lock and CV SVG icon - Integrate CV tab into inspector panel with enlarge action and state - Attach candidate resume to panelist notification emails when - Fix Outlook RFC 5546 iTIP element mismatch and add table credentials copy button
1255 lines
47 KiB
PHP
1255 lines
47 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Actions\Interview\FinalizeRecordingAction;
|
|
use App\Actions\Interview\ScheduleInterviewAction;
|
|
use App\Actions\Interview\StoreRecordingChunkAction;
|
|
use App\DTOs\Interview\ScheduleInterviewDto;
|
|
use App\Http\Requests\Interview\StoreInterviewRequest;
|
|
use App\Http\Requests\Interview\UploadRecordingChunkRequest;
|
|
use App\Http\Responses\Interview\RecordingChunkReceivedResponse;
|
|
use App\Http\Responses\Interview\RecordingFinalizedResponse;
|
|
use App\Models\Interview;
|
|
use App\Models\InterviewWarning;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Services\AiCheatingDetectorService;
|
|
use Illuminate\Contracts\Support\Responsable;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
class InterviewController extends Controller
|
|
{
|
|
/**
|
|
* Display the HR & Interviewer Live Assessment Portal.
|
|
*/
|
|
public function index()
|
|
{
|
|
$user = Auth::user();
|
|
|
|
// Check if user is Admin, HR, or has active assigned interview zone timeline
|
|
if (! $user->isAdmin() && strtolower($user->role) !== 'hr' && ! $user->hasActiveInterviewZone()) {
|
|
return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.');
|
|
}
|
|
|
|
$interviews = $user->getAccessibleInterviews();
|
|
|
|
// Auto-terminate call session if interview timeline has expired
|
|
foreach ($interviews as $interview) {
|
|
if ($interview->isExpired() && $interview->call_status === 'active') {
|
|
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
|
|
$interview->call_status = 'ended';
|
|
}
|
|
}
|
|
|
|
$interviewers = User::orderBy('name', 'asc')->get();
|
|
|
|
return view('interview.index', compact('interviews', 'interviewers'));
|
|
}
|
|
|
|
/**
|
|
* Display a specific Candidate Interview Live Review & Call Session.
|
|
*/
|
|
public function show($id)
|
|
{
|
|
$user = Auth::user();
|
|
|
|
if (! $user->isAdmin() && strtolower($user->role) !== 'hr' && ! $user->hasActiveInterviewZone()) {
|
|
return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.');
|
|
}
|
|
|
|
$interview = Interview::findOrFail($id);
|
|
|
|
// Auto-terminate call session if interview timeline has expired
|
|
if ($interview->isExpired() && $interview->call_status === 'active') {
|
|
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
|
|
$interview->call_status = 'ended';
|
|
}
|
|
|
|
$interviewers = User::orderBy('name', 'asc')->get();
|
|
|
|
return view('interview.show', compact('interview', 'interviewers'));
|
|
}
|
|
|
|
/**
|
|
* Store and schedule a new Candidate Interview session.
|
|
*/
|
|
public function store(StoreInterviewRequest $request, ScheduleInterviewAction $scheduleAction)
|
|
{
|
|
$dto = ScheduleInterviewDto::fromRequest($request);
|
|
$interview = $scheduleAction->execute($dto, Auth::id());
|
|
|
|
return redirect()->route('interview.index')->with('success', 'Interview session scheduled and invitations sent for '.$interview->candidate_name.'. Temporary login credentials generated!');
|
|
}
|
|
|
|
/**
|
|
* Show Candidate Login Screen.
|
|
*/
|
|
public function showCandidateLogin()
|
|
{
|
|
return view('interview.candidate_login');
|
|
}
|
|
|
|
/**
|
|
* Handle Candidate Temporary Credentials Authentication.
|
|
*/
|
|
public function loginCandidate(Request $request)
|
|
{
|
|
$request->validate([
|
|
'identifier' => 'required|string', // Email or Phone
|
|
'temp_password' => 'required|string',
|
|
]);
|
|
|
|
$identifier = trim($request->identifier);
|
|
|
|
$interview = Interview::where(function ($q) use ($identifier) {
|
|
$q->where('candidate_email', strtolower($identifier))
|
|
->orWhere('candidate_phone', $identifier)
|
|
->orWhere('submission_unique_id', $identifier);
|
|
})->where('temp_password', $request->temp_password)->first();
|
|
|
|
if (! $interview) {
|
|
return back()->with('error', 'Invalid candidate credentials or temporary password.');
|
|
}
|
|
|
|
if ($interview->status === 'blocked' || $interview->blocked_ip === $request->ip()) {
|
|
return back()->with('error', 'Access Denied: Candidate email and IP address have been blocked by HR/Interviewer.');
|
|
}
|
|
|
|
if ($interview->isExpired()) {
|
|
$interview->update(['status' => 'expired']);
|
|
|
|
return back()->with('error', 'This candidate interview access window has expired. Please contact HR.');
|
|
}
|
|
|
|
// Set candidate session
|
|
session(['candidate_interview_id' => $interview->id]);
|
|
|
|
if ($interview->status === 'scheduled') {
|
|
$interview->update(['status' => 'in_progress']);
|
|
}
|
|
|
|
return redirect()->route('interview.room', $interview->submission_unique_id);
|
|
}
|
|
|
|
/**
|
|
* Show Candidate Live IDE Proctored Room.
|
|
*/
|
|
public function showCandidateRoom($uniqueId)
|
|
{
|
|
$interview = Interview::where('submission_unique_id', $uniqueId)->firstOrFail();
|
|
|
|
if ($interview->status === 'blocked') {
|
|
return response()->view('interview.expired', compact('interview'), 403);
|
|
}
|
|
|
|
if ($interview->isExpired()) {
|
|
$interview->update(['status' => 'expired']);
|
|
|
|
return response()->view('interview.expired', compact('interview'), 403);
|
|
}
|
|
|
|
return view('interview.candidate_room', compact('interview'));
|
|
}
|
|
|
|
/**
|
|
* Proxy Live Code Execution to Piston Multi-Language API.
|
|
*/
|
|
public function executeCode(Request $request)
|
|
{
|
|
$request->validate([
|
|
'language' => 'required|string',
|
|
'code' => 'required|string',
|
|
'interview_id' => 'nullable|string',
|
|
]);
|
|
|
|
$langKey = strtolower(trim($request->language));
|
|
$code = $request->code;
|
|
$output = '';
|
|
|
|
// 1. Ultra-Fast Public Judge0 CE Code Execution API (no auth needed)
|
|
$judge0LangMap = [
|
|
'python' => 71, // Python 3
|
|
'py' => 71,
|
|
'javascript' => 63, // JavaScript (Node.js)
|
|
'js' => 63,
|
|
'c' => 50, // C (GCC)
|
|
'cpp' => 54, // C++ (GCC)
|
|
'c++' => 54,
|
|
'java' => 62, // Java (OpenJDK)
|
|
'php' => 68, // PHP
|
|
'laravel' => 68,
|
|
'go' => 60, // Go
|
|
'rust' => 73, // Rust
|
|
];
|
|
if (isset($judge0LangMap[$langKey])) {
|
|
try {
|
|
$judge0LangId = $judge0LangMap[$langKey];
|
|
$response = Http::timeout(6)->post('https://ce.judge0.com/submissions?base64_encoded=false&wait=true', [
|
|
'language_id' => $judge0LangId,
|
|
'source_code' => $code,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$resData = $response->json();
|
|
$compileOutput = $resData['compile_output'] ?? '';
|
|
$stdout = $resData['stdout'] ?? '';
|
|
$stderr = $resData['stderr'] ?? '';
|
|
|
|
if (! empty($compileOutput)) {
|
|
$output = $compileOutput;
|
|
} elseif (! empty($stdout)) {
|
|
$output = $stdout;
|
|
} elseif (! empty($stderr)) {
|
|
$output = $stderr;
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::warning('Judge0 Code execute failed.', [$e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
// 2. Instant Local Process Runner Fallback
|
|
if (empty($output)) {
|
|
try {
|
|
if (in_array($langKey, ['javascript', 'js'])) {
|
|
$process = new Process(['node', '-e', $code]);
|
|
$process->setTimeout(3);
|
|
$process->run();
|
|
$out = $process->getOutput() ?: $process->getErrorOutput();
|
|
$output = trim($out) ?: 'JavaScript executed successfully (no stdout).';
|
|
} elseif ($langKey === 'python') {
|
|
$process = new Process(['python', '-c', $code]);
|
|
$process->setTimeout(3);
|
|
$process->run();
|
|
$out = $process->getOutput() ?: $process->getErrorOutput();
|
|
$output = trim($out) ?: 'Python executed successfully (no stdout).';
|
|
} elseif (in_array($langKey, ['php', 'laravel'])) {
|
|
ob_start();
|
|
try {
|
|
$cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $code);
|
|
eval($cleanCode);
|
|
$out = ob_get_clean();
|
|
$output = trim($out) ?: 'PHP code executed successfully.';
|
|
} catch (\Throwable $e) {
|
|
ob_end_clean();
|
|
$output = 'PHP Evaluation Error: '.$e->getMessage();
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
}
|
|
}
|
|
|
|
if (empty($output)) {
|
|
$output = "=== Code Execution Status ===\nSolution executed.\nLanguage: ".strtoupper($langKey);
|
|
}
|
|
|
|
// Auto-persist candidate code + execution output directly to interview model
|
|
$interviewId = $request->input('interview_id');
|
|
if ($interviewId) {
|
|
$interview = Interview::where('id', $interviewId)->orWhere('submission_unique_id', $interviewId)->first();
|
|
if ($interview) {
|
|
$interview->update([
|
|
'submitted_code' => $code,
|
|
'submitted_language' => $langKey,
|
|
'code_output' => $output,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return response()->json(['success' => true, 'output' => $output]);
|
|
}
|
|
|
|
/**
|
|
* Save Candidate Submitted Code & Execution Results.
|
|
*/
|
|
public function submitCode(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
|
|
$request->validate([
|
|
'code' => 'required|string',
|
|
'language' => 'required|string',
|
|
'output' => 'nullable|string',
|
|
]);
|
|
|
|
$interview->update([
|
|
'submitted_code' => $request->code,
|
|
'submitted_language' => $request->language,
|
|
'code_output' => $request->output,
|
|
'status' => 'completed',
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Code submitted successfully under unique ID: '.$interview->submission_unique_id,
|
|
'unique_id' => $interview->submission_unique_id,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, MediaPipe ML Events, AI Detection).
|
|
*/
|
|
public function logViolation(Request $request, $id)
|
|
{
|
|
$interview = Interview::where('id', $id)
|
|
->orWhere('submission_unique_id', $id)
|
|
->firstOrFail();
|
|
|
|
$type = $request->input('type');
|
|
$details = $request->input('details', '');
|
|
$confidence = $request->input('confidence', null);
|
|
$snapshot = $request->input('snapshot', null);
|
|
|
|
$detailsStr = is_array($details) ? json_encode($details) : (string) $details;
|
|
|
|
// Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call
|
|
$aiVerdict = null;
|
|
if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) {
|
|
$aiDetector = new AiCheatingDetectorService;
|
|
$aiVerdict = $aiDetector->analyzeSpeechTranscript($detailsStr, $interview->candidate_notes ?? '');
|
|
}
|
|
|
|
$logs = $interview->proctor_logs ?? [];
|
|
$logEntry = [
|
|
'type' => $type,
|
|
'details' => $details,
|
|
'confidence' => $confidence,
|
|
'snapshot' => $snapshot,
|
|
'ai_verdict' => $aiVerdict,
|
|
'timestamp' => $request->input('timestamp', now()->toIso8601String()),
|
|
];
|
|
if ($request->has('event_id')) {
|
|
$logEntry['event_id'] = $request->input('event_id');
|
|
}
|
|
$logs[] = $logEntry;
|
|
|
|
$interview->update(['proctor_logs' => $logs]);
|
|
|
|
return response()->json(['success' => true, 'total_violations' => count($logs), 'ai_verdict' => $aiVerdict]);
|
|
}
|
|
|
|
/**
|
|
* Interviewer / HR sends a Silent Warning to Candidate.
|
|
*/
|
|
public function sendWarning(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
|
|
$request->validate([
|
|
'message' => 'required|string|max:500',
|
|
]);
|
|
|
|
$warning = InterviewWarning::create([
|
|
'interview_id' => $interview->id,
|
|
'message' => $request->message,
|
|
'sent_by' => Auth::id(),
|
|
]);
|
|
|
|
return response()->json(['success' => true, 'warning' => $warning]);
|
|
}
|
|
|
|
/**
|
|
* Send Real-Time Chat Message from Candidate to Interviewer/HR.
|
|
*/
|
|
public function candidateSendMessage(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$request->validate(['message' => 'required|string|max:500']);
|
|
|
|
$warning = InterviewWarning::create([
|
|
'interview_id' => $interview->id,
|
|
'message' => $request->message,
|
|
'sent_by' => null, // null sent_by indicates candidate sender
|
|
]);
|
|
|
|
return response()->json(['success' => true, 'warning' => $warning]);
|
|
}
|
|
|
|
/**
|
|
* Live Sync Candidate Code (before submission).
|
|
*/
|
|
public function syncCandidateCode(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$updateData = [
|
|
'submitted_code' => $request->input('code'),
|
|
'submitted_language' => $request->input('language', $interview->language),
|
|
];
|
|
if ($request->has('output')) {
|
|
$updateData['code_output'] = $request->input('output');
|
|
}
|
|
$interview->update($updateData);
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* Save Candidate Live Notepad Content.
|
|
*/
|
|
public function saveCandidateNotes(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$interview->update(['candidate_notes' => $request->input('notes')]);
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* Save Candidate Live Canvas Drawing Data URL.
|
|
*/
|
|
public function saveCandidateDrawing(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$interview->update(['candidate_drawing' => $request->input('drawing')]);
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* Regenerate Temporary Candidate Password (by Interviewer/HR).
|
|
*/
|
|
public function regeneratePassword($id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$newPass = 'Pass-'.rand(100000, 999999);
|
|
$interview->update(['temp_password' => $newPass]);
|
|
|
|
return response()->json(['success' => true, 'new_password' => $newPass]);
|
|
}
|
|
|
|
/**
|
|
* Block Candidate Session, Email & Client IP (by Interviewer/HR).
|
|
*/
|
|
public function blockCandidate(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$clientIp = $request->ip();
|
|
|
|
$interview->update([
|
|
'status' => 'blocked',
|
|
'blocked_ip' => $clientIp,
|
|
]);
|
|
|
|
// Also block user record if exists
|
|
User::where('email', strtolower($interview->candidate_email))
|
|
->update(['is_blocked' => true]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Candidate '.$interview->candidate_name.' ('.$interview->candidate_email.') and IP '.$clientIp.' have been blocked successfully.',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Serve Storage File (Recordings & Public Assets) directly with Range Support for Video Streaming.
|
|
*/
|
|
public function serveStorageFile($path)
|
|
{
|
|
$sanitizedPath = ltrim(str_replace('..', '', $path), '/');
|
|
|
|
$candidatePaths = [
|
|
storage_path('app/public/'.$sanitizedPath),
|
|
storage_path('app/private/public/'.$sanitizedPath),
|
|
storage_path('app/private/'.$sanitizedPath),
|
|
storage_path('app/'.$sanitizedPath),
|
|
public_path('storage/'.$sanitizedPath),
|
|
public_path($sanitizedPath),
|
|
];
|
|
|
|
// Also check with alternate extensions (.webm / .mp4) if requested file extension differs
|
|
$baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath);
|
|
if ($baseNoExt !== $sanitizedPath) {
|
|
$candidatePaths[] = storage_path('app/public/'.$baseNoExt.'.webm');
|
|
$candidatePaths[] = storage_path('app/public/'.$baseNoExt.'.mp4');
|
|
$candidatePaths[] = storage_path('app/private/public/'.$baseNoExt.'.webm');
|
|
$candidatePaths[] = storage_path('app/private/public/'.$baseNoExt.'.mp4');
|
|
$candidatePaths[] = public_path($baseNoExt.'.webm');
|
|
$candidatePaths[] = public_path($baseNoExt.'.mp4');
|
|
}
|
|
|
|
$filePath = null;
|
|
foreach ($candidatePaths as $cp) {
|
|
if (file_exists($cp) && is_file($cp)) {
|
|
$filePath = $cp;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (! $filePath) {
|
|
abort(404, 'Storage file not found.');
|
|
}
|
|
|
|
// Read first 12 bytes to accurately detect container format (WebM vs MP4 vs Image)
|
|
$handle = @fopen($filePath, 'rb');
|
|
$header = $handle ? fread($handle, 12) : '';
|
|
if ($handle) {
|
|
fclose($handle);
|
|
}
|
|
|
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
|
$isWebmHeader = str_starts_with($header, "\x1A\x45\xDF\xA3");
|
|
|
|
if ($isWebmHeader || $extension === 'webm') {
|
|
$mimeType = 'video/webm';
|
|
} elseif (str_contains($header, 'ftyp') || in_array($extension, ['mp4', 'm4v'])) {
|
|
$mimeType = 'video/mp4';
|
|
} elseif ($extension === 'png') {
|
|
$mimeType = 'image/png';
|
|
} elseif (in_array($extension, ['jpg', 'jpeg'])) {
|
|
$mimeType = 'image/jpeg';
|
|
} else {
|
|
$mimeType = @mime_content_type($filePath) ?: 'application/octet-stream';
|
|
}
|
|
|
|
$fileSize = filesize($filePath);
|
|
$headers = [
|
|
'Content-Type' => $mimeType,
|
|
'Accept-Ranges' => 'bytes',
|
|
'Cache-Control' => 'public, max-age=86400',
|
|
];
|
|
|
|
// Enable HTTP 206 Partial Content Range support required for HTML5 video playback/seeking
|
|
$range = request()->header('Range');
|
|
if ($range && preg_match('/bytes=(\d+)-(\d+)?/', $range, $matches)) {
|
|
$start = (int) $matches[1];
|
|
$end = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : ($fileSize - 1);
|
|
|
|
if ($start > $end || $start >= $fileSize) {
|
|
return response('', 416, ['Content-Range' => "bytes */{$fileSize}"]);
|
|
}
|
|
|
|
$end = min($end, $fileSize - 1);
|
|
$length = $end - $start + 1;
|
|
|
|
$headers['Content-Range'] = "bytes {$start}-{$end}/{$fileSize}";
|
|
$headers['Content-Length'] = (string) $length;
|
|
|
|
return response()->stream(function () use ($filePath, $start, $length) {
|
|
$file = fopen($filePath, 'rb');
|
|
if ($file) {
|
|
fseek($file, $start);
|
|
$bufferSize = 1024 * 64;
|
|
$bytesLeft = $length;
|
|
while ($bytesLeft > 0 && ! feof($file)) {
|
|
$readSize = min($bufferSize, $bytesLeft);
|
|
$data = fread($file, $readSize);
|
|
if ($data === false) {
|
|
break;
|
|
}
|
|
echo $data;
|
|
flush();
|
|
$bytesLeft -= strlen($data);
|
|
}
|
|
fclose($file);
|
|
}
|
|
}, 206, $headers);
|
|
}
|
|
|
|
$headers['Content-Length'] = (string) $fileSize;
|
|
|
|
return response()->file($filePath, $headers);
|
|
}
|
|
|
|
/**
|
|
* Get normalized recordings list sorted in descending order of creation timestamp (newest first).
|
|
*/
|
|
private function getFormattedRecordings($interview)
|
|
{
|
|
$rawRecordings = $interview->recordings ?? [];
|
|
if (! is_array($rawRecordings) || empty($rawRecordings)) {
|
|
if (! empty($interview->recording_path)) {
|
|
$rawRecordings = [[
|
|
'url' => $interview->recording_path,
|
|
'filename' => basename($interview->recording_path),
|
|
'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(),
|
|
]];
|
|
} else {
|
|
$rawRecordings = [];
|
|
}
|
|
}
|
|
|
|
$formatted = [];
|
|
foreach ($rawRecordings as $idx => $rec) {
|
|
if (is_string($rec)) {
|
|
$rec = [
|
|
'url' => $rec,
|
|
'filename' => basename($rec),
|
|
'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(),
|
|
];
|
|
}
|
|
$url = $rec['url'] ?? '';
|
|
$urlPath = parse_url($url, PHP_URL_PATH) ?? $url;
|
|
$cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
|
|
$streamUrl = url('/media-stream/'.$cleanPath);
|
|
|
|
$ext = strtolower(pathinfo($cleanPath, PATHINFO_EXTENSION));
|
|
$mimeType = in_array($ext, ['mp4', 'm4v']) ? 'video/mp4' : 'video/webm';
|
|
|
|
$rec['original_index'] = $idx;
|
|
$rec['stream_url'] = $streamUrl;
|
|
$rec['download_url'] = route('interview.download-recording', $interview->id).'?index='.$idx;
|
|
$rec['mime_type'] = $mimeType;
|
|
$rec['duration'] = isset($rec['duration']) ? (int) $rec['duration'] : 0;
|
|
$formatted[] = $rec;
|
|
}
|
|
|
|
// Sort descending by created_at timestamp (newest first)
|
|
usort($formatted, function ($a, $b) {
|
|
$timeA = isset($a['created_at']) ? strtotime($a['created_at']) : 0;
|
|
$timeB = isset($b['created_at']) ? strtotime($b['created_at']) : 0;
|
|
|
|
return $timeB <=> $timeA;
|
|
});
|
|
|
|
return $formatted;
|
|
}
|
|
|
|
/**
|
|
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
|
|
/**
|
|
* Get all currently active interview calls accessible to the logged-in user.
|
|
*/
|
|
public function getActiveCalls(Request $request)
|
|
{
|
|
$user = Auth::user();
|
|
if (! $user) {
|
|
return response()->json(['active_calls' => [], 'current_user_id' => null]);
|
|
}
|
|
|
|
$accessible = $user->getAccessibleInterviews();
|
|
$activeCalls = [];
|
|
|
|
foreach ($accessible as $interview) {
|
|
// Auto-terminate call session if interview timeline has expired
|
|
if ($interview->isExpired()) {
|
|
if ($interview->call_status === 'active') {
|
|
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
|
|
$interview->call_status = 'ended';
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($interview->call_status === 'active') {
|
|
$starterName = 'Panelist';
|
|
if ($interview->call_started_by) {
|
|
$starter = User::find($interview->call_started_by);
|
|
if ($starter) {
|
|
$starterName = $starter->name;
|
|
}
|
|
}
|
|
|
|
$activeCalls[] = [
|
|
'id' => $interview->id,
|
|
'candidate_name' => $interview->candidate_name,
|
|
'candidate_email' => $interview->candidate_email,
|
|
'submission_unique_id' => $interview->submission_unique_id,
|
|
'call_status' => $interview->call_status,
|
|
'call_started_by' => $interview->call_started_by,
|
|
'starter_name' => $starterName,
|
|
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
|
];
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'active_calls' => $activeCalls,
|
|
'current_user_id' => $user->id,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
|
|
*/
|
|
public function getPollData($id)
|
|
{
|
|
$interview = Interview::with(['warnings.sender', 'callStarter'])
|
|
->where('id', $id)
|
|
->orWhere('submission_unique_id', $id)
|
|
->firstOrFail();
|
|
|
|
// Enforce expiration: ended call status if timeline is expired
|
|
if ($interview->isExpired()) {
|
|
if ($interview->call_status === 'active') {
|
|
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
|
|
}
|
|
}
|
|
|
|
// Clean up stale active_peers (> 30 seconds)
|
|
$activePeers = $interview->active_peers ?? [];
|
|
$now = now()->timestamp;
|
|
$validPeers = array_values(array_filter($activePeers, function ($p) use ($now) {
|
|
return ($now - ($p['last_seen'] ?? 0)) < 30;
|
|
}));
|
|
if (count($validPeers) !== count($activePeers)) {
|
|
$interview->update(['active_peers' => $validPeers]);
|
|
}
|
|
|
|
$formattedRecordings = $this->getFormattedRecordings($interview);
|
|
|
|
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
|
|
|
|
$globalScreenshotsEnabled = Setting::get('enable_candidate_screenshots', '1') === '1';
|
|
$interviewScreenshotsEnabled = (bool) ($interview->enable_tab_switch_screenshot ?? true);
|
|
$effectiveScreenshotEnabled = $globalScreenshotsEnabled && $interviewScreenshotsEnabled;
|
|
|
|
return response()->json([
|
|
'status' => $interview->status,
|
|
'call_status' => $interview->isExpired() ? 'ended' : ($interview->call_status ?? 'idle'),
|
|
'call_started_by' => $interview->call_started_by,
|
|
'starter_name' => $starterName,
|
|
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
|
'active_peers' => $validPeers,
|
|
'enable_tab_switch_screenshot' => $effectiveScreenshotEnabled,
|
|
'global_screenshot_enabled' => $globalScreenshotsEnabled,
|
|
'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [],
|
|
'submitted_code' => $interview->submitted_code,
|
|
'submitted_language' => $interview->submitted_language,
|
|
'code_output' => $interview->code_output,
|
|
'candidate_notes' => $interview->candidate_notes,
|
|
'candidate_drawing' => $interview->candidate_drawing,
|
|
'temp_password' => $interview->temp_password,
|
|
'blocked_ip' => $interview->blocked_ip,
|
|
'proctor_logs' => $interview->proctor_logs ?? [],
|
|
'recording_path' => $interview->recording_path,
|
|
'recordings' => $formattedRecordings,
|
|
'warnings' => $interview->warnings,
|
|
'is_expired' => $interview->isExpired(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Register or update active peer presence heartbeat for WebRTC Mesh call.
|
|
*/
|
|
public function registerPeerHeartbeat(Request $request, $id)
|
|
{
|
|
$interview = Interview::where('id', $id)
|
|
->orWhere('submission_unique_id', $id)
|
|
->firstOrFail();
|
|
|
|
$peerId = $request->input('peer_id');
|
|
if (! $peerId) {
|
|
return response()->json(['error' => 'peer_id is required'], 422);
|
|
}
|
|
|
|
$action = $request->input('action', 'heartbeat'); // heartbeat or leave
|
|
$user = Auth::user();
|
|
$name = $request->input('name', $user ? $user->name : $interview->candidate_name);
|
|
$role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate');
|
|
$userId = $user ? $user->id : 0;
|
|
|
|
$interview->refresh();
|
|
$activePeers = $interview->active_peers ?? [];
|
|
$now = now()->timestamp;
|
|
|
|
$updatedPeers = [];
|
|
foreach ($activePeers as $p) {
|
|
if (($now - ($p['last_seen'] ?? 0)) < 30) {
|
|
if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) {
|
|
continue;
|
|
}
|
|
if (($p['peer_id'] ?? '') !== $peerId) {
|
|
$updatedPeers[] = $p;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($action === 'heartbeat') {
|
|
$micOn = $request->has('mic_on') ? filter_var($request->input('mic_on'), FILTER_VALIDATE_BOOLEAN) : true;
|
|
$camOn = $request->has('cam_on') ? filter_var($request->input('cam_on'), FILTER_VALIDATE_BOOLEAN) : true;
|
|
|
|
$updatedPeers[] = [
|
|
'peer_id' => $peerId,
|
|
'user_id' => $userId,
|
|
'name' => $name,
|
|
'role' => $role,
|
|
'mic_on' => $micOn,
|
|
'cam_on' => $camOn,
|
|
'last_seen' => $now,
|
|
];
|
|
}
|
|
|
|
$interview->update(['active_peers' => array_values($updatedPeers)]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'active_peers' => $updatedPeers,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Update Interview Call Session Status (active, idle, ended).
|
|
*/
|
|
public function updateCallStatus(Request $request, $id)
|
|
{
|
|
$interview = Interview::where('id', $id)
|
|
->orWhere('submission_unique_id', $id)
|
|
->firstOrFail();
|
|
$status = $request->input('call_status', 'idle');
|
|
$isRejoin = $request->boolean('is_rejoin', false);
|
|
|
|
$updateData = ['call_status' => $status];
|
|
$isNewCall = false;
|
|
|
|
if ($status === 'active') {
|
|
if (! $isRejoin && ($interview->call_status !== 'active' || ! $interview->call_started_at)) {
|
|
$updateData['call_started_by'] = Auth::id();
|
|
$updateData['call_started_at'] = now();
|
|
$isNewCall = true;
|
|
}
|
|
} elseif (in_array($status, ['ended', 'idle'])) {
|
|
$updateData['call_started_by'] = null;
|
|
$updateData['call_started_at'] = null;
|
|
$updateData['active_peers'] = [];
|
|
}
|
|
|
|
$interview->update($updateData);
|
|
|
|
$starterName = Auth::user() ? Auth::user()->name : 'Panelist';
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'call_status' => $status,
|
|
'is_new_call' => $isNewCall,
|
|
'call_started_by' => $interview->call_started_by,
|
|
'starter_name' => $starterName,
|
|
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Upload & Save Candidate System Screen Screenshot under public/uploads/candidate_screenshots/{unique_id}/.
|
|
*/
|
|
public function uploadTabScreenshot(Request $request, $id)
|
|
{
|
|
$interview = Interview::where('id', $id)
|
|
->orWhere('submission_unique_id', $id)
|
|
->firstOrFail();
|
|
|
|
$globalEnabled = Setting::get('enable_candidate_screenshots', '1') === '1';
|
|
$interviewEnabled = ! isset($interview->enable_tab_switch_screenshot) || $interview->enable_tab_switch_screenshot;
|
|
|
|
if (! $globalEnabled || ! $interviewEnabled) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Candidate system screenshot capture is disabled by admin setting.',
|
|
]);
|
|
}
|
|
|
|
$reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch'
|
|
$filename = 'screenshot_'.$reason.'_'.time().'_'.rand(100, 999).'.jpg';
|
|
$uniqueFolder = $interview->submission_unique_id ?: $interview->id;
|
|
$subDir = 'uploads/candidate_screenshots/'.$uniqueFolder;
|
|
$destinationPath = public_path($subDir);
|
|
|
|
if (! file_exists($destinationPath)) {
|
|
mkdir($destinationPath, 0777, true);
|
|
}
|
|
|
|
$url = null;
|
|
if ($request->hasFile('screenshot')) {
|
|
$file = $request->file('screenshot');
|
|
$file->move($destinationPath, $filename);
|
|
$url = asset($subDir.'/'.$filename);
|
|
} elseif ($request->input('image')) {
|
|
$base64Image = $request->input('image');
|
|
if (str_contains($base64Image, ',')) {
|
|
$base64Image = explode(',', $base64Image)[1];
|
|
}
|
|
$base64Image = str_replace(' ', '+', $base64Image);
|
|
$imageData = base64_decode($base64Image);
|
|
if ($imageData !== false && strlen($imageData) > 0) {
|
|
file_put_contents($destinationPath.'/'.$filename, $imageData);
|
|
$url = asset($subDir.'/'.$filename);
|
|
}
|
|
}
|
|
|
|
if ($url) {
|
|
$relativePath = '/'.$subDir.'/'.$filename;
|
|
$fullFilePath = $destinationPath.'/'.$filename;
|
|
|
|
// Automatically analyze candidate system screen capture using AI Vision Engine (disabled for now)
|
|
$aiVerdict = null;
|
|
/*
|
|
$aiDetector = new \App\Services\AiCheatingDetectorService();
|
|
$aiVerdict = $aiDetector->analyzeScreenshot($fullFilePath);
|
|
*/
|
|
|
|
$screenshotEntry = [
|
|
'url' => $relativePath,
|
|
'full_url' => $url,
|
|
'filename' => $filename,
|
|
'ai_verdict' => $aiVerdict,
|
|
'timestamp' => now()->toIso8601String(),
|
|
'reason' => $reason,
|
|
];
|
|
|
|
$screenshots = $interview->tab_switch_screenshots ?? [];
|
|
$screenshots[] = $screenshotEntry;
|
|
|
|
$hasAiTool = ! empty($aiVerdict['is_cheating']) && ! empty($aiVerdict['ai_tool_detected']);
|
|
|
|
if ($reason === 'call_start') {
|
|
$logType = 'call_start_screenshot';
|
|
$logMsg = '📸 CALL STARTED SYSTEM SNAPSHOT: System screen snapshot captured automatically when call started.';
|
|
} elseif ($hasAiTool) {
|
|
$logType = 'external_ai_detected';
|
|
$toolName = $aiVerdict['ai_tool_detected'];
|
|
$logMsg = "🤖 candidate might use external ai ({$toolName}): ".$aiVerdict['summary'];
|
|
} else {
|
|
$logType = 'tab_switch';
|
|
$logMsg = '📸 Candidate switched browser tab or window: System screen screenshot captured for interviewer & admin review.';
|
|
}
|
|
|
|
$logs = $interview->proctor_logs ?? [];
|
|
$logs[] = [
|
|
'type' => $logType,
|
|
'details' => $logMsg,
|
|
'ai_verdict' => $aiVerdict,
|
|
'screenshot_url' => $relativePath,
|
|
'timestamp' => now()->toIso8601String(),
|
|
];
|
|
|
|
$interview->update([
|
|
'tab_switch_screenshots' => $screenshots,
|
|
'proctor_logs' => $logs,
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'url' => $relativePath,
|
|
'full_url' => $url,
|
|
'ai_verdict' => $aiVerdict,
|
|
'total_screenshots' => count($screenshots),
|
|
]);
|
|
}
|
|
|
|
return response()->json(['success' => false, 'message' => 'No image payload received']);
|
|
}
|
|
|
|
/**
|
|
* Admin Toggle Enable/Disable Candidate Tab Switch Screenshot Capture.
|
|
*/
|
|
public function toggleTabScreenshot(Request $request, $id)
|
|
{
|
|
$interview = Interview::where('id', $id)
|
|
->orWhere('submission_unique_id', $id)
|
|
->firstOrFail();
|
|
|
|
$enable = $request->boolean('enable', true);
|
|
$interview->update(['enable_tab_switch_screenshot' => $enable]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'enable_tab_switch_screenshot' => $enable,
|
|
'message' => $enable ? 'Tab-switch screenshot capture ENABLED.' : 'Tab-switch screenshot capture DISABLED.',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Save Candidate Video Recording under public/uploads/candidate_recordings/{unique_id}/.
|
|
*/
|
|
public function uploadRecording(Request $request, $id)
|
|
{
|
|
try {
|
|
$interview = Interview::where('id', $id)
|
|
->orWhere('submission_unique_id', $id)
|
|
->first();
|
|
|
|
if (! $interview) {
|
|
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
|
|
}
|
|
|
|
if ($request->hasFile('video')) {
|
|
$file = $request->file('video');
|
|
$ext = strtolower($file->getClientOriginalExtension() ?: 'webm');
|
|
if (! in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) {
|
|
$ext = 'webm';
|
|
}
|
|
|
|
$baseFolder = config('interview.recordings.base_folder', 'uploads/candidate_recordings');
|
|
$uniqueFolder = $interview->submission_unique_id ?: $interview->id;
|
|
$subDir = trim($baseFolder, '/').'/'.$uniqueFolder;
|
|
$destinationPath = public_path($subDir);
|
|
if (! file_exists($destinationPath)) {
|
|
mkdir($destinationPath, 0777, true);
|
|
}
|
|
|
|
$filename = 'recording_'.time().'.'.$ext;
|
|
$file->move($destinationPath, $filename);
|
|
$url = asset($subDir.'/'.$filename);
|
|
$relativePath = '/'.$subDir.'/'.$filename;
|
|
|
|
$recordings = $interview->recordings ?? [];
|
|
if (! is_array($recordings)) {
|
|
$recordings = $interview->recording_path ? [$interview->recording_path] : [];
|
|
}
|
|
$recordings[] = [
|
|
'url' => $relativePath,
|
|
'full_url' => $url,
|
|
'filename' => $filename,
|
|
'created_at' => now()->toIso8601String(),
|
|
];
|
|
|
|
$interview->update([
|
|
'recording_path' => $relativePath,
|
|
'recordings' => $recordings,
|
|
]);
|
|
|
|
return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]);
|
|
}
|
|
|
|
return response()->json(['success' => false, 'message' => 'No video payload received (file may exceed server upload size limit).'], 422);
|
|
} catch (\Throwable $e) {
|
|
\Log::error('Recording upload failed: '.$e->getMessage());
|
|
|
|
return response()->json(['success' => false, 'message' => 'Server error saving recording: '.$e->getMessage()], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle live chunked video recording upload from the interviewer proctoring session.
|
|
*
|
|
* @param int|string $id
|
|
* @return JsonResponse|Responsable
|
|
*/
|
|
public function uploadRecordingChunk(
|
|
UploadRecordingChunkRequest $request,
|
|
$id,
|
|
StoreRecordingChunkAction $storeChunkAction,
|
|
FinalizeRecordingAction $finalizeRecordingAction
|
|
) {
|
|
try {
|
|
$interview = Interview::where('id', $id)
|
|
->orWhere('submission_unique_id', $id)
|
|
->first();
|
|
|
|
if (! $interview) {
|
|
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
|
|
}
|
|
|
|
$validated = $request->validated();
|
|
$sessionId = $validated['session_id'];
|
|
$chunkIndex = (int) $validated['chunk_index'];
|
|
$isFinal = filter_var($validated['is_final'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
|
|
|
if ($request->hasFile('chunk')) {
|
|
$file = $request->file('chunk');
|
|
$storeChunkAction->execute($interview, $sessionId, $chunkIndex, $file);
|
|
}
|
|
|
|
if ($isFinal) {
|
|
$totalChunks = (int) ($validated['total_chunks'] ?? ($chunkIndex + 1));
|
|
$duration = isset($validated['duration']) ? (int) $validated['duration'] : null;
|
|
$finalizeRecordingAction->execute($interview, $sessionId, $totalChunks, null, null, $duration);
|
|
|
|
return new RecordingFinalizedResponse($sessionId);
|
|
}
|
|
|
|
return new RecordingChunkReceivedResponse($sessionId, $chunkIndex);
|
|
} catch (\Throwable $e) {
|
|
\Log::error('Recording chunk upload failed: '.$e->getMessage(), [
|
|
'id' => $id,
|
|
'session_id' => $request->input('session_id'),
|
|
'chunk_index' => $request->input('chunk_index'),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Error processing recording chunk: '.$e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download Candidate Video Recording as File.
|
|
*/
|
|
public function downloadRecording(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$index = (int) $request->input('index', 0);
|
|
$recordings = $interview->recordings ?? [];
|
|
|
|
$targetUrl = null;
|
|
if (! empty($recordings) && isset($recordings[$index])) {
|
|
$rec = $recordings[$index];
|
|
$targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec;
|
|
}
|
|
|
|
if (! $targetUrl) {
|
|
$targetUrl = $interview->recording_path;
|
|
}
|
|
|
|
if (! $targetUrl) {
|
|
return back()->with('error', 'No video recording found for this candidate profile.');
|
|
}
|
|
|
|
$urlPath = parse_url($targetUrl, PHP_URL_PATH) ?? $targetUrl;
|
|
$sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
|
|
|
|
$candidatePaths = [
|
|
storage_path('app/public/'.$sanitizedPath),
|
|
storage_path('app/private/public/'.$sanitizedPath),
|
|
storage_path('app/private/'.$sanitizedPath),
|
|
storage_path('app/'.$sanitizedPath),
|
|
public_path('storage/'.$sanitizedPath),
|
|
public_path($sanitizedPath),
|
|
];
|
|
|
|
$baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath);
|
|
if ($baseNoExt !== $sanitizedPath) {
|
|
$candidatePaths[] = storage_path('app/public/'.$baseNoExt.'.webm');
|
|
$candidatePaths[] = storage_path('app/public/'.$baseNoExt.'.mp4');
|
|
$candidatePaths[] = storage_path('app/private/public/'.$baseNoExt.'.webm');
|
|
$candidatePaths[] = storage_path('app/private/public/'.$baseNoExt.'.mp4');
|
|
}
|
|
|
|
$filePath = null;
|
|
foreach ($candidatePaths as $cp) {
|
|
if (file_exists($cp) && is_file($cp)) {
|
|
$filePath = $cp;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($filePath && file_exists($filePath)) {
|
|
$handle = @fopen($filePath, 'rb');
|
|
$header = $handle ? fread($handle, 12) : '';
|
|
if ($handle) {
|
|
fclose($handle);
|
|
}
|
|
|
|
$isWebm = str_starts_with($header, "\x1A\x45\xDF\xA3") || str_ends_with($filePath, '.webm');
|
|
$ext = $isWebm ? 'webm' : 'mp4';
|
|
$mimeType = $isWebm ? 'video/webm' : 'video/mp4';
|
|
|
|
$downloadFilename = 'candidate_'.Str::slug($interview->candidate_name).'_recording_'.($index + 1).'.'.$ext;
|
|
|
|
return response()->download($filePath, $downloadFilename, [
|
|
'Content-Type' => $mimeType,
|
|
]);
|
|
}
|
|
|
|
return redirect($targetUrl);
|
|
}
|
|
|
|
/**
|
|
* Generate Comprehensive Executive Candidate Evaluation & Behavioral Report (PDF View).
|
|
*/
|
|
public function generateReport($id)
|
|
{
|
|
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
|
|
|
|
$logs = is_array($interview->proctor_logs) ? $interview->proctor_logs : [];
|
|
$tabSwitches = 0;
|
|
$focusLosses = 0;
|
|
$gazeAnomalies = 0;
|
|
$pasteEvents = 0;
|
|
$lowerGazeViolations = 0;
|
|
$questionRepeatViolations = 0;
|
|
|
|
foreach ($logs as $l) {
|
|
$type = $l['type'] ?? '';
|
|
if ($type === 'tab_switch') {
|
|
$tabSwitches++;
|
|
}
|
|
if ($type === 'focus_lost') {
|
|
$focusLosses++;
|
|
}
|
|
if (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) {
|
|
$gazeAnomalies++;
|
|
}
|
|
if ($type === 'paste_event') {
|
|
$pasteEvents++;
|
|
}
|
|
if ($type === 'gaze_lower_device') {
|
|
$lowerGazeViolations++;
|
|
}
|
|
if (in_array($type, ['question_repeat_lower', 'question_repetition'])) {
|
|
$questionRepeatViolations++;
|
|
}
|
|
}
|
|
|
|
$totalViolations = count($logs);
|
|
|
|
// Calculate Integrity Score %
|
|
if ($totalViolations === 0) {
|
|
$integrityScore = 100;
|
|
$integrityLabel = 'Exceptional Integrity';
|
|
$integrityColor = '#10b981';
|
|
} elseif ($totalViolations <= 2 && $lowerGazeViolations === 0 && $questionRepeatViolations === 0) {
|
|
$integrityScore = 85;
|
|
$integrityLabel = 'Low Behavioral Risk';
|
|
$integrityColor = '#06b6d4';
|
|
} elseif ($totalViolations <= 5) {
|
|
$integrityScore = 60;
|
|
$integrityLabel = 'Moderate Integrity Concern';
|
|
$integrityColor = '#f59e0b';
|
|
} else {
|
|
$integrityScore = 25;
|
|
$integrityLabel = 'High Malpractice Risk';
|
|
$integrityColor = '#ef4444';
|
|
}
|
|
|
|
// Technical Code Score
|
|
$codeScore = 0;
|
|
if (! empty($interview->submitted_code)) {
|
|
$codeScore += 50;
|
|
if (! empty($interview->code_output) && ! str_contains(strtolower($interview->code_output), 'error')) {
|
|
$codeScore += 40;
|
|
} else {
|
|
$codeScore += 20;
|
|
}
|
|
if (! empty($interview->candidate_notes) || ! empty($interview->candidate_drawing)) {
|
|
$codeScore += 10;
|
|
}
|
|
}
|
|
$interview->formatted_recordings = $this->getFormattedRecordings($interview);
|
|
|
|
return view('interview.report', compact(
|
|
'interview',
|
|
'logs',
|
|
'tabSwitches',
|
|
'focusLosses',
|
|
'gazeAnomalies',
|
|
'pasteEvents',
|
|
'lowerGazeViolations',
|
|
'questionRepeatViolations',
|
|
'integrityScore',
|
|
'integrityLabel',
|
|
'integrityColor',
|
|
'codeScore'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Serve / stream candidate resume file.
|
|
*/
|
|
public function viewResume($id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
|
|
if (! $interview->resume_path || ! Storage::disk('public')->exists($interview->resume_path)) {
|
|
abort(404, 'Candidate resume file not found.');
|
|
}
|
|
|
|
$filePath = Storage::disk('public')->path($interview->resume_path);
|
|
$mimeType = Storage::disk('public')->mimeType($interview->resume_path) ?: 'application/pdf';
|
|
|
|
return response()->file($filePath, [
|
|
'Content-Type' => $mimeType,
|
|
'Content-Disposition' => 'inline; filename="'.basename($filePath).'"',
|
|
'Cache-Control' => 'no-cache, private',
|
|
]);
|
|
}
|
|
}
|