sls/app/Services/Interview/InterviewCalendarService.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

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';
}
}