interview-feedback-enhancements and convexsol turn server #23

Merged
subhajit.pal merged 4 commits from feat/interview-feedback-enhancements into main 2026-08-28 05:37:53 +00:00
17 changed files with 493 additions and 20 deletions
Showing only changes of commit 1541c8193e - Show all commits

View File

@ -27,7 +27,14 @@ public function execute(ScheduleInterviewDto $dto, ?int $creatorId = null): Inte
$scheduledAt = $dto->scheduledAt ? Carbon::parse($dto->scheduledAt) : now(); $scheduledAt = $dto->scheduledAt ? Carbon::parse($dto->scheduledAt) : now();
$expiresAt = $scheduledAt->copy()->addHours($dto->validHours); $expiresAt = $scheduledAt->copy()->addHours($dto->validHours);
$interview = DB::transaction(function () use ($dto, $tempPassword, $submissionUniqueId, $scheduledAt, $expiresAt, $creatorId) { $resumePath = null;
if ($dto->resume) {
$resumePath = $dto->resume->store('interview_resumes', 'public');
} elseif ($dto->resumePath) {
$resumePath = $dto->resumePath;
}
$interview = DB::transaction(function () use ($dto, $tempPassword, $submissionUniqueId, $scheduledAt, $expiresAt, $creatorId, $resumePath) {
return Interview::create([ return Interview::create([
'candidate_name' => $dto->candidateName, 'candidate_name' => $dto->candidateName,
'candidate_email' => $dto->candidateEmail, 'candidate_email' => $dto->candidateEmail,
@ -35,6 +42,7 @@ public function execute(ScheduleInterviewDto $dto, ?int $creatorId = null): Inte
'job_title' => $dto->jobTitle, 'job_title' => $dto->jobTitle,
'round' => $dto->round, 'round' => $dto->round,
'description' => $dto->description, 'description' => $dto->description,
'resume_path' => $resumePath,
'temp_password' => $tempPassword, 'temp_password' => $tempPassword,
'scheduled_at' => $scheduledAt, 'scheduled_at' => $scheduledAt,
'expires_at' => $expiresAt, 'expires_at' => $expiresAt,

View File

@ -5,6 +5,7 @@
use App\Http\Requests\Interview\StoreInterviewRequest; use App\Http\Requests\Interview\StoreInterviewRequest;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\UploadedFile;
use JsonSerializable; use JsonSerializable;
class ScheduleInterviewDto implements Arrayable, JsonSerializable class ScheduleInterviewDto implements Arrayable, JsonSerializable
@ -23,6 +24,8 @@ public function __construct(
public readonly string $language, public readonly string $language,
public readonly array $assignedInterviewers = [], public readonly array $assignedInterviewers = [],
public readonly ?string $description = null, public readonly ?string $description = null,
public readonly ?UploadedFile $resume = null,
public readonly ?string $resumePath = null,
) {} ) {}
public static function fromRequest(StoreInterviewRequest $request): self public static function fromRequest(StoreInterviewRequest $request): self
@ -42,6 +45,7 @@ public static function fromRequest(StoreInterviewRequest $request): self
language: $request->input('language', 'python'), language: $request->input('language', 'python'),
assignedInterviewers: array_map('intval', (array) $request->input('assigned_interviewers', [])), assignedInterviewers: array_map('intval', (array) $request->input('assigned_interviewers', [])),
description: $request->filled('description') ? trim($request->input('description')) : null, description: $request->filled('description') ? trim($request->input('description')) : null,
resume: $request->file('resume'),
); );
} }

View File

@ -1230,4 +1230,25 @@ public function generateReport($id)
'codeScore' 'codeScore'
)); ));
} }
/**
* Serve / stream candidate resume file.
*/
public function viewResume($id)
{
$interview = Interview::findOrFail($id);
if (! $interview->resume_path || ! Storage::disk('public')->exists($interview->resume_path)) {
abort(404, 'Candidate resume file not found.');
}
$filePath = Storage::disk('public')->path($interview->resume_path);
$mimeType = Storage::disk('public')->mimeType($interview->resume_path) ?: 'application/pdf';
return response()->file($filePath, [
'Content-Type' => $mimeType,
'Content-Disposition' => 'inline; filename="'.basename($filePath).'"',
'Cache-Control' => 'no-cache, private',
]);
}
} }

View File

@ -32,6 +32,7 @@ public function rules(): array
'scheduled_at' => ['nullable', 'date'], 'scheduled_at' => ['nullable', 'date'],
'valid_hours' => ['required', 'integer', 'min:1', 'max:72'], 'valid_hours' => ['required', 'integer', 'min:1', 'max:72'],
'language' => ['required', 'string', 'in:python,cpp,c,java,php,javascript'], 'language' => ['required', 'string', 'in:python,cpp,c,java,php,javascript'],
'resume' => ['nullable', 'file', 'mimes:pdf,doc,docx', 'max:10240'],
'assigned_interviewers' => ['nullable', 'array'], 'assigned_interviewers' => ['nullable', 'array'],
'assigned_interviewers.*' => ['integer', 'exists:users,id'], 'assigned_interviewers.*' => ['integer', 'exists:users,id'],
]; ];
@ -53,6 +54,8 @@ public function messages(): array
'valid_hours.required' => 'Access duration is required.', 'valid_hours.required' => 'Access duration is required.',
'language.required' => 'Coding language selection is required.', 'language.required' => 'Coding language selection is required.',
'language.in' => 'Selected coding language is not supported.', 'language.in' => 'Selected coding language is not supported.',
'resume.mimes' => 'The candidate resume must be a PDF or Word document (.pdf, .doc, .docx).',
'resume.max' => 'The candidate resume size must not exceed 10MB.',
'assigned_interviewers.*.exists' => 'One or more selected interviewers are invalid.', 'assigned_interviewers.*.exists' => 'One or more selected interviewers are invalid.',
]; ];
} }

View File

@ -11,6 +11,8 @@
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope; use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class InterviewerAssessmentNotificationMail extends Mailable implements ShouldQueue class InterviewerAssessmentNotificationMail extends Mailable implements ShouldQueue
{ {
@ -58,9 +60,21 @@ public function content(): Content
*/ */
public function attachments(): array public function attachments(): array
{ {
return [ $attachments = [
Attachment::fromData(fn () => $this->icsContent, $this->icsFilename) Attachment::fromData(fn () => $this->icsContent, $this->icsFilename)
->withMime('text/calendar; charset=UTF-8; method=REQUEST'), ->withMime('text/calendar; charset=UTF-8; method=REQUEST'),
]; ];
if (! empty($this->interview->resume_path) && Storage::disk('public')->exists($this->interview->resume_path)) {
$resumeFullPath = Storage::disk('public')->path($this->interview->resume_path);
$cleanCandidateName = Str::slug($this->interview->candidate_name ?: 'Candidate');
$ext = pathinfo($this->interview->resume_path, PATHINFO_EXTENSION) ?: 'pdf';
$downloadFilename = "Resume_{$cleanCandidateName}.{$ext}";
$attachments[] = Attachment::fromPath($resumeFullPath)
->as($downloadFilename);
}
return $attachments;
} }
} }

View File

@ -16,6 +16,7 @@ class Interview extends Model
'job_title', 'job_title',
'round', 'round',
'description', 'description',
'resume_path',
'temp_password', 'temp_password',
'scheduled_at', 'scheduled_at',
'expires_at', 'expires_at',
@ -191,4 +192,16 @@ public function assignedUsers()
return User::whereIn('id', (array) $this->assigned_interviewers)->get(); return User::whereIn('id', (array) $this->assigned_interviewers)->get();
} }
/**
* Get the URL to view the candidate's resume.
*/
public function getResumeUrlAttribute(): ?string
{
if (! $this->resume_path) {
return null;
}
return route('interview.resume', $this->id);
}
} }

View File

@ -89,3 +89,101 @@ window.copyInterviewCredentials = function (btn) {
}); });
}; };
// Candidate Resume Lightbox Previewer
window.openResumeLightbox = function (url, title = 'Candidate Resume') {
if (!url) return;
const modal = document.getElementById('resume-lightbox-modal');
const frame = document.getElementById('resume-lightbox-frame');
const loader = document.getElementById('resume-lightbox-loader');
const titleEl = document.getElementById('resume-lightbox-title');
const newTabBtn = document.getElementById('resume-lightbox-newtab');
if (!modal || !frame) return;
if (titleEl) titleEl.textContent = title;
if (newTabBtn) newTabBtn.href = url;
if (loader) loader.classList.remove('hidden');
frame.classList.add('opacity-0');
frame.onload = function () {
if (loader) loader.classList.add('hidden');
frame.classList.remove('opacity-0');
};
frame.src = url;
modal.classList.add('active');
// Disable background page scrolling
if (typeof document !== 'undefined') {
document.body.classList.add('overflow-hidden');
document.documentElement.classList.add('overflow-hidden');
}
};
window.closeResumeLightbox = function (e = null) {
if (e && e.stopPropagation) {
e.stopPropagation();
}
const modal = document.getElementById('resume-lightbox-modal');
const frame = document.getElementById('resume-lightbox-frame');
if (modal) modal.classList.remove('active');
if (frame) frame.src = 'about:blank';
// Restore background page scrolling
if (typeof document !== 'undefined') {
document.body.classList.remove('overflow-hidden');
document.documentElement.classList.remove('overflow-hidden');
}
};
// Close on Escape key
if (typeof document !== 'undefined') {
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
window.closeResumeLightbox();
}
});
}
// Drawer Candidate Resume Upload Handlers
window.handleDrawerResumeSelect = function (input) {
if (!input || !input.files || !input.files[0]) return;
const file = input.files[0];
const dropzone = document.getElementById('drawer-resume-dropzone');
const selectedBadge = document.getElementById('drawer-resume-selected');
const filenameEl = document.getElementById('drawer-resume-filename');
const filesizeEl = document.getElementById('drawer-resume-filesize');
if (filenameEl) filenameEl.textContent = file.name;
if (filesizeEl) {
const sizeKb = file.size / 1024;
filesizeEl.textContent = sizeKb >= 1024
? (sizeKb / 1024).toFixed(2) + ' MB'
: sizeKb.toFixed(1) + ' KB';
}
if (dropzone) dropzone.classList.add('hidden');
if (selectedBadge) selectedBadge.classList.remove('hidden');
};
window.previewSelectedDrawerResume = function () {
const input = document.getElementById('drawer_resume_input');
if (!input || !input.files || !input.files[0]) return;
const file = input.files[0];
const objectUrl = URL.createObjectURL(file);
window.openResumeLightbox(objectUrl, `${file.name} (Uploaded Preview)`);
};
window.clearSelectedDrawerResume = function () {
const input = document.getElementById('drawer_resume_input');
const dropzone = document.getElementById('drawer-resume-dropzone');
const selectedBadge = document.getElementById('drawer-resume-selected');
if (input) input.value = '';
if (selectedBadge) selectedBadge.classList.add('hidden');
if (dropzone) dropzone.classList.remove('hidden');
};

View File

@ -379,7 +379,7 @@ export function closeInspectorPanel() {
const details = document.getElementById('inspector-details'); const details = document.getElementById('inspector-details');
if (details) details.open = false; if (details) details.open = false;
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots']; const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots', 'resume'];
tabs.forEach(t => { tabs.forEach(t => {
const contentEl = document.getElementById(`tab-content-${t}`); const contentEl = document.getElementById(`tab-content-${t}`);
const btnEl = document.getElementById(`tab-btn-${t}`); const btnEl = document.getElementById(`tab-btn-${t}`);
@ -389,6 +389,11 @@ export function closeInspectorPanel() {
btnEl.classList.add('text-slate-400', 'hover:bg-white/5'); btnEl.classList.add('text-slate-400', 'hover:bg-white/5');
} }
}); });
const expandResumeBtn = document.getElementById('inspector-expand-resume-btn');
if (expandResumeBtn) {
expandResumeBtn.style.display = 'none';
}
} }
export function switchIdeTab(tab, event = null) { export function switchIdeTab(tab, event = null) {
@ -413,24 +418,30 @@ export function switchIdeTab(tab, event = null) {
if (details) details.open = true; if (details) details.open = true;
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots']; const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots', 'resume'];
const tabMeta = { const tabMeta = {
logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' }, logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' },
notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' }, notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' },
drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' }, drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' },
recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' }, recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' },
screenshots: { title: 'Tab Switch Screenshots', icon: 'fa-solid fa-image' }, screenshots: { title: 'Tab Switch Screenshots', icon: 'fa-solid fa-image' },
code: { title: 'Candidate Code', icon: 'fa-solid fa-code' } code: { title: 'Candidate Code', icon: 'fa-solid fa-code' },
resume: { title: 'Candidate Resume (CV)', icon: 'fa-solid fa-file-lines' }
}; };
const floatingTitle = document.getElementById('inspector-floating-text'); const floatingTitle = document.getElementById('inspector-floating-text');
const floatingIcon = document.getElementById('inspector-floating-icon'); const floatingIcon = document.getElementById('inspector-floating-icon');
const expandResumeBtn = document.getElementById('inspector-expand-resume-btn');
if (tabMeta[tab]) { if (tabMeta[tab]) {
if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title; if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title;
if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`; if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`;
} }
if (expandResumeBtn) {
expandResumeBtn.style.display = (tab === 'resume') ? 'inline-flex' : 'none';
}
tabs.forEach(t => { tabs.forEach(t => {
const contentEl = document.getElementById(`tab-content-${t}`); const contentEl = document.getElementById(`tab-content-${t}`);
const btnEl = document.getElementById(`tab-btn-${t}`); const btnEl = document.getElementById(`tab-btn-${t}`);
@ -454,6 +465,15 @@ if (typeof document !== 'undefined') {
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const details = document.getElementById('inspector-details'); const details = document.getElementById('inspector-details');
if (details && details.open && !details.contains(e.target)) { if (details && details.open && !details.contains(e.target)) {
// If click was inside or on the resume lightbox modal or any other modal dialog, do NOT close inspector panel
const lightbox = document.getElementById('resume-lightbox-modal');
if (lightbox && (lightbox.contains(e.target) || lightbox === e.target)) {
return;
}
const anyModal = e.target.closest('.admin-modal, [id*="modal"]');
if (anyModal) {
return;
}
closeInspectorPanel(); closeInspectorPanel();
} }
}); });

View File

@ -0,0 +1,7 @@
@props([
'class' => 'w-3.5 h-3.5',
])
<svg {{ $attributes->merge(['class' => $class]) }} viewBox="0 0 43.916 43.916" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M34.395,0H9.522c-2.762,0-5,2.239-5,5v33.916c0,2.761,2.238,5,5,5h24.871c2.762,0,5-2.239,5-5V5 C39.395,2.239,37.154,0,34.395,0z M9.208,16.855c0-1.172,0.951-2.121,2.121-2.121h0.742c-0.791-0.874-1.277-2.03-1.277-3.304 c0-2.723,2.209-4.931,4.932-4.931c2.725,0,4.932,2.207,4.932,4.932c0,1.272-0.486,2.429-1.279,3.303h0.709 c1.172,0,2.121,0.949,2.121,2.121v3.578c0,1.122-0.875,2.03-1.975,2.106h-9.051c-1.1-0.076-1.975-0.984-1.975-2.106V16.855 L9.208,16.855z M32.708,37.416h-21.5c-1.104,0-2-0.896-2-2s0.896-2,2-2h21.5c1.104,0,2,0.896,2,2S33.812,37.416,32.708,37.416z M32.708,29.916h-21.5c-1.104,0-2-0.896-2-2s0.896-2,2-2h21.5c1.104,0,2,0.896,2,2S33.812,29.916,32.708,29.916z M32.708,22.416 h-6.5c-1.104,0-2-0.896-2-2c0-1.104,0.896-2,2-2h6.5c1.104,0,2,0.896,2,2C34.708,21.52,33.812,22.416,32.708,22.416z"/>
</svg>

View File

@ -58,6 +58,15 @@ class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold
> >
<i class="fa-solid fa-image"></i> <i class="fa-solid fa-image"></i>
</button> </button>
<button
type="button"
id="tab-btn-resume"
title="Candidate CV / Resume"
onclick="switchIdeTab('resume', event)"
class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold transition-all cursor-pointer text-slate-400 hover:text-white hover:bg-white/5"
>
<x-icons.cv class="w-3.5 h-3.5" />
</button>
</div> </div>
</div> </div>
</summary> </summary>
@ -70,6 +79,16 @@ class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold
<i id="inspector-floating-icon" class="fa-solid fa-shield-halved text-indigo-400"></i> <i id="inspector-floating-icon" class="fa-solid fa-shield-halved text-indigo-400"></i>
<span id="inspector-floating-text">Proctoring Logs</span> <span id="inspector-floating-text">Proctoring Logs</span>
</div> </div>
<div class="flex items-center gap-1.5">
<button
type="button"
id="inspector-expand-resume-btn"
title="View Resume in Large Dialog"
onclick="openResumeLightbox('{{ $interview->resume_url }}', '{{ addslashes($interview->candidate_name) }} - Resume')"
class="hidden w-6 h-6 rounded-md bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors"
>
<i class="fa-solid fa-eye text-[11px]"></i>
</button>
<button <button
type="button" type="button"
id="inspector-close-btn" id="inspector-close-btn"
@ -80,6 +99,7 @@ class="w-6 h-6 rounded-md bg-white/5 hover:bg-white/15 text-slate-400 hover:text
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</button> </button>
</div> </div>
</div>
<!-- Floating Tab Content Panes --> <!-- Floating Tab Content Panes -->
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
@ -90,6 +110,7 @@ class="w-6 h-6 rounded-md bg-white/5 hover:bg-white/15 text-slate-400 hover:text
<x-interview.tabs.drawing-tab /> <x-interview.tabs.drawing-tab />
<x-interview.tabs.recordings-tab /> <x-interview.tabs.recordings-tab />
<x-interview.tabs.screenshots-tab /> <x-interview.tabs.screenshots-tab />
<x-interview.tabs.resume-tab :interview="$interview" />
</div> </div>
</div> </div>
</details> </details>

View File

@ -0,0 +1,65 @@
<div
id="resume-lightbox-modal"
class="admin-modal fixed inset-0 w-screen h-screen bg-slate-950/85 backdrop-blur-md flex items-center justify-center z-[1050] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto p-4 sm:p-6"
>
<!-- Background Backdrop -->
<div class="fixed inset-0" onclick="closeResumeLightbox(event)"></div>
<!-- Modal Content Card -->
<div class="admin-modal-content relative z-10 bg-slate-900 border border-slate-700/70 rounded-2xl w-full max-w-5xl h-[92vh] max-h-[92vh] p-4 sm:p-5 shadow-2xl text-white flex flex-col overflow-hidden">
<!-- Lightbox Header -->
<div class="flex items-center justify-between pb-3 mb-3 border-b border-slate-700/60 shrink-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400 shrink-0">
<x-icons.cv class="w-4 h-4 text-indigo-400" />
</div>
<div class="min-w-0">
<h3 id="resume-lightbox-title" class="font-outfit text-base font-bold text-white m-0 truncate">Candidate Resume (CV)</h3>
<div class="text-[11px] text-slate-400">PDF & Document Lightbox Previewer</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Open in New Tab / Download -->
<a
id="resume-lightbox-newtab"
href="#"
target="_blank"
download
title="Open in new tab / Download"
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/5 border border-slate-700/60 text-slate-300 hover:text-white hover:bg-white/10 text-xs font-medium transition-colors"
>
<i class="fa-solid fa-arrow-up-right-from-square text-[11px]"></i>
<span class="hidden sm:inline">Open in Tab</span>
</a>
<!-- Close Lightbox Button -->
<button
type="button"
onclick="closeResumeLightbox(event)"
title="Close preview"
class="w-8 h-8 rounded-lg bg-slate-800 border border-slate-700/60 text-slate-400 hover:text-white hover:bg-slate-700 transition-colors flex items-center justify-center cursor-pointer text-sm"
>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<!-- Lightbox Preview Body -->
<div class="relative flex-1 w-full h-full bg-slate-950/80 rounded-xl overflow-hidden flex items-center justify-center border border-slate-800">
<!-- Spinner / Loader -->
<div id="resume-lightbox-loader" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-slate-950/90 z-10 transition-opacity">
<i class="fa-solid fa-circle-notch fa-spin text-indigo-400 text-3xl"></i>
<span class="text-xs text-slate-400 font-medium">Loading resume preview&hellip;</span>
</div>
<!-- Embedded Document Frame -->
<iframe
id="resume-lightbox-frame"
src="about:blank"
class="w-full h-full border-0 rounded-xl opacity-0 transition-opacity duration-300"
title="Resume Preview"
></iframe>
</div>
</div>
</div>

View File

@ -13,6 +13,7 @@
$errorBag->has('scheduled_at') || $errorBag->has('scheduled_at') ||
$errorBag->has('valid_hours') || $errorBag->has('valid_hours') ||
$errorBag->has('language') || $errorBag->has('language') ||
$errorBag->has('resume') ||
$errorBag->has('assigned_interviewers') || $errorBag->has('assigned_interviewers') ||
$errorBag->has('assigned_interviewers.*') || $errorBag->has('assigned_interviewers.*') ||
$errorBag->has('description') $errorBag->has('description')
@ -26,7 +27,7 @@
width="lg" width="lg"
class="{{ $hasInterviewErrors ? 'active' : '' }}" class="{{ $hasInterviewErrors ? 'active' : '' }}"
> >
<form action="{{ route('interview.store') }}" method="POST"> <form action="{{ route('interview.store') }}" method="POST" enctype="multipart/form-data">
@csrf @csrf
@if($hasInterviewErrors) @if($hasInterviewErrors)
@ -140,6 +141,80 @@ class="{{ $hasInterviewErrors ? 'active' : '' }}"
</div> </div>
</div> </div>
<div>
<x-label>Candidate Resume (PDF, DOCX)</x-label>
<div class="space-y-2">
<!-- Dropzone / File Picker Container -->
<div
id="drawer-resume-dropzone"
onclick="document.getElementById('drawer_resume_input').click()"
class="border-2 border-dashed @if($errorBag->has('resume')) border-rose-500/80 bg-rose-500/5 @else border-slate-700/80 hover:border-indigo-500/50 bg-slate-900/60 hover:bg-slate-900 @endif rounded-xl p-3 text-center cursor-pointer transition-all flex flex-col items-center justify-center gap-1"
>
<div class="w-7 h-7 rounded-full bg-indigo-500/10 text-indigo-400 flex items-center justify-center text-xs">
<i class="fa-solid fa-cloud-arrow-up"></i>
</div>
<div class="text-xs text-slate-300 font-medium">
<span class="text-indigo-400 font-semibold">Click to upload</span> or drag and drop
</div>
<p class="text-[11px] text-slate-500 m-0">PDF, DOC, DOCX (Max 10MB)</p>
</div>
<input
type="file"
id="drawer_resume_input"
name="resume"
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
class="hidden"
onchange="handleDrawerResumeSelect(this)"
/>
<!-- Selected File Info & Preview Badge -->
<div
id="drawer-resume-selected"
class="hidden bg-slate-900 border border-slate-700/80 rounded-xl p-2.5 flex items-center justify-between gap-2"
>
<div class="flex items-center gap-2.5 min-w-0">
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 text-indigo-400 flex items-center justify-center shrink-0">
<x-icons.cv class="w-4 h-4 text-indigo-400" />
</div>
<div class="min-w-0">
<p id="drawer-resume-filename" class="text-xs font-semibold text-white truncate m-0">resume.pdf</p>
<span id="drawer-resume-filesize" class="text-[10.5px] text-slate-400 font-mono">0 KB</span>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<!-- Eye icon preview button -->
<button
type="button"
id="drawer-resume-preview-btn"
onclick="previewSelectedDrawerResume()"
title="Preview Resume"
class="w-7 h-7 rounded-md bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 hover:text-white flex items-center justify-center text-xs transition-colors cursor-pointer"
>
<i class="fa-solid fa-eye text-[11px]"></i>
</button>
<!-- Remove file button -->
<button
type="button"
onclick="clearSelectedDrawerResume()"
title="Remove file"
class="w-7 h-7 rounded-md bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 flex items-center justify-center text-xs transition-colors cursor-pointer"
>
<i class="fa-solid fa-trash text-[11px]"></i>
</button>
</div>
</div>
@if($errorBag->has('resume'))
<p class="text-[11px] text-rose-400 mt-1 font-medium flex items-center gap-1">
<i class="fa-solid fa-circle-exclamation text-[10px]"></i> {{ $errorBag->first('resume') }}
</p>
@endif
</div>
</div>
<div> <div>
<x-label>Additional Instructions to candidate</x-label> <x-label>Additional Instructions to candidate</x-label>
<textarea name="description" rows="2" class="w-full bg-slate-900 border @if($errorBag->has('description')) border-rose-500/80 focus:border-rose-500 @else border-slate-700/60 focus:border-indigo-500 @endif rounded-xl p-3 text-xs text-white placeholder-slate-500 focus:outline-none transition-colors" placeholder="Special requirements, interview topics, or instructions...">{{ old('description') }}</textarea> <textarea name="description" rows="2" class="w-full bg-slate-900 border @if($errorBag->has('description')) border-rose-500/80 focus:border-rose-500 @else border-slate-700/60 focus:border-indigo-500 @endif rounded-xl p-3 text-xs text-white placeholder-slate-500 focus:outline-none transition-colors" placeholder="Special requirements, interview topics, or instructions...">{{ old('description') }}</textarea>

View File

@ -0,0 +1,24 @@
@props([
'interview',
])
<div id="tab-content-resume" class="hidden space-y-2.5">
@if(!empty($interview->resume_path))
<div class="relative rounded-xl border border-slate-800 overflow-hidden bg-slate-950/80">
<iframe
src="{{ $interview->resume_url }}"
class="w-full h-80 border-0 rounded-xl"
title="Candidate Resume Preview"
></iframe>
</div>
@else
<div class="p-8 text-center bg-slate-950/50 border border-slate-800/80 rounded-xl">
<div class="w-10 h-10 mx-auto rounded-full bg-slate-800/60 flex items-center justify-center text-slate-500 mb-2.5">
<x-icons.cv class="w-5 h-5 text-slate-500" />
</div>
<p class="text-xs font-semibold text-slate-300 m-0">No Resume Attached</p>
<p class="text-[11px] text-slate-500 mt-1 mb-0">No resume document was uploaded when scheduling this assessment.</p>
</div>
@endif
</div>

View File

@ -44,6 +44,9 @@
<!-- Global shared Incoming Call Modal --> <!-- Global shared Incoming Call Modal -->
<x-interview.incoming-call-modal /> <x-interview.incoming-call-modal />
<!-- Global shared Candidate Resume Lightbox Modal -->
<x-interview.resume-lightbox-modal />
<script> <script>
window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }}; window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }};
window.currentUserId = {{ Auth::id() ?? 'null' }}; window.currentUserId = {{ Auth::id() ?? 'null' }};

View File

@ -155,6 +155,12 @@
<span class="card-label">Submission ID</span> <span class="card-label">Submission ID</span>
<span class="card-value" style="font-family: monospace; font-size: 11px;">{{ $interview->submission_unique_id }}</span> <span class="card-value" style="font-family: monospace; font-size: 11px;">{{ $interview->submission_unique_id }}</span>
</div> </div>
@if(!empty($interview->resume_path))
<div class="card-row">
<span class="card-label">Candidate Resume</span>
<span class="card-value" style="color: #a5b4fc;">&#128206; Attached to Email</span>
</div>
@endif
</div> </div>
@if(!empty($interview->description)) @if(!empty($interview->description))

View File

@ -2,10 +2,10 @@
use App\Http\Controllers\AdminController; use App\Http\Controllers\AdminController;
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
use App\Http\Controllers\IceServerController;
use App\Http\Controllers\InterviewController; use App\Http\Controllers\InterviewController;
use App\Http\Controllers\LoginController; use App\Http\Controllers\LoginController;
use App\Http\Controllers\OnboardingController; use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\IceServerController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// Authentication & Portal landing // Authentication & Portal landing
@ -47,7 +47,6 @@
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file'); Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
Route::get('/ice-servers', IceServerController::class)->name('ice-servers'); Route::get('/ice-servers', IceServerController::class)->name('ice-servers');
// User Dashboard Portal (requires authenticated user) // User Dashboard Portal (requires authenticated user)
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () { Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
@ -83,12 +82,12 @@
Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password'); Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password');
Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate'); Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report'); Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');
Route::get('/interviews/{id}/resume', [InterviewController::class, 'viewResume'])->name('interview.resume');
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording'); Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status'); Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot'); Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
}); });
// Admin Control Panel (requires admin role) // Admin Control Panel (requires admin role)
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () { Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session'); Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session');

View File

@ -8,7 +8,9 @@
use App\Models\Interview; use App\Models\Interview;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase; use Tests\TestCase;
class ScheduleInterviewWithInvitationsTest extends TestCase class ScheduleInterviewWithInvitationsTest extends TestCase
@ -166,4 +168,94 @@ public function test_scheduling_validation_errors_render_open_drawer_and_error_d
$response->assertSee('The job title / position is required.'); $response->assertSee('The job title / position is required.');
$response->assertSee('Test instructions'); $response->assertSee('Test instructions');
} }
public function test_scheduling_interview_with_resume_file_stores_path_and_serves_via_route(): void
{
Storage::fake('public');
Mail::fake();
$hr = User::create([
'name' => 'HR Manager',
'email' => 'hr@company.com',
'role' => 'hr',
]);
$interviewer = User::create([
'name' => 'Tech Panelist',
'email' => 'techpanelist@company.com',
'role' => 'user',
]);
$file = UploadedFile::fake()->create('resume.pdf', 500, 'application/pdf');
$response = $this->actingAs($hr)->post(route('interview.store'), [
'candidate_name' => 'Kushal Candidate',
'candidate_email' => 'kushal@example.com',
'candidate_phone' => '9988776655',
'job_title' => 'Lead AI Engineer',
'round' => 'Technical',
'valid_hours' => 2,
'language' => 'python',
'assigned_interviewers' => [$interviewer->id],
'resume' => $file,
]);
$response->assertRedirect(route('interview.index'));
$interview = Interview::where('candidate_email', 'kushal@example.com')->first();
$this->assertNotNull($interview);
$this->assertNotNull($interview->resume_path);
Storage::disk('public')->assertExists($interview->resume_path);
// Test view / download route
$viewResponse = $this->actingAs($hr)->get(route('interview.resume', $interview->id));
$viewResponse->assertOk();
$viewResponse->assertHeader('Content-Disposition', 'inline; filename="'.basename($interview->resume_path).'"');
// Test interviewer mail attachments include the resume
Mail::assertQueued(InterviewerAssessmentNotificationMail::class, function ($mail) {
$attachments = $mail->attachments();
$hasIcs = false;
$hasResume = false;
foreach ($attachments as $att) {
if (str_ends_with($att->as ?? '', '.ics') || str_contains($att->mime ?? '', 'text/calendar')) {
$hasIcs = true;
}
if (str_contains($att->as ?? '', 'Resume_kushal-candidate.pdf')) {
$hasResume = true;
}
}
return $mail->hasTo('techpanelist@company.com') && $hasIcs && $hasResume;
});
}
public function test_scheduling_interview_rejects_invalid_resume_mime_type(): void
{
$this->withMiddleware();
$hr = User::create([
'name' => 'HR Manager',
'email' => 'hr@company.com',
'role' => 'hr',
]);
$invalidFile = UploadedFile::fake()->create('malicious.exe', 500, 'application/octet-stream');
$response = $this->actingAs($hr)
->from(route('interview.index'))
->followingRedirects()
->post(route('interview.store'), [
'candidate_name' => 'Invalid File Candidate',
'candidate_email' => 'invalidfile@example.com',
'candidate_phone' => '9988776655',
'job_title' => 'Developer',
'valid_hours' => 2,
'language' => 'python',
'resume' => $invalidFile,
]);
$response->assertOk();
$response->assertSee('The candidate resume must be a PDF or Word document (.pdf, .doc, .docx).');
}
} }