interview-feedback-enhancements and convexsol turn server #23
837
.agents/skills/icalender-generator/SKILL.md
Normal file
837
.agents/skills/icalender-generator/SKILL.md
Normal file
@ -0,0 +1,837 @@
|
||||
---
|
||||
name: icalendar-generator
|
||||
description: "Want to create online calendars so that you can display them on an iPhone's calendar app or in Google Calendar? This can be done by generating calendars in the iCalendar format (RFC 5545), a textual format that can be loaded by different applications. The format of such calendars is defined in [RFC 5545](https://tools.ietf.org/html/rfc5545), which is not a pleasant reading experience. This package implements [RFC 5545](https://tools.ietf.org/html/rfc5545) and some extensions from [RFC 7986](https://tools.ietf.org/html/rfc7986) to provide you an easy to use API for creating calendars. It's not our intention to implement these RFC's entirely but to provide a straightforward API that's easy to use."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: spatie
|
||||
---
|
||||
|
||||
# ICalender Generatir
|
||||
|
||||
## Generate calendars in the iCalendar format
|
||||
|
||||
## Readme
|
||||
|
||||
Here's an example of how to use it:
|
||||
|
||||
```php
|
||||
use Spatie\IcalendarGenerator\Components\Calendar;
|
||||
use Spatie\IcalendarGenerator\Components\Event;
|
||||
|
||||
Calendar::create('Laracon online')
|
||||
->event(Event::create('Creating calender feeds')
|
||||
->startsAt(new DateTime('6 March 2019 15:00'))
|
||||
->endsAt(new DateTime('6 March 2019 16:00'))
|
||||
)
|
||||
->get();
|
||||
```
|
||||
|
||||
The above code will generate this string:
|
||||
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:spatie/icalendar-generator
|
||||
NAME:Laracon online
|
||||
X-WR-CALNAME:Laracon online
|
||||
BEGIN:VEVENT
|
||||
UID:5ef5c3f64cb2c
|
||||
DTSTAMP;TZID=UTC:20200626T094630
|
||||
SUMMARY:Creating calendar feeds
|
||||
DTSTART:20190306T150000Z
|
||||
DTEND:20190306T160000Z
|
||||
DTSTAMP:20190419T135034Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
You can install the package via composer:
|
||||
|
||||
```bash
|
||||
composer require spatie/icalendar-generator
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
There were some substantial changes between v1 and v2 of the package. Check the [upgrade](https://github.com/spatie/icalendar-generator/blob/master/UPGRADING.md) guide for more information.
|
||||
|
||||
## Usage
|
||||
|
||||
Here's how you can create a calendar:
|
||||
|
||||
``` php
|
||||
$calendar = Calendar::create();
|
||||
```
|
||||
|
||||
You can give a name to the calendar:
|
||||
|
||||
``` php
|
||||
$calendar = Calendar::create('Laracon Online');
|
||||
```
|
||||
|
||||
A description can be added to a calendar:
|
||||
|
||||
``` php
|
||||
$calendar = Calendar::create()
|
||||
->name('Laracon Online')
|
||||
->description('Experience Laracon all around the world');
|
||||
```
|
||||
|
||||
In the end, you want to convert your calendar to text so that it can be streamed or downloaded to the user. Here's how you do that:
|
||||
|
||||
``` php
|
||||
Calendar::create('Laracon Online')->get(); // BEGIN:VCALENDAR ...
|
||||
```
|
||||
|
||||
When [streaming](#use-with-laravel) a calendar to an application, it is possible to set the calendar's refresh interval by duration in minutes. When setting this, the calendar application will check your server every time after the specified duration for changes to the calendar:
|
||||
|
||||
``` php
|
||||
Calendar::create('Laracon Online')
|
||||
->refreshInterval(5)
|
||||
...
|
||||
```
|
||||
|
||||
### Event
|
||||
|
||||
An event can be created as follows. A name is not required, but a start date should always be given:
|
||||
|
||||
``` php
|
||||
Event::create('Laracon Online')
|
||||
->startsAt(new DateTime('6 march 2019'));
|
||||
```
|
||||
|
||||
You can set the following properties on an event:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->name('Laracon Online')
|
||||
->description('Experience Laracon all around the world')
|
||||
->uniqueIdentifier('A unique identifier can be set here')
|
||||
->createdAt(new DateTime('6 march 2019'))
|
||||
->startsAt(new DateTime('6 march 2019 15:00'))
|
||||
->endsAt(new DateTime('6 march 2019 16:00'));
|
||||
```
|
||||
|
||||
Want to create an event quickly with a start and end date?
|
||||
|
||||
``` php
|
||||
Event::create('Laracon Online')
|
||||
->period(new DateTime('6 march 2019'), new DateTime('7 march 2019'));
|
||||
```
|
||||
|
||||
You can add a location to an event a such:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->address('Kruikstraat 22, 2018 Antwerp, Belgium')
|
||||
->addressName('Spatie HQ')
|
||||
->coordinates(51.2343, 4.4287)
|
||||
...
|
||||
```
|
||||
|
||||
You can set the organizer of an event, the email address is required, but the name can be omitted:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->organizer('ruben@spatie.be', 'Ruben')
|
||||
...
|
||||
```
|
||||
|
||||
Attendees of an event can be added as such:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->attendee('ruben@spatie.be') // only an email address is required
|
||||
->attendee('brent@spatie.be', 'Brent')
|
||||
...
|
||||
```
|
||||
|
||||
You can also set the participation status of an attendee:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->attendee('ruben@spatie.be', 'Ruben', ParticipationStatus::Accepted)
|
||||
...
|
||||
```
|
||||
|
||||
There are five participation statuses:
|
||||
|
||||
- `ParticipationStatus::Accepted`
|
||||
- `ParticipationStatus::Declined`
|
||||
- `ParticipationStatus::Tentative`
|
||||
- `ParticipationStatus::NeedsAction`
|
||||
- `ParticipationStatus::Delegated`
|
||||
|
||||
|
||||
You can indicate that an attendee is required to RSVP to an event:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->attendee('ruben@spatie.be', 'Ruben', ParticipationStatus::NeedsAction, requiresResponse: true)
|
||||
...
|
||||
```
|
||||
|
||||
An event can be made transparent, so it does not overlap visually with other events in a calendar:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->transparent()
|
||||
...
|
||||
```
|
||||
|
||||
It is possible to create an event that spans a full day:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->fullDay()
|
||||
...
|
||||
```
|
||||
|
||||
Please notice that an end date according to the spec is always non inclusive. So an event that spans a full day on March 6th 2019 should have March 7th 2019 as end date. Your calendar application should handle this correctly and display the event on March 6th only.
|
||||
|
||||
The status of an event can be set:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->status(EventStatus::Cancelled)
|
||||
...
|
||||
```
|
||||
|
||||
There are three event statuses:
|
||||
|
||||
- `EventStatus::Confirmed`
|
||||
- `EventStatus::Cancelled`
|
||||
- `EventStatus::Tentative`
|
||||
|
||||
An event can be classified(`Public`, `Private`, `Confidential`) as such:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->classification(Classification::Private)
|
||||
...
|
||||
```
|
||||
|
||||
You can add a url attachment as such:
|
||||
|
||||
```php
|
||||
Event::create()
|
||||
->attachment('https://spatie.be/logo.svg')
|
||||
->attachment('https://spatie.be/feed.xml', 'application/json')
|
||||
...
|
||||
```
|
||||
|
||||
You can add an embedded attachment (base64) as such:
|
||||
|
||||
```php
|
||||
Event::create()
|
||||
->embeddedAttachment($file->toString())
|
||||
->embeddedAttachment($fileString, 'application/json')
|
||||
->embeddedAttachment($base64String, 'application/json', needsEncoding: false)
|
||||
...
|
||||
```
|
||||
|
||||
You can add an image as such:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->image('https://spatie.be/logo.svg')
|
||||
->image('https://spatie.be/logo.svg', 'text/svg+xml')
|
||||
->image('https://spatie.be/logo.svg', 'text/svg+xml', Display::Badge)
|
||||
...
|
||||
```
|
||||
|
||||
There are four different image display types:
|
||||
|
||||
- `Display::Badge`
|
||||
- `Display::Graphic`
|
||||
- `Display::Fullsize`
|
||||
- `Display::Thumbnail`
|
||||
|
||||
You can add a sequence to an event as such:
|
||||
|
||||
``` php
|
||||
Event::create()
|
||||
->sequence(1)
|
||||
...
|
||||
```
|
||||
|
||||
After creating your event, it should be added to a calendar. There are multiple options to do this:
|
||||
|
||||
``` php
|
||||
// As a single event parameter
|
||||
$event = Event::create('Creating calendar feeds');
|
||||
|
||||
Calendar::create('Laracon Online')
|
||||
->event($event)
|
||||
...
|
||||
|
||||
// As an array of events
|
||||
Calendar::create('Laracon Online')
|
||||
->event([
|
||||
Event::create('Creating calender feeds'),
|
||||
Event::create('Creating contact lists'),
|
||||
])
|
||||
...
|
||||
|
||||
// As a closure
|
||||
Calendar::create('Laracon Online')
|
||||
->event(function(Event $event){
|
||||
$event->name('Creating calender feeds');
|
||||
})
|
||||
...
|
||||
```
|
||||
|
||||
#### Using Carbon
|
||||
|
||||
You can use the popular [Carbon library](https://carbon.nesbot.com/):
|
||||
|
||||
``` php
|
||||
use Carbon\Carbon;
|
||||
|
||||
Event::create('Laracon Online')
|
||||
->startsAt(Carbon::now())
|
||||
...
|
||||
```
|
||||
|
||||
#### Timezones
|
||||
|
||||
Events will use the [timezones]((https://www.php.net/manual/en/datetime.settimezone.php)) defined in the `DateTime` objects you provide. PHP always sets these timezones in a `DateTime` object. By default, this will be the UTC timezone, but it is possible to [change](https://www.php.net/manual/en/function.date-default-timezone-set.php) this.
|
||||
|
||||
Just a reminder: do not use PHP's `setTimezone` function on a `DateTime` object, it will change the time according to the timezone! It is better to create a new `DateTime` object with a timezone as such:
|
||||
|
||||
``` php
|
||||
new DateTime('6 march 2019 15:00', new DateTimeZone('Europe/Brussels'))
|
||||
```
|
||||
|
||||
A point can be made for omitting timezones. For example, when you want to show an event at noon in the world. We define noon at 12 o'clock, but that time is relative. It is not the same for people in Belgium, Australia, or any other country in the world.
|
||||
|
||||
That's why you can disable timezones on events:
|
||||
|
||||
``` php
|
||||
$starts = new DateTime('6 march 2019 12:00')
|
||||
|
||||
Event::create()
|
||||
->startsAt($starts)
|
||||
->withoutTimezone()
|
||||
...
|
||||
```
|
||||
|
||||
You can even disable timezones for a whole calendar:
|
||||
|
||||
``` php
|
||||
Calendar::create()
|
||||
->withoutTimezone()
|
||||
...
|
||||
```
|
||||
|
||||
Each calendar should have Timezone components describing the timezones used within your calendar. Although not all calendar clients require this, it is recommended to add these components.
|
||||
|
||||
Creating such Timezone components is quite complicated. That's why this package will automatically add them for you without configuration.
|
||||
|
||||
You can disable this behaviour as such:
|
||||
|
||||
``` php
|
||||
Calendar::create()
|
||||
->withoutAutoTimezoneComponents()
|
||||
...
|
||||
```
|
||||
|
||||
Quick note, when using UTC offsets as timezones(e.g. `+02:00`), no Timezone components will be added automatically and the dates will automatically be converted to UTC.
|
||||
|
||||
``` php
|
||||
$starts = new DateTime('6 march 2019 12:00', new DateTimeZone('+02:00'))
|
||||
|
||||
Event::create()->startsAt($starts); // DTSTART will be 20190306T100000Z
|
||||
```
|
||||
|
||||
You can manually add timezones to a calendar if desired as such:
|
||||
|
||||
```php
|
||||
$timezoneEntry = TimezoneEntry::create(
|
||||
TimezoneEntryType::Daylight,
|
||||
new DateTime('23 march 2020'),
|
||||
'+00:00',
|
||||
'+02:00'
|
||||
);
|
||||
|
||||
$timezone = Timezone::create('Europe/Brussels')
|
||||
->entry($timezoneEntry)
|
||||
...
|
||||
|
||||
Calendar::create()
|
||||
->timezone($timezone)
|
||||
...
|
||||
```
|
||||
|
||||
More on these timezones later.
|
||||
|
||||
#### Alerts
|
||||
|
||||
Alerts allow calendar clients to send reminders about specific events. For example, Apple Mail on an iPhone will send users a notification about the event. An alert always belongs to an event has a description and a number of minutes before the event it will be triggered:
|
||||
|
||||
``` php
|
||||
Event::create('Laracon Online')
|
||||
->alertMinutesBefore(5, 'Laracon online is going to start in five minutes');
|
||||
```
|
||||
|
||||
You can also trigger an alert after the event:
|
||||
|
||||
``` php
|
||||
Event::create('Laracon Online')
|
||||
->alertMinutesAfter(5, 'Laracon online has ended, see you next year!');
|
||||
```
|
||||
|
||||
Or trigger an alert on a specific date:
|
||||
|
||||
``` php
|
||||
Event::create('Laracon Online')
|
||||
->alertAt(
|
||||
new DateTime('05/16/2020 12:00:00'),
|
||||
'Laracon online has ended, see you next year!'
|
||||
);
|
||||
```
|
||||
|
||||
Removing timezones on a calendar or event will also remove timezones on the alert.
|
||||
|
||||
|
||||
### Repeating events
|
||||
|
||||
It is possible for events to repeat, for example your monthly company dinner. This can be done as such:
|
||||
|
||||
```php
|
||||
Event::create('Laracon Online')
|
||||
->repeatOn(new DateTime('05/16/2020 12:00:00'));
|
||||
```
|
||||
|
||||
And you can also repeat the event on a set of dates:
|
||||
|
||||
```php
|
||||
Event::create('Laracon Online')
|
||||
->repeatOn([new DateTime('05/16/2020 12:00:00'), new DateTime('08/13/2020 15:00:00')]);
|
||||
```
|
||||
|
||||
#### Recurrence rules
|
||||
|
||||
Recurrence rules or RRule's in short, make it possible to add a repeating event in your calendar by describing when it repeats within an RRule. First, we have to create an RRule:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Daily);
|
||||
```
|
||||
|
||||
This rule describes an event that will be repeated daily. You can also set the frequency to `secondly`, `minutely`, `hourly`, `weekly`, `monthly` or `yearly`.
|
||||
|
||||
The RRULE can be added to an event as such:
|
||||
|
||||
``` php
|
||||
Event::create('Laracon Online')
|
||||
->rrule(RRule::frequency(RecurrenceFrequency::Monthly));
|
||||
```
|
||||
|
||||
It is possible to finetune the RRule to your personal taste; let's have a look!
|
||||
|
||||
A RRule can start from a certain point in time:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Daily)->starting(new DateTime('now'));
|
||||
```
|
||||
|
||||
And stop at a certain point:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Daily)->until(new DateTime('now'));
|
||||
```
|
||||
|
||||
It can only be repeated for a few times, 10 times for example:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Daily)->times(10);
|
||||
```
|
||||
|
||||
The interval of the repetition can be changed:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Daily)->interval(2);
|
||||
```
|
||||
|
||||
When this event starts on Monday, for example, the next repetition of this event will not occur on Tuesday but Wednesday. You can do the same for all the frequencies.
|
||||
|
||||
It is also possible to repeat the event on a specific weekday:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Monthly)->onWeekDay(
|
||||
RecurrenceDay::Friday
|
||||
);
|
||||
```
|
||||
|
||||
Or on a specific weekday of a week in the month:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Monthly)->onWeekDay(
|
||||
RecurrenceDay::Friday, 3
|
||||
);
|
||||
```
|
||||
|
||||
Or on the last weekday of a month:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Monthly)->onWeekDay(
|
||||
RecurrenceDay::Sunday, -1
|
||||
);
|
||||
```
|
||||
|
||||
You can repeat on a specific day in the month:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Monthly)->onMonthDay(16);
|
||||
```
|
||||
|
||||
It is even possible to give an array of days in the month:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Monthly)->onMonthDay(
|
||||
[5, 10, 15, 20]
|
||||
);
|
||||
```
|
||||
|
||||
Repeating can be done for certain months (for example only in the second quarter):
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Monthly)->onMonth(
|
||||
[RecurrenceMonth::April, RecurrenceMonth::May, RecurrenceMonth::June]
|
||||
);
|
||||
```
|
||||
|
||||
Or just on one month only:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Monthly)->onMonth(
|
||||
RecurrenceMonth::October
|
||||
);
|
||||
```
|
||||
|
||||
It is possible to set the day when the week starts:
|
||||
|
||||
```php
|
||||
$rrule = RRule::frequency(RecurrenceFrequency::Monthly)->weekStartsOn(
|
||||
ReccurenceDay::monday()
|
||||
);
|
||||
```
|
||||
|
||||
You can provide a specific date on which an event won't be repeated:
|
||||
|
||||
```php
|
||||
Event::create('Laracon Online')
|
||||
->rrule(RRule::frequency(RecurrenceFrequency::Daily))
|
||||
->doNotRepeatOn(new DateTime('05/16/2020 12:00:00'));
|
||||
```
|
||||
|
||||
It is also possible to give an array of dates on which the event won't be repeated:
|
||||
|
||||
```php
|
||||
Event::create('Laracon Online')
|
||||
->rrule(RRule::frequency(RecurrenceFrequency::Daily))
|
||||
->doNotRepeatOn([new DateTime('05/16/2020 12:00:00'), new DateTime('08/13/2020 15:00:00')]);
|
||||
```
|
||||
|
||||
Alternatively you can add RRules as a string:
|
||||
|
||||
```php
|
||||
Event::create('SymfonyCon')
|
||||
->rruleAsString('FREQ=DAILY;INTERVAL=1');
|
||||
```
|
||||
|
||||
If you add RRules as a string the timezones included in DTSTART and UNTIL are unknown to the package as the string is never parsed and evaluated. If they are known you can add DTSTART and UNTIL separately to help the package discover the timezones:
|
||||
|
||||
```php
|
||||
Event::create('SymfonyCon')
|
||||
->rruleAsString(
|
||||
'DTSTART=20231207T090000Z;FREQ=DAILY;INTERVAL=1;UNTIL=20231208T090000Z',
|
||||
new DateTime('7 december 2023 09:00:00', new DateTimeZone('UTC')),
|
||||
new DateTime('8 december 2023 09:00:00', new DateTimeZone('UTC'))
|
||||
);
|
||||
```
|
||||
|
||||
### Todo's
|
||||
|
||||
It is possible to add todo's to a calendar as such:
|
||||
|
||||
```php
|
||||
$todo = Todo::create('My first todo')
|
||||
|
||||
$calendar = Calendar::create('My calendar')->todo($todo);
|
||||
```
|
||||
|
||||
Adding an array of todo's or using a closure for creating an inline todo is also possible, similar to events.
|
||||
|
||||
A todo can be at a specified date:
|
||||
|
||||
```php
|
||||
Todo::create()
|
||||
->starts(new DateTime('2023-12-31 23:59:59'))
|
||||
...
|
||||
```
|
||||
|
||||
And have a duration:
|
||||
|
||||
```php
|
||||
Todo::create()
|
||||
->duration(new DateInterval('PT1H30M'))
|
||||
...
|
||||
```
|
||||
|
||||
Please notice that a todo with a duration always needs a start date.
|
||||
|
||||
It is also possible to set the due date of a todo:
|
||||
|
||||
```php
|
||||
Todo::create()
|
||||
->due(new DateTime('2023-12-31 23:59:59'))
|
||||
...
|
||||
```
|
||||
|
||||
It is impossible to set a start date and a due date on a todo, only one of them can be set.
|
||||
|
||||
A todo can have a completion date:
|
||||
|
||||
```php
|
||||
Todo::create()
|
||||
->completedAt(new DateTime('2023-12-25 12:00:00'))
|
||||
...
|
||||
```
|
||||
|
||||
You can set the percentage complete as such:
|
||||
|
||||
```php
|
||||
Todo::create()
|
||||
->percentComplete(75) // In a range from 0 to 100
|
||||
...
|
||||
```
|
||||
|
||||
It is possible to set the priority of a todo:
|
||||
|
||||
```php
|
||||
Todo::create()
|
||||
->priority(1) // In a range from 0 to 9
|
||||
...
|
||||
```
|
||||
|
||||
The status of a todo can be set:
|
||||
|
||||
```php
|
||||
Todo::create()
|
||||
->status(TodoStatus::Completed)
|
||||
...
|
||||
```
|
||||
|
||||
There are four todo statuses:
|
||||
|
||||
- `TodoStatus::NeedsAction`
|
||||
- `TodoStatus::Completed`
|
||||
- `TodoStatus::InProcess`
|
||||
- `TodoStatus::Cancelled`
|
||||
|
||||
A todo has many similarities with events so the following methods are also available on todos:
|
||||
|
||||
```php
|
||||
Todo::create()
|
||||
->description('Meeting about project updates')
|
||||
->uniqueIdentifier('event-12345')
|
||||
->createdAt(new DateTime('2024-01-15 09:00:00'))
|
||||
->withoutTimezone()
|
||||
->classification(Classification::Public)
|
||||
->url('https://example.com/details')
|
||||
->sequence(1)
|
||||
->attendee('john@example.com')
|
||||
->attachment('https://example.com/file.pdf')
|
||||
->address('123 Main St, New York')
|
||||
->addressName('Conference Room A')
|
||||
->coordinates(40.7128, -74.0060)
|
||||
->organizer('admin@example.com')
|
||||
->rrule('FREQ=DAILY')
|
||||
->alert(Alert::minutesBeforeStart(15))
|
||||
```
|
||||
|
||||
### Use with Laravel
|
||||
|
||||
You can use Laravel Responses to stream to calendar applications:
|
||||
|
||||
``` php
|
||||
$calendar = Calendar::create('Laracon Online');
|
||||
|
||||
return response($calendar->get())
|
||||
->header('Content-Type', 'text/calendar; charset=utf-8');
|
||||
```
|
||||
|
||||
If you want to add the possibility for users to download a calendar and import it into a calendar application:
|
||||
|
||||
``` php
|
||||
$calendar = Calendar::create('Laracon Online');
|
||||
|
||||
return response($calendar->get(), 200, [
|
||||
'Content-Type' => 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="my-awesome-calendar.ics"',
|
||||
]);
|
||||
```
|
||||
|
||||
### Crafting Timezones
|
||||
|
||||
If you want to craft timezone components yourself, you're in the right place, although we advise you to read the [section](https://tools.ietf.org/html/rfc5545#section-3.6.5) on timezones from the RFC first.
|
||||
|
||||
You can create a timezone as such:
|
||||
|
||||
```php
|
||||
$timezone = Timezone::create('Europe/Brussels');
|
||||
```
|
||||
|
||||
It is possible to provide the last modified date:
|
||||
|
||||
```php
|
||||
$timezone = Timezone::create('Europe/Brussels')
|
||||
->lastModified(new DateTime('16 may 2020 12:00:00'));
|
||||
```
|
||||
|
||||
Or add an url with more information about the timezone:
|
||||
|
||||
```php
|
||||
$timezone = Timezone::create('Europe/Brussels')
|
||||
->url('https://spatie.be');
|
||||
```
|
||||
|
||||
A timezone consists of multiple entries where the time of the timezone changed relative to UTC, such entry can be constructed for standard or daylight time:
|
||||
|
||||
```php
|
||||
$entry = TimezoneEntry::create(
|
||||
TimezoneEntryType::Standard,
|
||||
new DateTime('16 may 2020 12:00:00'),
|
||||
'+00:00',
|
||||
'+02:00'
|
||||
);
|
||||
```
|
||||
|
||||
Firstly you provide the type of entry (`standard` or `daylight`). Then a `DateTime` when the time changes. Lastly, an offset relative to UTC from before the change and an offset relative to UTC after the change.
|
||||
|
||||
It is also possible to give this entry a name and description:
|
||||
|
||||
```php
|
||||
$entry = TimezoneEntry::create(...)
|
||||
->name('Europe - Brussels')
|
||||
->description('Belgian timezones ftw!');
|
||||
```
|
||||
|
||||
An RRule for the entry can be given as such:
|
||||
|
||||
```php
|
||||
$entry = TimezoneEntry::create(...)
|
||||
->rrule(RRule::frequency(RecurrenceFrequency::Daily));
|
||||
```
|
||||
|
||||
In the end you can add an entry to a timezone:
|
||||
|
||||
```php
|
||||
$timezone = Timezone::create('Europe/Brussels')
|
||||
->entry($timezoneEntry);
|
||||
```
|
||||
|
||||
Or even add multiple entries:
|
||||
|
||||
```php
|
||||
$timezone = Timezone::create('Europe/Brussels')
|
||||
->entry([$timezoneEntryOne, $timezoneEntryTwo]);
|
||||
```
|
||||
|
||||
Now we've constructed our timezone it is time(👀) to add this timezone to our calendar:
|
||||
|
||||
```php
|
||||
$calendar = Calendar::create('Calendar with timezones')
|
||||
->timezone($timezone);
|
||||
```
|
||||
|
||||
It is also possible to add multiple timezones:
|
||||
|
||||
```php
|
||||
$calendar = Calendar::create('Calendar with timezones')
|
||||
->timezone([$timezoneOne, $timezoneTwo]);
|
||||
```
|
||||
|
||||
|
||||
### Extending the package
|
||||
|
||||
We try to keep this package as straightforward as possible. That's why a lot of properties and subcomponents from the RFC are not included in this package. We've made it possible to add other properties or subcomponents to each component if you might need something not included in the package. But be careful! From this moment, you're on your own correctly implementing the RFC's.
|
||||
|
||||
#### Appending properties
|
||||
|
||||
You can add a new property to a component like this:
|
||||
|
||||
```php
|
||||
Calendar::create()
|
||||
->appendProperty(
|
||||
TextProperty::create('ORGANIZER', 'ruben@spatie.be')
|
||||
)
|
||||
...
|
||||
```
|
||||
|
||||
Here we've added a `TextProperty `, and this is a default key-value property type with a text as value. You can also use one of the default properties included in the package or create your own by extending the `Property` class.
|
||||
|
||||
Sometimes a property can have some additional parameters, these are key-value entries and can be added to properties as such:
|
||||
|
||||
```php
|
||||
$property = TextProperty::create('ORGANIZER', 'ruben@spatie.be')
|
||||
->addParameter(Parameter::create('CN', 'RUBEN VAN ASSCHE'));
|
||||
|
||||
Calendar::create()
|
||||
->appendProperty($property)
|
||||
...
|
||||
```
|
||||
|
||||
#### Appending subcomponents
|
||||
|
||||
|
||||
A subcomponent can be appended as such:
|
||||
|
||||
```php
|
||||
Calendar::create()
|
||||
->appendSubComponent(
|
||||
Event::create('Extending icalendar-generator')
|
||||
)
|
||||
...
|
||||
```
|
||||
|
||||
It is possible to create your subcomponents by extending the `Component` class.
|
||||
|
||||
### Testing
|
||||
|
||||
``` bash
|
||||
composer test
|
||||
```
|
||||
|
||||
### Changelog
|
||||
|
||||
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details.
|
||||
|
||||
### Security
|
||||
|
||||
If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker.
|
||||
|
||||
## Postcardware
|
||||
|
||||
You're free to use this package, but if it makes it to your production environment we highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using.
|
||||
|
||||
Our address is: Spatie, Kruikstraat 22, box 12, 2018 Antwerp, Belgium.
|
||||
|
||||
We publish all received postcards [on our company website](https://spatie.be/en/opensource/postcards).
|
||||
|
||||
## Credits
|
||||
|
||||
- [Ruben Van Assche](https://github.com/rubenvanassche)
|
||||
- [All Contributors](../../contributors)
|
||||
|
||||
## License
|
||||
|
||||
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
|
||||
11
.env.example
11
.env.example
@ -4,6 +4,7 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_TIMEZONE='Asia/Kolkata'
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
@ -70,10 +71,20 @@ MICROSOFT_CLIENT_SECRET=
|
||||
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
|
||||
MICROSOFT_TENANT_ID=common
|
||||
|
||||
# ICE Candidate / TURN Server Configurations
|
||||
ICE_CANDIDATE_PROVIDER=metered
|
||||
ICE_FALLBACK_PROVIDER=convexsol
|
||||
|
||||
# Metered TURN Server Credentials
|
||||
METERED_URL=
|
||||
METERED_KEY=
|
||||
|
||||
# ConvexSol TURN / STUN Server Credentials
|
||||
CONVEXSOL_USERNAME=
|
||||
CONVEXSOL_PASSWORD=
|
||||
CONVEXSOL_TURN_URLS=
|
||||
CONVEXSOL_STUN_URLS=
|
||||
|
||||
# Interview Configurations
|
||||
MAX_INTERVIEWER=2
|
||||
INTERVIEW_RECORDINGS_FOLDER=uploads/candidate_recordings
|
||||
|
||||
63
app/Actions/Interview/ScheduleInterviewAction.php
Normal file
63
app/Actions/Interview/ScheduleInterviewAction.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Interview;
|
||||
|
||||
use App\DTOs\Interview\ScheduleInterviewDto;
|
||||
use App\Models\Interview;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ScheduleInterviewAction
|
||||
{
|
||||
public function __construct(
|
||||
protected SendInterviewInvitationsAction $sendInvitationsAction
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Provision and schedule an interview assessment session, then trigger calendar invitations.
|
||||
*/
|
||||
public function execute(ScheduleInterviewDto $dto, ?int $creatorId = null): Interview
|
||||
{
|
||||
$tempPassword = 'Pass-'.rand(100000, 999999);
|
||||
$cleanPhone = preg_replace('/[^0-9]/', '', $dto->candidatePhone);
|
||||
$cleanName = Str::slug($dto->candidateName);
|
||||
$submissionUniqueId = $cleanName.'_'.$cleanPhone.'_'.time();
|
||||
|
||||
$scheduledAt = $dto->scheduledAt ? Carbon::parse($dto->scheduledAt) : now();
|
||||
$expiresAt = $scheduledAt->copy()->addHours($dto->validHours);
|
||||
|
||||
$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([
|
||||
'candidate_name' => $dto->candidateName,
|
||||
'candidate_email' => $dto->candidateEmail,
|
||||
'candidate_phone' => $dto->candidatePhone,
|
||||
'job_title' => $dto->jobTitle,
|
||||
'round' => $dto->round,
|
||||
'description' => $dto->description,
|
||||
'resume_path' => $resumePath,
|
||||
'temp_password' => $tempPassword,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'expires_at' => $expiresAt,
|
||||
'language' => $dto->language,
|
||||
'status' => 'scheduled',
|
||||
'assigned_interviewers' => $dto->assignedInterviewers,
|
||||
'proctor_logs' => [],
|
||||
'submission_unique_id' => $submissionUniqueId,
|
||||
'created_by' => $creatorId,
|
||||
]);
|
||||
});
|
||||
|
||||
// Trigger iCalendar generation & queued emails
|
||||
$this->sendInvitationsAction->execute($interview);
|
||||
|
||||
return $interview;
|
||||
}
|
||||
}
|
||||
56
app/Actions/Interview/SendInterviewInvitationsAction.php
Normal file
56
app/Actions/Interview/SendInterviewInvitationsAction.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Interview;
|
||||
|
||||
use App\Mail\CandidateInterviewInvitationMail;
|
||||
use App\Mail\InterviewerAssessmentNotificationMail;
|
||||
use App\Models\Interview;
|
||||
use App\Services\Interview\InterviewCalendarService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendInterviewInvitationsAction
|
||||
{
|
||||
public function __construct(
|
||||
protected InterviewCalendarService $calendarService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate calendar invitation (.ics) and dispatch queued invitation emails to candidate and panelists.
|
||||
*/
|
||||
public function execute(Interview $interview): void
|
||||
{
|
||||
$icsContent = $this->calendarService->generateIcs($interview);
|
||||
$icsFilename = $this->calendarService->generateFilename($interview);
|
||||
|
||||
// 1. Send candidate invitation with RSVP required & 15m alert
|
||||
if (! empty($interview->candidate_email)) {
|
||||
try {
|
||||
Mail::to($interview->candidate_email)
|
||||
->queue(new CandidateInterviewInvitationMail($interview, $icsContent, $icsFilename));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to queue interview invitation email for candidate: '.$e->getMessage(), [
|
||||
'interview_id' => $interview->id,
|
||||
'candidate_email' => $interview->candidate_email,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Send interviewer assignment notification to all assigned panelists
|
||||
$assignedUsers = $interview->assignedUsers();
|
||||
foreach ($assignedUsers as $interviewer) {
|
||||
if (! empty($interviewer->email)) {
|
||||
try {
|
||||
Mail::to($interviewer->email)
|
||||
->queue(new InterviewerAssessmentNotificationMail($interview, $interviewer, $icsContent, $icsFilename));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to queue interview notification for panelist: '.$e->getMessage(), [
|
||||
'interview_id' => $interview->id,
|
||||
'interviewer_id' => $interviewer->id,
|
||||
'interviewer_email' => $interviewer->email,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Interview;
|
||||
|
||||
use App\Mail\InterviewReminderMail;
|
||||
use App\Models\Interview;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendUpcomingInterviewRemindersAction
|
||||
{
|
||||
/**
|
||||
* Scan and dispatch 15-minute advance email reminders to candidates and panelists.
|
||||
*
|
||||
* @return int Count of reminded interview sessions
|
||||
*/
|
||||
public function execute(int $windowMinutes = 20): int
|
||||
{
|
||||
$now = now();
|
||||
$targetWindow = $now->copy()->addMinutes($windowMinutes);
|
||||
|
||||
$interviews = Interview::where('status', 'scheduled')
|
||||
->whereNull('reminder_sent_at')
|
||||
->whereNotNull('scheduled_at')
|
||||
->where('scheduled_at', '>=', $now->subMinutes(5)) // Within 5m grace
|
||||
->where('scheduled_at', '<=', $targetWindow)
|
||||
->get();
|
||||
|
||||
$processedCount = 0;
|
||||
|
||||
foreach ($interviews as $interview) {
|
||||
// 1. Candidate reminder email
|
||||
if (! empty($interview->candidate_email)) {
|
||||
try {
|
||||
Mail::to($interview->candidate_email)
|
||||
->queue(new InterviewReminderMail($interview, 'candidate', $interview->candidate_name));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send 15m reminder to candidate: '.$e->getMessage(), [
|
||||
'interview_id' => $interview->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Panelists reminder emails
|
||||
$assignedUsers = $interview->assignedUsers();
|
||||
foreach ($assignedUsers as $interviewer) {
|
||||
if (! empty($interviewer->email)) {
|
||||
try {
|
||||
Mail::to($interviewer->email)
|
||||
->queue(new InterviewReminderMail($interview, 'interviewer', $interviewer->name));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send 15m reminder to interviewer: '.$e->getMessage(), [
|
||||
'interview_id' => $interview->id,
|
||||
'interviewer_id' => $interviewer->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$interview->update(['reminder_sent_at' => now()]);
|
||||
$processedCount++;
|
||||
}
|
||||
|
||||
return $processedCount;
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/SendInterviewRemindersCommand.php
Normal file
38
app/Console/Commands/SendInterviewRemindersCommand.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Interview\SendUpcomingInterviewRemindersAction;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendInterviewRemindersCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'interview:send-reminders {--window=20 : Minutes ahead to look for upcoming interviews}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Scan and send 15-minute advance reminder emails for upcoming scheduled interviews';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(SendUpcomingInterviewRemindersAction $action): int
|
||||
{
|
||||
$window = (int) $this->option('window');
|
||||
$this->info("Checking for scheduled interviews starting within next {$window} minutes...");
|
||||
|
||||
$count = $action->execute($window);
|
||||
|
||||
$this->info("Successfully processed and dispatched reminders for {$count} interview session(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
13
app/Contracts/IceCandidateProviderInterface.php
Normal file
13
app/Contracts/IceCandidateProviderInterface.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
use App\DTOs\IceCandidatesDto;
|
||||
|
||||
interface IceCandidateProviderInterface
|
||||
{
|
||||
/**
|
||||
* Retrieve the ICE candidate / TURN / STUN server configurations.
|
||||
*/
|
||||
public function getIceCandidates(): IceCandidatesDto;
|
||||
}
|
||||
46
app/DTOs/IceCandidateDto.php
Normal file
46
app/DTOs/IceCandidateDto.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use JsonSerializable;
|
||||
|
||||
class IceCandidateDto implements Arrayable, JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param string|array<string> $urls
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string|array $urls,
|
||||
public readonly ?string $username = null,
|
||||
public readonly ?string $credential = null,
|
||||
public readonly ?string $credentialType = null,
|
||||
) {}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$rawUrls = $data['urls'] ?? $data['url'] ?? '';
|
||||
|
||||
return new self(
|
||||
urls: is_array($rawUrls) ? array_values($rawUrls) : (string) $rawUrls,
|
||||
username: isset($data['username']) ? (string) $data['username'] : null,
|
||||
credential: isset($data['credential']) ? (string) $data['credential'] : (isset($data['password']) ? (string) $data['password'] : null),
|
||||
credentialType: isset($data['credentialType']) ? (string) $data['credentialType'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'urls' => $this->urls,
|
||||
'username' => $this->username,
|
||||
'credential' => $this->credential,
|
||||
'credentialType' => $this->credentialType,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
}
|
||||
69
app/DTOs/IceCandidatesDto.php
Normal file
69
app/DTOs/IceCandidatesDto.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
use ArrayIterator;
|
||||
use Countable;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use IteratorAggregate;
|
||||
use JsonSerializable;
|
||||
use Traversable;
|
||||
|
||||
class IceCandidatesDto implements Arrayable, Countable, IteratorAggregate, JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param array<IceCandidateDto> $servers
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $servers = []
|
||||
) {}
|
||||
|
||||
public static function fromArray(array $items): self
|
||||
{
|
||||
$servers = [];
|
||||
foreach ($items as $item) {
|
||||
if ($item instanceof IceCandidateDto) {
|
||||
$servers[] = $item;
|
||||
} elseif (is_array($item)) {
|
||||
$servers[] = IceCandidateDto::fromArray($item);
|
||||
}
|
||||
}
|
||||
|
||||
return new self($servers);
|
||||
}
|
||||
|
||||
public static function defaultStun(): self
|
||||
{
|
||||
return new self([
|
||||
new IceCandidateDto(urls: [
|
||||
'stun:stun.l.google.com:19302',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_map(fn (IceCandidateDto $server) => $server->toArray(), $this->servers);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->servers);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->servers);
|
||||
}
|
||||
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return new ArrayIterator($this->servers);
|
||||
}
|
||||
}
|
||||
92
app/DTOs/Interview/ScheduleInterviewDto.php
Normal file
92
app/DTOs/Interview/ScheduleInterviewDto.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs\Interview;
|
||||
|
||||
use App\Http\Requests\Interview\StoreInterviewRequest;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use JsonSerializable;
|
||||
|
||||
class ScheduleInterviewDto implements Arrayable, JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param array<int> $assignedInterviewers
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $candidateName,
|
||||
public readonly string $candidateEmail,
|
||||
public readonly string $candidatePhone,
|
||||
public readonly string $jobTitle,
|
||||
public readonly string $round,
|
||||
public readonly ?Carbon $scheduledAt,
|
||||
public readonly int $validHours,
|
||||
public readonly string $language,
|
||||
public readonly array $assignedInterviewers = [],
|
||||
public readonly ?string $description = null,
|
||||
public readonly ?UploadedFile $resume = null,
|
||||
public readonly ?string $resumePath = null,
|
||||
) {}
|
||||
|
||||
public static function fromRequest(StoreInterviewRequest $request): self
|
||||
{
|
||||
$scheduledAt = $request->filled('scheduled_at')
|
||||
? Carbon::parse($request->input('scheduled_at'))
|
||||
: now();
|
||||
|
||||
return new self(
|
||||
candidateName: trim($request->input('candidate_name')),
|
||||
candidateEmail: strtolower(trim($request->input('candidate_email'))),
|
||||
candidatePhone: trim($request->input('candidate_phone')),
|
||||
jobTitle: trim($request->input('job_title', 'Software Engineer')),
|
||||
round: trim($request->input('round', 'R1')) ?: 'R1',
|
||||
scheduledAt: $scheduledAt,
|
||||
validHours: (int) $request->input('valid_hours', 2),
|
||||
language: $request->input('language', 'python'),
|
||||
assignedInterviewers: array_map('intval', (array) $request->input('assigned_interviewers', [])),
|
||||
description: $request->filled('description') ? trim($request->input('description')) : null,
|
||||
resume: $request->file('resume'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$scheduledAt = isset($data['scheduled_at'])
|
||||
? ($data['scheduled_at'] instanceof Carbon ? $data['scheduled_at'] : Carbon::parse($data['scheduled_at']))
|
||||
: now();
|
||||
|
||||
return new self(
|
||||
candidateName: trim($data['candidate_name'] ?? ''),
|
||||
candidateEmail: strtolower(trim($data['candidate_email'] ?? '')),
|
||||
candidatePhone: trim($data['candidate_phone'] ?? ''),
|
||||
jobTitle: trim($data['job_title'] ?? 'Software Engineer'),
|
||||
round: trim($data['round'] ?? 'R1') ?: 'R1',
|
||||
scheduledAt: $scheduledAt,
|
||||
validHours: (int) ($data['valid_hours'] ?? 2),
|
||||
language: $data['language'] ?? 'python',
|
||||
assignedInterviewers: array_map('intval', (array) ($data['assigned_interviewers'] ?? [])),
|
||||
description: isset($data['description']) && filled($data['description']) ? trim($data['description']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'candidate_name' => $this->candidateName,
|
||||
'candidate_email' => $this->candidateEmail,
|
||||
'candidate_phone' => $this->candidatePhone,
|
||||
'job_title' => $this->jobTitle,
|
||||
'round' => $this->round,
|
||||
'scheduled_at' => $this->scheduledAt?->toDateTimeString(),
|
||||
'valid_hours' => $this->validHours,
|
||||
'language' => $this->language,
|
||||
'assigned_interviewers' => $this->assignedInterviewers,
|
||||
'description' => $this->description,
|
||||
];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
}
|
||||
@ -1,65 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Contracts\IceCandidateProviderInterface;
|
||||
use App\Http\Resources\Interview\IceCandidatesResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class IceServerController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
public function __construct(
|
||||
protected IceCandidateProviderInterface $iceCandidateProvider
|
||||
) {}
|
||||
|
||||
public function __invoke(): JsonResponse|IceCandidatesResource
|
||||
{
|
||||
if (! Auth::check() && ! session()->has('candidate_interview_id')) {
|
||||
return response()->json(['error' => 'Unauthenticated access.'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$baseUrl = config(
|
||||
"services.metered.url",
|
||||
);
|
||||
$apiKey = config(
|
||||
"services.metered.key",
|
||||
);
|
||||
|
||||
$url = $baseUrl;
|
||||
if ($apiKey && !str_contains($url, "apiKey=")) {
|
||||
$separator = str_contains($url, "?") ? "&" : "?";
|
||||
$url .= $separator . "apiKey=" . urlencode($apiKey);
|
||||
}
|
||||
|
||||
$data = Cache::remember('metered_credentials_cache', now()->addMinute(10), function() use($url) {
|
||||
$response = Http::timeout(5)->get($url);
|
||||
if(!$response->successful())
|
||||
{
|
||||
Log::error(
|
||||
"Metered API error response",
|
||||
[
|
||||
"status" => $response->status(),
|
||||
"body" => $response->body(),
|
||||
],
|
||||
);
|
||||
|
||||
throw new \RuntimeException('Metered API Error');
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
});
|
||||
return response()->json($data);
|
||||
$candidates = $this->iceCandidateProvider->getIceCandidates();
|
||||
|
||||
return new IceCandidatesResource($candidates);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error(
|
||||
"ICE servers fetch exception: " . $e->getMessage(),
|
||||
);
|
||||
Log::error('ICE servers fetch exception: '.$e->getMessage());
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
"error" => "An error occurred while fetching ICE servers.",
|
||||
"message" => $e->getMessage(),
|
||||
],
|
||||
500,
|
||||
);
|
||||
return response()->json([
|
||||
'error' => 'An error occurred while fetching ICE servers.',
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,19 +3,27 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Interview\FinalizeRecordingAction;
|
||||
use App\Actions\Interview\ScheduleInterviewAction;
|
||||
use App\Actions\Interview\StoreRecordingChunkAction;
|
||||
use App\DTOs\Interview\ScheduleInterviewDto;
|
||||
use App\Http\Requests\Interview\StoreInterviewRequest;
|
||||
use App\Http\Requests\Interview\UploadRecordingChunkRequest;
|
||||
use App\Http\Responses\Interview\RecordingChunkReceivedResponse;
|
||||
use App\Http\Responses\Interview\RecordingFinalizedResponse;
|
||||
use App\Models\Interview;
|
||||
use App\Models\InterviewWarning;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\AiCheatingDetectorService;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class InterviewController extends Controller
|
||||
{
|
||||
@ -71,45 +79,14 @@ public function show($id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new Candidate Interview session created by HR.
|
||||
* Store and schedule a new Candidate Interview session.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
public function store(StoreInterviewRequest $request, ScheduleInterviewAction $scheduleAction)
|
||||
{
|
||||
$request->validate([
|
||||
'candidate_name' => 'required|string|max:255',
|
||||
'candidate_email' => 'required|email|max:255',
|
||||
'candidate_phone' => 'required|string|max:50',
|
||||
'scheduled_at' => 'nullable|date',
|
||||
'valid_hours' => 'required|integer|min:1|max:72',
|
||||
'language' => 'required|string',
|
||||
'assigned_interviewers' => 'nullable|array',
|
||||
]);
|
||||
$dto = ScheduleInterviewDto::fromRequest($request);
|
||||
$interview = $scheduleAction->execute($dto, Auth::id());
|
||||
|
||||
|
||||
$tempPassword = 'Pass-' . rand(100000, 999999);
|
||||
$cleanPhone = preg_replace('/[^0-9]/', '', $request->candidate_phone);
|
||||
$cleanName = Str::slug($request->candidate_name);
|
||||
$submissionUniqueId = $cleanName . '_' . $cleanPhone . '_' . time();
|
||||
|
||||
$scheduledAt = $request->scheduled_at ? \Carbon\Carbon::parse($request->scheduled_at) : now();
|
||||
$expiresAt = $scheduledAt->copy()->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,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'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!');
|
||||
return redirect()->route('interview.index')->with('success', 'Interview session scheduled and invitations sent for '.$interview->candidate_name.'. Temporary login credentials generated!');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -148,6 +125,7 @@ public function loginCandidate(Request $request)
|
||||
|
||||
if ($interview->isExpired()) {
|
||||
$interview->update(['status' => 'expired']);
|
||||
|
||||
return back()->with('error', 'This candidate interview access window has expired. Please contact HR.');
|
||||
}
|
||||
|
||||
@ -174,6 +152,7 @@ public function showCandidateRoom($uniqueId)
|
||||
|
||||
if ($interview->isExpired()) {
|
||||
$interview->update(['status' => 'expired']);
|
||||
|
||||
return response()->view('interview.expired', compact('interview'), 403);
|
||||
}
|
||||
|
||||
@ -233,7 +212,7 @@ public function executeCode(Request $request)
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Judge0 Code execute failed.", [$e->getMessage()]);
|
||||
Log::warning('Judge0 Code execute failed.', [$e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,13 +220,13 @@ public function executeCode(Request $request)
|
||||
if (empty($output)) {
|
||||
try {
|
||||
if (in_array($langKey, ['javascript', 'js'])) {
|
||||
$process = new \Symfony\Component\Process\Process(['node', '-e', $code]);
|
||||
$process = new Process(['node', '-e', $code]);
|
||||
$process->setTimeout(3);
|
||||
$process->run();
|
||||
$out = $process->getOutput() ?: $process->getErrorOutput();
|
||||
$output = trim($out) ?: 'JavaScript executed successfully (no stdout).';
|
||||
} elseif ($langKey === 'python') {
|
||||
$process = new \Symfony\Component\Process\Process(['python', '-c', $code]);
|
||||
$process = new Process(['python', '-c', $code]);
|
||||
$process->setTimeout(3);
|
||||
$process->run();
|
||||
$out = $process->getOutput() ?: $process->getErrorOutput();
|
||||
@ -264,7 +243,8 @@ public function executeCode(Request $request)
|
||||
$output = 'PHP Evaluation Error: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($output)) {
|
||||
@ -310,7 +290,7 @@ public function submitCode(Request $request, $id)
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Code submitted successfully under unique ID: '.$interview->submission_unique_id,
|
||||
'unique_id' => $interview->submission_unique_id
|
||||
'unique_id' => $interview->submission_unique_id,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -333,7 +313,7 @@ public function logViolation(Request $request, $id)
|
||||
// Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call
|
||||
$aiVerdict = null;
|
||||
if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) {
|
||||
$aiDetector = new \App\Services\AiCheatingDetectorService();
|
||||
$aiDetector = new AiCheatingDetectorService;
|
||||
$aiVerdict = $aiDetector->analyzeSpeechTranscript($detailsStr, $interview->candidate_notes ?? '');
|
||||
}
|
||||
|
||||
@ -407,6 +387,7 @@ public function syncCandidateCode(Request $request, $id)
|
||||
$updateData['code_output'] = $request->input('output');
|
||||
}
|
||||
$interview->update($updateData);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
@ -417,6 +398,7 @@ public function saveCandidateNotes(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview->update(['candidate_notes' => $request->input('notes')]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
@ -427,6 +409,7 @@ public function saveCandidateDrawing(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview->update(['candidate_drawing' => $request->input('drawing')]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
@ -438,6 +421,7 @@ public function regeneratePassword($id)
|
||||
$interview = Interview::findOrFail($id);
|
||||
$newPass = 'Pass-'.rand(100000, 999999);
|
||||
$interview->update(['temp_password' => $newPass]);
|
||||
|
||||
return response()->json(['success' => true, 'new_password' => $newPass]);
|
||||
}
|
||||
|
||||
@ -460,7 +444,7 @@ public function blockCandidate(Request $request, $id)
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Candidate ' . $interview->candidate_name . ' (' . $interview->candidate_email . ') and IP ' . $clientIp . ' have been blocked successfully.'
|
||||
'message' => 'Candidate '.$interview->candidate_name.' ('.$interview->candidate_email.') and IP '.$clientIp.' have been blocked successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
@ -506,7 +490,9 @@ public function serveStorageFile($path)
|
||||
// Read first 12 bytes to accurately detect container format (WebM vs MP4 vs Image)
|
||||
$handle = @fopen($filePath, 'rb');
|
||||
$header = $handle ? fread($handle, 12) : '';
|
||||
if ($handle) fclose($handle);
|
||||
if ($handle) {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$isWebmHeader = str_starts_with($header, "\x1A\x45\xDF\xA3");
|
||||
@ -555,7 +541,9 @@ public function serveStorageFile($path)
|
||||
while ($bytesLeft > 0 && ! feof($file)) {
|
||||
$readSize = min($bufferSize, $bytesLeft);
|
||||
$data = fread($file, $readSize);
|
||||
if ($data === false) break;
|
||||
if ($data === false) {
|
||||
break;
|
||||
}
|
||||
echo $data;
|
||||
flush();
|
||||
$bytesLeft -= strlen($data);
|
||||
@ -566,6 +554,7 @@ public function serveStorageFile($path)
|
||||
}
|
||||
|
||||
$headers['Content-Length'] = (string) $fileSize;
|
||||
|
||||
return response()->file($filePath, $headers);
|
||||
}
|
||||
|
||||
@ -616,6 +605,7 @@ private function getFormattedRecordings($interview)
|
||||
usort($formatted, function ($a, $b) {
|
||||
$timeA = isset($a['created_at']) ? strtotime($a['created_at']) : 0;
|
||||
$timeB = isset($b['created_at']) ? strtotime($b['created_at']) : 0;
|
||||
|
||||
return $timeB <=> $timeA;
|
||||
});
|
||||
|
||||
@ -644,6 +634,7 @@ public function getActiveCalls(Request $request)
|
||||
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
|
||||
$interview->call_status = 'ended';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -706,7 +697,7 @@ public function getPollData($id)
|
||||
|
||||
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
|
||||
|
||||
$globalScreenshotsEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1';
|
||||
$globalScreenshotsEnabled = Setting::get('enable_candidate_screenshots', '1') === '1';
|
||||
$interviewScreenshotsEnabled = (bool) ($interview->enable_tab_switch_screenshot ?? true);
|
||||
$effectiveScreenshotEnabled = $globalScreenshotsEnabled && $interviewScreenshotsEnabled;
|
||||
|
||||
@ -843,13 +834,13 @@ public function uploadTabScreenshot(Request $request, $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
$globalEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1';
|
||||
$globalEnabled = Setting::get('enable_candidate_screenshots', '1') === '1';
|
||||
$interviewEnabled = ! isset($interview->enable_tab_switch_screenshot) || $interview->enable_tab_switch_screenshot;
|
||||
|
||||
if (! $globalEnabled || ! $interviewEnabled) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Candidate system screenshot capture is disabled by admin setting.'
|
||||
'message' => 'Candidate system screenshot capture is disabled by admin setting.',
|
||||
]);
|
||||
}
|
||||
|
||||
@ -959,7 +950,7 @@ public function toggleTabScreenshot(Request $request, $id)
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'enable_tab_switch_screenshot' => $enable,
|
||||
'message' => $enable ? 'Tab-switch screenshot capture ENABLED.' : 'Tab-switch screenshot capture DISABLED.'
|
||||
'message' => $enable ? 'Tab-switch screenshot capture ENABLED.' : 'Tab-switch screenshot capture DISABLED.',
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1019,6 +1010,7 @@ public function uploadRecording(Request $request, $id)
|
||||
return response()->json(['success' => false, 'message' => 'No video payload received (file may exceed server upload size limit).'], 422);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Recording upload failed: '.$e->getMessage());
|
||||
|
||||
return response()->json(['success' => false, 'message' => 'Server error saving recording: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
@ -1026,11 +1018,8 @@ public function uploadRecording(Request $request, $id)
|
||||
/**
|
||||
* Handle live chunked video recording upload from the interviewer proctoring session.
|
||||
*
|
||||
* @param UploadRecordingChunkRequest $request
|
||||
* @param int|string $id
|
||||
* @param StoreRecordingChunkAction $storeChunkAction
|
||||
* @param FinalizeRecordingAction $finalizeRecordingAction
|
||||
* @return \Illuminate\Http\JsonResponse|\Illuminate\Contracts\Support\Responsable
|
||||
* @return JsonResponse|Responsable
|
||||
*/
|
||||
public function uploadRecordingChunk(
|
||||
UploadRecordingChunkRequest $request,
|
||||
@ -1076,7 +1065,7 @@ public function uploadRecordingChunk(
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error processing recording chunk: ' . $e->getMessage()
|
||||
'message' => 'Error processing recording chunk: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@ -1135,13 +1124,16 @@ public function downloadRecording(Request $request, $id)
|
||||
if ($filePath && file_exists($filePath)) {
|
||||
$handle = @fopen($filePath, 'rb');
|
||||
$header = $handle ? fread($handle, 12) : '';
|
||||
if ($handle) fclose($handle);
|
||||
if ($handle) {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
$isWebm = str_starts_with($header, "\x1A\x45\xDF\xA3") || str_ends_with($filePath, '.webm');
|
||||
$ext = $isWebm ? 'webm' : 'mp4';
|
||||
$mimeType = $isWebm ? 'video/webm' : 'video/mp4';
|
||||
|
||||
$downloadFilename = 'candidate_'.Str::slug($interview->candidate_name).'_recording_'.($index + 1).'.'.$ext;
|
||||
|
||||
return response()->download($filePath, $downloadFilename, [
|
||||
'Content-Type' => $mimeType,
|
||||
]);
|
||||
@ -1167,12 +1159,24 @@ public function generateReport($id)
|
||||
|
||||
foreach ($logs as $l) {
|
||||
$type = $l['type'] ?? '';
|
||||
if ($type === 'tab_switch') $tabSwitches++;
|
||||
if ($type === 'focus_lost') $focusLosses++;
|
||||
if (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) $gazeAnomalies++;
|
||||
if ($type === 'paste_event') $pasteEvents++;
|
||||
if ($type === 'gaze_lower_device') $lowerGazeViolations++;
|
||||
if (in_array($type, ['question_repeat_lower', 'question_repetition'])) $questionRepeatViolations++;
|
||||
if ($type === 'tab_switch') {
|
||||
$tabSwitches++;
|
||||
}
|
||||
if ($type === 'focus_lost') {
|
||||
$focusLosses++;
|
||||
}
|
||||
if (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) {
|
||||
$gazeAnomalies++;
|
||||
}
|
||||
if ($type === 'paste_event') {
|
||||
$pasteEvents++;
|
||||
}
|
||||
if ($type === 'gaze_lower_device') {
|
||||
$lowerGazeViolations++;
|
||||
}
|
||||
if (in_array($type, ['question_repeat_lower', 'question_repetition'])) {
|
||||
$questionRepeatViolations++;
|
||||
}
|
||||
}
|
||||
|
||||
$totalViolations = count($logs);
|
||||
@ -1226,4 +1230,25 @@ public function generateReport($id)
|
||||
'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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
62
app/Http/Requests/Interview/StoreInterviewRequest.php
Normal file
62
app/Http/Requests/Interview/StoreInterviewRequest.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Interview;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInterviewRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'candidate_name' => ['required', 'string', 'max:255'],
|
||||
'candidate_email' => ['required', 'email', 'max:255'],
|
||||
'candidate_phone' => ['required', 'string', 'max:50'],
|
||||
'job_title' => ['required', 'string', 'max:255'],
|
||||
'round' => ['nullable', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
'valid_hours' => ['required', 'integer', 'min:1', 'max:72'],
|
||||
'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.*' => ['integer', 'exists:users,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'candidate_name.required' => 'The candidate name is required.',
|
||||
'candidate_email.required' => 'The candidate email address is required.',
|
||||
'candidate_email.email' => 'Please provide a valid candidate email address.',
|
||||
'candidate_phone.required' => 'The candidate phone number is required.',
|
||||
'job_title.required' => 'The job title / position is required.',
|
||||
'valid_hours.required' => 'Access duration is required.',
|
||||
'language.required' => 'Coding language selection is required.',
|
||||
'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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Http/Resources/Interview/IceCandidateResource.php
Normal file
40
app/Http/Resources/Interview/IceCandidateResource.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Interview;
|
||||
|
||||
use App\DTOs\IceCandidateDto;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class IceCandidateResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Disable the default "data" wrapper.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
public static $wrap = null;
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
if ($this->resource instanceof IceCandidateDto) {
|
||||
return $this->resource->toArray();
|
||||
}
|
||||
|
||||
if (is_array($this->resource)) {
|
||||
return array_filter([
|
||||
'urls' => $this->resource['urls'] ?? $this->resource['url'] ?? null,
|
||||
'username' => $this->resource['username'] ?? null,
|
||||
'credential' => $this->resource['credential'] ?? $this->resource['password'] ?? null,
|
||||
'credentialType' => $this->resource['credentialType'] ?? null,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
return (array) $this->resource;
|
||||
}
|
||||
}
|
||||
31
app/Http/Resources/Interview/IceCandidatesResource.php
Normal file
31
app/Http/Resources/Interview/IceCandidatesResource.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Interview;
|
||||
|
||||
use App\DTOs\IceCandidatesDto;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class IceCandidatesResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Disable the default "data" wrapper.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
public static $wrap = null;
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
if ($this->resource instanceof IceCandidatesDto) {
|
||||
return $this->resource->toArray();
|
||||
}
|
||||
|
||||
return (array) $this->resource;
|
||||
}
|
||||
}
|
||||
64
app/Mail/CandidateInterviewInvitationMail.php
Normal file
64
app/Mail/CandidateInterviewInvitationMail.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Interview;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Attachment;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CandidateInterviewInvitationMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Interview $interview,
|
||||
public readonly string $icsContent,
|
||||
public readonly string $icsFilename = 'invitation.ics',
|
||||
) {
|
||||
$this->afterCommit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$jobTitle = $this->interview->job_title ?: 'Candidate Assessment';
|
||||
$round = $this->interview->round ?: 'R1';
|
||||
|
||||
return new Envelope(
|
||||
subject: "Interview Invitation: {$this->interview->candidate_name} - {$jobTitle} ({$round})",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.interview.candidate-invitation',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [
|
||||
Attachment::fromData(fn () => $this->icsContent, $this->icsFilename)
|
||||
->withMime('text/calendar; charset=UTF-8; method=REQUEST'),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Mail/InterviewReminderMail.php
Normal file
54
app/Mail/InterviewReminderMail.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Interview;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InterviewReminderMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Interview $interview,
|
||||
public readonly string $recipientType = 'candidate', // 'candidate' or 'interviewer'
|
||||
public readonly ?string $recipientName = null,
|
||||
) {
|
||||
$this->afterCommit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$jobTitle = $this->interview->job_title ?: 'Candidate Assessment';
|
||||
$round = $this->interview->round ?: 'R1';
|
||||
|
||||
$prefix = $this->recipientType === 'interviewer'
|
||||
? "Interviewer Reminder (15 Mins): {$this->interview->candidate_name}"
|
||||
: "Interview Reminder (15 Mins): {$this->interview->candidate_name}";
|
||||
|
||||
return new Envelope(
|
||||
subject: "{$prefix} - {$jobTitle} ({$round})",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.interview.reminder',
|
||||
);
|
||||
}
|
||||
}
|
||||
80
app/Mail/InterviewerAssessmentNotificationMail.php
Normal file
80
app/Mail/InterviewerAssessmentNotificationMail.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Interview;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Attachment;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class InterviewerAssessmentNotificationMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Interview $interview,
|
||||
public readonly User $interviewer,
|
||||
public readonly string $icsContent,
|
||||
public readonly string $icsFilename = 'invitation.ics',
|
||||
) {
|
||||
$this->afterCommit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$jobTitle = $this->interview->job_title ?: 'Candidate Assessment';
|
||||
$round = $this->interview->round ?: 'R1';
|
||||
|
||||
return new Envelope(
|
||||
subject: "Panelist Assignment: {$this->interview->candidate_name} - {$jobTitle} ({$round})",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.interview.interviewer-notification',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
$attachments = [
|
||||
Attachment::fromData(fn () => $this->icsContent, $this->icsFilename)
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@ -13,9 +13,14 @@ class Interview extends Model
|
||||
'candidate_name',
|
||||
'candidate_email',
|
||||
'candidate_phone',
|
||||
'job_title',
|
||||
'round',
|
||||
'description',
|
||||
'resume_path',
|
||||
'temp_password',
|
||||
'scheduled_at',
|
||||
'expires_at',
|
||||
'reminder_sent_at',
|
||||
'language',
|
||||
'status',
|
||||
'assigned_interviewers',
|
||||
@ -41,6 +46,7 @@ class Interview extends Model
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'reminder_sent_at' => 'datetime',
|
||||
'call_started_at' => 'datetime',
|
||||
'enable_tab_switch_screenshot' => 'boolean',
|
||||
'assigned_interviewers' => 'array',
|
||||
@ -111,6 +117,7 @@ public function isAssignedInterviewer($userId): bool
|
||||
}
|
||||
|
||||
$interviewers = array_map('strval', (array) $this->assigned_interviewers);
|
||||
|
||||
return in_array((string) $userId, $interviewers, true);
|
||||
}
|
||||
|
||||
@ -124,6 +131,7 @@ public function getCandidateInitialsAttribute(): string
|
||||
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) : ''));
|
||||
}
|
||||
|
||||
@ -133,6 +141,7 @@ public function getCandidateInitialsAttribute(): string
|
||||
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')),
|
||||
@ -151,6 +160,48 @@ public function getProctorMetricsAttribute(): array
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
use SocialiteProviders\Microsoft\MicrosoftExtendSocialite;
|
||||
|
||||
|
||||
38
app/Providers/IceCandidateServiceProvider.php
Normal file
38
app/Providers/IceCandidateServiceProvider.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Contracts\IceCandidateProviderInterface;
|
||||
use App\Services\IceCandidate\IceCandidateManager;
|
||||
use Illuminate\Contracts\Support\DeferrableProvider;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class IceCandidateServiceProvider extends ServiceProvider implements DeferrableProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(IceCandidateManager::class, function ($app) {
|
||||
return new IceCandidateManager($app);
|
||||
});
|
||||
|
||||
$this->app->bind(IceCandidateProviderInterface::class, function ($app) {
|
||||
return $app->make(IceCandidateManager::class);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the services provided by the provider.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function provides(): array
|
||||
{
|
||||
return [
|
||||
IceCandidateManager::class,
|
||||
IceCandidateProviderInterface::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
85
app/Services/IceCandidate/IceCandidateManager.php
Normal file
85
app/Services/IceCandidate/IceCandidateManager.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IceCandidate;
|
||||
|
||||
use App\Contracts\IceCandidateProviderInterface;
|
||||
use App\DTOs\IceCandidatesDto;
|
||||
use App\Services\IceCandidate\Providers\ConvexSolIceCandidateProvider;
|
||||
use App\Services\IceCandidate\Providers\MeteredIceCandidateProvider;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Manager;
|
||||
use Throwable;
|
||||
|
||||
class IceCandidateManager extends Manager implements IceCandidateProviderInterface
|
||||
{
|
||||
public function getDefaultDriver(): string
|
||||
{
|
||||
return $this->config->get('services.ice.default', 'metered');
|
||||
}
|
||||
|
||||
public function getFallbackDriver(): ?string
|
||||
{
|
||||
return $this->config->get('services.ice.fallback', 'convexsol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve ICE candidates with automatic failover and structured logging.
|
||||
*/
|
||||
public function getIceCandidates(): IceCandidatesDto
|
||||
{
|
||||
$primaryDriver = $this->getDefaultDriver();
|
||||
|
||||
try {
|
||||
return $this->driver($primaryDriver)->getIceCandidates();
|
||||
} catch (Throwable $primaryException) {
|
||||
$fallbackDriver = $this->getFallbackDriver();
|
||||
|
||||
if ($fallbackDriver && $fallbackDriver !== $primaryDriver) {
|
||||
Log::warning("Primary ICE candidate provider [{$primaryDriver}] failed: {$primaryException->getMessage()}. Falling back to [{$fallbackDriver}].", [
|
||||
'primary_driver' => $primaryDriver,
|
||||
'fallback_driver' => $fallbackDriver,
|
||||
'error' => $primaryException->getMessage(),
|
||||
]);
|
||||
|
||||
try {
|
||||
return $this->driver($fallbackDriver)->getIceCandidates();
|
||||
} catch (Throwable $fallbackException) {
|
||||
Log::error("Fallback ICE candidate provider [{$fallbackDriver}] also failed: {$fallbackException->getMessage()}. Using default STUN servers.", [
|
||||
'primary_error' => $primaryException->getMessage(),
|
||||
'fallback_error' => $fallbackException->getMessage(),
|
||||
]);
|
||||
|
||||
return IceCandidatesDto::defaultStun();
|
||||
}
|
||||
}
|
||||
|
||||
Log::error("ICE candidate provider [{$primaryDriver}] failed without fallback: {$primaryException->getMessage()}. Using default STUN servers.", [
|
||||
'error' => $primaryException->getMessage(),
|
||||
]);
|
||||
|
||||
return IceCandidatesDto::defaultStun();
|
||||
}
|
||||
}
|
||||
|
||||
protected function createMeteredDriver(): IceCandidateProviderInterface
|
||||
{
|
||||
$config = $this->config->get('services.ice.providers.metered', [
|
||||
'url' => $this->config->get('services.metered.url'),
|
||||
'key' => $this->config->get('services.metered.key'),
|
||||
]);
|
||||
|
||||
return new MeteredIceCandidateProvider($config);
|
||||
}
|
||||
|
||||
protected function createConvexsolDriver(): IceCandidateProviderInterface
|
||||
{
|
||||
$config = $this->config->get('services.ice.providers.convexsol', [
|
||||
'username' => $this->config->get('services.convexsol.username'),
|
||||
'password' => $this->config->get('services.convexsol.password'),
|
||||
'turn_urls' => $this->config->get('services.convexsol.turn_urls'),
|
||||
'stun_urls' => $this->config->get('services.convexsol.stun_urls'),
|
||||
]);
|
||||
|
||||
return new ConvexSolIceCandidateProvider($config);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IceCandidate\Providers;
|
||||
|
||||
use App\Contracts\IceCandidateProviderInterface;
|
||||
use App\DTOs\IceCandidatesDto;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ConvexSolIceCandidateProvider implements IceCandidateProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected array $config = []
|
||||
) {}
|
||||
|
||||
public function getIceCandidates(): IceCandidatesDto
|
||||
{
|
||||
$cacheKey = 'convexsol_coturn_credentials_cache_'.md5(serialize($this->config));
|
||||
$cacheTtl = (int) ($this->config['cache_ttl'] ?? 600);
|
||||
|
||||
$servers = Cache::remember($cacheKey, now()->addSeconds($cacheTtl), function () {
|
||||
$list = [];
|
||||
|
||||
// 1. STUN servers
|
||||
$stunUrls = $this->config['stun_urls'] ?? ['stun:stun.convexsol.com:3478', 'stun:stun.l.google.com:19302'];
|
||||
if (is_string($stunUrls)) {
|
||||
$stunUrls = array_filter(array_map('trim', explode(',', $stunUrls)));
|
||||
}
|
||||
if (! empty($stunUrls)) {
|
||||
$list[] = [
|
||||
'urls' => array_values($stunUrls),
|
||||
];
|
||||
}
|
||||
|
||||
// 2. TURN servers with username and password
|
||||
$turnUrls = $this->config['turn_urls'] ?? ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'];
|
||||
if (is_string($turnUrls)) {
|
||||
$turnUrls = array_filter(array_map('trim', explode(',', $turnUrls)));
|
||||
}
|
||||
|
||||
$username = $this->config['username'] ?? null;
|
||||
$password = $this->config['password'] ?? null;
|
||||
|
||||
if (! empty($turnUrls)) {
|
||||
$turnEntry = [
|
||||
'urls' => array_values($turnUrls),
|
||||
];
|
||||
if ($username !== null) {
|
||||
$turnEntry['username'] = $username;
|
||||
}
|
||||
if ($password !== null) {
|
||||
$turnEntry['credential'] = $password;
|
||||
}
|
||||
$list[] = $turnEntry;
|
||||
}
|
||||
|
||||
return $list;
|
||||
});
|
||||
|
||||
return IceCandidatesDto::fromArray($servers);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IceCandidate\Providers;
|
||||
|
||||
use App\Contracts\IceCandidateProviderInterface;
|
||||
use App\DTOs\IceCandidatesDto;
|
||||
use App\Services\IceCandidate\Validators\IceResponseValidator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class MeteredIceCandidateProvider implements IceCandidateProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected array $config = []
|
||||
) {}
|
||||
|
||||
public function getIceCandidates(): IceCandidatesDto
|
||||
{
|
||||
$baseUrl = $this->config['url'] ?? config('services.metered.url');
|
||||
$apiKey = $this->config['key'] ?? config('services.metered.key');
|
||||
$timeout = (int) ($this->config['timeout'] ?? 5);
|
||||
$cacheTtl = (int) ($this->config['cache_ttl'] ?? 600);
|
||||
|
||||
if (empty($baseUrl)) {
|
||||
throw new RuntimeException('Metered ICE provider URL is not configured.');
|
||||
}
|
||||
|
||||
$url = $baseUrl;
|
||||
if ($apiKey && ! str_contains($url, 'apiKey=')) {
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
$url .= $separator.'apiKey='.urlencode($apiKey);
|
||||
}
|
||||
|
||||
$cacheKey = 'metered_credentials_cache_'.md5($url);
|
||||
|
||||
$data = Cache::remember($cacheKey, now()->addSeconds($cacheTtl), function () use ($url, $timeout) {
|
||||
$response = Http::timeout($timeout)->get($url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Metered API error response', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('Metered API Error: '.$response->status());
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return IceResponseValidator::validate($json, 'Metered');
|
||||
});
|
||||
|
||||
return IceCandidatesDto::fromArray($data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IceCandidate\Validators;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class IceResponseValidator
|
||||
{
|
||||
/**
|
||||
* Validate raw API response payload and normalize into an array of server definitions.
|
||||
*
|
||||
* @return array<array>
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public static function validate(mixed $data, string $providerName = 'ICE Provider'): array
|
||||
{
|
||||
if (! is_array($data)) {
|
||||
throw new RuntimeException("{$providerName} API returned an invalid non-array response.");
|
||||
}
|
||||
|
||||
// Support wrapped responses like ['iceServers' => [...]] or ['v' => [...]]
|
||||
$servers = isset($data['iceServers']) && is_array($data['iceServers'])
|
||||
? $data['iceServers']
|
||||
: (isset($data['v']) && is_array($data['v']) ? $data['v'] : $data);
|
||||
|
||||
if (empty($servers) || ! is_array($servers)) {
|
||||
throw new RuntimeException("{$providerName} API returned an empty or invalid list of ICE servers.");
|
||||
}
|
||||
|
||||
$validServers = [];
|
||||
$validProtocols = ['stun:', 'stuns:', 'turn:', 'turns:'];
|
||||
|
||||
foreach ($servers as $index => $server) {
|
||||
if (! is_array($server)) {
|
||||
throw new RuntimeException("{$providerName} server entry at index [{$index}] must be an object/array.");
|
||||
}
|
||||
|
||||
$rawUrls = $server['urls'] ?? $server['url'] ?? null;
|
||||
if (empty($rawUrls)) {
|
||||
throw new RuntimeException("{$providerName} server entry at index [{$index}] is missing 'urls'.");
|
||||
}
|
||||
|
||||
$urlList = is_array($rawUrls) ? $rawUrls : [$rawUrls];
|
||||
foreach ($urlList as $url) {
|
||||
if (! is_string($url) || trim($url) === '') {
|
||||
throw new RuntimeException("{$providerName} URL at index [{$index}] must be a non-empty string.");
|
||||
}
|
||||
|
||||
$hasValidProtocol = false;
|
||||
foreach ($validProtocols as $protocol) {
|
||||
if (str_starts_with(strtolower(trim($url)), $protocol)) {
|
||||
$hasValidProtocol = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $hasValidProtocol) {
|
||||
throw new RuntimeException("{$providerName} invalid ICE URL scheme [{$url}] at index [{$index}].");
|
||||
}
|
||||
}
|
||||
|
||||
$validServers[] = $server;
|
||||
}
|
||||
|
||||
return $validServers;
|
||||
}
|
||||
}
|
||||
152
app/Services/Interview/InterviewCalendarService.php
Normal file
152
app/Services/Interview/InterviewCalendarService.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\IceCandidateServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
IceCandidateServiceProvider::class,
|
||||
];
|
||||
|
||||
@ -14,7 +14,8 @@
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/socialite": "^5.28",
|
||||
"laravel/tinker": "^3.0",
|
||||
"socialiteproviders/microsoft": "^4.9"
|
||||
"socialiteproviders/microsoft": "^4.9",
|
||||
"spatie/icalendar-generator": "^3.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
61
composer.lock
generated
61
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d8109d7765ee6f6846cd672d742ad3e6",
|
||||
"content-hash": "80d788c07c363103e99cadbacd821b17",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@ -4012,6 +4012,65 @@
|
||||
},
|
||||
"time": "2026-03-26T00:32:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/icalendar-generator",
|
||||
"version": "3.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/icalendar-generator.git",
|
||||
"reference": "6817d3f405563eca1afc9ea870077a898e11bc27"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/icalendar-generator/zipball/6817d3f405563eca1afc9ea870077a898e11bc27",
|
||||
"reference": "6817d3f405563eca1afc9ea870077a898e11bc27",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-json": "*",
|
||||
"larapack/dd": "^1.1",
|
||||
"nesbot/carbon": "^3.5",
|
||||
"pestphp/pest": "^2.34 || ^3.0 || ^4.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"spatie/pest-plugin-snapshots": "^2.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\IcalendarGenerator\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ruben Van Assche",
|
||||
"email": "ruben@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Build calendars in the iCalendar format",
|
||||
"homepage": "https://github.com/spatie/icalendar-generator",
|
||||
"keywords": [
|
||||
"calendar",
|
||||
"iCalendar",
|
||||
"ical",
|
||||
"ics",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/icalendar-generator/issues",
|
||||
"source": "https://github.com/spatie/icalendar-generator/tree/3.3.0"
|
||||
},
|
||||
"time": "2026-03-18T09:51:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v8.1.0",
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -47,4 +47,32 @@
|
||||
'key' => env('METERED_KEY'),
|
||||
],
|
||||
|
||||
'convexsol' => [
|
||||
'username' => env('CONVEXSOL_USERNAME', env('CONVEXSOL_TURN_USERNAME')),
|
||||
'password' => env('CONVEXSOL_PASSWORD', env('CONVEXSOL_TURN_PASSWORD')),
|
||||
'turn_urls' => env('CONVEXSOL_TURN_URLS', 'turn:turn.convexsol.com:3478,turns:turn.convexsol.com:5349'),
|
||||
'stun_urls' => env('CONVEXSOL_STUN_URLS', 'stun:stun.convexsol.com:3478,stun:stun.l.google.com:19302'),
|
||||
],
|
||||
|
||||
'ice' => [
|
||||
'default' => env('ICE_CANDIDATE_PROVIDER', env('ICE_PROVIDER', 'metered')),
|
||||
'fallback' => env('ICE_FALLBACK_PROVIDER', 'convexsol'),
|
||||
'providers' => [
|
||||
'metered' => [
|
||||
'url' => env('METERED_URL'),
|
||||
'key' => env('METERED_KEY'),
|
||||
'timeout' => (int) env('METERED_TIMEOUT', 5),
|
||||
'cache_ttl' => (int) env('METERED_CACHE_TTL', 600),
|
||||
],
|
||||
'convexsol' => [
|
||||
'username' => env('CONVEXSOL_USERNAME', env('CONVEXSOL_TURN_USERNAME')),
|
||||
'password' => env('CONVEXSOL_PASSWORD', env('CONVEXSOL_TURN_PASSWORD')),
|
||||
'turn_urls' => env('CONVEXSOL_TURN_URLS', 'turn:turn.convexsol.com:3478,turns:turn.convexsol.com:5349'),
|
||||
'stun_urls' => env('CONVEXSOL_STUN_URLS', 'stun:stun.convexsol.com:3478,stun:stun.l.google.com:19302'),
|
||||
'timeout' => (int) env('CONVEXSOL_TIMEOUT', 5),
|
||||
'cache_ttl' => (int) env('CONVEXSOL_CACHE_TTL', 600),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->string('job_title')->nullable()->after('candidate_phone');
|
||||
$table->string('round')->default('R1')->after('job_title');
|
||||
$table->text('description')->nullable()->after('round');
|
||||
$table->timestamp('reminder_sent_at')->nullable()->after('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['job_title', 'round', 'description', 'reminder_sent_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -54,3 +54,28 @@ #interviewer-screen-video {
|
||||
transform: scaleX(1) !important;
|
||||
}
|
||||
|
||||
/* Ensure datetime-local, date, and time picker icons are bright pure white on dark backgrounds */
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="time"],
|
||||
input[type="month"],
|
||||
input[type="week"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator,
|
||||
input[type="month"]::-webkit-calendar-picker-indicator,
|
||||
input[type="week"]::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover,
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator:hover,
|
||||
input[type="month"]::-webkit-calendar-picker-indicator:hover,
|
||||
input[type="week"]::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@ -11,3 +11,179 @@ window.initMeteredIceServers = initMeteredIceServers;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.getMeteredIceServers();
|
||||
}
|
||||
|
||||
// Global Button Loader Handler
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-show-loader="true"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
|
||||
if (btn.type === 'submit' && btn.form && !btn.form.checkValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = btn.querySelector('.btn-spinner');
|
||||
const icon = btn.querySelector('.btn-icon');
|
||||
const text = btn.querySelector('.btn-text');
|
||||
|
||||
if (spinner) {
|
||||
spinner.classList.remove('hidden!');
|
||||
if (icon) icon.classList.add('hidden!');
|
||||
}
|
||||
|
||||
const loadingText = btn.getAttribute('data-loading-text');
|
||||
if (loadingText && text) {
|
||||
text.innerText = loadingText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Copy Interview Assessment Credentials Handler
|
||||
window.copyInterviewCredentials = function (btn) {
|
||||
const text = btn.getAttribute('data-credentials');
|
||||
if (!text) return;
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-999999px';
|
||||
textarea.style.top = '-999999px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
copyToClipboard().then(() => {
|
||||
const icon = btn.querySelector('i');
|
||||
const label = btn.querySelector('.copy-label');
|
||||
const originalIconClass = icon ? icon.className : '';
|
||||
const originalText = label ? label.textContent : '';
|
||||
const originalBtnClass = btn.className;
|
||||
|
||||
if (icon) icon.className = 'fa-solid fa-check text-emerald-400 text-[11px]';
|
||||
if (label) {
|
||||
label.textContent = 'Copied!';
|
||||
label.classList.add('text-emerald-300');
|
||||
}
|
||||
btn.classList.add('border-emerald-500/50', 'bg-emerald-500/10');
|
||||
|
||||
setTimeout(() => {
|
||||
if (icon) icon.className = originalIconClass;
|
||||
if (label) {
|
||||
label.textContent = originalText;
|
||||
label.classList.remove('text-emerald-300');
|
||||
}
|
||||
btn.className = originalBtnClass;
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy credentials: ', err);
|
||||
});
|
||||
};
|
||||
|
||||
// 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');
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -379,7 +379,7 @@ export function closeInspectorPanel() {
|
||||
const details = document.getElementById('inspector-details');
|
||||
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 => {
|
||||
const contentEl = document.getElementById(`tab-content-${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');
|
||||
}
|
||||
});
|
||||
|
||||
const expandResumeBtn = document.getElementById('inspector-expand-resume-btn');
|
||||
if (expandResumeBtn) {
|
||||
expandResumeBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export function switchIdeTab(tab, event = null) {
|
||||
@ -413,24 +418,30 @@ export function switchIdeTab(tab, event = null) {
|
||||
|
||||
if (details) details.open = true;
|
||||
|
||||
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots'];
|
||||
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots', 'resume'];
|
||||
const tabMeta = {
|
||||
logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' },
|
||||
notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' },
|
||||
drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' },
|
||||
recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' },
|
||||
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 floatingIcon = document.getElementById('inspector-floating-icon');
|
||||
const expandResumeBtn = document.getElementById('inspector-expand-resume-btn');
|
||||
|
||||
if (tabMeta[tab]) {
|
||||
if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title;
|
||||
if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`;
|
||||
}
|
||||
|
||||
if (expandResumeBtn) {
|
||||
expandResumeBtn.style.display = (tab === 'resume') ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
tabs.forEach(t => {
|
||||
const contentEl = document.getElementById(`tab-content-${t}`);
|
||||
const btnEl = document.getElementById(`tab-btn-${t}`);
|
||||
@ -454,6 +465,15 @@ if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', function(e) {
|
||||
const details = document.getElementById('inspector-details');
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -3,10 +3,14 @@
|
||||
'size' => 'md', // sm, md, lg
|
||||
'type' => 'button',
|
||||
'icon' => null,
|
||||
'showLoader' => false,
|
||||
'loading' => false,
|
||||
'loadingText' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$baseClasses = 'inline-flex items-center gap-1.5 rounded-lg border font-medium text-xs transition-all duration-150 cursor-pointer select-none whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
$showLoader = (bool) ($showLoader || $loading);
|
||||
$baseClasses = 'inline-flex items-center justify-center gap-1.5 rounded-lg border font-medium text-xs transition-all duration-150 cursor-pointer select-none whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
$variants = [
|
||||
'default' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]',
|
||||
@ -31,11 +35,23 @@
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<button type="{{ $type }}" {{ $attributes->twMerge(['class' => $classes]) }}>
|
||||
@if($icon)
|
||||
<i class="{{ $icon }}"></i>
|
||||
<button
|
||||
type="{{ $type }}"
|
||||
@if($showLoader)
|
||||
data-show-loader="true"
|
||||
@if($loadingText) data-loading-text="{{ $loadingText }}" @endif
|
||||
@endif
|
||||
{{ $attributes->twMerge(['class' => $classes]) }}
|
||||
>
|
||||
@if($showLoader)
|
||||
<i class="fa-solid fa-circle-notch fa-spin btn-spinner hidden!"></i>
|
||||
@endif
|
||||
|
||||
@if($icon)
|
||||
<i class="{{ $icon }} btn-icon"></i>
|
||||
@endif
|
||||
|
||||
@if(trim($slot))
|
||||
<span>{{ $slot }}</span>
|
||||
<span class="btn-text">{{ $slot }}</span>
|
||||
@endif
|
||||
</button>
|
||||
|
||||
7
resources/views/components/icons/cv.blade.php
Normal file
7
resources/views/components/icons/cv.blade.php
Normal 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>
|
||||
@ -6,6 +6,7 @@
|
||||
'required' => false,
|
||||
'disabled' => false,
|
||||
'size' => 'md', // sm, md, lg
|
||||
'error' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
@ -15,7 +16,19 @@
|
||||
'lg' => 'px-3.5 py-2.5 text-sm',
|
||||
];
|
||||
|
||||
$classes = 'w-full bg-white/5 border border-slate-700/60 rounded-lg text-white outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 placeholder-slate-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ' . ($sizeClasses[$size] ?? $sizeClasses['md']);
|
||||
$isDateType = in_array($type, ['date', 'datetime-local', 'time', 'month', 'week']);
|
||||
|
||||
$dateClasses = $isDateType
|
||||
? '[&::-webkit-calendar-picker-indicator]:color-scheme:auto '
|
||||
: '';
|
||||
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$hasError = (bool) ($error ?? ($name && $errors->has($name)));
|
||||
$borderClasses = $hasError
|
||||
? 'border-rose-500/80 focus:border-rose-500 focus:ring-1 focus:ring-rose-500/40'
|
||||
: 'border-slate-700/60 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40';
|
||||
|
||||
$classes = 'w-full bg-white/5 border rounded-lg text-white outline-none placeholder-slate-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ' . ($sizeClasses[$size] ?? $sizeClasses['md']) . ' ' . $borderClasses . ' ' . $dateClasses;
|
||||
@endphp
|
||||
|
||||
<input
|
||||
@ -25,5 +38,5 @@
|
||||
@if($placeholder) placeholder="{{ $placeholder }}" @endif
|
||||
@if($required) required @endif
|
||||
@if($disabled) disabled @endif
|
||||
{{ $attributes->merge(['class' => $classes]) }}
|
||||
{{ $attributes->twMerge(['class' => $classes]) }}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
</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>
|
||||
</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>
|
||||
<span id="inspector-floating-text">Proctoring Logs</span>
|
||||
</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
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Tab Content Panes -->
|
||||
<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.recordings-tab />
|
||||
<x-interview.tabs.screenshots-tab />
|
||||
<x-interview.tabs.resume-tab :interview="$interview" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@ -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…</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>
|
||||
@ -2,71 +2,251 @@
|
||||
'interviewers' => [],
|
||||
])
|
||||
|
||||
@php
|
||||
$errorBag = session('errors') ?? (isset($errors) ? $errors : null) ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$hasInterviewErrors = $errorBag->any() && (
|
||||
$errorBag->has('candidate_name') ||
|
||||
$errorBag->has('candidate_email') ||
|
||||
$errorBag->has('candidate_phone') ||
|
||||
$errorBag->has('job_title') ||
|
||||
$errorBag->has('round') ||
|
||||
$errorBag->has('scheduled_at') ||
|
||||
$errorBag->has('valid_hours') ||
|
||||
$errorBag->has('language') ||
|
||||
$errorBag->has('resume') ||
|
||||
$errorBag->has('assigned_interviewers') ||
|
||||
$errorBag->has('assigned_interviewers.*') ||
|
||||
$errorBag->has('description')
|
||||
);
|
||||
@endphp
|
||||
|
||||
<x-drawer
|
||||
id="create-interview-drawer"
|
||||
title="Schedule Candidate Assessment"
|
||||
description="Provision candidate temporary credentials, IDE access timeline, and assign review panelists."
|
||||
width="lg"
|
||||
class="{{ $hasInterviewErrors ? 'active' : '' }}"
|
||||
>
|
||||
<form action="{{ route('interview.store') }}" method="POST">
|
||||
<form action="{{ route('interview.store') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
@if($hasInterviewErrors)
|
||||
<div class="mb-4 bg-rose-500/10 border border-rose-500/30 rounded-xl p-3.5 text-xs text-rose-300 flex items-start gap-2.5">
|
||||
<i class="fa-solid fa-triangle-exclamation text-rose-400 mt-0.5 shrink-0 text-sm"></i>
|
||||
<div class="flex-1">
|
||||
<strong class="block font-semibold text-rose-200">Please correct the following errors:</strong>
|
||||
<ul class="list-disc list-inside mt-1 space-y-0.5 text-[11px] text-rose-300">
|
||||
@foreach($errorBag->all() as $errorMsg)
|
||||
<li>{{ $errorMsg }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<x-label required>Candidate Name</x-label>
|
||||
<x-input type="text" name="candidate_name" required placeholder="e.g. John Doe" />
|
||||
<x-input type="text" name="candidate_name" value="{{ old('candidate_name') }}" required placeholder="e.g. Jhon Doe" />
|
||||
@if($errorBag->has('candidate_name'))
|
||||
<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('candidate_name') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
|
||||
<div>
|
||||
<x-label required>Position / Job Title</x-label>
|
||||
<x-input type="text" name="job_title" value="{{ old('job_title') }}" required placeholder="e.g. AIML Engineer" />
|
||||
@if($errorBag->has('job_title'))
|
||||
<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('job_title') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<x-label>Assessment Round</x-label>
|
||||
<x-input type="text" name="round" value="{{ old('round', 'R1') }}" placeholder="e.g. R1, Technical" />
|
||||
@if($errorBag->has('round'))
|
||||
<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('round') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-label required>Candidate Email</x-label>
|
||||
<x-input type="email" name="candidate_email" required placeholder="john@gmail.com" />
|
||||
<x-input type="email" name="candidate_email" value="{{ old('candidate_email') }}" required placeholder="candidate@example.com" />
|
||||
@if($errorBag->has('candidate_email'))
|
||||
<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('candidate_email') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
|
||||
<div>
|
||||
<x-label required>Phone Number</x-label>
|
||||
<x-input type="text" name="candidate_phone" required placeholder="9876543210" />
|
||||
<x-input type="text" name="candidate_phone" value="{{ old('candidate_phone') }}" required placeholder="9XXXXXXXXX" />
|
||||
@if($errorBag->has('candidate_phone'))
|
||||
<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('candidate_phone') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<x-label required>Access Duration</x-label>
|
||||
<x-select name="valid_hours" required>
|
||||
<option value="1">1 Hour</option>
|
||||
<option value="2" selected>2 Hours</option>
|
||||
<option value="4">4 Hours</option>
|
||||
<option value="12">12 Hours</option>
|
||||
<option value="24">24 Hours</option>
|
||||
<option value="1" @selected(old('valid_hours') === '1')>1 Hour</option>
|
||||
<option value="2" @selected(old('valid_hours', '2') === '2')>2 Hours</option>
|
||||
<option value="4" @selected(old('valid_hours') === '4')>4 Hours</option>
|
||||
<option value="12" @selected(old('valid_hours') === '12')>12 Hours</option>
|
||||
<option value="24" @selected(old('valid_hours') === '24')>24 Hours</option>
|
||||
</x-select>
|
||||
@if($errorBag->has('valid_hours'))
|
||||
<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('valid_hours') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
|
||||
<div>
|
||||
<x-label>Start Time (Timeline)</x-label>
|
||||
<x-input type="datetime-local" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="bg-slate-900" />
|
||||
<x-label for="scheduled_at">Start Time (Timeline)</x-label>
|
||||
<x-input type="datetime-local" id="scheduled_at" name="scheduled_at" value="{{ old('scheduled_at', now()->format('Y-m-d\TH:i')) }}" class="bg-slate-900" />
|
||||
@if($errorBag->has('scheduled_at'))
|
||||
<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('scheduled_at') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-label required>Coding Language</x-label>
|
||||
<x-select name="language" required>
|
||||
<option value="python">Python 3</option>
|
||||
<option value="cpp">C++ (GCC)</option>
|
||||
<option value="c">C (GCC)</option>
|
||||
<option value="java">Java 15</option>
|
||||
<option value="php">PHP 8.2 / Laravel</option>
|
||||
<option value="javascript">JavaScript (Node.js)</option>
|
||||
<option value="python" @selected(old('language', 'python') === 'python')>Python 3</option>
|
||||
<option value="cpp" @selected(old('language') === 'cpp')>C++ (GCC)</option>
|
||||
<option value="c" @selected(old('language') === 'c')>C (GCC)</option>
|
||||
<option value="java" @selected(old('language') === 'java')>Java 15</option>
|
||||
<option value="php" @selected(old('language') === 'php')>PHP 8.2 / Laravel</option>
|
||||
<option value="javascript" @selected(old('language') === 'javascript')>JavaScript (Node.js)</option>
|
||||
</x-select>
|
||||
@if($errorBag->has('language'))
|
||||
<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('language') }}
|
||||
</p>
|
||||
@endif
|
||||
</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>
|
||||
<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>
|
||||
@if($errorBag->has('description'))
|
||||
<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('description') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<x-label>Assign Internal Interviewers / Panelists</x-label>
|
||||
</div>
|
||||
<div class="space-y-2 max-h-[160px] overflow-y-auto bg-black/30 p-3 rounded-xl border border-slate-700/60">
|
||||
<div class="space-y-2 max-h-35 overflow-y-auto bg-black/30 p-3 rounded-xl border @if($errorBag->has('assigned_interviewers')) border-rose-500/80 @else border-slate-700/60 @endif">
|
||||
@foreach($interviewers as $usr)
|
||||
<label class="flex items-center gap-2.5 text-xs text-white cursor-pointer hover:text-indigo-300 transition-colors py-0.5">
|
||||
<input type="checkbox" name="assigned_interviewers[]" value="{{ $usr->id }}" class="accent-indigo-500 rounded w-4 h-4">
|
||||
<input type="checkbox" name="assigned_interviewers[]" value="{{ $usr->id }}" @checked(in_array($usr->id, (array) old('assigned_interviewers', []))) class="accent-indigo-500 rounded w-4 h-4">
|
||||
<span>{{ $usr->name }} <span class="text-slate-400 font-mono text-[11px]">({{ $usr->email }})</span></span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@if($errorBag->has('assigned_interviewers'))
|
||||
<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('assigned_interviewers') }}
|
||||
</p>
|
||||
@endif
|
||||
@if($errorBag->has('assigned_interviewers.*'))
|
||||
<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('assigned_interviewers.*') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -74,8 +254,8 @@
|
||||
<x-button type="button" onclick="document.getElementById('create-interview-drawer').classList.remove('active')" variant="secondary">
|
||||
Cancel
|
||||
</x-button>
|
||||
<x-button type="submit" variant="primary" icon="fa-solid fa-check">
|
||||
Create Session & Password
|
||||
<x-button type="submit" variant="primary" icon="fa-solid fa-paper-plane" :showLoader="true" loadingText="Scheduling...">
|
||||
Schedule
|
||||
</x-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -2,73 +2,176 @@
|
||||
'interviewers' => [],
|
||||
])
|
||||
|
||||
<x-modal id="create-interview-modal" title="Schedule Candidate Assessment" maxWidth="lg">
|
||||
@php
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$hasInterviewErrors = $errors->any() && (
|
||||
$errors->has('candidate_name') ||
|
||||
$errors->has('candidate_email') ||
|
||||
$errors->has('candidate_phone') ||
|
||||
$errors->has('job_title') ||
|
||||
$errors->has('round') ||
|
||||
$errors->has('scheduled_at') ||
|
||||
$errors->has('valid_hours') ||
|
||||
$errors->has('language') ||
|
||||
$errors->has('assigned_interviewers') ||
|
||||
$errors->has('assigned_interviewers.*') ||
|
||||
$errors->has('description')
|
||||
);
|
||||
@endphp
|
||||
|
||||
<x-modal id="create-interview-modal" title="Schedule Candidate Assessment" maxWidth="lg" class="{{ $hasInterviewErrors ? 'active' : '' }}">
|
||||
<form action="{{ route('interview.store') }}" method="POST">
|
||||
@csrf
|
||||
|
||||
@if($hasInterviewErrors)
|
||||
<div class="mb-4 bg-rose-500/10 border border-rose-500/30 rounded-xl p-3.5 text-xs text-rose-300 flex items-start gap-2.5">
|
||||
<i class="fa-solid fa-triangle-exclamation text-rose-400 mt-0.5 shrink-0 text-sm"></i>
|
||||
<div class="flex-1">
|
||||
<strong class="block font-semibold text-rose-200">Please correct the following errors:</strong>
|
||||
<ul class="list-disc list-inside mt-1 space-y-0.5 text-[11px] text-rose-300">
|
||||
@foreach($errors->all() as $errorMsg)
|
||||
<li>{{ $errorMsg }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-3.5">
|
||||
<div>
|
||||
<x-label required>Candidate Name</x-label>
|
||||
<x-input type="text" name="candidate_name" required placeholder="e.g. John Doe" />
|
||||
<x-input type="text" name="candidate_name" value="{{ old('candidate_name') }}" required placeholder="e.g. Soumyadeep Mondal" />
|
||||
@error('candidate_name')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<x-label required>Candidate Email</x-label>
|
||||
<x-input type="email" name="candidate_email" required placeholder="john@gmail.com" />
|
||||
<x-input type="email" name="candidate_email" value="{{ old('candidate_email') }}" required placeholder="candidate@example.com" />
|
||||
@error('candidate_email')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-3.5">
|
||||
<div>
|
||||
<x-label required>Position / Job Title</x-label>
|
||||
<x-input type="text" name="job_title" value="{{ old('job_title') }}" required placeholder="e.g. AIML Engineer" />
|
||||
@error('job_title')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<x-label>Assessment Round</x-label>
|
||||
<x-input type="text" name="round" value="{{ old('round', 'R1') }}" placeholder="e.g. R1, Technical" />
|
||||
@error('round')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3.5">
|
||||
<div>
|
||||
<x-label required>Phone Number</x-label>
|
||||
<x-input type="text" name="candidate_phone" required placeholder="9876543210" />
|
||||
<x-input type="text" name="candidate_phone" value="{{ old('candidate_phone') }}" required placeholder="9876543210" />
|
||||
@error('candidate_phone')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<x-label>Start Time (Timeline)</x-label>
|
||||
<x-input type="datetime-local" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="bg-slate-900" />
|
||||
<x-input type="datetime-local" name="scheduled_at" value="{{ old('scheduled_at', now()->format('Y-m-d\TH:i')) }}" class="bg-slate-900" />
|
||||
@error('scheduled_at')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<x-label required>Access Duration</x-label>
|
||||
<x-select name="valid_hours" required>
|
||||
<option value="1">1 Hour</option>
|
||||
<option value="2" selected>2 Hours</option>
|
||||
<option value="4">4 Hours</option>
|
||||
<option value="12">12 Hours</option>
|
||||
<option value="24">24 Hours</option>
|
||||
<option value="1" @selected(old('valid_hours') === '1')>1 Hour</option>
|
||||
<option value="2" @selected(old('valid_hours', '2') === '2')>2 Hours</option>
|
||||
<option value="4" @selected(old('valid_hours') === '4')>4 Hours</option>
|
||||
<option value="12" @selected(old('valid_hours') === '12')>12 Hours</option>
|
||||
<option value="24" @selected(old('valid_hours') === '24')>24 Hours</option>
|
||||
</x-select>
|
||||
@error('valid_hours')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3.5">
|
||||
<x-label required>Coding Language</x-label>
|
||||
<x-select name="language" required>
|
||||
<option value="python">Python 3</option>
|
||||
<option value="cpp">C++ (GCC)</option>
|
||||
<option value="c">C (GCC)</option>
|
||||
<option value="java">Java 15</option>
|
||||
<option value="php">PHP 8.2 / Laravel</option>
|
||||
<option value="javascript">JavaScript (Node.js)</option>
|
||||
<option value="python" @selected(old('language', 'python') === 'python')>Python 3</option>
|
||||
<option value="cpp" @selected(old('language') === 'cpp')>C++ (GCC)</option>
|
||||
<option value="c" @selected(old('language') === 'c')>C (GCC)</option>
|
||||
<option value="java" @selected(old('language') === 'java')>Java 15</option>
|
||||
<option value="php" @selected(old('language') === 'php')>PHP 8.2 / Laravel</option>
|
||||
<option value="javascript" @selected(old('language') === 'javascript')>JavaScript (Node.js)</option>
|
||||
</x-select>
|
||||
@error('language')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3.5">
|
||||
<x-label>Instructions / Notes (Optional)</x-label>
|
||||
<textarea name="description" rows="2" class="w-full bg-slate-900 border @error('description') border-rose-500/80 focus:border-rose-500 @else border-slate-700/60 focus:border-indigo-500 @enderror 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>
|
||||
@error('description')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<x-label>Assign Internal Interviewers / Panelists</x-label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[120px] overflow-y-auto bg-black/30 p-2.5 rounded-lg border border-slate-700/60">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[120px] overflow-y-auto bg-black/30 p-2.5 rounded-lg border @error('assigned_interviewers') border-rose-500/80 @else border-slate-700/60 @enderror">
|
||||
@foreach($interviewers as $usr)
|
||||
<label class="flex items-center gap-2 text-xs text-white cursor-pointer hover:text-indigo-300 transition-colors">
|
||||
<input type="checkbox" name="assigned_interviewers[]" value="{{ $usr->id }}" class="accent-indigo-500 rounded">
|
||||
<input type="checkbox" name="assigned_interviewers[]" value="{{ $usr->id }}" @checked(in_array($usr->id, (array) old('assigned_interviewers', []))) class="accent-indigo-500 rounded">
|
||||
{{ $usr->name }} ({{ $usr->email }})
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('assigned_interviewers')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
@error('assigned_interviewers.*')
|
||||
<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> {{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2.5 mt-5">
|
||||
<x-button type="button" onclick="document.getElementById('create-interview-modal').classList.remove('active')" variant="secondary">
|
||||
Cancel
|
||||
</x-button>
|
||||
<x-button type="submit" variant="primary" icon="fa-solid fa-check">
|
||||
Create Session & Password
|
||||
<x-button type="submit" variant="primary" icon="fa-solid fa-paper-plane" :showLoader="true" loadingText="Scheduling...">
|
||||
Schedule & Send Invites
|
||||
</x-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -8,14 +8,51 @@
|
||||
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
|
||||
$interview->call_status = 'ended';
|
||||
}
|
||||
|
||||
$loginUrl = route('interview.candidate.login');
|
||||
$credentialsText = implode("\n", array_filter([
|
||||
"Candidate Assessment Credentials",
|
||||
"--------------------------------",
|
||||
"Candidate: " . $interview->candidate_name . (!empty($interview->round) ? " ({$interview->round})" : ""),
|
||||
!empty($interview->job_title) ? "Position: {$interview->job_title}" : null,
|
||||
"Email: {$interview->candidate_email}",
|
||||
"Phone: {$interview->candidate_phone}",
|
||||
"Login URL: {$loginUrl}",
|
||||
"Temp Password: {$interview->temp_password}",
|
||||
"Timeline: {$interview->formatted_timeline}",
|
||||
]));
|
||||
@endphp
|
||||
|
||||
<tr class="hover:bg-white/[0.02] transition-colors">
|
||||
<!-- Candidate Info & Temp Pass -->
|
||||
<td class="py-3.5 px-4">
|
||||
<div>
|
||||
<strong class="text-white text-sm block">{{ $interview->candidate_name }}</strong>
|
||||
<div class="text-xs text-slate-400 mt-0.5">{{ $interview->candidate_email }} • {{ $interview->candidate_phone }}</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<strong class="text-white text-sm block truncate">{{ $interview->candidate_name }}</strong>
|
||||
@if(!empty($interview->round))
|
||||
<span class="bg-indigo-500/20 text-indigo-300 text-[10px] font-semibold px-1.5 py-0.5 rounded border border-indigo-500/30 font-mono shrink-0">{{ $interview->round }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if(!empty($interview->job_title))
|
||||
<div class="text-[11px] text-slate-300 font-medium mt-0.5 truncate">{{ $interview->job_title }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Copy Credentials Button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick="copyInterviewCredentials(this)"
|
||||
data-credentials="{{ $credentialsText }}"
|
||||
title="Copy candidate & meeting credentials"
|
||||
class="copy-cred-btn inline-flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium rounded-md bg-white/5 border border-slate-700/60 text-slate-300 hover:text-white hover:bg-indigo-500/20 hover:border-indigo-500/40 transition-all cursor-pointer shrink-0 group select-none"
|
||||
>
|
||||
<i class="fa-regular fa-copy text-[11px] group-hover:scale-110 transition-transform"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-slate-400 mt-1">{{ $interview->candidate_email }} • {{ $interview->candidate_phone }}</div>
|
||||
<div class="mt-1.5 flex flex-col gap-1">
|
||||
<div class="text-[0.7rem] text-emerald-300 bg-emerald-500/10 px-2 py-0.5 rounded inline-block w-fit">
|
||||
<i class="fa-solid fa-key text-[0.65rem] mr-1"></i> Temp Pass: <strong>{{ $interview->temp_password }}</strong>
|
||||
|
||||
@ -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>
|
||||
@ -44,6 +44,9 @@
|
||||
<!-- Global shared Incoming Call Modal -->
|
||||
<x-interview.incoming-call-modal />
|
||||
|
||||
<!-- Global shared Candidate Resume Lightbox Modal -->
|
||||
<x-interview.resume-lightbox-modal />
|
||||
|
||||
<script>
|
||||
window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }};
|
||||
window.currentUserId = {{ Auth::id() ?? 'null' }};
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
'required' => false,
|
||||
'disabled' => false,
|
||||
'size' => 'md',
|
||||
'error' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
@ -12,7 +13,13 @@
|
||||
'lg' => 'px-3.5 py-2.5 text-sm',
|
||||
];
|
||||
|
||||
$classes = 'w-full bg-slate-900 border border-slate-700/60 rounded-lg text-white outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ' . ($sizeClasses[$size] ?? $sizeClasses['md']);
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$hasError = (bool) ($error ?? ($name && $errors->has($name)));
|
||||
$borderClasses = $hasError
|
||||
? 'border-rose-500/80 focus:border-rose-500 focus:ring-1 focus:ring-rose-500/40'
|
||||
: 'border-slate-700/60 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40';
|
||||
|
||||
$classes = 'w-full bg-slate-900 border rounded-lg text-white outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed ' . ($sizeClasses[$size] ?? $sizeClasses['md']) . ' ' . $borderClasses;
|
||||
@endphp
|
||||
|
||||
<select
|
||||
|
||||
225
resources/views/emails/interview/candidate-invitation.blade.php
Normal file
225
resources/views/emails/interview/candidate-invitation.blade.php
Normal file
@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Interview Invitation - SingleLogin</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #0b0f19;
|
||||
color: #e2e8f0;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #131b2e;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #4338ca 0%, #312e81 100%);
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
margin: 0 0 6px 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.content {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.lead-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.card-label {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
.card-value {
|
||||
color: #f1f5f9;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.credentials-box {
|
||||
background: #1e1b4b;
|
||||
border: 1px solid #4f46e5;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.credentials-box h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #c7d2fe;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.temp-pass {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #38bdf8;
|
||||
background: #0f172a;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
letter-spacing: 1px;
|
||||
border: 1px dashed #38bdf8;
|
||||
}
|
||||
.btn-container {
|
||||
text-align: center;
|
||||
margin: 28px 0;
|
||||
}
|
||||
.btn {
|
||||
background: #4f46e5;
|
||||
color: #ffffff !important;
|
||||
padding: 12px 28px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
}
|
||||
.rsvp-notice {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 13px;
|
||||
color: #6ee7b7;
|
||||
}
|
||||
.rsvp-notice strong {
|
||||
color: #a7f3d0;
|
||||
}
|
||||
.instructions {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
border-top: 1px solid #1e293b;
|
||||
padding-top: 18px;
|
||||
}
|
||||
.footer {
|
||||
background: #0b0f19;
|
||||
padding: 16px 24px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
border-top: 1px solid #1e293b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Interview Assessment Invitation</h1>
|
||||
<p>SingleLogin Assessment & Technical Evaluation Portal</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Hello {{ $interview->candidate_name }},</div>
|
||||
<div class="lead-text">
|
||||
You have been invited for a technical assessment session.
|
||||
Please accept the invitation to appear in the assesment. You are requested to attend the interview from Laptop with a proper internet Connection.
|
||||
<br />
|
||||
Please review your session details and login credentials below.
|
||||
</div>
|
||||
|
||||
<!-- Assessment Details Card -->
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<span class="card-label">Position / Role</span>
|
||||
<span class="card-value">{{ $interview->job_title ?? 'Candidate Assessment' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Round</span>
|
||||
<span class="card-value">{{ $interview->round ?? 'R1' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Start Time</span>
|
||||
<span class="card-value">{{ $interview->scheduled_at ? $interview->scheduled_at->format('M d, Y h:i A') : 'Immediate' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Access Duration</span>
|
||||
<span class="card-value">{{ $interview->valid_hours ?? round($interview->scheduled_at?->diffInHours($interview->expires_at) ?? 2) }} Hour(s)</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Coding Language</span>
|
||||
<span class="card-value" style="text-transform: uppercase;">{{ $interview->language ?? 'Python' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Unique Session ID</span>
|
||||
<span class="card-value" style="font-family: monospace; font-size: 11px;">{{ $interview->submission_unique_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credentials Box -->
|
||||
<div class="credentials-box">
|
||||
<h3>Your Temporary Login Credentials</h3>
|
||||
<div style="font-size: 12px; color: #94a3b8; margin-bottom: 8px;">Login Identifier: <strong style="color: #f1f5f9;">{{ $interview->candidate_email }}</strong></div>
|
||||
<div class="temp-pass">{{ $interview->temp_password }}</div>
|
||||
<div style="font-size: 11px; color: #64748b; margin-top: 8px;">Keep this temporary password confidential. It expires after the interview window.</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-container">
|
||||
<a href="{{ route('interview.candidate.login') }}" class="btn" target="_blank">Open Candidate Assessment Portal</a>
|
||||
</div>
|
||||
|
||||
@if(!empty($interview->description))
|
||||
<div style="background: #0f172a; border-left: 3px solid #6366f1; padding: 12px 16px; margin-bottom: 24px; border-radius: 0 6px 6px 0;">
|
||||
<div style="font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; margin-bottom: 4px;">Additional Instructions</div>
|
||||
<div style="font-size: 13px; color: #cbd5e1; white-space: pre-line;">{{ $interview->description }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="instructions">
|
||||
<strong>Important Guidelines for Candidates:</strong>
|
||||
<ul>
|
||||
<li>Use a laptop or desktop computer with a supported modern browser (Chrome, Edge, Firefox).</li>
|
||||
<li>Ensure your web camera and microphone permissions are granted for live proctoring.</li>
|
||||
<li>Do not switch tabs, navigate away, or attempt unauthorized external assistance during the active assessment session.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
© {{ date('Y') }} SingleLogin Assessment System. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Panelist Assignment - SingleLogin Assessment</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #0b0f19;
|
||||
color: #e2e8f0;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #131b2e;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #1e1b4b 100%);
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
margin: 0 0 6px 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.content {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.lead-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.card-label {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
.card-value {
|
||||
color: #f1f5f9;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.btn-container {
|
||||
text-align: center;
|
||||
margin: 28px 0;
|
||||
}
|
||||
.btn {
|
||||
background: #4f46e5;
|
||||
color: #ffffff !important;
|
||||
padding: 12px 28px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
}
|
||||
.calendar-notice {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 13px;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
.footer {
|
||||
background: #0b0f19;
|
||||
padding: 16px 24px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
border-top: 1px solid #1e293b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Panelist Assignment</h1>
|
||||
<p>SingleLogin Assessment & Technical Evaluation Portal</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Hi {{ $interviewer->name }},</div>
|
||||
<div class="lead-text">
|
||||
You have been assigned as an interviewer/panelist for an upcoming candidate technical assessment. Please review the details below:
|
||||
</div>
|
||||
|
||||
<!-- Assessment Details Card -->
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<span class="card-label">Candidate Name</span>
|
||||
<span class="card-value">{{ $interview->candidate_name }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Candidate Email</span>
|
||||
<span class="card-value">{{ $interview->candidate_email }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Position / Role</span>
|
||||
<span class="card-value">{{ $interview->job_title ?? 'Candidate Assessment' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Assessment Round</span>
|
||||
<span class="card-value">{{ $interview->round ?? 'R1' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Scheduled Start Time</span>
|
||||
<span class="card-value">{{ $interview->scheduled_at ? $interview->scheduled_at->format('M d, Y h:i A') : 'Immediate' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Access Duration</span>
|
||||
<span class="card-value">{{ $interview->valid_hours ?? round($interview->scheduled_at?->diffInHours($interview->expires_at) ?? 2) }} Hour(s)</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Coding Language</span>
|
||||
<span class="card-value" style="text-transform: uppercase;">{{ $interview->language ?? 'Python' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Submission ID</span>
|
||||
<span class="card-value" style="font-family: monospace; font-size: 11px;">{{ $interview->submission_unique_id }}</span>
|
||||
</div>
|
||||
@if(!empty($interview->resume_path))
|
||||
<div class="card-row">
|
||||
<span class="card-label">Candidate Resume</span>
|
||||
<span class="card-value" style="color: #a5b4fc;">📎 Attached to Email</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(!empty($interview->description))
|
||||
<div style="background: #0f172a; border-left: 3px solid #6366f1; padding: 12px 16px; margin-bottom: 24px; border-radius: 0 6px 6px 0;">
|
||||
<div style="font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; margin-bottom: 4px;">Assessment Notes</div>
|
||||
<div style="font-size: 13px; color: #cbd5e1; white-space: pre-line;">{{ $interview->description }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="btn-container">
|
||||
<a href="{{ route('interview.show', $interview->id) }}" class="btn" target="_blank">Open Review & Live Assessment Portal</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
© {{ date('Y') }} SingleLogin Assessment System. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
155
resources/views/emails/interview/reminder.blade.php
Normal file
155
resources/views/emails/interview/reminder.blade.php
Normal file
@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Interview Reminder - SingleLogin Assessment</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #0b0f19;
|
||||
color: #e2e8f0;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #131b2e;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #e11d48 0%, #881337 100%);
|
||||
padding: 28px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
margin: 0 0 6px 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
color: #fecdd3;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
.content {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.lead-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.card-label {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
.card-value {
|
||||
color: #f1f5f9;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.btn-container {
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.btn {
|
||||
background: #e11d48;
|
||||
color: #ffffff !important;
|
||||
padding: 12px 28px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
}
|
||||
.footer {
|
||||
background: #0b0f19;
|
||||
padding: 16px 24px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
border-top: 1px solid #1e293b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>⏰ Assessment Reminder: Starting in 15 Minutes</h1>
|
||||
<p>SingleLogin Assessment & Technical Evaluation Portal</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
Hello {{ $recipientName ?? ($recipientType === 'interviewer' ? 'Panelist' : $interview->candidate_name) }},
|
||||
</div>
|
||||
<div class="lead-text">
|
||||
This is a quick reminder that the technical interview assessment is scheduled to begin in <strong>15 minutes</strong>.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<span class="card-label">Candidate</span>
|
||||
<span class="card-value">{{ $interview->candidate_name }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Position / Role</span>
|
||||
<span class="card-value">{{ $interview->job_title ?? 'Candidate Assessment' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Round</span>
|
||||
<span class="card-value">{{ $interview->round ?? 'R1' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">Scheduled Start Time</span>
|
||||
<span class="card-value">{{ $interview->scheduled_at ? $interview->scheduled_at->format('M d, Y h:i A') : 'Immediate' }}</span>
|
||||
</div>
|
||||
@if($recipientType === 'candidate')
|
||||
<div class="card-row">
|
||||
<span class="card-label">Temporary Password</span>
|
||||
<span class="card-value" style="color: #38bdf8; font-family: monospace;">{{ $interview->temp_password }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="btn-container">
|
||||
@if($recipientType === 'interviewer')
|
||||
<a href="{{ route('interview.show', $interview->id) }}" class="btn" target="_blank">Join Assessment Session</a>
|
||||
@else
|
||||
<a href="{{ route('interview.candidate.login') }}" class="btn" target="_blank">Enter Candidate Portal</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
© {{ date('Y') }} SingleLogin Assessment System. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -2,10 +2,10 @@
|
||||
|
||||
use App\Http\Controllers\AdminController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\IceServerController;
|
||||
use App\Http\Controllers\InterviewController;
|
||||
use App\Http\Controllers\LoginController;
|
||||
use App\Http\Controllers\OnboardingController;
|
||||
use App\Http\Controllers\IceServerController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Authentication & Portal landing
|
||||
@ -47,7 +47,6 @@
|
||||
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
|
||||
Route::get('/ice-servers', IceServerController::class)->name('ice-servers');
|
||||
|
||||
|
||||
// User Dashboard Portal (requires authenticated user)
|
||||
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
|
||||
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}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
|
||||
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::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');
|
||||
});
|
||||
|
||||
|
||||
// Admin Control Panel (requires admin role)
|
||||
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
|
||||
Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session');
|
||||
|
||||
@ -31,6 +31,7 @@ public function test_hr_can_create_candidate_interview_and_generate_temp_credent
|
||||
'candidate_name' => 'Alex Candidate',
|
||||
'candidate_email' => 'alex.cand@gmail.com',
|
||||
'candidate_phone' => '9876543210',
|
||||
'job_title' => 'Software Engineer',
|
||||
'valid_hours' => 2,
|
||||
'language' => 'python',
|
||||
]);
|
||||
@ -82,7 +83,7 @@ public function test_live_code_execution_via_judge0_api_proxy(): void
|
||||
'id' => 3,
|
||||
'description' => 'Accepted',
|
||||
],
|
||||
], 200)
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->post(route('interview.execute'), [
|
||||
@ -93,7 +94,7 @@ public function test_live_code_execution_via_judge0_api_proxy(): void
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'output' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n"
|
||||
'output' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n",
|
||||
]);
|
||||
}
|
||||
|
||||
@ -119,7 +120,7 @@ public function test_candidate_code_submission_saves_under_unique_submission_id(
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'unique_id' => 'michael-dev_9988776655_1752000001'
|
||||
'unique_id' => 'michael-dev_9988776655_1752000001',
|
||||
]);
|
||||
|
||||
$interview->refresh();
|
||||
|
||||
117
tests/Feature/IceServerControllerTest.php
Normal file
117
tests/Feature/IceServerControllerTest.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Interview;
|
||||
use App\Models\User;
|
||||
use App\Services\IceCandidate\IceCandidateManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class IceServerControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
$this->app->forgetInstance(IceCandidateManager::class);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_request_is_denied(): void
|
||||
{
|
||||
$response = $this->getJson(route('ice-servers'));
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJson(['error' => 'Unauthenticated access.']);
|
||||
}
|
||||
|
||||
public function test_authenticated_user_can_fetch_ice_servers(): void
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => 'John Interviewer',
|
||||
'email' => 'john@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*metered*' => Http::response([
|
||||
['urls' => 'stun:stun.relay.metered.ca:80'],
|
||||
[
|
||||
'urls' => 'turn:standard.relay.metered.ca:80',
|
||||
'username' => 'metered_usr',
|
||||
'credential' => 'metered_pwd',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson(route('ice-servers'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = $response->json();
|
||||
$this->assertIsArray($data);
|
||||
$this->assertCount(2, $data);
|
||||
$this->assertEquals('stun:stun.relay.metered.ca:80', $data[0]['urls']);
|
||||
$this->assertEquals('metered_usr', $data[1]['username']);
|
||||
}
|
||||
|
||||
public function test_authenticated_candidate_session_can_fetch_ice_servers(): void
|
||||
{
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'Alice Candidate',
|
||||
'candidate_email' => 'alice@gmail.com',
|
||||
'candidate_phone' => '9876543210',
|
||||
'temp_password' => 'Pass-123456',
|
||||
'expires_at' => now()->addHours(2),
|
||||
'language' => 'python',
|
||||
'status' => 'scheduled',
|
||||
'submission_unique_id' => 'alice-candidate_9876543210_1752000099',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*metered*' => Http::response([
|
||||
['urls' => 'stun:stun.relay.metered.ca:80'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withSession(['candidate_interview_id' => $interview->id])
|
||||
->getJson(route('ice-servers'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
$this->assertCount(1, $data);
|
||||
$this->assertEquals('stun:stun.relay.metered.ca:80', $data[0]['urls']);
|
||||
}
|
||||
|
||||
public function test_switches_to_convexsol_provider_via_configuration(): void
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@company.com',
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
config([
|
||||
'services.ice.default' => 'convexsol',
|
||||
'services.ice.providers.convexsol' => [
|
||||
'username' => 'convex_corp_user',
|
||||
'password' => 'convex_corp_pass',
|
||||
'turn_urls' => ['turn:turn.convexsol.com:3478'],
|
||||
'stun_urls' => ['stun:turn.convexsol.com:3478'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson(route('ice-servers'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
$this->assertCount(2, $data);
|
||||
$this->assertEquals(['stun:turn.convexsol.com:3478'], $data[0]['urls']);
|
||||
$this->assertEquals('convex_corp_user', $data[1]['username']);
|
||||
$this->assertEquals('convex_corp_pass', $data[1]['credential']);
|
||||
}
|
||||
}
|
||||
@ -55,7 +55,7 @@ public function test_poll_returns_media_stream_urls()
|
||||
'url' => '/storage/recordings/stream_test_123.webm',
|
||||
'filename' => 'stream_test_123.webm',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
]
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
261
tests/Feature/ScheduleInterviewWithInvitationsTest.php
Normal file
261
tests/Feature/ScheduleInterviewWithInvitationsTest.php
Normal file
@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Mail\CandidateInterviewInvitationMail;
|
||||
use App\Mail\InterviewerAssessmentNotificationMail;
|
||||
use App\Mail\InterviewReminderMail;
|
||||
use App\Models\Interview;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScheduleInterviewWithInvitationsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function test_scheduling_interview_creates_record_and_queues_candidate_and_interviewer_invitations(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$hr = User::create([
|
||||
'name' => 'HR Manager',
|
||||
'email' => 'hr@company.com',
|
||||
'role' => 'hr',
|
||||
]);
|
||||
|
||||
$interviewer1 = User::create([
|
||||
'name' => 'Tech Lead',
|
||||
'email' => 'techlead@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
$interviewer2 = User::create([
|
||||
'name' => 'Senior Engineer',
|
||||
'email' => 'srengineer@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($hr)->post(route('interview.store'), [
|
||||
'candidate_name' => 'Soumyadeep Mondal',
|
||||
'candidate_email' => 'soumyadeep@example.com',
|
||||
'candidate_phone' => '9876543210',
|
||||
'job_title' => 'AIML (Computer Vision & Agentic AI) Engineer',
|
||||
'round' => 'R1',
|
||||
'description' => 'Live coding session on Agentic workflows',
|
||||
'scheduled_at' => now()->addDays(2)->format('Y-m-d\TH:i'),
|
||||
'valid_hours' => 2,
|
||||
'language' => 'python',
|
||||
'assigned_interviewers' => [$interviewer1->id, $interviewer2->id],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('interview.index'));
|
||||
|
||||
// Database assertion
|
||||
$this->assertDatabaseHas('interviews', [
|
||||
'candidate_name' => 'Soumyadeep Mondal',
|
||||
'candidate_email' => 'soumyadeep@example.com',
|
||||
'job_title' => 'AIML (Computer Vision & Agentic AI) Engineer',
|
||||
'round' => 'R1',
|
||||
'description' => 'Live coding session on Agentic workflows',
|
||||
'language' => 'python',
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
|
||||
$interview = Interview::where('candidate_email', 'soumyadeep@example.com')->first();
|
||||
$this->assertNotNull($interview);
|
||||
|
||||
// Candidate mail assertion
|
||||
Mail::assertQueued(CandidateInterviewInvitationMail::class, function ($mail) use ($interview) {
|
||||
return $mail->hasTo('soumyadeep@example.com')
|
||||
&& $mail->interview->id === $interview->id
|
||||
&& ! empty($mail->icsContent);
|
||||
});
|
||||
|
||||
// Interviewers mail assertions
|
||||
Mail::assertQueued(InterviewerAssessmentNotificationMail::class, function ($mail) use ($interviewer1) {
|
||||
return $mail->hasTo('techlead@company.com') && $mail->interviewer->id === $interviewer1->id;
|
||||
});
|
||||
|
||||
Mail::assertQueued(InterviewerAssessmentNotificationMail::class, function ($mail) use ($interviewer2) {
|
||||
return $mail->hasTo('srengineer@company.com') && $mail->interviewer->id === $interviewer2->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_send_interview_reminders_command_dispatches_15min_advance_emails(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$interviewer = User::create([
|
||||
'name' => 'Panelist User',
|
||||
'email' => 'panelist@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
// Interview scheduled 10 minutes from now (within 20m window)
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'Upcoming Candidate',
|
||||
'candidate_email' => 'upcoming@example.com',
|
||||
'candidate_phone' => '1122334455',
|
||||
'job_title' => 'Backend Architect',
|
||||
'round' => 'Technical Round',
|
||||
'temp_password' => 'Pass-778899',
|
||||
'scheduled_at' => now()->addMinutes(10),
|
||||
'expires_at' => now()->addHours(2),
|
||||
'language' => 'php',
|
||||
'status' => 'scheduled',
|
||||
'assigned_interviewers' => [$interviewer->id],
|
||||
'submission_unique_id' => 'upcoming-candidate_1122334455_1752000099',
|
||||
'reminder_sent_at' => null,
|
||||
]);
|
||||
|
||||
$this->artisan('interview:send-reminders')
|
||||
->expectsOutputToContain('Successfully processed and dispatched reminders for 1 interview session(s).')
|
||||
->assertExitCode(0);
|
||||
|
||||
// Assert reminder email sent to candidate
|
||||
Mail::assertQueued(InterviewReminderMail::class, function ($mail) {
|
||||
return $mail->hasTo('upcoming@example.com') && $mail->recipientType === 'candidate';
|
||||
});
|
||||
|
||||
// Assert reminder email sent to interviewer
|
||||
Mail::assertQueued(InterviewReminderMail::class, function ($mail) {
|
||||
return $mail->hasTo('panelist@company.com') && $mail->recipientType === 'interviewer';
|
||||
});
|
||||
|
||||
$interview->refresh();
|
||||
$this->assertNotNull($interview->reminder_sent_at);
|
||||
}
|
||||
|
||||
public function test_scheduling_validation_errors_render_open_drawer_and_error_directives(): void
|
||||
{
|
||||
$this->withMiddleware();
|
||||
|
||||
$hr = User::create([
|
||||
'name' => 'HR Manager',
|
||||
'email' => 'hr@company.com',
|
||||
'role' => 'hr',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($hr)
|
||||
->from(route('interview.index'))
|
||||
->followingRedirects()
|
||||
->post(route('interview.store'), [
|
||||
'candidate_name' => '',
|
||||
'candidate_email' => 'invalid-email-address',
|
||||
'candidate_phone' => '',
|
||||
'job_title' => '',
|
||||
'valid_hours' => 2,
|
||||
'language' => 'python',
|
||||
'description' => 'Test instructions',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('create-interview-drawer');
|
||||
$response->assertSee('active');
|
||||
$response->assertSee('Please correct the following errors:');
|
||||
$response->assertSee('The candidate name is required.');
|
||||
$response->assertSee('Please provide a valid candidate email address.');
|
||||
$response->assertSee('The candidate phone number is required.');
|
||||
$response->assertSee('The job title / position is required.');
|
||||
$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).');
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,15 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\App;
|
||||
use App\Models\PersonalApp;
|
||||
use App\Models\Role;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAppOverride;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SecurityAndAdminTest extends TestCase
|
||||
@ -80,14 +84,14 @@ public function test_restricted_service_is_not_displayed_to_the_restricted_user(
|
||||
'name' => 'Outlook',
|
||||
'url' => 'https://outlook.office.com',
|
||||
'color' => '#0078d4',
|
||||
'tag' => 'Microsoft 365'
|
||||
'tag' => 'Microsoft 365',
|
||||
]);
|
||||
|
||||
$app2 = App::create([
|
||||
'name' => 'Keka HR',
|
||||
'url' => 'https://sentientgeeks.keka.com',
|
||||
'color' => '#f27059',
|
||||
'tag' => 'HR Portal'
|
||||
'tag' => 'HR Portal',
|
||||
]);
|
||||
|
||||
// Create a role and associate both apps
|
||||
@ -127,14 +131,14 @@ public function test_additional_service_is_displayed_to_the_user(): void
|
||||
'name' => 'Outlook',
|
||||
'url' => 'https://outlook.office.com',
|
||||
'color' => '#0078d4',
|
||||
'tag' => 'Microsoft 365'
|
||||
'tag' => 'Microsoft 365',
|
||||
]);
|
||||
|
||||
$app2 = App::create([
|
||||
'name' => 'Keka HR',
|
||||
'url' => 'https://sentientgeeks.keka.com',
|
||||
'color' => '#f27059',
|
||||
'tag' => 'HR Portal'
|
||||
'tag' => 'HR Portal',
|
||||
]);
|
||||
|
||||
// Create a role that only has Outlook
|
||||
@ -168,7 +172,7 @@ public function test_admin_cannot_authenticate_via_microsoft_oauth_callback(): v
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@company.com',
|
||||
'role' => 'admin',
|
||||
'microsoft_id' => 'admin-microsoft-id-999'
|
||||
'microsoft_id' => 'admin-microsoft-id-999',
|
||||
]);
|
||||
|
||||
$mockSocialite = \Mockery::mock('Laravel\Socialite\Contracts\Provider');
|
||||
@ -180,7 +184,7 @@ public function test_admin_cannot_authenticate_via_microsoft_oauth_callback(): v
|
||||
$mockUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
|
||||
$mockSocialite->shouldReceive('user')->andReturn($mockUser);
|
||||
\Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
|
||||
Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
|
||||
|
||||
$response = $this->get('/auth/microsoft/callback');
|
||||
|
||||
@ -248,7 +252,7 @@ public function test_sso_launch_redirects_to_microsoft_for_all_services_if_user_
|
||||
'name' => 'Keka HR',
|
||||
'url' => 'https://sentientgeeks.keka.com',
|
||||
'color' => '#f27059',
|
||||
'tag' => 'HR Portal'
|
||||
'tag' => 'HR Portal',
|
||||
]);
|
||||
|
||||
// Assign role with the app to user
|
||||
@ -261,12 +265,12 @@ public function test_sso_launch_redirects_to_microsoft_for_all_services_if_user_
|
||||
$mockSocialite->shouldReceive('with')->with([
|
||||
'login_hint' => $user->email,
|
||||
'domain_hint' => 'organizations',
|
||||
'prompt' => 'none'
|
||||
'prompt' => 'none',
|
||||
])->andReturnSelf();
|
||||
$mockSocialite->shouldReceive('redirect')
|
||||
->andReturn(redirect('https://login.microsoftonline.com/common/oauth2/v2.0/authorize'));
|
||||
|
||||
\Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
|
||||
Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('sso.launch', $app->id));
|
||||
@ -327,7 +331,7 @@ public function test_new_user_registration_default_role(): void
|
||||
$mockUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
|
||||
$mockSocialite->shouldReceive('user')->andReturn($mockUser);
|
||||
\Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
|
||||
Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
|
||||
|
||||
// Login as new user
|
||||
$response = $this->get('/auth/microsoft/callback');
|
||||
@ -354,7 +358,7 @@ public function test_new_user_registration_custom_default_role(): void
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class);
|
||||
$this->withoutMiddleware(PreventRequestForgery::class);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.settings.default-role'), [
|
||||
@ -362,7 +366,7 @@ public function test_new_user_registration_custom_default_role(): void
|
||||
])
|
||||
->assertRedirect(route('admin.dashboard'));
|
||||
|
||||
$this->assertEquals('Team Lead', \App\Models\Setting::get('default_role'));
|
||||
$this->assertEquals('Team Lead', Setting::get('default_role'));
|
||||
|
||||
// Mock Socialite callback for second new user
|
||||
$mockSocialite2 = \Mockery::mock('Laravel\Socialite\Contracts\Provider');
|
||||
@ -374,7 +378,7 @@ public function test_new_user_registration_custom_default_role(): void
|
||||
$mockUser2->shouldReceive('getAvatar')->andReturn(null);
|
||||
|
||||
$mockSocialite2->shouldReceive('user')->andReturn($mockUser2);
|
||||
\Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite2);
|
||||
Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite2);
|
||||
|
||||
// Login as second new user
|
||||
$response2 = $this->get('/auth/microsoft/callback');
|
||||
@ -403,7 +407,7 @@ public function test_admin_toggle_privileges(): void
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class);
|
||||
$this->withoutMiddleware(PreventRequestForgery::class);
|
||||
|
||||
// 1. Grant admin privilege
|
||||
$response = $this->actingAs($admin)
|
||||
@ -452,7 +456,7 @@ public function test_user_personal_custom_apps(): void
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class);
|
||||
$this->withoutMiddleware(PreventRequestForgery::class);
|
||||
|
||||
// 1. Create personal app
|
||||
$response = $this->actingAs($user1)
|
||||
@ -461,7 +465,7 @@ public function test_user_personal_custom_apps(): void
|
||||
'url' => 'https://example.com/test',
|
||||
'color' => '#123456',
|
||||
'tag' => 'Testing',
|
||||
'desc' => 'Some description'
|
||||
'desc' => 'Some description',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('dashboard'));
|
||||
@ -469,10 +473,10 @@ public function test_user_personal_custom_apps(): void
|
||||
'user_id' => $user1->id,
|
||||
'name' => 'My Test App',
|
||||
'url' => 'https://example.com/test',
|
||||
'color' => '#123456'
|
||||
'color' => '#123456',
|
||||
]);
|
||||
|
||||
$personalApp = \App\Models\PersonalApp::where('name', 'My Test App')->first();
|
||||
$personalApp = PersonalApp::where('name', 'My Test App')->first();
|
||||
$this->assertNotNull($personalApp);
|
||||
|
||||
// 2. View dashboard and see the custom app
|
||||
@ -487,7 +491,7 @@ public function test_user_personal_custom_apps(): void
|
||||
'url' => 'https://example.com/updated',
|
||||
'color' => '#654321',
|
||||
'tag' => 'UpdatedTag',
|
||||
'desc' => 'New description'
|
||||
'desc' => 'New description',
|
||||
]);
|
||||
|
||||
$updateResponse->assertRedirect(route('dashboard'));
|
||||
@ -495,7 +499,7 @@ public function test_user_personal_custom_apps(): void
|
||||
'id' => $personalApp->id,
|
||||
'name' => 'Updated Test App',
|
||||
'url' => 'https://example.com/updated',
|
||||
'color' => '#654321'
|
||||
'color' => '#654321',
|
||||
]);
|
||||
|
||||
// 4. Security: User 2 cannot update User 1's custom app
|
||||
@ -514,7 +518,7 @@ public function test_user_personal_custom_apps(): void
|
||||
|
||||
$deleteResponse->assertRedirect(route('dashboard'));
|
||||
$this->assertDatabaseMissing('personal_apps', [
|
||||
'id' => $personalApp->id
|
||||
'id' => $personalApp->id,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -530,11 +534,11 @@ public function test_convexcrm_silent_sso_redirection(): void
|
||||
'microsoft_id' => 'mock-ms-id-777',
|
||||
]);
|
||||
|
||||
$app = \App\Models\App::create([
|
||||
$app = App::create([
|
||||
'name' => 'Convex CRM',
|
||||
'url' => 'https://demo-convexcrm.convexsol.co/',
|
||||
'color' => '#123456',
|
||||
'tag' => 'CRM'
|
||||
'tag' => 'CRM',
|
||||
]);
|
||||
|
||||
// Enable access to this app
|
||||
|
||||
52
tests/Unit/ConvexSolIceCandidateProviderTest.php
Normal file
52
tests/Unit/ConvexSolIceCandidateProviderTest.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\IceCandidate\Providers\ConvexSolIceCandidateProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ConvexSolIceCandidateProviderTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_builds_ice_servers_from_coturn_credentials(): void
|
||||
{
|
||||
$provider = new ConvexSolIceCandidateProvider([
|
||||
'username' => 'convex_admin',
|
||||
'password' => 'super_secret_password',
|
||||
'turn_urls' => ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'],
|
||||
'stun_urls' => ['stun:turn.convexsol.com:3478', 'stun:stun.l.google.com:19302'],
|
||||
]);
|
||||
|
||||
$candidates = $provider->getIceCandidates();
|
||||
|
||||
$this->assertCount(2, $candidates);
|
||||
$array = $candidates->toArray();
|
||||
|
||||
$this->assertEquals(['stun:turn.convexsol.com:3478', 'stun:stun.l.google.com:19302'], $array[0]['urls']);
|
||||
$this->assertEquals(['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'], $array[1]['urls']);
|
||||
$this->assertEquals('convex_admin', $array[1]['username']);
|
||||
$this->assertEquals('super_secret_password', $array[1]['credential']);
|
||||
}
|
||||
|
||||
public function test_caches_coturn_credentials(): void
|
||||
{
|
||||
$provider = new ConvexSolIceCandidateProvider([
|
||||
'username' => 'cached_user',
|
||||
'password' => 'cached_pass',
|
||||
'turn_urls' => 'turn:turn.convexsol.com:3478',
|
||||
'stun_urls' => 'stun:turn.convexsol.com:3478',
|
||||
]);
|
||||
|
||||
$candidates1 = $provider->getIceCandidates();
|
||||
$candidates2 = $provider->getIceCandidates();
|
||||
|
||||
$this->assertCount(2, $candidates1);
|
||||
$this->assertEquals($candidates1->toArray(), $candidates2->toArray());
|
||||
}
|
||||
}
|
||||
70
tests/Unit/IceCandidateDtoTest.php
Normal file
70
tests/Unit/IceCandidateDtoTest.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\DTOs\IceCandidateDto;
|
||||
use App\DTOs\IceCandidatesDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class IceCandidateDtoTest extends TestCase
|
||||
{
|
||||
public function test_ice_candidate_dto_instantiation_and_serialization(): void
|
||||
{
|
||||
$dto = new IceCandidateDto(
|
||||
urls: 'stun:stun.l.google.com:19302',
|
||||
username: null,
|
||||
credential: null
|
||||
);
|
||||
|
||||
$array = $dto->toArray();
|
||||
$this->assertEquals(['urls' => 'stun:stun.l.google.com:19302'], $array);
|
||||
$this->assertArrayNotHasKey('username', $array);
|
||||
$this->assertArrayNotHasKey('credential', $array);
|
||||
$this->assertEquals($array, $dto->jsonSerialize());
|
||||
}
|
||||
|
||||
public function test_ice_candidate_dto_with_turn_credentials(): void
|
||||
{
|
||||
$dto = IceCandidateDto::fromArray([
|
||||
'urls' => ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'],
|
||||
'username' => 'convex_user',
|
||||
'password' => 'secret_pass',
|
||||
]);
|
||||
|
||||
$array = $dto->toArray();
|
||||
$this->assertEquals(['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'], $array['urls']);
|
||||
$this->assertEquals('convex_user', $array['username']);
|
||||
$this->assertEquals('secret_pass', $array['credential']);
|
||||
}
|
||||
|
||||
public function test_ice_candidates_dto_collection_handling(): void
|
||||
{
|
||||
$candidates = IceCandidatesDto::fromArray([
|
||||
['urls' => 'stun:stun.l.google.com:19302'],
|
||||
['urls' => 'turn:turn.convexsol.com:3478', 'username' => 'user1', 'credential' => 'pass1'],
|
||||
]);
|
||||
|
||||
$this->assertCount(2, $candidates);
|
||||
$this->assertFalse($candidates->isEmpty());
|
||||
|
||||
$array = $candidates->toArray();
|
||||
$this->assertCount(2, $array);
|
||||
$this->assertEquals('stun:stun.l.google.com:19302', $array[0]['urls']);
|
||||
$this->assertEquals('user1', $array[1]['username']);
|
||||
$this->assertEquals('pass1', $array[1]['credential']);
|
||||
|
||||
$json = json_encode($candidates);
|
||||
$this->assertStringContainsString('stun:stun.l.google.com:19302', $json);
|
||||
$this->assertStringContainsString('turn:turn.convexsol.com:3478', $json);
|
||||
}
|
||||
|
||||
public function test_default_stun_factory(): void
|
||||
{
|
||||
$default = IceCandidatesDto::defaultStun();
|
||||
|
||||
$this->assertCount(1, $default);
|
||||
$array = $default->toArray();
|
||||
$this->assertContains('stun:stun.l.google.com:19302', $array[0]['urls']);
|
||||
$this->assertContains('stun:stun.cloudflare.com:3478', $array[0]['urls']);
|
||||
}
|
||||
}
|
||||
134
tests/Unit/IceCandidateManagerTest.php
Normal file
134
tests/Unit/IceCandidateManagerTest.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Contracts\IceCandidateProviderInterface;
|
||||
use App\DTOs\IceCandidatesDto;
|
||||
use App\Services\IceCandidate\IceCandidateManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class IceCandidateManagerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
$this->app->forgetInstance(IceCandidateManager::class);
|
||||
}
|
||||
|
||||
public function test_manager_resolves_metered_as_default_driver(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*metered*' => Http::response([
|
||||
['urls' => 'stun:stun.relay.metered.ca:80'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$manager = app(IceCandidateManager::class);
|
||||
$candidates = $manager->getIceCandidates();
|
||||
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertEquals('stun:stun.relay.metered.ca:80', $candidates->toArray()[0]['urls']);
|
||||
}
|
||||
|
||||
public function test_manager_resolves_convexsol_when_configured(): void
|
||||
{
|
||||
config([
|
||||
'services.ice.default' => 'convexsol',
|
||||
'services.ice.providers.convexsol' => [
|
||||
'username' => 'convex_usr',
|
||||
'password' => 'convex_pwd',
|
||||
'turn_urls' => ['turn:turn.convexsol.com:3478'],
|
||||
'stun_urls' => ['stun:turn.convexsol.com:3478'],
|
||||
],
|
||||
]);
|
||||
|
||||
$manager = app(IceCandidateManager::class);
|
||||
$candidates = $manager->getIceCandidates();
|
||||
|
||||
$this->assertCount(2, $candidates);
|
||||
$array = $candidates->toArray();
|
||||
$this->assertEquals('convex_usr', $array[1]['username']);
|
||||
$this->assertEquals('convex_pwd', $array[1]['credential']);
|
||||
}
|
||||
|
||||
public function test_manager_falls_back_and_logs_warning_when_primary_fails(): void
|
||||
{
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message, $context) {
|
||||
return str_contains($message, 'Primary ICE candidate provider [metered] failed') &&
|
||||
$context['primary_driver'] === 'metered' &&
|
||||
$context['fallback_driver'] === 'convexsol';
|
||||
});
|
||||
|
||||
// Fail primary Metered HTTP request
|
||||
Http::fake([
|
||||
'*metered*' => Http::response('Gateway Error', 502),
|
||||
]);
|
||||
|
||||
config([
|
||||
'services.ice.default' => 'metered',
|
||||
'services.ice.fallback' => 'convexsol',
|
||||
'services.ice.providers.convexsol' => [
|
||||
'username' => 'fallback_user',
|
||||
'password' => 'fallback_password',
|
||||
'turn_urls' => ['turn:turn.convexsol.com:3478'],
|
||||
'stun_urls' => ['stun:turn.convexsol.com:3478'],
|
||||
],
|
||||
]);
|
||||
|
||||
$manager = app(IceCandidateManager::class);
|
||||
$candidates = $manager->getIceCandidates();
|
||||
|
||||
$this->assertCount(2, $candidates);
|
||||
$array = $candidates->toArray();
|
||||
$this->assertEquals('fallback_user', $array[1]['username']);
|
||||
}
|
||||
|
||||
public function test_manager_falls_back_to_default_stun_and_logs_error_when_both_fail(): void
|
||||
{
|
||||
Log::shouldReceive('warning')->once();
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'Fallback ICE candidate provider [convexsol] also failed');
|
||||
});
|
||||
|
||||
// Fail primary Metered HTTP request
|
||||
Http::fake([
|
||||
'*metered*' => Http::response('Gateway Error', 502),
|
||||
]);
|
||||
|
||||
$manager = app(IceCandidateManager::class);
|
||||
$manager->extend('convexsol', function () {
|
||||
return new class implements IceCandidateProviderInterface
|
||||
{
|
||||
public function getIceCandidates(): IceCandidatesDto
|
||||
{
|
||||
throw new RuntimeException('ConvexSol Server Error');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
config([
|
||||
'services.ice.default' => 'metered',
|
||||
'services.ice.fallback' => 'convexsol',
|
||||
]);
|
||||
|
||||
$candidates = $manager->getIceCandidates();
|
||||
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertContains('stun:stun.l.google.com:19302', $candidates->toArray()[0]['urls']);
|
||||
}
|
||||
|
||||
public function test_container_resolves_interface_to_manager(): void
|
||||
{
|
||||
$resolved = app(IceCandidateProviderInterface::class);
|
||||
$this->assertInstanceOf(IceCandidateManager::class, $resolved);
|
||||
}
|
||||
}
|
||||
54
tests/Unit/IceCandidatesResourceTest.php
Normal file
54
tests/Unit/IceCandidatesResourceTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\DTOs\IceCandidateDto;
|
||||
use App\DTOs\IceCandidatesDto;
|
||||
use App\Http\Resources\Interview\IceCandidateResource;
|
||||
use App\Http\Resources\Interview\IceCandidatesResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Tests\TestCase;
|
||||
|
||||
class IceCandidatesResourceTest extends TestCase
|
||||
{
|
||||
public function test_ice_candidate_resource_transforms_individual_dto(): void
|
||||
{
|
||||
$dto = new IceCandidateDto(
|
||||
urls: ['turn:turn.convexsol.com:3478'],
|
||||
username: 'convex_user',
|
||||
credential: 'password123'
|
||||
);
|
||||
|
||||
$resource = new IceCandidateResource($dto);
|
||||
$array = $resource->toArray(Request::create('/ice-servers', 'GET'));
|
||||
|
||||
$this->assertEquals(['turn:turn.convexsol.com:3478'], $array['urls']);
|
||||
$this->assertEquals('convex_user', $array['username']);
|
||||
$this->assertEquals('password123', $array['credential']);
|
||||
}
|
||||
|
||||
public function test_ice_candidates_resource_formats_json_array_without_data_wrap(): void
|
||||
{
|
||||
$dto = new IceCandidatesDto([
|
||||
new IceCandidateDto(urls: 'stun:stun.l.google.com:19302'),
|
||||
new IceCandidateDto(
|
||||
urls: ['turn:turn.convexsol.com:3478'],
|
||||
username: 'convex_user',
|
||||
credential: 'password123'
|
||||
),
|
||||
]);
|
||||
|
||||
$resource = new IceCandidatesResource($dto);
|
||||
$request = Request::create('/ice-servers', 'GET');
|
||||
$response = $resource->toResponse($request);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->getData(true);
|
||||
$this->assertIsArray($data);
|
||||
$this->assertCount(2, $data);
|
||||
$this->assertEquals('stun:stun.l.google.com:19302', $data[0]['urls']);
|
||||
$this->assertEquals('convex_user', $data[1]['username']);
|
||||
$this->assertEquals('password123', $data[1]['credential']);
|
||||
}
|
||||
}
|
||||
85
tests/Unit/IceResponseValidatorTest.php
Normal file
85
tests/Unit/IceResponseValidatorTest.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\IceCandidate\Validators\IceResponseValidator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
class IceResponseValidatorTest extends TestCase
|
||||
{
|
||||
public function test_validates_standard_ice_server_array(): void
|
||||
{
|
||||
$payload = [
|
||||
[
|
||||
'urls' => 'stun:stun.relay.metered.ca:80',
|
||||
],
|
||||
[
|
||||
'urls' => [
|
||||
'turn:standard.relay.metered.ca:80',
|
||||
'turns:standard.relay.metered.ca:443',
|
||||
],
|
||||
'username' => 'testuser',
|
||||
'credential' => 'testpass',
|
||||
],
|
||||
];
|
||||
|
||||
$validated = IceResponseValidator::validate($payload, 'Metered');
|
||||
|
||||
$this->assertCount(2, $validated);
|
||||
$this->assertEquals('stun:stun.relay.metered.ca:80', $validated[0]['urls']);
|
||||
$this->assertEquals('testuser', $validated[1]['username']);
|
||||
}
|
||||
|
||||
public function test_validates_wrapped_ice_servers_payload(): void
|
||||
{
|
||||
$payload = [
|
||||
'iceServers' => [
|
||||
[
|
||||
'urls' => 'stun:stun.convexsol.com:3478',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$validated = IceResponseValidator::validate($payload, 'ConvexSol');
|
||||
|
||||
$this->assertCount(1, $validated);
|
||||
$this->assertEquals('stun:stun.convexsol.com:3478', $validated[0]['urls']);
|
||||
}
|
||||
|
||||
public function test_throws_exception_for_non_array_payload(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Metered API returned an invalid non-array response.');
|
||||
|
||||
IceResponseValidator::validate('invalid-string', 'Metered');
|
||||
}
|
||||
|
||||
public function test_throws_exception_for_empty_array_payload(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Metered API returned an empty or invalid list of ICE servers.');
|
||||
|
||||
IceResponseValidator::validate([], 'Metered');
|
||||
}
|
||||
|
||||
public function test_throws_exception_for_missing_urls_field(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("Metered server entry at index [0] is missing 'urls'.");
|
||||
|
||||
IceResponseValidator::validate([
|
||||
['username' => 'someuser'],
|
||||
], 'Metered');
|
||||
}
|
||||
|
||||
public function test_throws_exception_for_invalid_url_protocol(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Metered invalid ICE URL scheme [http://invalid.url] at index [0].');
|
||||
|
||||
IceResponseValidator::validate([
|
||||
['urls' => 'http://invalid.url'],
|
||||
], 'Metered');
|
||||
}
|
||||
}
|
||||
101
tests/Unit/InterviewCalendarServiceTest.php
Normal file
101
tests/Unit/InterviewCalendarServiceTest.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Interview;
|
||||
use App\Models\User;
|
||||
use App\Services\Interview\InterviewCalendarService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InterviewCalendarServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_generates_standardized_event_title_matching_exact_pattern(): void
|
||||
{
|
||||
$service = new InterviewCalendarService;
|
||||
|
||||
// Target example: Interview Invitation_R1_Soumyadeep Mondal_AIML (Computer Vision & Agentic AI) Engineer_11/8/2026_1700
|
||||
$scheduledAt = Carbon::create(2026, 8, 11, 17, 0, 0);
|
||||
|
||||
$interview = new Interview([
|
||||
'candidate_name' => 'Soumyadeep Mondal',
|
||||
'candidate_email' => 'soumyadeep@example.com',
|
||||
'candidate_phone' => '9876543210',
|
||||
'job_title' => 'AIML (Computer Vision & Agentic AI) Engineer',
|
||||
'round' => 'R1',
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'expires_at' => $scheduledAt->copy()->addHours(2),
|
||||
'submission_unique_id' => 'soumyadeep-mondal_9876543210_1752000000',
|
||||
'temp_password' => 'Pass-123456',
|
||||
'language' => 'python',
|
||||
]);
|
||||
|
||||
$this->assertEquals(
|
||||
'Interview Invitation_R1_Soumyadeep Mondal_AIML (Computer Vision & Agentic AI) Engineer_11/8/2026_1700',
|
||||
$interview->event_title
|
||||
);
|
||||
}
|
||||
|
||||
public function test_ics_generation_includes_start_time_rsvp_15min_alert_and_matching_request_method(): void
|
||||
{
|
||||
$service = new InterviewCalendarService;
|
||||
|
||||
$interviewer = User::create([
|
||||
'name' => 'Panelist Reviewer',
|
||||
'email' => 'panelist@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
$scheduledAt = Carbon::create(2026, 8, 11, 17, 0, 0);
|
||||
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'Soumyadeep Mondal',
|
||||
'candidate_email' => 'soumyadeep@example.com',
|
||||
'candidate_phone' => '9876543210',
|
||||
'job_title' => 'AIML (Computer Vision & Agentic AI) Engineer',
|
||||
'round' => 'R1',
|
||||
'description' => 'Focus on PyTorch & OpenCV live coding',
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'expires_at' => $scheduledAt->copy()->addHours(2),
|
||||
'submission_unique_id' => 'soumyadeep-mondal_9876543210_1752000001',
|
||||
'temp_password' => 'Pass-998877',
|
||||
'language' => 'python',
|
||||
'status' => 'scheduled',
|
||||
'assigned_interviewers' => [$interviewer->id],
|
||||
]);
|
||||
|
||||
$ics = $service->generateIcs($interview);
|
||||
$unfoldedIcs = str_replace(["\r\n ", "\n "], '', $ics);
|
||||
|
||||
// 1. Title
|
||||
$this->assertStringContainsString('SUMMARY:Interview Invitation_R1_Soumyadeep Mondal_AIML (Computer Vision & Agentic AI) Engineer_11/8/2026_1700', $unfoldedIcs);
|
||||
|
||||
// 2. Start Time DTSTART is present
|
||||
$this->assertStringContainsString('DTSTART', $ics);
|
||||
|
||||
// 3. RFC 5546 METHOD:REQUEST matching MIME method=REQUEST
|
||||
$this->assertStringContainsString('METHOD:REQUEST', $ics);
|
||||
$this->assertStringContainsString('SEQUENCE:0', $ics);
|
||||
$this->assertStringContainsString('ORGANIZER', $ics);
|
||||
|
||||
// 4. Candidate Attendee with RSVP=TRUE
|
||||
$this->assertStringContainsString('mailto:soumyadeep@example.com', strtolower($unfoldedIcs));
|
||||
$this->assertStringContainsString('RSVP=TRUE', $ics);
|
||||
$this->assertStringContainsString('PARTSTAT=NEEDS-ACTION', $ics);
|
||||
|
||||
// 5. Interviewer Attendee is included
|
||||
$this->assertStringContainsString('mailto:panelist@company.com', strtolower($unfoldedIcs));
|
||||
|
||||
// 6. 15-Minute Alert / VALARM is included
|
||||
$this->assertStringContainsString('BEGIN:VALARM', $ics);
|
||||
$this->assertStringContainsString('-PT15M', $ics);
|
||||
$this->assertStringContainsString('END:VALARM', $ics);
|
||||
|
||||
// 7. Description contains credentials
|
||||
$this->assertStringContainsString('Pass-998877', $unfoldedIcs);
|
||||
$this->assertStringContainsString('Focus on PyTorch & OpenCV live coding', $unfoldedIcs);
|
||||
}
|
||||
}
|
||||
79
tests/Unit/MeteredIceCandidateProviderTest.php
Normal file
79
tests/Unit/MeteredIceCandidateProviderTest.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\IceCandidate\Providers\MeteredIceCandidateProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MeteredIceCandidateProviderTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_fetches_and_parses_metered_ice_servers_successfully(): void
|
||||
{
|
||||
$mockUrl = 'https://mock.metered.live/api/v1/turn/credentials';
|
||||
$apiKey = 'test-metered-key';
|
||||
|
||||
Http::fake([
|
||||
$mockUrl.'*' => Http::response([
|
||||
[
|
||||
'urls' => 'stun:stun.relay.metered.ca:80',
|
||||
],
|
||||
[
|
||||
'urls' => 'turn:standard.relay.metered.ca:80',
|
||||
'username' => 'metered_usr',
|
||||
'credential' => 'metered_pwd',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$provider = new MeteredIceCandidateProvider([
|
||||
'url' => $mockUrl,
|
||||
'key' => $apiKey,
|
||||
'timeout' => 3,
|
||||
'cache_ttl' => 60,
|
||||
]);
|
||||
|
||||
$candidates = $provider->getIceCandidates();
|
||||
|
||||
$this->assertCount(2, $candidates);
|
||||
$array = $candidates->toArray();
|
||||
$this->assertEquals('stun:stun.relay.metered.ca:80', $array[0]['urls']);
|
||||
$this->assertEquals('metered_usr', $array[1]['username']);
|
||||
$this->assertEquals('metered_pwd', $array[1]['credential']);
|
||||
|
||||
// Verify result is cached (subsequent call does not hit HTTP)
|
||||
Http::fake([
|
||||
$mockUrl.'*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$cachedCandidates = $provider->getIceCandidates();
|
||||
$this->assertCount(2, $cachedCandidates);
|
||||
}
|
||||
|
||||
public function test_throws_exception_on_metered_http_error(): void
|
||||
{
|
||||
$mockUrl = 'https://mock.metered.live/api/v1/turn/credentials';
|
||||
|
||||
Http::fake([
|
||||
$mockUrl.'*' => Http::response('Server Error', 500),
|
||||
]);
|
||||
|
||||
$provider = new MeteredIceCandidateProvider([
|
||||
'url' => $mockUrl,
|
||||
'key' => 'key',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Metered API Error: 500');
|
||||
|
||||
$provider->getIceCandidates();
|
||||
}
|
||||
}
|
||||
60
tests/Unit/ScheduleInterviewDtoTest.php
Normal file
60
tests/Unit/ScheduleInterviewDtoTest.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\DTOs\Interview\ScheduleInterviewDto;
|
||||
use Carbon\Carbon;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ScheduleInterviewDtoTest extends TestCase
|
||||
{
|
||||
public function test_can_instantiate_and_convert_dto(): void
|
||||
{
|
||||
$dto = new ScheduleInterviewDto(
|
||||
candidateName: 'Soumyadeep Mondal',
|
||||
candidateEmail: 'soumyadeep@example.com',
|
||||
candidatePhone: '9876543210',
|
||||
jobTitle: 'AIML (Computer Vision & Agentic AI) Engineer',
|
||||
round: 'R1',
|
||||
scheduledAt: Carbon::parse('2026-08-11 17:00:00'),
|
||||
validHours: 2,
|
||||
language: 'python',
|
||||
assignedInterviewers: [1, 2],
|
||||
description: 'Test notes',
|
||||
);
|
||||
|
||||
$this->assertEquals('Soumyadeep Mondal', $dto->candidateName);
|
||||
$this->assertEquals('soumyadeep@example.com', $dto->candidateEmail);
|
||||
$this->assertEquals('AIML (Computer Vision & Agentic AI) Engineer', $dto->jobTitle);
|
||||
$this->assertEquals('R1', $dto->round);
|
||||
$this->assertEquals(2, $dto->validHours);
|
||||
$this->assertEquals([1, 2], $dto->assignedInterviewers);
|
||||
$this->assertEquals('Test notes', $dto->description);
|
||||
|
||||
$array = $dto->toArray();
|
||||
$this->assertIsArray($array);
|
||||
$this->assertEquals('Soumyadeep Mondal', $array['candidate_name']);
|
||||
$this->assertEquals('R1', $array['round']);
|
||||
}
|
||||
|
||||
public function test_dto_from_array(): void
|
||||
{
|
||||
$dto = ScheduleInterviewDto::fromArray([
|
||||
'candidate_name' => 'Jane Doe',
|
||||
'candidate_email' => 'jane@example.com',
|
||||
'candidate_phone' => '1234567890',
|
||||
'job_title' => 'Fullstack Developer',
|
||||
'round' => 'Technical Round',
|
||||
'valid_hours' => 4,
|
||||
'language' => 'javascript',
|
||||
'assigned_interviewers' => [5],
|
||||
]);
|
||||
|
||||
$this->assertEquals('Jane Doe', $dto->candidateName);
|
||||
$this->assertEquals('Technical Round', $dto->round);
|
||||
$this->assertEquals('Fullstack Developer', $dto->jobTitle);
|
||||
$this->assertEquals(4, $dto->validHours);
|
||||
$this->assertEquals('javascript', $dto->language);
|
||||
$this->assertEquals([5], $dto->assignedInterviewers);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user