candidate portal

This commit is contained in:
subhajit 2026-07-21 14:55:47 +05:30
parent 0d4b9dcfe9
commit d1125bb69e
7 changed files with 677 additions and 75 deletions

View File

@ -102,6 +102,10 @@ public function loginCandidate(Request $request)
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.');
@ -124,6 +128,10 @@ 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);
@ -301,6 +309,60 @@ public function sendWarning(Request $request, $id)
return response()->json(['success' => true, 'warning' => $warning]);
}
/**
* 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.'
]);
}
/**
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
*/
@ -313,6 +375,10 @@ public function getPollData($id)
'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 ?? [],
'warnings' => $interview->warnings,
'is_expired' => $interview->isExpired(),

View File

@ -25,6 +25,9 @@ class Interview extends Model
'code_output',
'recording_path',
'submission_unique_id',
'candidate_notes',
'candidate_drawing',
'blocked_ip',
'created_by',
];

View File

@ -0,0 +1,30 @@
<?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->text('candidate_notes')->nullable();
$table->longText('candidate_drawing')->nullable();
$table->string('blocked_ip')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('interviews', function (Blueprint $table) {
$table->dropColumn(['candidate_notes', 'candidate_drawing', 'blocked_ip']);
});
}
};

View File

@ -202,6 +202,14 @@
</div>
<div style="display: flex; align-items: center; gap: 14px;">
<button onclick="toggleNotepadModal()" class="btn-ide" style="background: rgba(99,102,241,0.2); border-color: #6366f1;">
📝 Open Notepad
</button>
<button onclick="toggleDrawingModal()" class="btn-ide" style="background: rgba(6,182,212,0.2); border-color: #06b6d4;">
🎨 Open Drawing
</button>
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 0.75rem; color: #94a3b8; font-weight: 500;">Language:</label>
<select id="language-select" class="btn-ide" style="background: #151b2c;">
@ -224,6 +232,36 @@
</div>
</header>
<!-- Candidate Notepad Modal -->
<div id="notepad-modal" style="position: fixed; top: 70px; right: 360px; width: 420px; height: 350px; background: #0f172a; border: 1.5px solid #6366f1; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); display: none; flex-direction: column; z-index: 1000; overflow: hidden;">
<div style="background: #1e293b; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.1);">
<div style="font-weight: 700; font-size: 0.85rem; color: white;">📝 Candidate Notepad (Synced)</div>
<button onclick="toggleNotepadModal()" style="background: none; border: none; color: #94a3b8; font-size: 1.2rem; cursor: pointer;">&times;</button>
</div>
<textarea id="notepad-textarea" oninput="saveNotepadContent()" placeholder="Type rough notes, pseudo-code, or thoughts here..." style="flex: 1; width: 100%; padding: 14px; background: #0b0f19; color: #e2e8f0; border: none; outline: none; font-family: 'Fira Code', monospace; font-size: 0.85rem; resize: none;">{{ $interview->candidate_notes }}</textarea>
<div style="padding: 6px 12px; background: #0d1322; font-size: 0.65rem; color: #34d399; text-align: right;">
Auto-saved to interviewer portal
</div>
</div>
<!-- Candidate Canvas Drawing Modal -->
<div id="drawing-modal" style="position: fixed; top: 70px; right: 360px; width: 550px; height: 420px; background: #0f172a; border: 1.5px solid #06b6d4; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); display: none; flex-direction: column; z-index: 1000; overflow: hidden;">
<div style="background: #1e293b; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.1);">
<div style="font-weight: 700; font-size: 0.85rem; color: white;">🎨 Candidate Live Drawing Board</div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="color" id="draw-color" value="#38bdf8" style="width: 26px; height: 26px; border: none; border-radius: 50%; cursor: pointer;">
<button onclick="clearCanvasDraw()" class="btn-ide" style="padding: 2px 8px; font-size: 0.7rem;">Clear</button>
<button onclick="toggleDrawingModal()" style="background: none; border: none; color: #94a3b8; font-size: 1.2rem; cursor: pointer;">&times;</button>
</div>
</div>
<div style="flex: 1; background: #050811; position: relative;">
<canvas id="drawing-canvas" width="550" height="340" style="cursor: crosshair; display: block; width: 100%; height: 100%;"></canvas>
</div>
<div style="padding: 6px 12px; background: #0d1322; font-size: 0.65rem; color: #38bdf8; text-align: right;">
🎨 Draw architecture / logic diagrams (Auto-synced to Interviewer)
</div>
</div>
<!-- IDE Room Layout -->
<div class="ide-layout">
<!-- Main Editor & Terminal Area -->
@ -239,12 +277,28 @@
</div>
</div>
@php
$words = preg_split('/\s+/', trim($interview->candidate_name));
if (count($words) >= 2) {
$candInitials = strtoupper(substr($words[0], 0, 1) . substr(end($words), 0, 1));
} else {
$nameStr = trim($interview->candidate_name);
$candInitials = strtoupper(substr($nameStr, 0, 1) . (strlen($nameStr) > 1 ? substr($nameStr, -1) : ''));
}
@endphp
<!-- Sidebar: Real-Time 2-Way Interview Call & Screen Share -->
<div class="proctor-sidebar">
<!-- Candidate Camera Feed -->
<div class="webcam-box" style="position: relative;">
<video id="webcam" autoplay muted playsinline></video>
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600;">
<video id="webcam" autoplay muted playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
<div id="webcam-avatar-placeholder" style="position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; color: white; z-index: 5;">
<div style="width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 800; font-family: 'Outfit', sans-serif; box-shadow: 0 0 15px rgba(99,102,241,0.4);">
{{ $candInitials }}
</div>
<div style="font-size: 0.7rem; color: #94a3b8; margin-top: 6px;">Camera Disabled</div>
</div>
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600; z-index: 10;">
📷 Candidate Camera
</div>
</div>
@ -252,12 +306,14 @@
<!-- Remote Interviewer Video Feed (Live 2-Way Call) -->
<div class="webcam-box" style="position: relative; border-color: #6366f1;">
<video id="interviewer-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811;"></video>
<div id="interviewer-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.9); color: #94a3b8; font-size: 0.75rem; text-align: center; padding: 10px;">
<div style="font-size: 1.5rem; margin-bottom: 4px;">👤</div>
<div>Interviewer Video Feed</div>
<div id="call-status-sub" style="font-size: 0.65rem; color: #38bdf8; margin-top: 4px;">Waiting for panelist to join call...</div>
<div id="interviewer-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; text-align: center; padding: 10px; z-index: 5;">
<div id="interviewer-avatar-circle" style="width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(56,189,248,0.4);">
IV
</div>
<div id="call-status-sub" style="font-size: 0.7rem; color: #e2e8f0; font-weight: 600;">Interviewer Video Feed</div>
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 2px;">Waiting for panelist to join call...</div>
</div>
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600;">
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10;">
🎙️ Interviewer / Panelist
</div>
</div>
@ -487,15 +543,21 @@ function toggleMic() {
}
function toggleCam() {
if (!localMediaStream) return;
const videoTracks = localMediaStream.getVideoTracks();
if (videoTracks.length > 0) {
const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder');
if (localMediaStream) {
const videoTracks = localMediaStream.getVideoTracks();
if (videoTracks.length > 0) {
isCamEnabled = !isCamEnabled;
videoTracks[0].enabled = isCamEnabled;
const btn = document.getElementById('toggle-cam-btn');
btn.innerText = isCamEnabled ? '📷 Cam On' : '🚫 Cam Off';
btn.style.background = isCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
btn.style.borderColor = isCamEnabled ? '#10b981' : '#ef4444';
if (avatarPlaceholder) avatarPlaceholder.style.display = isCamEnabled ? 'none' : 'flex';
}
} else {
isCamEnabled = !isCamEnabled;
videoTracks[0].enabled = isCamEnabled;
const btn = document.getElementById('toggle-cam-btn');
btn.innerText = isCamEnabled ? '📷 Cam On' : '🚫 Cam Off';
btn.style.background = isCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
btn.style.borderColor = isCamEnabled ? '#10b981' : '#ef4444';
if (avatarPlaceholder) avatarPlaceholder.style.display = isCamEnabled ? 'none' : 'flex';
}
}
@ -561,6 +623,107 @@ function analyzeCameraFrame() {
}
});
}, 5000);
// --- NOTEPAD & CANVAS DRAWING HANDLERS ---
let notesTimeout = null;
function toggleNotepadModal() {
const modal = document.getElementById('notepad-modal');
modal.style.display = (modal.style.display === 'flex') ? 'none' : 'flex';
}
function saveNotepadContent() {
clearTimeout(notesTimeout);
notesTimeout = setTimeout(() => {
const notes = document.getElementById('notepad-textarea').value;
fetch(`{{ route('interview.candidate.notes', $interview->id) }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ notes: notes })
});
}, 800);
}
// HTML5 Canvas Drawing Logic
let isDrawing = false;
let canvas, ctx;
let drawingSaveTimeout = null;
function toggleDrawingModal() {
const modal = document.getElementById('drawing-modal');
modal.style.display = (modal.style.display === 'flex') ? 'none' : 'flex';
if (modal.style.display === 'flex' && !canvas) {
initCanvasDrawing();
}
}
function initCanvasDrawing() {
canvas = document.getElementById('drawing-canvas');
if (!canvas) return;
ctx = canvas.getContext('2d');
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.strokeStyle = '#38bdf8';
const savedDrawing = {!! json_encode($interview->candidate_drawing) !!};
if (savedDrawing) {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0);
img.src = savedDrawing;
}
canvas.addEventListener('mousedown', startDraw);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDraw);
canvas.addEventListener('mouseleave', stopDraw);
}
function startDraw(e) {
isDrawing = true;
ctx.beginPath();
ctx.moveTo(e.offsetX, e.offsetY);
}
function draw(e) {
if (!isDrawing) return;
ctx.strokeStyle = document.getElementById('draw-color').value;
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
function stopDraw() {
if (isDrawing) {
isDrawing = false;
ctx.closePath();
saveCanvasDrawing();
}
}
function clearCanvasDraw() {
if (ctx && canvas) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
saveCanvasDrawing();
}
}
function saveCanvasDrawing() {
clearTimeout(drawingSaveTimeout);
drawingSaveTimeout = setTimeout(() => {
if (!canvas) return;
const drawingData = canvas.toDataURL();
fetch(`{{ route('interview.candidate.drawing', $interview->id) }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ drawing: drawingData })
});
}, 1000);
}
</script>
</body>
</html>

View File

@ -398,15 +398,33 @@
</div>
</div>
<!-- Modal: Live Review, 2-Way Video Call, Screen Share & Code Inspection -->
<!-- Modal: Live Review, 2-Way Video Call, Screen Share, Large IDE Inspector & SOS Alert -->
<div id="review-modal" class="admin-modal">
<div class="admin-modal-content" style="max-width: 1100px;">
<div class="admin-modal-content" style="max-width: 1150px;">
<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 id="modal-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 700; color: white; margin: 0;">Review Candidate Session</h3>
<div id="modal-cand-uid" style="font-size: 0.75rem; color: #38bdf8;"></div>
</div>
<button onclick="closeReviewModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.5rem; cursor:pointer;">&times;</button>
<div style="display: flex; gap: 8px; align-items: center;">
<!-- SOS CHEATING ALERT BUTTON (Flashing Red) -->
<button id="sos-alert-btn" onclick="openSosModal()" class="btn-nav" style="display: none; background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); color: white; border: none; font-weight: 800; padding: 6px 14px; box-shadow: 0 0 15px rgba(239,68,68,0.7); cursor: pointer; animation: sosPulse 1.2s infinite alternate;">
🚨 SOS CHEATING DETECTED!
</button>
<!-- REGENERATE CANDIDATE PASSWORD -->
<button onclick="regenerateCandidatePassword()" class="btn-nav" style="background: rgba(234,179,8,0.15); color: #fde047; border-color: #eab308; font-size: 0.75rem; font-weight: 600;">
🔑 Regenerate Password
</button>
<!-- BLOCK CANDIDATE EMAIL & IP -->
<button onclick="blockCandidateAction()" class="btn-nav" style="background: rgba(239,68,68,0.15); color: #fca5a5; border-color: #ef4444; font-size: 0.75rem; font-weight: 600;">
Block Candidate & IP
</button>
<button onclick="closeReviewModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.5rem; cursor:pointer;">&times;</button>
</div>
</div>
<!-- LIVE 2-WAY VIDEO CALL & SCREEN SHARE PANEL -->
@ -431,75 +449,134 @@
</div>
</div>
<!-- Video Feeds Grid: Candidate Video, Candidate Screen, Interviewer Video -->
<div style="display: grid; grid-template-columns: 1fr 1fr 220px; gap: 12px; min-height: 180px;">
<!-- 1. Candidate Camera Stream -->
<div style="position: relative; background: #050811; border-radius: 12px; border: 1px solid var(--card-border); overflow: hidden; height: 180px;">
<!-- Video Feeds Grid: Candidate Video, Candidate Screen (with Zoom controls), Panelist Video -->
<div style="display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 12px; min-height: 220px;">
<!-- 1. Candidate Camera Stream (with Initials Avatar when Cam is Off) -->
<div style="position: relative; background: #050811; border-radius: 12px; border: 1.5px solid var(--card-border); overflow: hidden; height: 220px;">
<video id="interviewer-cand-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
<div id="cand-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.9); color: #94a3b8; font-size: 0.75rem;">
<div style="font-size: 1.8rem;">📷</div>
<div>Candidate Camera Stream</div>
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 4px;">Click 'Start 2-Way Call' to connect</div>
<div id="cand-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; z-index: 5;">
<div id="modal-cand-avatar-circle" style="width: 64px; height: 64px; border-radius: 50%; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 1.6rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(99,102,241,0.4);">
CD
</div>
<div id="cand-avatar-name" style="font-weight: 700; color: white;">Candidate Camera</div>
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 2px;">Click 'Start 2-Way Call' to connect</div>
</div>
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600;">
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600; z-index: 10;">
👤 Candidate Video
</div>
</div>
<!-- 2. Candidate Live Shared Screen -->
<div style="position: relative; background: #050811; border-radius: 12px; border: 1px solid var(--card-border); overflow: hidden; height: 180px;">
<video id="interviewer-screen-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: contain;"></video>
<div id="screen-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.9); color: #94a3b8; font-size: 0.75rem;">
<div style="font-size: 1.8rem;">🖥️</div>
<div>Candidate Live Screen Share</div>
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 4px;">Waiting for candidate to share screen...</div>
<!-- 2. Candidate Live Shared Screen (with Zoom In / Zoom Out Controls & Fullscreen) -->
<div id="screen-wrapper" style="position: relative; background: #050811; border-radius: 12px; border: 1.5px solid #38bdf8; overflow: hidden; height: 220px;">
<div style="position: absolute; top: 6px; right: 6px; z-index: 20; display: flex; gap: 4px; background: rgba(0,0,0,0.7); padding: 3px 6px; border-radius: 6px; backdrop-filter: blur(4px);">
<button onclick="zoomScreen(0.25)" class="btn-nav" style="padding: 2px 6px; font-size: 0.7rem; background: rgba(255,255,255,0.1);" title="Zoom In (🔍+)">🔍+</button>
<button onclick="zoomScreen(-0.25)" class="btn-nav" style="padding: 2px 6px; font-size: 0.7rem; background: rgba(255,255,255,0.1);" title="Zoom Out (🔎-)">🔎-</button>
<button onclick="resetZoomScreen()" class="btn-nav" style="padding: 2px 6px; font-size: 0.7rem; background: rgba(255,255,255,0.1);" title="Reset Zoom (↺)"></button>
<button onclick="toggleFullscreenScreen()" class="btn-nav" style="padding: 2px 6px; font-size: 0.7rem; background: #38bdf8; color: black; font-weight: bold;" title="Fullscreen (⛶)"></button>
</div>
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #38bdf8; font-weight: 600;">
🖥️ Candidate Shared Screen
<div id="screen-zoom-container" style="width: 100%; height: 100%; overflow: auto;">
<video id="interviewer-screen-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: contain; transform-origin: 0 0; transition: transform 0.2s ease;"></video>
</div>
<div id="screen-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; z-index: 5;">
<div style="font-size: 2rem; margin-bottom: 4px;">🖥️</div>
<div style="font-weight: 700; color: white;">Candidate Live Screen</div>
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 2px;">Waiting for candidate to share screen...</div>
</div>
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #38bdf8; font-weight: 600; z-index: 10;">
🖥️ Shared Screen <span id="zoom-level-badge" style="color: #a7f3d0; margin-left: 4px;">(100%)</span>
</div>
</div>
<!-- 3. Interviewer Self Camera Stream -->
<div style="position: relative; background: #050811; border-radius: 12px; border: 1px solid var(--card-border); overflow: hidden; height: 180px;">
<!-- 3. Interviewer / Panelist Camera Stream -->
<div style="position: relative; background: #050811; border-radius: 12px; border: 1.5px solid var(--card-border); overflow: hidden; height: 220px;">
<video id="interviewer-self-video" autoplay muted playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
<div id="self-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.9); color: #94a3b8; font-size: 0.75rem;">
<div style="font-size: 1.5rem;">🎙️</div>
<div>Interviewer Self Stream</div>
<div id="self-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; z-index: 5;">
@php
$usrWords = preg_split('/\s+/', trim(Auth::user()->name));
if (count($usrWords) >= 2) {
$usrInitials = strtoupper(substr($usrWords[0], 0, 1) . substr(end($usrWords), 0, 1));
} else {
$uStr = trim(Auth::user()->name);
$usrInitials = strtoupper(substr($uStr, 0, 1) . (strlen($uStr) > 1 ? substr($uStr, -1) : ''));
}
@endphp
<div style="width: 64px; height: 64px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #06b6d4 100%); display: flex; align-items: center; justify-content: center; font-size: 1.6rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(16,185,129,0.4);">
{{ $usrInitials }}
</div>
<div style="font-weight: 700; color: white;">{{ Auth::user()->name }}</div>
<div style="font-size: 0.65rem; color: #34d399; margin-top: 2px;">Panelist (Self)</div>
</div>
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600;">
Panelist Self
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10;">
🎙️ Panelist Self
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 20px;">
<!-- Left: Code Editor & Execution Output -->
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 0.8rem; font-weight: 600; color: white;">Submitted Code Solution:</span>
<span id="modal-code-lang" class="badge" style="background: rgba(99,102,241,0.2); color: #818cf8; font-size: 0.65rem;">PYTHON</span>
<!-- LARGE CARD: CANDIDATE IDE & SYNCED NOTEBOOK / DRAWING INSPECTOR (Visible by default even if Screen Share is OFF) -->
<div style="background: #090d16; border: 1.5px solid var(--accent-primary); border-radius: 16px; padding: 20px; margin-bottom: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; border-bottom: 1px solid rgba(255,255,255,0.08); padding-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">💻</span>
<strong style="color: white; font-size: 1.05rem;">Candidate Live IDE & Workspace Inspector</strong>
<span id="modal-code-lang" class="badge" style="background: rgba(99,102,241,0.2); color: #818cf8; font-size: 0.7rem;">PYTHON</span>
<span style="font-size: 0.7rem; color: #34d399;"> Live Synced</span>
</div>
<pre id="modal-code-display" style="background: #090d16; border: 1px solid var(--card-border); border-radius: 10px; padding: 14px; font-family: monospace; font-size: 0.8rem; color: #e2e8f0; max-height: 250px; overflow-y: auto; white-space: pre-wrap; margin-bottom: 14px;"></pre>
<span style="font-size: 0.8rem; font-weight: 600; color: white;">Execution Stdout / Stderr Output:</span>
<pre id="modal-output-display" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; font-family: monospace; font-size: 0.75rem; color: #34d399; max-height: 120px; overflow-y: auto; white-space: pre-wrap;"></pre>
<!-- Tab Switcher for Code Solution, Notepad, Drawing -->
<div style="display: flex; gap: 6px;">
<button id="tab-btn-code" onclick="switchIdeTab('code')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: #6366f1; color: white; border: none; font-weight: 700;">💻 Live IDE Code</button>
<button id="tab-btn-notes" onclick="switchIdeTab('notes')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📝 Candidate Notepad</button>
<button id="tab-btn-drawing" onclick="switchIdeTab('drawing')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">🎨 Candidate Drawing</button>
</div>
</div>
<!-- Right: Proctoring Logs & Silent Warning Sender -->
<div>
<span style="font-size: 0.8rem; font-weight: 600; color: white;">Proctoring Audit:</span>
<div id="modal-logs-display" style="background: rgba(0,0,0,0.3); border: 1px solid var(--card-border); border-radius: 10px; padding: 10px; max-height: 200px; overflow-y: auto; margin-bottom: 14px; font-size: 0.7rem; color: var(--text-muted);">
No violations recorded.
<div style="display: grid; grid-template-columns: 2.5fr 1fr; gap: 20px;">
<!-- Left Column: Tab Content (Code Solution, Notepad, or Canvas Drawing) -->
<div>
<!-- Tab 1: Live Code Solution & Terminal Output -->
<div id="tab-content-code" style="display: block;">
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Current Code Solution (Visible even if candidate does not share screen):</div>
<pre id="modal-code-display" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 16px; font-family: monospace; font-size: 0.85rem; color: #38bdf8; min-height: 200px; max-height: 280px; overflow-y: auto; white-space: pre-wrap; margin-bottom: 12px;"></pre>
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Execution Stdout / Stderr Output:</div>
<pre id="modal-output-display" style="background: #02040a; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; font-family: monospace; font-size: 0.75rem; color: #34d399; max-height: 90px; overflow-y: auto; white-space: pre-wrap;"></pre>
</div>
<!-- Tab 2: Candidate Notepad Content -->
<div id="tab-content-notes" style="display: none;">
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Candidate Live Notepad Notes (Auto-Synced):</div>
<pre id="modal-notes-display" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 16px; font-family: monospace; font-size: 0.85rem; color: #e2e8f0; min-height: 250px; max-height: 350px; overflow-y: auto; white-space: pre-wrap;"></pre>
</div>
<!-- Tab 3: Candidate Canvas Drawing Image -->
<div id="tab-content-drawing" style="display: none;">
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Candidate Live Drawing / Architecture Diagram:</div>
<div style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 10px; display: flex; justify-content: center; align-items: center; min-height: 250px;">
<img id="modal-drawing-img" src="" alt="Candidate Drawing Board" style="max-width: 100%; max-height: 320px; display: none; border-radius: 8px; border: 1px dashed rgba(255,255,255,0.2);" />
<div id="no-drawing-placeholder" style="color: #94a3b8; font-size: 0.8rem;">No drawing data submitted by candidate yet.</div>
</div>
</div>
</div>
<!-- Silent Warning Form -->
<div style="background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 12px; padding: 12px;">
<div style="font-size: 0.75rem; color: #fca5a5; font-weight: 600; margin-bottom: 6px;">Send Message to Candidate:</div>
<input type="text" id="warning-input" placeholder="e.g. Please walk me through your solution logic." class="form-input" style="font-size: 0.75rem; margin-bottom: 8px;">
<button onclick="sendSilentWarning()" class="btn-nav" style="width: 100%; background: #ef4444; color: white; border: none; font-size: 0.75rem; font-weight: 600; padding: 6px; cursor: pointer;">
💬 Send Message
</button>
<!-- Right Column: Proctoring Audit & Silent Warning Form -->
<div>
<span style="font-size: 0.8rem; font-weight: 600; color: white;">Proctoring Audit:</span>
<div id="modal-logs-display" style="background: rgba(0,0,0,0.4); border: 1px solid var(--card-border); border-radius: 10px; padding: 10px; max-height: 200px; overflow-y: auto; margin-bottom: 14px; font-size: 0.7rem; color: var(--text-muted);">
No violations recorded.
</div>
<!-- Silent Warning Form -->
<div style="background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 12px; padding: 12px;">
<div style="font-size: 0.75rem; color: #fca5a5; font-weight: 600; margin-bottom: 6px;">Send Message to Candidate:</div>
<input type="text" id="warning-input" placeholder="e.g. Please walk me through your logic." class="form-input" style="font-size: 0.75rem; margin-bottom: 8px;">
<button onclick="sendSilentWarning()" class="btn-nav" style="width: 100%; background: #ef4444; color: white; border: none; font-size: 0.75rem; font-weight: 600; padding: 6px; cursor: pointer;">
💬 Send Message
</button>
</div>
</div>
</div>
</div>
@ -510,6 +587,36 @@
</div>
</div>
<!-- SOS CHEATING ALERT MODAL (FOR INTERVIEWER ONLY) -->
<div id="sos-modal" class="admin-modal" style="z-index: 9999;">
<div class="admin-modal-content" style="max-width: 520px; border: 2px solid #ef4444; background: #0f172a; box-shadow: 0 0 40px rgba(239,68,68,0.8);">
<div style="display: flex; align-items: center; gap: 12px; border-bottom: 1px solid rgba(239,68,68,0.3); padding-bottom: 12px; margin-bottom: 14px;">
<div style="font-size: 2.2rem;">🚨</div>
<div>
<h3 style="color: #fca5a5; font-size: 1.25rem; font-weight: 800; margin: 0;">SOS CHEATING DETECTED</h3>
<div style="font-size: 0.72rem; color: #94a3b8;">Proctoring engine flagged candidate cheating activity!</div>
</div>
</div>
<div id="sos-violation-list" style="background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 12px; padding: 14px; font-size: 0.8rem; color: #fca5a5; margin-bottom: 18px; max-height: 180px; overflow-y: auto;">
Violation details loading...
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button onclick="stopSosAlarm()" class="btn-nav" style="background: #ef4444; color: white; border: none; font-weight: 800; padding: 8px 16px; cursor: pointer;">
🔕 Acknowledge & Stop SOS Alarm
</button>
</div>
</div>
</div>
<style>
@keyframes sosPulse {
0% { transform: scale(1); box-shadow: 0 0 10px rgba(239,68,68,0.5); }
100% { transform: scale(1.05); box-shadow: 0 0 25px rgba(239,68,68,1); }
}
</style>
<script>
let currentInterviewId = null;
let interviewerPeer = null;
@ -518,29 +625,121 @@
let activeCall = null;
let isInterviewerMicOn = true;
let isInterviewerCamOn = true;
let screenZoomLevel = 1.0;
function getInitials(name) {
if (!name) return 'CD';
let parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
let str = name.trim();
if (str.length >= 2) {
return (str[0] + str[str.length - 1]).toUpperCase();
}
return str.toUpperCase();
}
function zoomScreen(delta) {
screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta));
const screenVid = document.getElementById('interviewer-screen-video');
const badge = document.getElementById('zoom-level-badge');
if (screenVid) {
screenVid.style.transform = `scale(${screenZoomLevel})`;
}
if (badge) {
badge.innerText = `(${Math.round(screenZoomLevel * 100)}%)`;
}
}
function resetZoomScreen() {
screenZoomLevel = 1.0;
zoomScreen(0);
}
function toggleFullscreenScreen() {
const wrapper = document.getElementById('screen-wrapper');
if (!wrapper) return;
if (!document.fullscreenElement) {
wrapper.requestFullscreen().catch(err => console.log(err));
} else {
document.exitFullscreen();
}
}
let activeViolations = [];
let sosPollInterval = null;
function switchIdeTab(tab) {
document.getElementById('tab-content-code').style.display = (tab === 'code') ? 'block' : 'none';
document.getElementById('tab-content-notes').style.display = (tab === 'notes') ? 'block' : 'none';
document.getElementById('tab-content-drawing').style.display = (tab === 'drawing') ? 'block' : 'none';
document.getElementById('tab-btn-code').style.background = (tab === 'code') ? '#6366f1' : 'rgba(255,255,255,0.05)';
document.getElementById('tab-btn-code').style.color = (tab === 'code') ? 'white' : '#94a3b8';
document.getElementById('tab-btn-notes').style.background = (tab === 'notes') ? '#6366f1' : 'rgba(255,255,255,0.05)';
document.getElementById('tab-btn-notes').style.color = (tab === 'notes') ? 'white' : '#94a3b8';
document.getElementById('tab-btn-drawing').style.background = (tab === 'drawing') ? '#06b6d4' : 'rgba(255,255,255,0.05)';
document.getElementById('tab-btn-drawing').style.color = (tab === 'drawing') ? 'white' : '#94a3b8';
}
function openReviewModal(id, name, uid) {
currentInterviewId = id;
document.getElementById('modal-cand-name').innerText = name;
document.getElementById('modal-cand-uid').innerText = 'Unique Submission ID: ' + uid;
document.getElementById('review-modal').classList.add('active');
initInterviewerPeer();
const candInitials = getInitials(name);
const avatarCircle = document.getElementById('modal-cand-avatar-circle');
const avatarName = document.getElementById('cand-avatar-name');
if (avatarCircle) avatarCircle.innerText = candInitials;
if (avatarName) avatarName.innerText = name;
// Fetch live poll data
fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', id))
document.getElementById('review-modal').classList.add('active');
resetZoomScreen();
initInterviewerPeer();
switchIdeTab('code');
pollReviewData();
clearInterval(sosPollInterval);
sosPollInterval = setInterval(pollReviewData, 4000);
}
function pollReviewData() {
if (!currentInterviewId) return;
fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', currentInterviewId))
.then(r => r.json())
.then(data => {
document.getElementById('modal-code-lang').innerText = (data.submitted_language || 'UNKNOWN').toUpperCase();
document.getElementById('modal-code-display').innerText = data.submitted_code || '// Candidate has not submitted code yet.';
document.getElementById('modal-output-display').innerText = data.code_output || 'No execution output.';
document.getElementById('modal-notes-display').innerText = data.candidate_notes || 'No candidate notes written yet.';
const drawingImg = document.getElementById('modal-drawing-img');
const noDrawing = document.getElementById('no-drawing-placeholder');
if (data.candidate_drawing) {
drawingImg.src = data.candidate_drawing;
drawingImg.style.display = 'block';
noDrawing.style.display = 'none';
} else {
drawingImg.style.display = 'none';
noDrawing.style.display = 'block';
}
let logsHtml = '';
let cheatingDetected = false;
activeViolations = [];
if (data.proctor_logs && data.proctor_logs.length > 0) {
data.proctor_logs.forEach(l => {
let icon = '🔴';
if (l.type === 'focus_lost') icon = '🟡';
if (l.type === 'gaze_anomaly') icon = '👁️';
if (l.type === 'paste_event' || l.type === 'tab_switch' || l.type === 'focus_lost') {
cheatingDetected = true;
activeViolations.push(l);
}
logsHtml += `<div style="margin-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:4px;">
<strong>${icon} ${l.type.toUpperCase()}:</strong> ${l.details || ''}
<div style="font-size:0.65rem; color:#94a3b8;">${new Date(l.timestamp).toLocaleTimeString()}</div>
@ -550,9 +749,83 @@ function openReviewModal(id, name, uid) {
logsHtml = '<div style="color:#34d399;">✅ Zero proctoring violations recorded.</div>';
}
document.getElementById('modal-logs-display').innerHTML = logsHtml;
const sosBtn = document.getElementById('sos-alert-btn');
if (cheatingDetected) {
sosBtn.style.display = 'inline-block';
} else {
sosBtn.style.display = 'none';
}
});
}
function openSosModal() {
let html = '';
if (activeViolations.length > 0) {
activeViolations.forEach(v => {
html += `<div style="margin-bottom: 8px;">
<strong>🚨 ${v.type.toUpperCase()}:</strong> ${v.details || 'Suspicious candidate behavior detected.'}
<div style="font-size: 0.65rem; color: #94a3b8;">Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}</div>
</div>`;
});
} else {
html = '<div>Potential proctoring alert flagged by engine.</div>';
}
document.getElementById('sos-violation-list').innerHTML = html;
document.getElementById('sos-modal').classList.add('active');
}
function stopSosAlarm() {
document.getElementById('sos-modal').classList.remove('active');
const sosBtn = document.getElementById('sos-alert-btn');
if (sosBtn) {
sosBtn.style.animation = 'none';
sosBtn.innerText = '✅ SOS Acknowledged';
sosBtn.style.background = '#475569';
}
}
function regenerateCandidatePassword() {
if (!currentInterviewId) return;
if (!confirm('Are you sure you want to regenerate candidate temporary password?')) return;
fetch(`{{ route('interview.regenerate-password', ':id') }}`.replace(':id', currentInterviewId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(r => r.json())
.then(res => {
if (res.success) {
alert('New Candidate Temporary Password Generated: ' + res.new_password);
location.reload();
}
});
}
function blockCandidateAction() {
if (!currentInterviewId) return;
if (!confirm('CRITICAL ACTION: Block this candidate email and client IP address permanently?')) return;
fetch(`{{ route('interview.block-candidate', ':id') }}`.replace(':id', currentInterviewId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(r => r.json())
.then(res => {
if (res.success) {
alert(res.message);
closeReviewModal();
location.reload();
}
});
}
function initInterviewerPeer() {
if (!currentInterviewId || typeof Peer === 'undefined') return;
@ -628,14 +901,20 @@ function toggleInterviewerMic() {
}
function toggleInterviewerCam() {
if (!interviewerLocalStream) return;
const videoTracks = interviewerLocalStream.getVideoTracks();
if (videoTracks.length > 0) {
const selfPlaceholder = document.getElementById('self-video-placeholder');
if (interviewerLocalStream) {
const videoTracks = interviewerLocalStream.getVideoTracks();
if (videoTracks.length > 0) {
isInterviewerCamOn = !isInterviewerCamOn;
videoTracks[0].enabled = isInterviewerCamOn;
const btn = document.getElementById('btn-toggle-interviewer-cam');
btn.innerText = isInterviewerCamOn ? '📷 Cam On' : '🚫 Cam Off';
btn.style.background = isInterviewerCamOn ? 'rgba(255,255,255,0.1)' : 'rgba(239,68,68,0.3)';
if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex';
}
} else {
isInterviewerCamOn = !isInterviewerCamOn;
videoTracks[0].enabled = isInterviewerCamOn;
const btn = document.getElementById('btn-toggle-interviewer-cam');
btn.innerText = isInterviewerCamOn ? '📷 Cam On' : '🚫 Cam Off';
btn.style.background = isInterviewerCamOn ? 'rgba(255,255,255,0.1)' : 'rgba(239,68,68,0.3)';
if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex';
}
}
@ -666,6 +945,7 @@ function endInterviewerCall() {
}
function closeReviewModal() {
clearInterval(sosPollInterval);
endInterviewerCall();
document.getElementById('review-modal').classList.remove('active');
}

View File

@ -32,6 +32,8 @@
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/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');
Route::post('/candidate/drawing/{id}', [InterviewController::class, 'saveCandidateDrawing'])->name('interview.candidate.drawing');
// User Dashboard Portal (requires authenticated user)
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
@ -62,6 +64,8 @@
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');
});
// Admin Control Panel (requires admin role)

View File

@ -209,4 +209,60 @@ public function test_interview_zone_tab_visibility_for_assigned_interviewer_duri
$interview->update(['expires_at' => now()->subMinute()]);
$this->assertFalse($interviewer->fresh()->hasActiveInterviewZone());
}
public function test_candidate_notes_drawing_saving_and_interviewer_password_regen_blocking(): void
{
$hr = User::create([
'name' => 'HR Admin',
'email' => 'admin@company.com',
'role' => 'admin',
]);
$interview = Interview::create([
'candidate_name' => 'David Candidate',
'candidate_email' => 'david@gmail.com',
'candidate_phone' => '9777766666',
'temp_password' => 'Pass-777666',
'expires_at' => now()->addHours(2),
'language' => 'python',
'status' => 'in_progress',
'submission_unique_id' => 'david-candidate_9777766666_1752000004',
]);
// 1. Candidate saves notepad notes
$this->post(route('interview.candidate.notes', $interview->id), [
'notes' => 'Scratchpad logic: O(N log N) sorting approach',
])->assertStatus(200);
// 2. Candidate saves canvas drawing data
$this->post(route('interview.candidate.drawing', $interview->id), [
'drawing' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
])->assertStatus(200);
$interview->refresh();
$this->assertEquals('Scratchpad logic: O(N log N) sorting approach', $interview->candidate_notes);
$this->assertNotNull($interview->candidate_drawing);
// 3. Interviewer regenerates password
$regenResp = $this->actingAs($hr)
->post(route('interview.regenerate-password', $interview->id));
$regenResp->assertStatus(200)->assertJson(['success' => true]);
$interview->refresh();
$this->assertNotEquals('Pass-777666', $interview->temp_password);
// 4. Interviewer blocks candidate and IP
$blockResp = $this->actingAs($hr)
->post(route('interview.block-candidate', $interview->id));
$blockResp->assertStatus(200)->assertJson(['success' => true]);
$interview->refresh();
$this->assertEquals('blocked', $interview->status);
// Blocked candidate login should be denied
$this->post(route('interview.candidate.login.submit'), [
'identifier' => 'david@gmail.com',
'temp_password' => $interview->temp_password,
])->assertSessionHas('error');
}
}