From d1125bb69e58829855c5ce5599c0a544d61d76d5 Mon Sep 17 00:00:00 2001 From: subhajit Date: Tue, 21 Jul 2026 14:55:47 +0530 Subject: [PATCH] candidate portal --- app/Http/Controllers/InterviewController.php | 66 +++ app/Models/Interview.php | 3 + ...d_notes_drawing_ip_to_interviews_table.php | 30 ++ .../views/interview/candidate_room.blade.php | 193 ++++++++- resources/views/interview/index.blade.php | 400 +++++++++++++++--- routes/web.php | 4 + .../Feature/CandidateInterviewPortalTest.php | 56 +++ 7 files changed, 677 insertions(+), 75 deletions(-) create mode 100644 database/migrations/2026_07_21_091740_add_notes_drawing_ip_to_interviews_table.php diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index 3fd65d6..9c2b7d6 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -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(), diff --git a/app/Models/Interview.php b/app/Models/Interview.php index 9f21e62..3df16d8 100644 --- a/app/Models/Interview.php +++ b/app/Models/Interview.php @@ -25,6 +25,9 @@ class Interview extends Model 'code_output', 'recording_path', 'submission_unique_id', + 'candidate_notes', + 'candidate_drawing', + 'blocked_ip', 'created_by', ]; diff --git a/database/migrations/2026_07_21_091740_add_notes_drawing_ip_to_interviews_table.php b/database/migrations/2026_07_21_091740_add_notes_drawing_ip_to_interviews_table.php new file mode 100644 index 0000000..1242801 --- /dev/null +++ b/database/migrations/2026_07_21_091740_add_notes_drawing_ip_to_interviews_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 3c68701..077487c 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -202,6 +202,14 @@
+ + + +
+
+ ✓ Auto-saved to interviewer portal +
+
+ + + +
@@ -239,12 +277,28 @@
+ @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 +
- -
+ + +
📷 Candidate Camera
@@ -252,12 +306,14 @@
-
-
👤
-
Interviewer Video Feed
-
Waiting for panelist to join call...
+
+
+ IV +
+
Interviewer Video Feed
+
Waiting for panelist to join call...
-
+
🎙️ Interviewer / Panelist
@@ -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); + } diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 266849f..828bf11 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -398,15 +398,33 @@
- +
-
+
- + +
+ + + + + + + + + + +
@@ -431,75 +449,134 @@
- -
- -
+ +
+ +
-
-
📷
-
Candidate Camera Stream
-
Click 'Start 2-Way Call' to connect
+
+ +
Candidate Camera
+
Click 'Start 2-Way Call' to connect
-
+
👤 Candidate Video
- -
- -
-
🖥️
-
Candidate Live Screen Share
-
Waiting for candidate to share screen...
+ +
+
+ + + +
-
- 🖥️ Candidate Shared Screen + +
+ +
+ +
+
🖥️
+
Candidate Live Screen
+
Waiting for candidate to share screen...
+
+ +
+ 🖥️ Shared Screen (100%)
- -
+ +
-
-
🎙️
-
Interviewer Self Stream
+
+ @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 +
+ {{ $usrInitials }} +
+
{{ Auth::user()->name }}
+
Panelist (Self)
-
- Panelist Self +
+ 🎙️ Panelist Self
-
- -
-
- Submitted Code Solution: - PYTHON + +
+
+
+ 💻 + Candidate Live IDE & Workspace Inspector + PYTHON + ● Live Synced
- - Execution Stdout / Stderr Output: - + +
+ + + +
- -
- Proctoring Audit: -
+ +
+
+
+
🚨
+
+

SOS CHEATING DETECTED

+
Proctoring engine flagged candidate cheating activity!
+
+
+ +
+ Violation details loading... +
+ +
+ +
+
+
+ + +