diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index 335bb50..64ee6c5 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -411,6 +411,8 @@ public function getPollData($id) 'temp_password' => $interview->temp_password, 'blocked_ip' => $interview->blocked_ip, 'proctor_logs' => $interview->proctor_logs ?? [], + 'recording_path' => $interview->recording_path, + 'recordings' => $interview->recordings ?? [], 'warnings' => $interview->warnings, 'is_expired' => $interview->isExpired(), ]); @@ -436,16 +438,65 @@ public function uploadRecording(Request $request, $id) if ($request->hasFile('video')) { $file = $request->file('video'); - $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.webm'; + $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.mp4'; $path = $file->storeAs('public/recordings', $filename); + $url = Storage::url($path); - $interview->update(['recording_path' => Storage::url($path)]); - return response()->json(['success' => true, 'path' => Storage::url($path)]); + $recordings = $interview->recordings ?? []; + if (!is_array($recordings)) { + $recordings = $interview->recording_path ? [$interview->recording_path] : []; + } + $recordings[] = [ + 'url' => $url, + 'filename' => $filename, + 'created_at' => now()->toIso8601String(), + ]; + + $interview->update([ + 'recording_path' => $url, + 'recordings' => $recordings, + ]); + + return response()->json(['success' => true, 'path' => $url, 'recordings' => $recordings]); } return response()->json(['success' => false, 'message' => 'No video payload received']); } + /** + * Download Candidate Video Recording as MP4 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.'); + } + + $relativePath = str_replace('/storage/', 'public/', $targetUrl); + if (Storage::exists($relativePath)) { + $downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.mp4'; + return Storage::download($relativePath, $downloadFilename, [ + 'Content-Type' => 'video/mp4', + ]); + } + + return redirect($targetUrl); + } + /** * Generate Comprehensive Executive Candidate Evaluation & Behavioral Report (PDF View). */ @@ -453,18 +504,22 @@ public function generateReport($id) { $interview = Interview::with(['warnings.sender'])->findOrFail($id); - $logs = $interview->proctor_logs ?? []; + $logs = is_array($interview->proctor_logs) ? $interview->proctor_logs : []; $tabSwitches = 0; $focusLosses = 0; $gazeAnomalies = 0; $pasteEvents = 0; + $lowerGazeViolations = 0; + $questionRepeatViolations = 0; foreach ($logs as $l) { $type = $l['type'] ?? ''; if ($type === 'tab_switch') $tabSwitches++; if ($type === 'focus_lost') $focusLosses++; - if ($type === 'gaze_anomaly') $gazeAnomalies++; + if (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) $gazeAnomalies++; if ($type === 'paste_event') $pasteEvents++; + if ($type === 'gaze_lower_device') $lowerGazeViolations++; + if (in_array($type, ['question_repeat_lower', 'question_repetition'])) $questionRepeatViolations++; } $totalViolations = count($logs); @@ -474,7 +529,7 @@ public function generateReport($id) $integrityScore = 100; $integrityLabel = 'Exceptional Integrity'; $integrityColor = '#10b981'; - } elseif ($totalViolations <= 2) { + } elseif ($totalViolations <= 2 && $lowerGazeViolations === 0 && $questionRepeatViolations === 0) { $integrityScore = 85; $integrityLabel = 'Low Behavioral Risk'; $integrityColor = '#06b6d4'; @@ -488,7 +543,7 @@ public function generateReport($id) $integrityColor = '#ef4444'; } - // Calculate Technical Code Score + // Technical Code Score $codeScore = 0; if (!empty($interview->submitted_code)) { $codeScore += 50; @@ -510,6 +565,8 @@ public function generateReport($id) 'focusLosses', 'gazeAnomalies', 'pasteEvents', + 'lowerGazeViolations', + 'questionRepeatViolations', 'integrityScore', 'integrityLabel', 'integrityColor', diff --git a/app/Models/Interview.php b/app/Models/Interview.php index 1fc5d82..36d6eb5 100644 --- a/app/Models/Interview.php +++ b/app/Models/Interview.php @@ -24,6 +24,7 @@ class Interview extends Model 'submitted_language', 'code_output', 'recording_path', + 'recordings', 'submission_unique_id', 'candidate_notes', 'candidate_drawing', @@ -37,6 +38,7 @@ class Interview extends Model 'expires_at' => 'datetime', 'assigned_interviewers' => 'array', 'proctor_logs' => 'array', + 'recordings' => 'array', ]; public function creator() diff --git a/database/migrations/2026_07_22_122000_add_interviewer_notes_to_interviews_table.php b/database/migrations/2026_07_22_122000_add_interviewer_notes_to_interviews_table.php new file mode 100644 index 0000000..bad9a1b --- /dev/null +++ b/database/migrations/2026_07_22_122000_add_interviewer_notes_to_interviews_table.php @@ -0,0 +1,32 @@ +text('interviewer_notes')->nullable()->after('candidate_notes'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('interviews', function (Blueprint $table) { + if (Schema::hasColumn('interviews', 'interviewer_notes')) { + $table->dropColumn('interviewer_notes'); + } + }); + } +}; diff --git a/database/migrations/2026_07_23_105435_add_recordings_to_interviews_table.php b/database/migrations/2026_07_23_105435_add_recordings_to_interviews_table.php new file mode 100644 index 0000000..7e138b3 --- /dev/null +++ b/database/migrations/2026_07_23_105435_add_recordings_to_interviews_table.php @@ -0,0 +1,28 @@ +json('recordings')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('interviews', function (Blueprint $table) { + $table->dropColumn('recordings'); + }); + } +}; diff --git a/public/Screenshot_13.png b/public/Screenshot_13.png new file mode 100644 index 0000000..4230ee5 Binary files /dev/null and b/public/Screenshot_13.png differ diff --git a/public/Screenshot_14.png b/public/Screenshot_14.png new file mode 100644 index 0000000..7dcd1c9 Binary files /dev/null and b/public/Screenshot_14.png differ diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 15e4a3b..57110bb 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -188,11 +188,6 @@
- - -