283 lines
9.6 KiB
PHP
283 lines
9.6 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();
|
|
|
|
// Must be Admin, HR, or assigned interviewer
|
|
$interviews = Interview::with(['creator', 'warnings.sender'])
|
|
->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']);
|
|
}
|
|
}
|