577 lines
21 KiB
PHP
577 lines
21 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]);
|
|
}
|
|
|
|
/**
|
|
* Send Real-Time Chat Message from Candidate to Interviewer/HR.
|
|
*/
|
|
public function candidateSendMessage(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' => null, // null sent_by indicates candidate sender
|
|
]);
|
|
|
|
return response()->json(['success' => true, 'warning' => $warning]);
|
|
}
|
|
|
|
/**
|
|
* Live Sync Candidate Code (before submission).
|
|
*/
|
|
public function syncCandidateCode(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$interview->update([
|
|
'submitted_code' => $request->input('code'),
|
|
'submitted_language' => $request->input('language', $interview->language),
|
|
]);
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
'call_status' => $interview->call_status ?? 'idle',
|
|
'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 ?? [],
|
|
'recording_path' => $interview->recording_path,
|
|
'recordings' => $interview->recordings ?? [],
|
|
'warnings' => $interview->warnings,
|
|
'is_expired' => $interview->isExpired(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Update Interview Call Session Status (active, idle, ended).
|
|
*/
|
|
public function updateCallStatus(Request $request, $id)
|
|
{
|
|
$interview = Interview::findOrFail($id);
|
|
$status = $request->input('call_status', 'idle');
|
|
$interview->update(['call_status' => $status]);
|
|
return response()->json(['success' => true, 'call_status' => $status]);
|
|
}
|
|
|
|
/**
|
|
* 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() . '.mp4';
|
|
$path = $file->storeAs('public/recordings', $filename);
|
|
$url = 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).
|
|
*/
|
|
public function generateReport($id)
|
|
{
|
|
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
|
|
|
|
$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 (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);
|
|
|
|
// Calculate Integrity Score %
|
|
if ($totalViolations === 0) {
|
|
$integrityScore = 100;
|
|
$integrityLabel = 'Exceptional Integrity';
|
|
$integrityColor = '#10b981';
|
|
} elseif ($totalViolations <= 2 && $lowerGazeViolations === 0 && $questionRepeatViolations === 0) {
|
|
$integrityScore = 85;
|
|
$integrityLabel = 'Low Behavioral Risk';
|
|
$integrityColor = '#06b6d4';
|
|
} elseif ($totalViolations <= 5) {
|
|
$integrityScore = 60;
|
|
$integrityLabel = 'Moderate Integrity Concern';
|
|
$integrityColor = '#f59e0b';
|
|
} else {
|
|
$integrityScore = 25;
|
|
$integrityLabel = 'High Malpractice Risk';
|
|
$integrityColor = '#ef4444';
|
|
}
|
|
|
|
// Technical Code Score
|
|
$codeScore = 0;
|
|
if (!empty($interview->submitted_code)) {
|
|
$codeScore += 50;
|
|
if (!empty($interview->code_output) && !str_contains(strtolower($interview->code_output), 'error')) {
|
|
$codeScore += 40;
|
|
} else {
|
|
$codeScore += 20;
|
|
}
|
|
if (!empty($interview->candidate_notes) || !empty($interview->candidate_drawing)) {
|
|
$codeScore += 10;
|
|
}
|
|
}
|
|
$codeScore = min(100, $codeScore);
|
|
|
|
return view('interview.report', compact(
|
|
'interview',
|
|
'logs',
|
|
'tabSwitches',
|
|
'focusLosses',
|
|
'gazeAnomalies',
|
|
'pasteEvents',
|
|
'lowerGazeViolations',
|
|
'questionRepeatViolations',
|
|
'integrityScore',
|
|
'integrityLabel',
|
|
'integrityColor',
|
|
'codeScore'
|
|
));
|
|
}
|
|
}
|