- 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
153 lines
5.7 KiB
PHP
153 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Interview;
|
|
|
|
use App\Models\Interview;
|
|
use Carbon\Carbon;
|
|
use Spatie\IcalendarGenerator\Components\Calendar;
|
|
use Spatie\IcalendarGenerator\Components\Event;
|
|
use Spatie\IcalendarGenerator\Enums\EventStatus;
|
|
use Spatie\IcalendarGenerator\Enums\ParticipationStatus;
|
|
use Spatie\IcalendarGenerator\Properties\TextProperty;
|
|
|
|
class InterviewCalendarService
|
|
{
|
|
/**
|
|
* Generate an RFC 5545 / RFC 5546 compliant iCalendar (.ics) string for an interview assessment.
|
|
*/
|
|
public function generateIcs(Interview $interview): string
|
|
{
|
|
$calendar = Calendar::create($interview->event_title)
|
|
->appendProperty(TextProperty::create('METHOD', 'REQUEST'))
|
|
->event($this->generateEvent($interview));
|
|
|
|
return $calendar->get();
|
|
}
|
|
|
|
/**
|
|
* Build the Spatie Event component for the given interview.
|
|
*/
|
|
public function generateEvent(Interview $interview): Event
|
|
{
|
|
$startsAt = $interview->scheduled_at
|
|
? Carbon::parse($interview->scheduled_at)->toDateTime()
|
|
: now()->toDateTime();
|
|
|
|
$endsAt = $interview->expires_at
|
|
? Carbon::parse($interview->expires_at)->toDateTime()
|
|
: Carbon::parse($startsAt)->addHours(2)->toDateTime();
|
|
|
|
$organizerEmail = config('mail.from.address') ?: 'no-reply@singlelogin.com';
|
|
$organizerName = config('mail.from.name') ?: 'SingleLogin Assessment Portal';
|
|
|
|
$event = Event::create($interview->event_title)
|
|
->uniqueIdentifier('sls-interview-'.$interview->submission_unique_id)
|
|
->status(EventStatus::Confirmed)
|
|
->startsAt($startsAt)
|
|
->endsAt($endsAt)
|
|
->organizer($organizerEmail, $organizerName)
|
|
->appendProperty(TextProperty::create('SEQUENCE', '0'))
|
|
->description($this->generateDescription($interview));
|
|
|
|
// Candidate Attendee: RSVP / Response is required
|
|
if (! empty($interview->candidate_email)) {
|
|
$event->attendee(
|
|
email: $interview->candidate_email,
|
|
name: $interview->candidate_name,
|
|
participationStatus: ParticipationStatus::NeedsAction,
|
|
requiresResponse: true
|
|
);
|
|
}
|
|
|
|
// Assigned Panelists / Interviewers
|
|
$assignedUsers = $interview->assignedUsers();
|
|
foreach ($assignedUsers as $interviewer) {
|
|
if (! empty($interviewer->email)) {
|
|
$event->attendee(
|
|
email: $interviewer->email,
|
|
name: $interviewer->name,
|
|
participationStatus: ParticipationStatus::Accepted,
|
|
requiresResponse: false
|
|
);
|
|
}
|
|
}
|
|
|
|
// 15-minute advance alert notification (RFC 5545 VALARM)
|
|
$reminderMessage = sprintf(
|
|
'Interview Reminder: %s (%s) with %s starts in 15 minutes.',
|
|
$interview->job_title ?? 'Candidate Assessment',
|
|
$interview->round ?? 'R1',
|
|
$interview->candidate_name ?? 'Candidate'
|
|
);
|
|
|
|
$event->alertMinutesBefore(15, $reminderMessage);
|
|
|
|
// Note: No organizer is specified on the event as per requirement
|
|
|
|
return $event;
|
|
}
|
|
|
|
/**
|
|
* Generate structured event description with credentials, proctoring guidelines, and portal links.
|
|
*/
|
|
public function generateDescription(Interview $interview): string
|
|
{
|
|
$jobTitle = $interview->job_title ?: 'Assessment';
|
|
$round = $interview->round ?: 'R1';
|
|
$candidateName = $interview->candidate_name ?: 'Candidate';
|
|
$loginUrl = route('interview.candidate.login');
|
|
|
|
$assignedNames = $interview->assignedUsers()->pluck('name')->implode(', ');
|
|
if (empty($assignedNames)) {
|
|
$assignedNames = 'Hiring Panel';
|
|
}
|
|
|
|
$lines = [
|
|
'Candidate Assessment Invitation',
|
|
'----------------------------------------',
|
|
"Candidate: {$candidateName}",
|
|
"Position: {$jobTitle}",
|
|
"Assessment Round: {$round}",
|
|
'Coding Language: '.strtoupper($interview->language ?? 'Python'),
|
|
'Access Duration: '.($interview->valid_hours ?? 2).' Hour(s)',
|
|
'',
|
|
'CANDIDATE ACCESS & CREDENTIALS',
|
|
'----------------------------------------',
|
|
"Portal URL: {$loginUrl}",
|
|
"Identifier: {$interview->candidate_email}",
|
|
"Temporary Password: {$interview->temp_password}",
|
|
"Unique Session ID: {$interview->submission_unique_id}",
|
|
'',
|
|
'PANELISTS / REVIEWERS',
|
|
'----------------------------------------',
|
|
"Assigned Interviewers: {$assignedNames}",
|
|
];
|
|
|
|
if (! empty($interview->description)) {
|
|
$lines[] = '';
|
|
$lines[] = 'ADDITIONAL INSTRUCTIONS';
|
|
$lines[] = '----------------------------------------';
|
|
$lines[] = $interview->description;
|
|
}
|
|
|
|
$lines[] = '';
|
|
$lines[] = 'IMPORTANT NOTES';
|
|
$lines[] = '----------------------------------------';
|
|
$lines[] = '- Please join the assessment room 5 minutes prior to the scheduled start time.';
|
|
$lines[] = '- Ensure your web camera and microphone are connected for live proctoring.';
|
|
$lines[] = '- This session contains an automated 15-minute advance reminder alarm.';
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* Format a safe filename for the .ics attachment.
|
|
*/
|
|
public function generateFilename(Interview $interview): string
|
|
{
|
|
$cleanTitle = preg_replace('/[^A-Za-z0-9_-]/', '_', $interview->event_title);
|
|
|
|
return 'invitation_'.substr($cleanTitle, 0, 80).'.ics';
|
|
}
|
|
}
|