diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php new file mode 100644 index 0000000..9daee07 --- /dev/null +++ b/app/Http/Controllers/InterviewController.php @@ -0,0 +1,282 @@ +orderBy('created_at', 'desc') + ->get(); + + $interviewers = User::orderBy('name', 'asc')->get(); + + return view('interview.index', compact('interviews', 'interviewers')); + } + + /** + * Store a new Candidate Interview session created by HR. + */ + public function store(Request $request) + { + $request->validate([ + 'candidate_name' => 'required|string|max:255', + 'candidate_email' => 'required|email|max:255', + 'candidate_phone' => 'required|string|max:50', + 'valid_hours' => 'required|integer|min:1|max:72', + 'language' => 'required|string', + 'assigned_interviewers' => 'nullable|array', + ]); + + $tempPassword = 'Pass-' . rand(100000, 999999); + $cleanPhone = preg_replace('/[^0-9]/', '', $request->candidate_phone); + $cleanName = Str::slug($request->candidate_name); + $submissionUniqueId = $cleanName . '_' . $cleanPhone . '_' . time(); + + $expiresAt = now()->addHours((int)$request->valid_hours); + + $interview = Interview::create([ + 'candidate_name' => $request->candidate_name, + 'candidate_email' => strtolower(trim($request->candidate_email)), + 'candidate_phone' => $request->candidate_phone, + 'temp_password' => $tempPassword, + 'expires_at' => $expiresAt, + 'language' => $request->language, + 'status' => 'scheduled', + 'assigned_interviewers' => $request->assigned_interviewers ?? [], + 'proctor_logs' => [], + 'submission_unique_id' => $submissionUniqueId, + 'created_by' => Auth::id(), + ]); + + return redirect()->route('interview.index')->with('success', 'Interview session created for ' . $request->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->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->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', + ]); + + $langMap = [ + 'python' => ['language' => 'python', 'version' => '3.10.0'], + 'c' => ['language' => 'c', 'version' => '10.2.0'], + 'cpp' => ['language' => 'c++', 'version' => '10.2.0'], + 'java' => ['language' => 'java', 'version' => '15.0.2'], + 'php' => ['language' => 'php', 'version' => '8.2.3'], + 'laravel' => ['language' => 'php', 'version' => '8.2.3'], + 'javascript' => ['language' => 'javascript', 'version' => '18.15.0'], + ]; + + $targetLang = $langMap[strtolower($request->language)] ?? ['language' => 'python', 'version' => '3.10.0']; + + try { + $response = Http::post('https://emkc.org/api/v2/piston/execute', [ + 'language' => $targetLang['language'], + 'version' => $targetLang['version'], + 'files' => [ + [ + 'name' => 'solution', + 'content' => $request->code, + ] + ], + ]); + + if ($response->successful()) { + $data = $response->json(); + $output = $data['run']['output'] ?? 'No output generated.'; + return response()->json(['success' => true, 'output' => $output]); + } + + return response()->json(['success' => false, 'output' => 'Execution engine error: ' . $response->status()]); + } catch (\Exception $e) { + return response()->json(['success' => false, 'output' => 'Code runner service error: ' . $e->getMessage()]); + } + } + + /** + * 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, Gaze Anomaly). + */ + public function logViolation(Request $request, $id) + { + $interview = Interview::findOrFail($id); + + $type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly + $details = $request->input('details', ''); + + $logs = $interview->proctor_logs ?? []; + $logs[] = [ + 'type' => $type, + 'details' => $details, + 'timestamp' => now()->toIso8601String(), + ]; + + $interview->update(['proctor_logs' => $logs]); + + return response()->json(['success' => true, 'total_violations' => count($logs)]); + } + + /** + * 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]); + } + + /** + * Live Polling Endpoint for Candidate Warnings & HR Monitoring. + */ + public function getPollData($id) + { + $interview = Interview::with(['warnings.sender'])->findOrFail($id); + + return response()->json([ + 'status' => $interview->status, + 'submitted_code' => $interview->submitted_code, + 'submitted_language' => $interview->submitted_language, + 'code_output' => $interview->code_output, + 'proctor_logs' => $interview->proctor_logs ?? [], + 'warnings' => $interview->warnings, + 'is_expired' => $interview->isExpired(), + ]); + } + + /** + * Save Candidate Silent Video Recording Blob / Snapshot Stream. + */ + public function uploadRecording(Request $request, $id) + { + $interview = Interview::findOrFail($id); + + if ($request->hasFile('video')) { + $file = $request->file('video'); + $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.webm'; + $path = $file->storeAs('public/recordings', $filename); + + $interview->update(['recording_path' => Storage::url($path)]); + return response()->json(['success' => true, 'path' => Storage::url($path)]); + } + + return response()->json(['success' => false, 'message' => 'No video payload received']); + } +} diff --git a/app/Models/Interview.php b/app/Models/Interview.php new file mode 100644 index 0000000..683a91d --- /dev/null +++ b/app/Models/Interview.php @@ -0,0 +1,50 @@ + 'datetime', + 'assigned_interviewers' => 'array', + 'proctor_logs' => 'array', + ]; + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function warnings() + { + return $this->hasMany(InterviewWarning::class, 'interview_id'); + } + + public function isExpired(): bool + { + return now()->greaterThan($this->expires_at); + } +} diff --git a/app/Models/InterviewWarning.php b/app/Models/InterviewWarning.php new file mode 100644 index 0000000..a4c48a8 --- /dev/null +++ b/app/Models/InterviewWarning.php @@ -0,0 +1,27 @@ +belongsTo(Interview::class, 'interview_id'); + } + + public function sender() + { + return $this->belongsTo(User::class, 'sent_by'); + } +} diff --git a/database/migrations/2026_07_20_145229_create_interviews_table.php b/database/migrations/2026_07_20_145229_create_interviews_table.php new file mode 100644 index 0000000..642c7f3 --- /dev/null +++ b/database/migrations/2026_07_20_145229_create_interviews_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('candidate_name'); + $table->string('candidate_email'); + $table->string('candidate_phone'); + $table->string('temp_password'); + $table->timestamp('expires_at'); + $table->string('language')->default('python'); // python, c, cpp, java, php, javascript + $table->string('status')->default('scheduled'); // scheduled, in_progress, completed, expired + $table->json('assigned_interviewers')->nullable(); + $table->json('proctor_logs')->nullable(); + $table->longText('submitted_code')->nullable(); + $table->string('submitted_language')->nullable(); + $table->longText('code_output')->nullable(); + $table->string('recording_path')->nullable(); + $table->string('submission_unique_id')->unique(); + $table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('interviews'); + } +}; diff --git a/database/migrations/2026_07_20_145246_create_interview_warnings_table.php b/database/migrations/2026_07_20_145246_create_interview_warnings_table.php new file mode 100644 index 0000000..9615e99 --- /dev/null +++ b/database/migrations/2026_07_20_145246_create_interview_warnings_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('interview_id')->constrained('interviews')->onDelete('cascade'); + $table->text('message'); + $table->foreignId('sent_by')->nullable()->constrained('users')->onDelete('set null'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('interview_warnings'); + } +}; diff --git a/database/migrations/2026_07_20_153552_change_avatar_column_to_text_in_users_table.php b/database/migrations/2026_07_20_153552_change_avatar_column_to_text_in_users_table.php new file mode 100644 index 0000000..e720858 --- /dev/null +++ b/database/migrations/2026_07_20_153552_change_avatar_column_to_text_in_users_table.php @@ -0,0 +1,28 @@ +text('avatar')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('avatar', 255)->nullable()->change(); + }); + } +}; diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 6488c38..c457ecf 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -813,6 +813,9 @@