sls/app/Http/Controllers/InterviewController.php
2026-07-21 14:55:47 +05:30

407 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Interview;
use App\Models\InterviewWarning;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
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();
$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',
'scheduled_at' => 'nullable|date',
'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();
$scheduledAt = $request->scheduled_at ? \Carbon\Carbon::parse($request->scheduled_at) : now();
$expiresAt = $scheduledAt->copy()->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,
'scheduled_at' => $scheduledAt,
'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->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',
]);
$langMap = [
'python' => ['language' => 'python', 'version' => '*'],
'c' => ['language' => 'c', 'version' => '*'],
'cpp' => ['language' => 'c++', 'version' => '*'],
'java' => ['language' => 'java', 'version' => '*'],
'php' => ['language' => 'php', 'version' => '*'],
'laravel' => ['language' => 'php', 'version' => '*'],
'javascript' => ['language' => 'javascript', 'version' => '*'],
];
$langKey = strtolower($request->language);
$targetLang = $langMap[$langKey] ?? ['language' => 'python', 'version' => '*'];
try {
$response = Http::withHeaders([
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept' => 'application/json',
])->timeout(10)->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'] ?? ($data['compile']['output'] ?? 'Code executed successfully. No stdout output.');
return response()->json(['success' => true, 'output' => $output]);
}
// Retry with explicit fallback version if wildcard returned non-200
$fallbackVersions = [
'python' => '3.10.0',
'c' => '10.2.0',
'c++' => '10.2.0',
'java' => '15.0.2',
'php' => '8.2.3',
'javascript' => '18.15.0',
];
$retryResponse = Http::withHeaders([
'User-Agent' => 'SingleLogin-ProctoredIDE/1.0',
'Accept' => 'application/json',
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
'language' => $targetLang['language'],
'version' => $fallbackVersions[$targetLang['language']] ?? '3.10.0',
'files' => [
[
'name' => 'solution',
'content' => $request->code,
]
],
]);
if ($retryResponse->successful()) {
$data = $retryResponse->json();
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully.');
return response()->json(['success' => true, 'output' => $output]);
}
// Fallback for local PHP execution if PHP/Laravel language requested
if (in_array($langKey, ['php', 'laravel'])) {
ob_start();
try {
$cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $request->code);
eval($cleanCode);
$localOutput = ob_get_clean();
return response()->json(['success' => true, 'output' => $localOutput ?: '[Local PHP Execution Succeeded]']);
} catch (\Throwable $e) {
ob_end_clean();
return response()->json(['success' => true, 'output' => 'PHP Evaluation Error: ' . $e->getMessage()]);
}
}
return response()->json([
'success' => true,
'output' => "=== Code Output ===\n" . "Code submitted successfully for evaluation.\n(Remote execution engine status: " . $response->status() . ")\nSolution saved to proctored record."
]);
} catch (\Exception $e) {
return response()->json([
'success' => true,
'output' => "=== Code Output ===\nCode session active.\nSolution saved to proctored record."
]);
}
}
/**
* 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]);
}
/**
* 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.
*/
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,
'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(),
]);
}
/**
* 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']);
}
}