sls/app/Models/Interview.php
kushal.saha e4fabef1d3 feat(interview): add icalendar invites, white date pickers, and drawer validation feedback
- integrate spatie/icalendar-generator for candidate & panelist .ics invitations (RSVP & 15m alerts)
    - add StoreInterviewRequest, ScheduleInterviewDto, and invitation/reminder queued actions
    - add migration for job_title, round, description, and reminder_sent_at on interviews table
    - style datetime-local picker indicators to pure white on dark input backgrounds
    - support button loading spinners with :showLoader="true" and loadingText
    - auto-open schedule drawer on validation failure with inline @error alerts and old() bindings
2026-08-27 10:16:46 +00:00

195 lines
5.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Interview extends Model
{
use HasFactory;
protected $fillable = [
'candidate_name',
'candidate_email',
'candidate_phone',
'job_title',
'round',
'description',
'temp_password',
'scheduled_at',
'expires_at',
'reminder_sent_at',
'language',
'status',
'assigned_interviewers',
'proctor_logs',
'submitted_code',
'submitted_language',
'code_output',
'recording_path',
'recordings',
'submission_unique_id',
'candidate_notes',
'candidate_drawing',
'blocked_ip',
'created_by',
'call_status',
'call_started_by',
'call_started_at',
'enable_tab_switch_screenshot',
'tab_switch_screenshots',
'active_peers',
];
protected $casts = [
'scheduled_at' => 'datetime',
'expires_at' => 'datetime',
'reminder_sent_at' => 'datetime',
'call_started_at' => 'datetime',
'enable_tab_switch_screenshot' => 'boolean',
'assigned_interviewers' => 'array',
'proctor_logs' => 'array',
'recordings' => 'array',
'tab_switch_screenshots' => 'array',
'active_peers' => 'array',
];
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function callStarter()
{
return $this->belongsTo(User::class, 'call_started_by');
}
public function warnings()
{
return $this->hasMany(InterviewWarning::class, 'interview_id');
}
public function isExpired(): bool
{
return now()->greaterThan($this->expires_at);
}
/**
* Check if the interview call is active and unexpired.
*/
public function isCallActive(): bool
{
if ($this->isExpired() || in_array($this->status, ['completed', 'expired'])) {
return false;
}
return $this->call_status === 'active';
}
/**
* Check if the current time is within the interview timeline.
*/
public function isActiveTimeline(): bool
{
$now = now();
$start = $this->scheduled_at ?? $this->created_at;
if ($now->lessThan($start)) {
return false;
}
if ($now->greaterThan($this->expires_at)) {
return false;
}
return ! in_array($this->status, ['completed', 'expired']);
}
/**
* Check if a specific user is assigned as an interviewer.
*/
public function isAssignedInterviewer($userId): bool
{
if (empty($this->assigned_interviewers)) {
return false;
}
$interviewers = array_map('strval', (array) $this->assigned_interviewers);
return in_array((string) $userId, $interviewers, true);
}
/**
* Get computed initials for candidate (e.g. "John Doe" -> "JD").
*/
public function getCandidateInitialsAttribute(): string
{
$parts = preg_split('/\s+/', trim($this->candidate_name ?? ''));
if (count($parts) >= 2) {
return strtoupper(substr($parts[0], 0, 1).substr(end($parts), 0, 1));
}
$nameStr = trim($this->candidate_name ?? '');
return strtoupper(substr($nameStr, 0, 1).(strlen($nameStr) > 1 ? substr($nameStr, -1) : ''));
}
/**
* Get proctoring alert metrics summary.
*/
public function getProctorMetricsAttribute(): array
{
$logs = $this->proctor_logs ?? [];
return [
'tab_switches' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'tab_switch')),
'focus_lost' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'focus_lost')),
'gaze_alerts' => count(array_filter($logs, fn ($l) => in_array($l['type'] ?? '', ['gaze_anomaly', 'gaze_fixed_staring', 'looking_away']))),
'lower_gaze' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'gaze_lower_device')),
'question_repeat' => count(array_filter($logs, fn ($l) => in_array($l['type'] ?? '', ['question_repeat_lower', 'question_repetition']))),
'external_ai' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'external_ai_detected')),
'paste_events' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'paste_event')),
'total_incidents' => count($logs),
];
}
/**
* Format timeline string: "M d, H:i H:i".
*/
public function getFormattedTimelineAttribute(): string
{
$start = $this->scheduled_at ?? $this->created_at;
return $start->format('M d, H:i').' '.$this->expires_at->format('H:i');
}
/**
* Get the standardized iCalendar event title / name.
* Pattern: Interview Invitation_{Round}_{Candidate First & Last Name}_{Job Title}_{Date}_{Time}
* Example: Interview Invitation_R1_Soumyadeep Mondal_AIML (Computer Vision & Agentic AI) Engineer_11/8/2026_1700
*/
public function getEventTitleAttribute(): string
{
$round = ! empty($this->round) ? $this->round : 'R1';
$candidateName = ! empty($this->candidate_name) ? $this->candidate_name : 'Candidate';
$jobTitle = ! empty($this->job_title) ? $this->job_title : 'Candidate Assessment';
$scheduled = $this->scheduled_at ?? $this->created_at ?? now();
$dateStr = $scheduled->format('j/n/Y');
$timeStr = $scheduled->format('Hi');
return "Interview Invitation_{$round}_{$candidateName}_{$jobTitle}_{$dateStr}_{$timeStr}";
}
/**
* Get the User models corresponding to the assigned interviewers.
*/
public function assignedUsers()
{
if (empty($this->assigned_interviewers)) {
return collect();
}
return User::whereIn('id', (array) $this->assigned_interviewers)->get();
}
}