Compare commits

...

20 Commits

Author SHA1 Message Date
be55b88180 Merge pull request 'fix(interview): restore missing resume_path migration file' (#24) from fix/interview into main
Reviewed-on: #24
2026-08-28 08:03:22 +00:00
f9dd679abc fix(interview): restore missing resume_path migration file
- Recreate 2026_08_27_103000_add_resume_path_to_interviews_table migration
2026-08-28 08:00:40 +00:00
4bd29b2994 Merge pull request 'interview-feedback-enhancements and convexsol turn server' (#23) from feat/interview-feedback-enhancements into main
Reviewed-on: #23
2026-08-28 05:37:53 +00:00
cd790ec92e fix(interview): correct timezone on interview schedule
- add option to set timezone from env
- removed local fix code from appservice provider
2026-08-28 05:33:30 +00:00
1541c8193e feat(interview): add candidate resume upload, lightbox viewer, and panelist email attachments
- Add resume file upload to schedule drawer with instant preview and validation
- Create resume lightbox modal viewer with background scroll lock and CV SVG icon
- Integrate CV tab into inspector panel with enlarge action and state 
- Attach candidate resume to panelist notification emails when 
- Fix Outlook RFC 5546 iTIP element mismatch and add table credentials copy button
2026-08-27 11:37:01 +00:00
e4fabef1d3 feat(interview): add icalendar invites, white date pickers, and drawer validation feedback
- integrate spatie/icalendar-generator for candidate & panelist .ics invitations (RSVP & 15m alerts)
    - add StoreInterviewRequest, ScheduleInterviewDto, and invitation/reminder queued actions
    - add migration for job_title, round, description, and reminder_sent_at on interviews table
    - style datetime-local picker indicators to pure white on dark input backgrounds
    - support button loading spinners with :showLoader="true" and loadingText
    - auto-open schedule drawer on validation failure with inline @error alerts and old() bindings
2026-08-27 10:16:46 +00:00
3aca6d90f3 feat(webrtc): implement IceCandidateProviderInterface with Metered and ConvexSol drivers
- Add Metered and ConvexSol (CoTURN) provider drivers with response validation and caching
- Add IceCandidateManager with automatic failover and structured logging
- Add IceCandidateProviderInterface and DTOs conforming to RTCIceServer format
2026-08-26 12:44:41 +00:00
0aec99f575 Merge pull request 'fix/call-status-and-video-recording' (#22) from fix/call-status-and-video-recording into main
Reviewed-on: #22
2026-08-24 07:38:06 +00:00
68e2d62888 fix: handle joining expired active calls
- Mark the call as ended when it has expired but was not gracefully terminated.
- Prevent users from joining calls that are already expired due to peers closing the tab or an ungraceful call termination.
2026-08-24 06:24:01 +00:00
9fdd101bfc fix: crop recorded video
- Calculate video dimensions dynamically in the compositor.
- Ensure recorded videos are rendered with the correct size and aspect ratio.
2026-08-24 06:24:01 +00:00
a22a8915e1 Merge pull request 'feat: UI Refactor, multi-people recording and noise cancellation' (#21) from feature/noise-cancellation into main
Reviewed-on: #21
2026-08-21 11:33:03 +00:00
7a939ff4ca feat(noise-cancellation): implementaed using RNNoise 2026-08-20 05:29:14 +00:00
121b4beaf8 feat(call recording): chuked recoding upload
- upload recording of call in chunks 
- recordings are merged on server side in jobs
- improve UI of the recording tab
2026-08-19 05:43:32 +00:00
dfbbd1263d feat(call recording): add recording compositor to merge multiple peers
- current compositor has a 2 pane layout, with candidate video/screenshare on left 
- other peers are added on right and bellow
2026-08-18 10:56:53 +00:00
03a36c00e6 feat(UI): add speech indicator
- add a audio monitor
- when any peer speaks, and green outline is shown around the camera card
2026-08-18 07:21:39 +00:00
b787f04914 refactor(UI): improve global screenshot capture and onboarding
- compact ui
- fix routing after toggling settings
2026-08-18 06:45:10 +00:00
1c84a162ac refactor(UI): interviewer panel
- overhaul the interviewer panel setup for more compact design
- assesment scheduling is now in a drawer
- replaced emojies with icon in candidate panel
2026-08-18 06:07:17 +00:00
834813731b refactor: move candidate details to caller card. Shift call controls into
hamburger menu
2026-08-17 07:50:48 +00:00
9f25fdd648 refactor: UI Layouts and components
Interview module is refactored to use layouts and re-usable components
along with domain components. Style remains identitical to previous
design.
2026-08-17 07:03:55 +00:00
e6a177028b deps: add SKILLS
Add boost and Skills for this project.
2026-08-17 07:02:17 +00:00
151 changed files with 12548 additions and 2249 deletions

View 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.

View File

@ -0,0 +1,104 @@
---
name: infer-conventions
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
license: MIT
metadata:
author: laravel
---
# Infer Conventions
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
## Ground Rules (read before you start)
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
## Process
Each step ends on a checkable completion criterion. Do not advance until it holds.
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
### Step 0: Orient
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
This app has no Livewire/Inertia/Flux packages installed. Treat the frontend group as likely API-only: confirm from `resources/views` before spending time there, and skip the Livewire/Inertia/Flux dimensions.
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
### Step 1: Predefined sweep
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
- Tooling-owned or Already-recorded. Skip per the ground rules.
Done when: every applicable dimension carries exactly one of those verdicts.
### Step 2: Open-ended pass
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
### Step 3: Confirm
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
Done when: every candidate is approved, rejected, or (conflicts) decided.
### Step 4: Record
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
Record this:
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
Not this:
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
### Step 5: Summarize
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
## Glob mapping
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
Examples:
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
- Tests: `tests/**`.
- Migrations and database: `database/migrations/**`.
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
## Edge cases
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.

View File

@ -0,0 +1,137 @@
# Detection Checklist
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
---
## A. Validation & HTTP input
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
## B. Controllers & routing
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
- Hint: read a few controller methods; `ls app/Actions app/Services`.
7. Route handler style: closures in `routes/*.php` vs controller classes.
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
## C. Authorization
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
## D. Eloquent & models
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
## E. Architecture & organization
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
24. Decoupling: events + listeners vs direct service calls.
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
27. Enums: backed vs pure; case naming; where they live.
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
## F. Frontend & views
No Livewire/Inertia/Flux package is installed. This app may be API-only. Confirm from `resources/views` before sweeping, and treat the Livewire/Flux dimensions as not applicable.
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
## G. Database & migrations
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
- Hint: grep `function down` vs the migration count.
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
## H. Testing
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
- Hint: grep those trait names in `tests/`.
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
## I. Responses & API resources
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
## J. Strings, collections & dates
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
---
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.

View File

@ -0,0 +1,59 @@
---
name: laravel-best-practices
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
license: MIT
metadata:
author: laravel
---
# Laravel Best Practices
Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
## Consistency First
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
## How to Apply
1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
6. Re-read the diff against every mapped rule before finishing.
## Rule Index
Cross-cutting changes often need more than one rule file.
| Concern | Read |
| --- | --- |
| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
## Decision Rules
- Prefer framework features and existing application abstractions over new helpers or dependencies.
- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.

View File

@ -0,0 +1,106 @@
# Advanced Query Patterns
## Use `addSelect()` Subqueries for Single Values from Has-Many
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
```php
public function scopeWithLastLoginAt($query): void
{
$query->addSelect([
'last_login_at' => Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])->withCasts(['last_login_at' => 'datetime']);
}
```
## Create Dynamic Relationships via Subquery FK
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
```php
public function lastLogin(): BelongsTo
{
return $this->belongsTo(Login::class);
}
public function scopeWithLastLogin($query): void
{
$query->addSelect([
'last_login_id' => Login::select('id')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])->with('lastLogin');
}
```
## Use Conditional Aggregates Instead of Multiple Count Queries
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
```php
$statuses = Feature::toBase()
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
->first();
```
## Use `setRelation()` to Prevent Circular N+1
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
```php
$feature->load('comments.user');
$feature->comments->each->setRelation('feature', $feature);
```
## Prefer `whereIn` + Subquery Over `whereHas`
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
Incorrect (correlated EXISTS re-executes per row):
```php
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
```
Correct (index-friendly subquery, no PHP memory overhead):
```php
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
```
## Sometimes Two Simple Queries Beat One Complex Query
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
## Use Compound Indexes Matching `orderBy` Column Order
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
```php
// Migration
$table->index(['last_name', 'first_name']);
// Query — column order must match the index
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
```
## Use Correlated Subqueries for Has-Many Ordering
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
```php
public function scopeOrderByLastLogin($query): void
{
$query->orderByDesc(Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1)
);
}
```

View File

@ -0,0 +1,202 @@
# Architecture Best Practices
## Single-Purpose Action Classes
Extract discrete business operations into invokable Action classes.
```php
class CreateOrderAction
{
public function __construct(private InventoryService $inventory) {}
public function handle(array $data): Order
{
$order = Order::create($data);
$this->inventory->reserve($order);
return $order;
}
}
```
## Use Dependency Injection
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
Incorrect:
```php
class OrderController extends Controller
{
public function store(StoreOrderRequest $request)
{
$service = app(OrderService::class);
return $service->create($request->validated());
}
}
```
Correct:
```php
class OrderController extends Controller
{
public function __construct(private OrderService $service) {}
public function store(StoreOrderRequest $request)
{
return $this->service->create($request->validated());
}
}
```
## Code to Interfaces
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
Incorrect (concrete dependency):
```php
class OrderService
{
public function __construct(private StripeGateway $gateway) {}
}
```
Correct (interface dependency):
```php
interface PaymentGateway
{
public function charge(int $amount, string $customerId): PaymentResult;
}
class OrderService
{
public function __construct(private PaymentGateway $gateway) {}
}
```
Bind in a service provider:
```php
$this->app->bind(PaymentGateway::class, StripeGateway::class);
```
## Default Sort by Descending
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
Incorrect:
```php
$posts = Post::paginate();
```
Correct:
```php
$posts = Post::latest()->paginate();
```
## Use Atomic Locks for Race Conditions
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
```php
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
$order->process();
});
// Or at query level
$product = Product::where('id', $id)->lockForUpdate()->first();
```
## Use `mb_*` String Functions
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
Incorrect:
```php
strlen('José'); // 5 (bytes, not characters)
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
```
Correct:
```php
mb_strlen('José'); // 4 (characters)
mb_strtolower('MÜNCHEN'); // 'münchen'
// Prefer Laravel's Str helpers when available
Str::length('José'); // 4
Str::lower('MÜNCHEN'); // 'münchen'
```
## Use `defer()` for Post-Response Work
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
Incorrect (job overhead for trivial work):
```php
dispatch(new LogPageView($page));
```
Correct (runs after response, same process):
```php
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
```
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
## Use `Context` for Request-Scoped Data
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
```php
// In middleware
Context::add('tenant_id', $request->header('X-Tenant-ID'));
// Anywhere later — controllers, jobs, log context
$tenantId = Context::get('tenant_id');
```
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
## Use `Concurrency::run()` for Parallel Execution
Run independent operations in parallel using child processes — no async libraries needed.
```php
use Illuminate\Support\Facades\Concurrency;
[$users, $orders] = Concurrency::run([
fn () => User::count(),
fn () => Order::where('status', 'pending')->count(),
]);
```
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
## Convention Over Configuration
Follow Laravel conventions. Don't override defaults unnecessarily.
Incorrect:
```php
class Customer extends Model
{
protected $table = 'Customer';
protected $primaryKey = 'customer_id';
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
}
}
```
Correct:
```php
class Customer extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
}
```

View File

@ -0,0 +1,36 @@
# Blade & Views Best Practices
## Use `$attributes->merge()` in Component Templates
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
```blade
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
{{ $message }}
</div>
```
## Use `@pushOnce` for Per-Component Scripts
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
## Prefer Blade Components Over `@include`
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
## Use View Composers for Shared View Data
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
A single view can return either the full page or just a fragment, keeping routing clean.
```php
return view('dashboard', compact('users'))
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
```
## Use `@aware` for Deeply Nested Component Props
Avoids re-passing parent props through every level of nested components.

View File

@ -0,0 +1,70 @@
# Caching Best Practices
## Use `Cache::remember()` Instead of Manual Get/Put
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
Incorrect:
```php
$val = Cache::get('stats');
if (! $val) {
$val = $this->computeStats();
Cache::put('stats', $val, 60);
}
```
Correct:
```php
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
```
## Use `Cache::flexible()` for Stale-While-Revalidate
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
## Use Cache Tags to Invalidate Related Groups
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
```php
Cache::tags(['user-1'])->flush();
```
## Use `Cache::add()` for Atomic Conditional Writes
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
Correct: `Cache::add('lock', true, 10);`
## Use `once()` for Per-Request Memoization
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
```php
public function roles(): Collection
{
return once(fn () => $this->loadRoles());
}
```
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
## Configure Failover Cache Stores in Production
If Redis goes down, the app falls back to a secondary store automatically.
```php
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
```

View File

@ -0,0 +1,44 @@
# Collection Best Practices
## Use Higher-Order Messages for Simple Operations
Incorrect:
```php
$users->each(function (User $user) {
$user->markAsVip();
});
```
Correct: `$users->each->markAsVip();`
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
## Choose `cursor()` vs. `lazy()` Correctly
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
## Use `lazyById()` When Updating Records While Iterating
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
## Use `toQuery()` for Bulk Operations on Collections
Avoids manual `whereIn` construction.
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
Correct: `$users->toQuery()->update([...]);`
## Use `#[CollectedBy]` for Custom Collection Classes
More declarative than overriding `newCollection()`.
```php
#[CollectedBy(UserCollection::class)]
class User extends Model {}
```

View File

@ -0,0 +1,73 @@
# Configuration Best Practices
## `env()` Only in Config Files
Direct `env()` calls may return `null` when config is cached.
Incorrect:
```php
$key = env('API_KEY');
```
Correct:
```php
// config/services.php
'key' => env('API_KEY'),
// Application code
$key = config('services.key');
```
## Use Encrypted Env or External Secrets
Never store production secrets in plain `.env` files in version control.
Incorrect:
```bash
# .env committed to repo or shared in Slack
STRIPE_SECRET=sk_live_abc123
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
```
Correct:
```bash
php artisan env:encrypt --env=production --readable
php artisan env:decrypt --env=production
```
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
## Use `App::environment()` for Environment Checks
Incorrect:
```php
if (env('APP_ENV') === 'production') {
```
Correct:
```php
if (app()->isProduction()) {
// or
if (App::environment('production')) {
```
## Use Constants and Language Files
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
```php
// Incorrect
return $this->type === 'normal';
// Correct
return $this->type === self::TYPE_NORMAL;
```
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
```php
// Only when lang files already exist in the project
return back()->with('message', __('app.article_added'));
```

View File

@ -0,0 +1,192 @@
# Database Performance Best Practices
## Always Eager Load Relationships
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
Incorrect (N+1 — executes 1 + N queries):
```php
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
```
Correct (2 queries total):
```php
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name;
}
```
Constrain eager loads to select only needed columns (always include the foreign key):
```php
$users = User::with(['posts' => function ($query) {
$query->select('id', 'user_id', 'title')
->where('published', true)
->latest()
->limit(10);
}])->get();
```
## Prevent Lazy Loading in Development
Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
```php
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
```
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
## Select Only Needed Columns
Avoid `SELECT *` — especially when tables have large text or JSON columns.
Incorrect:
```php
$posts = Post::with('author')->get();
```
Correct:
```php
$posts = Post::select('id', 'title', 'user_id', 'created_at')
->with(['author:id,name,avatar'])
->get();
```
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
## Chunk Large Datasets
Never load thousands of records at once. Use chunking for batch processing.
Incorrect:
```php
$users = User::all();
foreach ($users as $user) {
$user->notify(new WeeklyDigest);
}
```
Correct:
```php
User::where('subscribed', true)->chunk(200, function ($users) {
foreach ($users as $user) {
$user->notify(new WeeklyDigest);
}
});
```
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
```php
User::where('active', false)->chunkById(200, function ($users) {
$users->each->delete();
});
```
## Add Database Indexes
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
Incorrect:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamps();
});
```
Correct:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->index()->constrained();
$table->string('status')->index();
$table->timestamps();
$table->index(['status', 'created_at']);
});
```
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
## Use `withCount()` for Counting Relations
Never load entire collections just to count them.
Incorrect:
```php
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments->count();
}
```
Correct:
```php
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
```
Conditional counting:
```php
$posts = Post::withCount([
'comments',
'comments as approved_comments_count' => function ($query) {
$query->where('approved', true);
},
])->get();
```
## Use `cursor()` for Memory-Efficient Iteration
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
Incorrect:
```php
$users = User::where('active', true)->get();
```
Correct:
```php
foreach (User::where('active', true)->cursor() as $user) {
ProcessUser::dispatch($user->id);
}
```
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
## No Queries in Blade Templates
Never execute queries in Blade templates. Pass data from controllers.
Incorrect:
```blade
@foreach (User::all() as $user)
{{ $user->profile->name }}
@endforeach
```
Correct:
```php
// Controller
$users = User::with('profile')->get();
return view('users.index', compact('users'));
```
```blade
@foreach ($users as $user)
{{ $user->profile->name }}
@endforeach
```

View File

@ -0,0 +1,150 @@
# Eloquent Best Practices
## Use Correct Relationship Types
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
```php
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
```
## Use Local Scopes for Reusable Queries
Extract reusable query constraints into local scopes to avoid duplication.
Incorrect:
```php
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
$articles = Article::whereHas('user', function ($q) {
$q->where('verified', true)->whereNotNull('activated_at');
})->get();
```
Correct:
```php
#[Scope]
protected function active(Builder $query): Builder
{
return $query->where('verified', true)->whereNotNull('activated_at');
}
// Usage
$active = User::active()->get();
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
```
## Apply Global Scopes Sparingly
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
Incorrect (global scope for a conditional filter):
```php
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('published', true);
}
}
// Now admin panels, reports, and background jobs all silently skip drafts
```
Correct (local scope you opt into):
```php
#[Scope]
protected function published(Builder $query): Builder
{
return $query->where('published', true);
}
Post::published()->paginate(); // Explicit
Post::paginate(); // Admin sees all
```
## Define Attribute Casts
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
```php
protected function casts(): array
{
return [
'is_active' => 'boolean',
'metadata' => 'array',
'total' => 'decimal:2',
];
}
```
## Cast Date Columns Properly
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
Incorrect:
```blade
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
```
Correct:
```php
protected function casts(): array
{
return [
'ordered_at' => 'datetime',
];
}
```
```blade
{{ $order->ordered_at->toDateString() }}
{{ $order->ordered_at->format('m-d') }}
```
## Use `whereBelongsTo()` for Relationship Queries
Cleaner than manually specifying foreign keys.
Incorrect:
```php
Post::where('user_id', $user->id)->get();
```
Correct:
```php
Post::whereBelongsTo($user)->get();
Post::whereBelongsTo($user, 'author')->get();
```
## Avoid Hardcoded Table Names in Queries
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
Incorrect:
```php
DB::table('users')->where('active', true)->get();
$query->join('companies', 'companies.id', '=', 'users.company_id');
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
```
Correct — reference the model's table:
```php
DB::table((new User)->getTable())->where('active', true)->get();
// Even better — use Eloquent or the query builder instead of raw SQL
User::where('active', true)->get();
Order::where('status', 'pending')->get();
```
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.

View File

@ -0,0 +1,72 @@
# Error Handling Best Practices
## Exception Reporting and Rendering
There are two valid approaches — choose one and apply it consistently across the project.
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
```php
class InvalidOrderException extends Exception
{
public function report(): void { /* custom reporting */ }
public function render(Request $request): Response
{
return response()->view('errors.invalid-order', status: 422);
}
}
```
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
```php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
$exceptions->render(function (InvalidOrderException $e, Request $request) {
return response()->view('errors.invalid-order', status: 422);
});
})
```
Check the existing codebase and follow whichever pattern is already established.
## Use `ShouldntReport` for Exceptions That Should Never Log
More discoverable than listing classes in `dontReport()`.
```php
class PodcastProcessingException extends Exception implements ShouldntReport {}
```
## Throttle High-Volume Exceptions
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
## Enable `dontReportDuplicates()`
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
## Force JSON Error Rendering for API Routes
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
```php
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
return $request->is('api/*') || $request->expectsJson();
});
```
## Add Context to Exception Classes
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
```php
class InvalidOrderException extends Exception
{
public function context(): array
{
return ['order_id' => $this->orderId];
}
}
```

View File

@ -0,0 +1,52 @@
# Events & Notifications Best Practices
## Rely on Event Discovery
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
## Run `event:cache` in Production Deploy
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
## Use `ShouldDispatchAfterCommit` Inside Transactions
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
```php
class OrderShipped implements ShouldDispatchAfterCommit {}
```
## Always Queue Notifications
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
```php
class InvoicePaid extends Notification implements ShouldQueue
{
use Queueable;
}
```
## Use `afterCommit()` on Notifications in Transactions
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
```php
$user->notify((new InvoicePaid($invoice))->afterCommit());
```
## Route Notification Channels to Dedicated Queues
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
## Use On-Demand Notifications for Non-User Recipients
Avoid creating dummy models to send notifications to arbitrary addresses.
```php
Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
```
## Implement `HasLocalePreference` on Notifiable Models
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.

View File

@ -0,0 +1,160 @@
# HTTP Client Best Practices
## Always Set Explicit Timeouts
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
Incorrect:
```php
$response = Http::get('https://api.example.com/users');
```
Correct:
```php
$response = Http::timeout(5)
->connectTimeout(3)
->get('https://api.example.com/users');
```
For service-specific clients, define timeouts in a macro:
```php
Http::macro('github', function () {
return Http::baseUrl('https://api.github.com')
->timeout(10)
->connectTimeout(3)
->withToken(config('services.github.token'));
});
$response = Http::github()->get('/repos/laravel/framework');
```
## Use Retry with Backoff for External APIs
External APIs have transient failures. Use `retry()` with increasing delays.
Incorrect:
```php
$response = Http::post('https://api.stripe.com/v1/charges', $data);
if ($response->failed()) {
throw new PaymentFailedException('Charge failed');
}
```
Correct:
```php
$response = Http::retry([100, 500, 1000])
->timeout(10)
->post('https://api.stripe.com/v1/charges', $data);
```
Only retry on specific errors:
```php
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError());
})->post('https://api.example.com/data');
```
## Handle Errors Explicitly
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
Incorrect:
```php
$response = Http::get('https://api.example.com/users/1');
$user = $response->json(); // Could be an error body
```
Correct:
```php
$response = Http::timeout(5)
->get('https://api.example.com/users/1')
->throw();
$user = $response->json();
```
For graceful degradation:
```php
$response = Http::get('https://api.example.com/users/1');
if ($response->successful()) {
return $response->json();
}
if ($response->notFound()) {
return null;
}
$response->throw();
```
## Use Request Pooling for Concurrent Requests
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
Incorrect:
```php
$users = Http::get('https://api.example.com/users')->json();
$posts = Http::get('https://api.example.com/posts')->json();
$comments = Http::get('https://api.example.com/comments')->json();
```
Correct:
```php
use Illuminate\Http\Client\Pool;
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('users')->get('https://api.example.com/users'),
$pool->as('posts')->get('https://api.example.com/posts'),
$pool->as('comments')->get('https://api.example.com/comments'),
]);
$users = $responses['users']->json();
$posts = $responses['posts']->json();
```
## Fake HTTP Calls in Tests
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
Incorrect:
```php
it('syncs user from API', function () {
$service = new UserSyncService;
$service->sync(1); // Hits the real API
});
```
Correct:
```php
it('syncs user from API', function () {
Http::preventStrayRequests();
Http::fake([
'api.example.com/users/1' => Http::response([
'name' => 'John Doe',
'email' => 'john@example.com',
]),
]);
$service = new UserSyncService;
$service->sync(1);
Http::assertSent(function (Request $request) {
return $request->url() === 'https://api.example.com/users/1';
});
});
```
Test failure scenarios too:
```php
Http::fake([
'api.example.com/*' => Http::failedConnection(),
]);
```

View File

@ -0,0 +1,27 @@
# Mail Best Practices
## Implement `ShouldQueue` on the Mailable Class
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
## Use `afterCommit()` on Mailables Inside Transactions
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
Correct: `Mail::assertQueued(OrderShipped::class);`
## Use Markdown Mailables for Transactional Emails
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
## Separate Content Tests from Sending Tests
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
Don't mix them — it conflates concerns and makes tests brittle.

View File

@ -0,0 +1,121 @@
# Migration Best Practices
## Generate Migrations with Artisan
Always use `php artisan make:migration` for consistent naming and timestamps.
Incorrect (manually created file):
```php
// database/migrations/posts_migration.php ← wrong naming, no timestamp
```
Correct (Artisan-generated):
```bash
php artisan make:migration create_posts_table
php artisan make:migration add_slug_to_posts_table
```
## Use `constrained()` for Foreign Keys
Automatic naming and referential integrity.
```php
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Non-standard names
$table->foreignId('author_id')->constrained('users');
```
## Never Modify Deployed Migrations
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
Incorrect (editing a deployed migration):
```php
// 2024_01_01_create_posts_table.php — already in production
$table->string('slug')->unique(); // ← added after deployment
```
Correct (new migration to alter):
```php
// 2024_03_15_add_slug_to_posts_table.php
Schema::table('posts', function (Blueprint $table) {
$table->string('slug')->unique()->after('title');
});
```
## Add Indexes in the Migration
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
Incorrect:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamps();
});
```
Correct:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->index();
$table->string('status')->index();
$table->timestamp('shipped_at')->nullable()->index();
$table->timestamps();
});
```
## Mirror Defaults in Model `$attributes`
When a column has a database default, mirror it in the model so new instances have correct values before saving.
```php
// Migration
$table->string('status')->default('pending');
// Model
protected $attributes = [
'status' => 'pending',
];
```
## Write Reversible `down()` Methods by Default
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
```php
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('slug');
});
}
```
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
## Keep Migrations Focused
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
Incorrect (partial failure creates unrecoverable state):
```php
public function up(): void
{
Schema::create('settings', function (Blueprint $table) { ... });
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
}
```
Correct (separate migrations):
```php
// Migration 1: create_settings_table
Schema::create('settings', function (Blueprint $table) { ... });
// Migration 2: seed_default_settings
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
```

View File

@ -0,0 +1,144 @@
# Queue & Job Best Practices
## Set `retry_after` Greater Than `timeout`
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
Incorrect (`retry_after``timeout`):
```php
class ProcessReport implements ShouldQueue
{
public $timeout = 120;
}
// config/queue.php — retry_after: 90 ← job retried while still running!
```
Correct (`retry_after` > `timeout`):
```php
class ProcessReport implements ShouldQueue
{
public $timeout = 120;
}
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
```
## Use Exponential Backoff
Use progressively longer delays between retries to avoid hammering failing services.
Incorrect (fixed retry interval):
```php
class SyncWithStripe implements ShouldQueue
{
public $tries = 3;
// Default: retries immediately, overwhelming the API
}
```
Correct (exponential backoff):
```php
class SyncWithStripe implements ShouldQueue
{
public $tries = 3;
public $backoff = [1, 5, 10];
}
```
## Implement `ShouldBeUnique`
Prevent duplicate job processing.
```php
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return $this->order->id;
}
public $uniqueFor = 3600;
}
```
## Always Implement `failed()`
Handle errors explicitly — don't rely on silent failure.
```php
public function failed(?Throwable $exception): void
{
$this->podcast->update(['status' => 'failed']);
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
}
```
## Rate Limit External API Calls in Jobs
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
```php
public function middleware(): array
{
return [new RateLimited('external-api')];
}
```
## Batch Related Jobs
Use `Bus::batch()` when jobs should succeed or fail together.
```php
Bus::batch([
new ImportCsvChunk($chunk1),
new ImportCsvChunk($chunk2),
])
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
->dispatch();
```
## `retryUntil()` Needs `$tries = 0`
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
```php
public $tries = 0;
public function retryUntil(): \DateTimeInterface
{
return now()->addHours(4);
}
```
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
```php
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// Lock releases when processing begins, not when it finishes
}
```
## Use Horizon for Complex Queue Scenarios
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
```php
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'tries' => 3,
],
],
],
```

View File

@ -0,0 +1,99 @@
# Routing & Controllers Best Practices
## Use Implicit Route Model Binding
Let Laravel resolve models automatically from route parameters.
Incorrect:
```php
public function show(int $id)
{
$post = Post::findOrFail($id);
}
```
Correct:
```php
public function show(Post $post)
{
return view('posts.show', ['post' => $post]);
}
```
## Use Scoped Bindings for Nested Resources
Enforce parent-child relationships automatically.
```php
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
// $post is automatically scoped to $user
})->scopeBindings();
```
## Use Resource Controllers
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
```php
Route::resource('posts', PostController::class);
// In routes/api.php — the /api prefix is applied automatically
Route::apiResource('posts', Api\PostController::class);
```
## Keep Controllers Thin
Aim for under 10 lines per method. Extract business logic to action or service classes.
Incorrect:
```php
public function store(Request $request)
{
$validated = $request->validate([...]);
if ($request->hasFile('image')) {
$request->file('image')->move(public_path('images'));
}
$post = Post::create($validated);
$post->tags()->sync($validated['tags']);
event(new PostCreated($post));
return redirect()->route('posts.show', $post);
}
```
Correct:
```php
public function store(StorePostRequest $request, CreatePostAction $create)
{
$post = $create->execute($request->validated());
return redirect()->route('posts.show', $post);
}
```
## Type-Hint Form Requests
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
Incorrect:
```php
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'max:255'],
'body' => ['required'],
]);
Post::create($validated);
return redirect()->route('posts.index');
}
```
Correct:
```php
public function store(StorePostRequest $request): RedirectResponse
{
Post::create($request->validated());
return redirect()->route('posts.index');
}
```

View File

@ -0,0 +1,39 @@
# Task Scheduling Best Practices
## Use `withoutOverlapping()` on Variable-Duration Tasks
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
## Use `onOneServer()` on Multi-Server Deployments
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
## Use `runInBackground()` for Concurrent Long Tasks
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
## Use `environments()` to Restrict Tasks
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
```php
Schedule::command('billing:charge')->monthly()->environments(['production']);
```
## Use `takeUntilTimeout()` for Time-Bounded Processing
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
## Use Schedule Groups for Shared Configuration
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
```php
Schedule::daily()
->onOneServer()
->timezone('America/New_York')
->group(function () {
Schedule::command('emails:send --force');
Schedule::command('emails:prune');
});
```

View File

@ -0,0 +1,198 @@
# Security Best Practices
## Mass Assignment Protection
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
Incorrect:
```php
class User extends Model
{
protected $guarded = []; // All fields are mass assignable
}
```
Correct:
```php
class User extends Model
{
protected $fillable = [
'name',
'email',
'password',
];
}
```
Never use `$guarded = []` on models that accept user input.
## Authorize Every Action
Use policies or gates in controllers. Never skip authorization.
Incorrect:
```php
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
}
```
Correct:
```php
public function update(UpdatePostRequest $request, Post $post)
{
Gate::authorize('update', $post);
$post->update($request->validated());
}
```
Or via Form Request:
```php
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
}
```
## Prevent SQL Injection
Always use parameter binding. Never interpolate user input into queries.
Incorrect:
```php
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
```
Correct:
```php
User::where('name', $request->name)->get();
// Raw expressions with bindings
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
```
## Escape Output to Prevent XSS
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
Incorrect:
```blade
{!! $user->bio !!}
```
Correct:
```blade
{{ $user->bio }}
```
## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
Incorrect:
```blade
<form method="POST" action="/posts">
<input type="text" name="title">
</form>
```
Correct:
```blade
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
</form>
```
## Rate Limit Auth and API Routes
Apply `throttle` middleware to authentication and API routes.
```php
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
Route::post('/login', LoginController::class)->middleware('throttle:login');
```
## Validate File Uploads
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
```php
public function rules(): array
{
return [
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
];
}
```
Store with generated filenames:
```php
$path = $request->file('avatar')->store('avatars', 'public');
```
## Keep Secrets Out of Code
Never commit `.env`. Access secrets via `config()` only.
Incorrect:
```php
$key = env('API_KEY');
```
Correct:
```php
// config/services.php
'api_key' => env('API_KEY'),
// In application code
$key = config('services.api_key');
```
## Audit Dependencies
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
```bash
composer audit
```
## Encrypt Sensitive Database Fields
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
Incorrect:
```php
class Integration extends Model
{
protected function casts(): array
{
return [
'api_key' => 'string',
];
}
}
```
Correct:
```php
class Integration extends Model
{
protected $hidden = ['api_key', 'api_secret'];
protected function casts(): array
{
return [
'api_key' => 'encrypted',
'api_secret' => 'encrypted',
];
}
}
```

View File

@ -0,0 +1,125 @@
# Conventions & Style
## Follow Laravel Naming Conventions
| What | Convention | Good | Bad |
|------|-----------|------|-----|
| Controller | singular | `ArticleController` | `ArticlesController` |
| Model | singular | `User` | `Users` |
| Table | plural, snake_case | `article_comments` | `articleComments` |
| Pivot table | singular alphabetical | `article_user` | `user_article` |
| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
| Route | plural | `articles/1` | `article/1` |
| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
| Method | camelCase | `getAll` | `get_all` |
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
| Collection | descriptive, plural | `$activeUsers` | `$data` |
| Object | descriptive, singular | `$activeUser` | `$users` |
| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
| Enum | singular | `UserType` | `UserTypes` |
## Prefer Shorter Readable Syntax
| Verbose | Shorter |
|---------|---------|
| `Session::get('cart')` | `session('cart')` |
| `$request->session()->get('cart')` | `session('cart')` |
| `$request->input('name')` | `$request->name` |
| `return Redirect::back()` | `return back()` |
| `Carbon::now()` | `now()` |
| `App::make('Class')` | `app('Class')` |
| `->where('column', '=', 1)` | `->where('column', 1)` |
| `->orderBy('created_at', 'desc')` | `->latest()` |
| `->orderBy('created_at', 'asc')` | `->oldest()` |
| `->first()->name` | `->value('name')` |
## Use Laravel String & Array Helpers
Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
Strings — use `Str` and fluent `Str::of()` over raw PHP:
```php
// Incorrect
$slug = strtolower(str_replace(' ', '-', $title));
$short = substr($text, 0, 100) . '...';
$class = substr(strrchr('App\Models\User', '\\'), 1);
// Correct
$slug = Str::slug($title);
$short = Str::limit($text, 100);
$class = class_basename('App\Models\User');
```
Fluent strings — chain operations for complex transformations:
```php
// Incorrect
$result = strtolower(trim(str_replace('_', '-', $input)));
// Correct
$result = Str::of($input)->trim()->replace('_', '-')->lower();
```
Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
Arrays — use `Arr` over raw PHP:
```php
// Incorrect
$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
// Correct
$name = Arr::get($array, 'user.name', 'default');
```
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
Numbers — use `Number` for display formatting:
```php
Number::format(1000000); // "1,000,000"
Number::currency(1500, 'USD'); // "$1,500.00"
Number::abbreviate(1000000); // "1M"
Number::fileSize(1024 * 1024); // "1 MB"
Number::percentage(75.5); // "75.5%"
```
URIs — use `Uri` for URL manipulation:
```php
$uri = Uri::of('https://example.com/search')
->withQuery(['q' => 'laravel', 'page' => 1]);
```
Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
Use `search-docs` for the full list of available methods — these helpers are extensive.
## No Inline JS/CSS in Blade
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
Incorrect:
```blade
let article = `{{ json_encode($article) }}`;
```
Correct:
```blade
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
```
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
## No Unnecessary Comments
Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
Incorrect:
```php
// Check if there are any joins
if (count((array) $builder->getQuery()->joins) > 0)
```
Correct:
```php
if ($this->hasJoins())
```

View File

@ -0,0 +1,43 @@
# Testing Best Practices
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
## Use Model Assertions Over Raw Database Assertions
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
Correct: `$this->assertModelExists($user);`
More expressive, type-safe, and fails with clearer messages.
## Use Factory States and Sequences
Named states make tests self-documenting. Sequences eliminate repetitive setup.
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
Correct: `User::factory()->unverified()->create();`
## Use `Exceptions::fake()` to Assert Exception Reporting
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
## Call `Event::fake()` After Factory Setup
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
Incorrect: `Event::fake(); $user = User::factory()->create();`
Correct: `$user = User::factory()->create(); Event::fake();`
## Use `recycle()` to Share Relationship Instances Across Factories
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
```php
Ticket::factory()
->recycle(Airline::factory()->create())
->create();
```

View File

@ -0,0 +1,75 @@
# Validation & Forms Best Practices
## Use Form Request Classes
Extract validation from controllers into dedicated Form Request classes.
Incorrect:
```php
public function store(Request $request)
{
$request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
}
```
Correct:
```php
public function store(StorePostRequest $request)
{
Post::create($request->validated());
}
```
## Array vs. String Notation for Rules
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
```php
// Preferred for new code
'email' => ['required', 'email', Rule::unique('users')],
// Follow existing convention if the project uses string notation
'email' => 'required|email|unique:users',
```
## Always Use `validated()`
Get only validated data. Never use `$request->all()` for mass operations.
Incorrect:
```php
Post::create($request->all());
```
Correct:
```php
Post::create($request->validated());
```
## Use `Rule::when()` for Conditional Validation
```php
'company_name' => [
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
],
```
## Use the `after()` Method for Custom Validation
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
```php
public function after(): array
{
return [
function (Validator $validator) {
if ($this->quantity > Product::find($this->product_id)?->stock) {
$validator->errors()->add('quantity', 'Not enough stock.');
}
},
];
}
```

View File

@ -0,0 +1,80 @@
---
name: socialite-development
description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication."
license: MIT
metadata:
author: laravel
---
# Socialite Authentication
## Documentation
Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth).
## Available Providers
Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch`
Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`.
Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`.
Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand.
Community providers differ from built-in providers in the following ways:
- Installed via `composer require socialiteproviders/{name}`
- Must register via event listener — NOT auto-discovered like built-in providers
- Use `search-docs` for the registration pattern
## Adding a Provider
### 1. Configure the provider
Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly.
### 2. Create redirect and callback routes
Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details.
### 3. Authenticate and store the user
In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`.
### 4. Customize the redirect (optional)
- `scopes()` — merge additional scopes with the provider's defaults
- `setScopes()` — replace all scopes entirely
- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google)
- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object.
- `stateless()` — for API/SPA contexts where session state is not maintained
### 5. Verify
1. Config key matches driver name exactly (check the list above for hyphenated names)
2. `client_id`, `client_secret`, and `redirect` are all present
3. Redirect URL matches what is registered in the provider's OAuth dashboard
4. Callback route handles denied grants (when user declines authorization)
Use `search-docs` for complete code examples of each step.
## Additional Features
Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details.
User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes`
## Testing
Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods.
## Common Pitfalls
- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails.
- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors.
- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely.
- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`.
- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol).
- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved.
- Community providers require event listener registration via `SocialiteWasCalled`.
- `user()` throws when the user declines authorization. Always handle denied grants.

View File

@ -0,0 +1,119 @@
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

View File

@ -4,6 +4,7 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_TIMEZONE='Asia/Kolkata'
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
@ -70,9 +71,21 @@ MICROSOFT_CLIENT_SECRET=
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback" MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
MICROSOFT_TENANT_ID=common MICROSOFT_TENANT_ID=common
# ICE Candidate / TURN Server Configurations
ICE_CANDIDATE_PROVIDER=metered
ICE_FALLBACK_PROVIDER=convexsol
# Metered TURN Server Credentials # Metered TURN Server Credentials
METERED_URL= METERED_URL=
METERED_KEY= METERED_KEY=
# ConvexSol TURN / STUN Server Credentials
CONVEXSOL_USERNAME=
CONVEXSOL_PASSWORD=
CONVEXSOL_TURN_URLS=
CONVEXSOL_STUN_URLS=
# Interview Configurations # Interview Configurations
MAX_INTERVIEWER=2 MAX_INTERVIEWER=2
INTERVIEW_RECORDINGS_FOLDER=uploads/candidate_recordings
INTERVIEW_RECORDINGS_TEMP_FOLDER=app/temp_recordings

View File

@ -0,0 +1,38 @@
<?php
namespace App\Actions\Interview;
use App\Jobs\MergeRecordingChunksJob;
use App\Models\Interview;
class FinalizeRecordingAction
{
/**
* Finalize the recording session by dispatching the background merge job.
*
* @param Interview $interview
* @param string $sessionId
* @param int $totalChunks
* @param string|null $tempDir
* @param string|null $folderName
* @param int|null $duration
* @return void
*/
public function execute(
Interview $interview,
string $sessionId,
int $totalChunks = 0,
?string $tempDir = null,
?string $folderName = null,
?int $duration = null
): void {
MergeRecordingChunksJob::dispatch(
$interview->id,
$sessionId,
$tempDir,
$folderName,
$totalChunks,
$duration
);
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace App\Actions\Interview;
use App\Models\Interview;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class MergeRecordingChunksAction
{
/**
* Execute the heavy stream merge of recording chunk parts into a single video file.
*
* @param Interview $interview
* @param string $sessionId
* @param string|null $tempDir
* @param string|null $folderName
* @param int|null $duration
* @return string|null The relative path to the merged recording file, or null on failure.
*/
public function execute(
Interview $interview,
string $sessionId,
?string $tempDir = null,
?string $folderName = null,
?int $duration = null
): ?string {
$safeSessionId = preg_replace('/[^a-zA-Z0-9_\-]/', '', $sessionId);
$tempBase = config('interview.recordings.temp_folder', 'app/temp_recordings');
$resolvedTempDir = $tempDir ?: storage_path(trim($tempBase, '/') . '/' . $safeSessionId);
if (!is_dir($resolvedTempDir)) {
Log::warning('[MergeRecordingChunksAction] Temporary chunk directory not found', [
'session_id' => $sessionId,
'temp_dir' => $resolvedTempDir,
]);
return null;
}
$parts = $this->findAndSortParts($resolvedTempDir);
if (empty($parts)) {
Log::warning('[MergeRecordingChunksAction] No chunk parts found to merge', [
'session_id' => $sessionId,
'temp_dir' => $resolvedTempDir,
]);
return null;
}
// Validate that part_000000.webm exists and starts with valid EBML container header
$firstPart = $parts[0];
$headerCheck = @file_get_contents($firstPart, false, null, 0, 4);
if ($headerCheck === false || !str_starts_with($headerCheck, "\x1a\x45\xdf\xa3")) {
Log::error('[MergeRecordingChunksAction] First chunk missing EBML container header. Possible incomplete initial chunk upload.', [
'session_id' => $sessionId,
'first_part' => $firstPart,
'header_hex' => $headerCheck ? bin2hex($headerCheck) : 'null',
]);
return null;
}
$baseFolder = config('interview.recordings.base_folder', 'uploads/candidate_recordings');
$uniqueFolder = $folderName ?: ($interview->submission_unique_id ?: (string) $interview->id);
$subDir = trim($baseFolder, '/') . '/' . $uniqueFolder;
$destinationDir = public_path($subDir);
$this->ensureDestinationDirectory($destinationDir);
$filename = 'recording_' . time() . '.webm';
$finalPath = $destinationDir . DIRECTORY_SEPARATOR . $filename;
$this->streamMergeFiles($parts, $finalPath);
if (!file_exists($finalPath) || filesize($finalPath) === 0) {
Log::error('[MergeRecordingChunksAction] Output recording file is empty or was not created', [
'final_path' => $finalPath,
'session_id' => $sessionId,
]);
return null;
}
$relativePath = '/' . $subDir . '/' . $filename;
$url = asset($subDir . '/' . $filename);
$this->updateInterviewRecordings($interview, $relativePath, $url, $filename, $duration);
$this->cleanupTempParts($parts, $resolvedTempDir);
Log::info('[MergeRecordingChunksAction] Recording assembled successfully', [
'interview_id' => $interview->id,
'session_id' => $sessionId,
'total_parts' => count($parts),
'duration' => $duration,
'path' => $relativePath,
]);
return $relativePath;
}
/**
* Locate and sort chunk part files in ascending numerical order.
*
* @param string $tempDir
* @return array<string>
*/
private function findAndSortParts(string $tempDir): array
{
$parts = glob($tempDir . DIRECTORY_SEPARATOR . 'part_*.webm') ?: [];
natsort($parts);
return array_values($parts);
}
/**
* Ensure that the destination directory exists and is writable.
*
* @param string $dir
* @return void
*/
private function ensureDestinationDirectory(string $dir): void
{
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new RuntimeException("Failed to create destination directory: {$dir}");
}
}
}
/**
* Stream merge multiple binary chunk files into a single destination file.
*
* @param array<string> $parts
* @param string $finalPath
* @return void
*/
private function streamMergeFiles(array $parts, string $finalPath): void
{
$out = fopen($finalPath, 'wb');
if ($out === false) {
throw new RuntimeException("Cannot open destination file for writing: {$finalPath}");
}
try {
foreach ($parts as $partFile) {
$in = fopen($partFile, 'rb');
if ($in === false) {
Log::warning("[MergeRecordingChunksAction] Could not read part: {$partFile}");
continue;
}
stream_copy_to_stream($in, $out);
fclose($in);
}
} finally {
fclose($out);
}
}
/**
* Update the interview model with the newly merged recording metadata.
*
* @param Interview $interview
* @param string $relativePath
* @param string $url
* @param string $filename
* @param int|null $duration
* @return void
*/
private function updateInterviewRecordings(Interview $interview, string $relativePath, string $url, string $filename, ?int $duration = null): void
{
$recordings = $interview->recordings ?? [];
if (!is_array($recordings)) {
$recordings = $interview->recording_path ? [$interview->recording_path] : [];
}
$recordings[] = [
'url' => $relativePath,
'full_url' => $url,
'filename' => $filename,
'created_at' => now()->toIso8601String(),
'duration' => $duration ?: 0,
];
$interview->update([
'recording_path' => $relativePath,
'recordings' => $recordings,
]);
}
/**
* Clean up temporary parts and remove the session directory.
*
* @param array<string> $parts
* @param string $tempDir
* @return void
*/
private function cleanupTempParts(array $parts, string $tempDir): void
{
foreach ($parts as $partFile) {
@unlink($partFile);
}
@rmdir($tempDir);
}
}

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

View 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,
]);
}
}
}
}
}

View File

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

View File

@ -0,0 +1,34 @@
<?php
namespace App\Actions\Interview;
use App\Models\Interview;
use Illuminate\Http\UploadedFile;
class StoreRecordingChunkAction
{
/**
* Store an individual video recording chunk in a temporary session directory.
*
* @param Interview $interview
* @param string $sessionId
* @param int $chunkIndex
* @param UploadedFile $file
* @return string
*/
public function execute(Interview $interview, string $sessionId, int $chunkIndex, UploadedFile $file): string
{
$safeSessionId = preg_replace('/[^a-zA-Z0-9_\-]/', '', $sessionId);
$tempBase = config('interview.recordings.temp_folder', 'app/temp_recordings');
$tempDir = storage_path(trim($tempBase, '/') . '/' . $safeSessionId);
if (!file_exists($tempDir)) {
mkdir($tempDir, 0777, true);
}
$partName = sprintf('part_%06d.webm', $chunkIndex);
$file->move($tempDir, $partName);
return $tempDir . DIRECTORY_SEPARATOR . $partName;
}
}

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

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

View 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();
}
}

View 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);
}
}

View 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();
}
}

View File

@ -303,7 +303,7 @@ public function updateOnboardingToggle(Request $request)
\App\Models\Setting::set('onboarding_enabled', $enabled); \App\Models\Setting::set('onboarding_enabled', $enabled);
$msg = $enabled === '1' ? 'Onboarding & Offboarding module enabled.' : 'Onboarding & Offboarding module disabled.'; $msg = $enabled === '1' ? 'Onboarding & Offboarding module enabled.' : 'Onboarding & Offboarding module disabled.';
return redirect()->route('admin.dashboard')->with('success', $msg); return redirect()->back(fallback: route('admin.dashboard'))->with('success', $msg);
} }
/** /**
@ -315,7 +315,7 @@ public function updateScreenshotToggle(Request $request)
\App\Models\Setting::set('enable_candidate_screenshots', $enabled); \App\Models\Setting::set('enable_candidate_screenshots', $enabled);
$msg = $enabled === '1' ? 'Candidate System Screenshot Capture ENABLED globally.' : 'Candidate System Screenshot Capture DISABLED globally by Admin.'; $msg = $enabled === '1' ? 'Candidate System Screenshot Capture ENABLED globally.' : 'Candidate System Screenshot Capture DISABLED globally by Admin.';
return redirect()->route('admin.dashboard')->with('success', $msg); return redirect()->back(fallback: route('admin.dashboard'))->with('success', $msg);
} }
/** /**

View File

@ -1,65 +1,36 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Contracts\IceCandidateProviderInterface;
use App\Http\Resources\Interview\IceCandidatesResource;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class IceServerController extends Controller 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')) { if (! Auth::check() && ! session()->has('candidate_interview_id')) {
return response()->json(['error' => 'Unauthenticated access.'], 401); return response()->json(['error' => 'Unauthenticated access.'], 401);
} }
try { try {
$baseUrl = config( $candidates = $this->iceCandidateProvider->getIceCandidates();
"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);
return new IceCandidatesResource($candidates);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error( Log::error('ICE servers fetch exception: '.$e->getMessage());
"ICE servers fetch exception: " . $e->getMessage(),
);
return response()->json( return response()->json([
[ 'error' => 'An error occurred while fetching ICE servers.',
"error" => "An error occurred while fetching ICE servers.", 'message' => $e->getMessage(),
"message" => $e->getMessage(), ], 500);
],
500,
);
} }
} }
} }

View File

@ -2,15 +2,28 @@
namespace App\Http\Controllers; 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\Interview;
use App\Models\InterviewWarning; use App\Models\InterviewWarning;
use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Services\AiCheatingDetectorService;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
class InterviewController extends Controller class InterviewController extends Controller
{ {
@ -22,11 +35,20 @@ public function index()
$user = Auth::user(); $user = Auth::user();
// Check if user is Admin, HR, or has active assigned interview zone timeline // Check if user is Admin, HR, or has active assigned interview zone timeline
if (!$user->isAdmin() && strtolower($user->role) !== 'hr' && !$user->hasActiveInterviewZone()) { if (! $user->isAdmin() && strtolower($user->role) !== 'hr' && ! $user->hasActiveInterviewZone()) {
return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.'); return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.');
} }
$interviews = $user->getAccessibleInterviews(); $interviews = $user->getAccessibleInterviews();
// Auto-terminate call session if interview timeline has expired
foreach ($interviews as $interview) {
if ($interview->isExpired() && $interview->call_status === 'active') {
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
}
}
$interviewers = User::orderBy('name', 'asc')->get(); $interviewers = User::orderBy('name', 'asc')->get();
return view('interview.index', compact('interviews', 'interviewers')); return view('interview.index', compact('interviews', 'interviewers'));
@ -39,56 +61,32 @@ public function show($id)
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user->isAdmin() && strtolower($user->role) !== 'hr' && !$user->hasActiveInterviewZone()) { if (! $user->isAdmin() && strtolower($user->role) !== 'hr' && ! $user->hasActiveInterviewZone()) {
return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.'); return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.');
} }
$interview = Interview::findOrFail($id); $interview = Interview::findOrFail($id);
// Auto-terminate call session if interview timeline has expired
if ($interview->isExpired() && $interview->call_status === 'active') {
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
}
$interviewers = User::orderBy('name', 'asc')->get(); $interviewers = User::orderBy('name', 'asc')->get();
return view('interview.show', compact('interview', 'interviewers')); return view('interview.show', compact('interview', 'interviewers'));
} }
/** /**
* 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([ $dto = ScheduleInterviewDto::fromRequest($request);
'candidate_name' => 'required|string|max:255', $interview = $scheduleAction->execute($dto, Auth::id());
'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',
]);
return redirect()->route('interview.index')->with('success', 'Interview session scheduled and invitations sent for '.$interview->candidate_name.'. Temporary login credentials generated!');
$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!');
} }
/** /**
@ -117,7 +115,7 @@ public function loginCandidate(Request $request)
->orWhere('submission_unique_id', $identifier); ->orWhere('submission_unique_id', $identifier);
})->where('temp_password', $request->temp_password)->first(); })->where('temp_password', $request->temp_password)->first();
if (!$interview) { if (! $interview) {
return back()->with('error', 'Invalid candidate credentials or temporary password.'); return back()->with('error', 'Invalid candidate credentials or temporary password.');
} }
@ -127,6 +125,7 @@ public function loginCandidate(Request $request)
if ($interview->isExpired()) { if ($interview->isExpired()) {
$interview->update(['status' => 'expired']); $interview->update(['status' => 'expired']);
return back()->with('error', 'This candidate interview access window has expired. Please contact HR.'); return back()->with('error', 'This candidate interview access window has expired. Please contact HR.');
} }
@ -153,6 +152,7 @@ public function showCandidateRoom($uniqueId)
if ($interview->isExpired()) { if ($interview->isExpired()) {
$interview->update(['status' => 'expired']); $interview->update(['status' => 'expired']);
return response()->view('interview.expired', compact('interview'), 403); return response()->view('interview.expired', compact('interview'), 403);
} }
@ -203,16 +203,16 @@ public function executeCode(Request $request)
$stdout = $resData['stdout'] ?? ''; $stdout = $resData['stdout'] ?? '';
$stderr = $resData['stderr'] ?? ''; $stderr = $resData['stderr'] ?? '';
if (!empty($compileOutput)) { if (! empty($compileOutput)) {
$output = $compileOutput; $output = $compileOutput;
} elseif (!empty($stdout)) { } elseif (! empty($stdout)) {
$output = $stdout; $output = $stdout;
} elseif (!empty($stderr)) { } elseif (! empty($stderr)) {
$output = $stderr; $output = $stderr;
} }
} }
} catch (\Exception $e) { } catch (\Exception $e) {
Log::warning("Judge0 Code execute failed.", [$e->getMessage()]); Log::warning('Judge0 Code execute failed.', [$e->getMessage()]);
} }
} }
@ -220,13 +220,13 @@ public function executeCode(Request $request)
if (empty($output)) { if (empty($output)) {
try { try {
if (in_array($langKey, ['javascript', 'js'])) { 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->setTimeout(3);
$process->run(); $process->run();
$out = $process->getOutput() ?: $process->getErrorOutput(); $out = $process->getOutput() ?: $process->getErrorOutput();
$output = trim($out) ?: 'JavaScript executed successfully (no stdout).'; $output = trim($out) ?: 'JavaScript executed successfully (no stdout).';
} elseif ($langKey === 'python') { } elseif ($langKey === 'python') {
$process = new \Symfony\Component\Process\Process(['python', '-c', $code]); $process = new Process(['python', '-c', $code]);
$process->setTimeout(3); $process->setTimeout(3);
$process->run(); $process->run();
$out = $process->getOutput() ?: $process->getErrorOutput(); $out = $process->getOutput() ?: $process->getErrorOutput();
@ -240,14 +240,15 @@ public function executeCode(Request $request)
$output = trim($out) ?: 'PHP code executed successfully.'; $output = trim($out) ?: 'PHP code executed successfully.';
} catch (\Throwable $e) { } catch (\Throwable $e) {
ob_end_clean(); ob_end_clean();
$output = 'PHP Evaluation Error: ' . $e->getMessage(); $output = 'PHP Evaluation Error: '.$e->getMessage();
} }
} }
} catch (\Exception $e) {} } catch (\Exception $e) {
}
} }
if (empty($output)) { if (empty($output)) {
$output = "=== Code Execution Status ===\nSolution executed.\nLanguage: " . strtoupper($langKey); $output = "=== Code Execution Status ===\nSolution executed.\nLanguage: ".strtoupper($langKey);
} }
// Auto-persist candidate code + execution output directly to interview model // Auto-persist candidate code + execution output directly to interview model
@ -288,8 +289,8 @@ public function submitCode(Request $request, $id)
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Code submitted successfully under unique ID: ' . $interview->submission_unique_id, 'message' => 'Code submitted successfully under unique ID: '.$interview->submission_unique_id,
'unique_id' => $interview->submission_unique_id 'unique_id' => $interview->submission_unique_id,
]); ]);
} }
@ -307,12 +308,12 @@ public function logViolation(Request $request, $id)
$confidence = $request->input('confidence', null); $confidence = $request->input('confidence', null);
$snapshot = $request->input('snapshot', null); $snapshot = $request->input('snapshot', null);
$detailsStr = is_array($details) ? json_encode($details) : (string)$details; $detailsStr = is_array($details) ? json_encode($details) : (string) $details;
// Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call // Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call
$aiVerdict = null; $aiVerdict = null;
if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) { 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 ?? ''); $aiVerdict = $aiDetector->analyzeSpeechTranscript($detailsStr, $interview->candidate_notes ?? '');
} }
@ -386,6 +387,7 @@ public function syncCandidateCode(Request $request, $id)
$updateData['code_output'] = $request->input('output'); $updateData['code_output'] = $request->input('output');
} }
$interview->update($updateData); $interview->update($updateData);
return response()->json(['success' => true]); return response()->json(['success' => true]);
} }
@ -396,6 +398,7 @@ public function saveCandidateNotes(Request $request, $id)
{ {
$interview = Interview::findOrFail($id); $interview = Interview::findOrFail($id);
$interview->update(['candidate_notes' => $request->input('notes')]); $interview->update(['candidate_notes' => $request->input('notes')]);
return response()->json(['success' => true]); return response()->json(['success' => true]);
} }
@ -406,6 +409,7 @@ public function saveCandidateDrawing(Request $request, $id)
{ {
$interview = Interview::findOrFail($id); $interview = Interview::findOrFail($id);
$interview->update(['candidate_drawing' => $request->input('drawing')]); $interview->update(['candidate_drawing' => $request->input('drawing')]);
return response()->json(['success' => true]); return response()->json(['success' => true]);
} }
@ -415,8 +419,9 @@ public function saveCandidateDrawing(Request $request, $id)
public function regeneratePassword($id) public function regeneratePassword($id)
{ {
$interview = Interview::findOrFail($id); $interview = Interview::findOrFail($id);
$newPass = 'Pass-' . rand(100000, 999999); $newPass = 'Pass-'.rand(100000, 999999);
$interview->update(['temp_password' => $newPass]); $interview->update(['temp_password' => $newPass]);
return response()->json(['success' => true, 'new_password' => $newPass]); return response()->json(['success' => true, 'new_password' => $newPass]);
} }
@ -439,7 +444,7 @@ public function blockCandidate(Request $request, $id)
return response()->json([ return response()->json([
'success' => true, '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.',
]); ]);
} }
@ -451,23 +456,23 @@ public function serveStorageFile($path)
$sanitizedPath = ltrim(str_replace('..', '', $path), '/'); $sanitizedPath = ltrim(str_replace('..', '', $path), '/');
$candidatePaths = [ $candidatePaths = [
storage_path('app/public/' . $sanitizedPath), storage_path('app/public/'.$sanitizedPath),
storage_path('app/private/public/' . $sanitizedPath), storage_path('app/private/public/'.$sanitizedPath),
storage_path('app/private/' . $sanitizedPath), storage_path('app/private/'.$sanitizedPath),
storage_path('app/' . $sanitizedPath), storage_path('app/'.$sanitizedPath),
public_path('storage/' . $sanitizedPath), public_path('storage/'.$sanitizedPath),
public_path($sanitizedPath), public_path($sanitizedPath),
]; ];
// Also check with alternate extensions (.webm / .mp4) if requested file extension differs // Also check with alternate extensions (.webm / .mp4) if requested file extension differs
$baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath); $baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath);
if ($baseNoExt !== $sanitizedPath) { if ($baseNoExt !== $sanitizedPath) {
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/public/'.$baseNoExt.'.webm');
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/public/'.$baseNoExt.'.mp4');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/private/public/'.$baseNoExt.'.webm');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/private/public/'.$baseNoExt.'.mp4');
$candidatePaths[] = public_path($baseNoExt . '.webm'); $candidatePaths[] = public_path($baseNoExt.'.webm');
$candidatePaths[] = public_path($baseNoExt . '.mp4'); $candidatePaths[] = public_path($baseNoExt.'.mp4');
} }
$filePath = null; $filePath = null;
@ -478,14 +483,16 @@ public function serveStorageFile($path)
} }
} }
if (!$filePath) { if (! $filePath) {
abort(404, 'Storage file not found.'); abort(404, 'Storage file not found.');
} }
// Read first 12 bytes to accurately detect container format (WebM vs MP4 vs Image) // Read first 12 bytes to accurately detect container format (WebM vs MP4 vs Image)
$handle = @fopen($filePath, 'rb'); $handle = @fopen($filePath, 'rb');
$header = $handle ? fread($handle, 12) : ''; $header = $handle ? fread($handle, 12) : '';
if ($handle) fclose($handle); if ($handle) {
fclose($handle);
}
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$isWebmHeader = str_starts_with($header, "\x1A\x45\xDF\xA3"); $isWebmHeader = str_starts_with($header, "\x1A\x45\xDF\xA3");
@ -531,10 +538,12 @@ public function serveStorageFile($path)
fseek($file, $start); fseek($file, $start);
$bufferSize = 1024 * 64; $bufferSize = 1024 * 64;
$bytesLeft = $length; $bytesLeft = $length;
while ($bytesLeft > 0 && !feof($file)) { while ($bytesLeft > 0 && ! feof($file)) {
$readSize = min($bufferSize, $bytesLeft); $readSize = min($bufferSize, $bytesLeft);
$data = fread($file, $readSize); $data = fread($file, $readSize);
if ($data === false) break; if ($data === false) {
break;
}
echo $data; echo $data;
flush(); flush();
$bytesLeft -= strlen($data); $bytesLeft -= strlen($data);
@ -545,6 +554,7 @@ public function serveStorageFile($path)
} }
$headers['Content-Length'] = (string) $fileSize; $headers['Content-Length'] = (string) $fileSize;
return response()->file($filePath, $headers); return response()->file($filePath, $headers);
} }
@ -554,8 +564,8 @@ public function serveStorageFile($path)
private function getFormattedRecordings($interview) private function getFormattedRecordings($interview)
{ {
$rawRecordings = $interview->recordings ?? []; $rawRecordings = $interview->recordings ?? [];
if (!is_array($rawRecordings) || empty($rawRecordings)) { if (! is_array($rawRecordings) || empty($rawRecordings)) {
if (!empty($interview->recording_path)) { if (! empty($interview->recording_path)) {
$rawRecordings = [[ $rawRecordings = [[
'url' => $interview->recording_path, 'url' => $interview->recording_path,
'filename' => basename($interview->recording_path), 'filename' => basename($interview->recording_path),
@ -578,15 +588,16 @@ private function getFormattedRecordings($interview)
$url = $rec['url'] ?? ''; $url = $rec['url'] ?? '';
$urlPath = parse_url($url, PHP_URL_PATH) ?? $url; $urlPath = parse_url($url, PHP_URL_PATH) ?? $url;
$cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); $cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
$streamUrl = url('/media-stream/' . $cleanPath); $streamUrl = url('/media-stream/'.$cleanPath);
$ext = strtolower(pathinfo($cleanPath, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($cleanPath, PATHINFO_EXTENSION));
$mimeType = in_array($ext, ['mp4', 'm4v']) ? 'video/mp4' : 'video/webm'; $mimeType = in_array($ext, ['mp4', 'm4v']) ? 'video/mp4' : 'video/webm';
$rec['original_index'] = $idx; $rec['original_index'] = $idx;
$rec['stream_url'] = $streamUrl; $rec['stream_url'] = $streamUrl;
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx; $rec['download_url'] = route('interview.download-recording', $interview->id).'?index='.$idx;
$rec['mime_type'] = $mimeType; $rec['mime_type'] = $mimeType;
$rec['duration'] = isset($rec['duration']) ? (int) $rec['duration'] : 0;
$formatted[] = $rec; $formatted[] = $rec;
} }
@ -594,6 +605,7 @@ private function getFormattedRecordings($interview)
usort($formatted, function ($a, $b) { usort($formatted, function ($a, $b) {
$timeA = isset($a['created_at']) ? strtotime($a['created_at']) : 0; $timeA = isset($a['created_at']) ? strtotime($a['created_at']) : 0;
$timeB = isset($b['created_at']) ? strtotime($b['created_at']) : 0; $timeB = isset($b['created_at']) ? strtotime($b['created_at']) : 0;
return $timeB <=> $timeA; return $timeB <=> $timeA;
}); });
@ -608,7 +620,7 @@ private function getFormattedRecordings($interview)
public function getActiveCalls(Request $request) public function getActiveCalls(Request $request)
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user) { if (! $user) {
return response()->json(['active_calls' => [], 'current_user_id' => null]); return response()->json(['active_calls' => [], 'current_user_id' => null]);
} }
@ -619,8 +631,10 @@ public function getActiveCalls(Request $request)
// Auto-terminate call session if interview timeline has expired // Auto-terminate call session if interview timeline has expired
if ($interview->isExpired()) { if ($interview->isExpired()) {
if ($interview->call_status === 'active') { if ($interview->call_status === 'active') {
$interview->update(['call_status' => 'ended']); $interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
} }
continue; continue;
} }
@ -683,7 +697,7 @@ public function getPollData($id)
$starterName = $interview->callStarter ? $interview->callStarter->name : null; $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); $interviewScreenshotsEnabled = (bool) ($interview->enable_tab_switch_screenshot ?? true);
$effectiveScreenshotEnabled = $globalScreenshotsEnabled && $interviewScreenshotsEnabled; $effectiveScreenshotEnabled = $globalScreenshotsEnabled && $interviewScreenshotsEnabled;
@ -722,7 +736,7 @@ public function registerPeerHeartbeat(Request $request, $id)
->firstOrFail(); ->firstOrFail();
$peerId = $request->input('peer_id'); $peerId = $request->input('peer_id');
if (!$peerId) { if (! $peerId) {
return response()->json(['error' => 'peer_id is required'], 422); return response()->json(['error' => 'peer_id is required'], 422);
} }
@ -786,7 +800,7 @@ public function updateCallStatus(Request $request, $id)
$isNewCall = false; $isNewCall = false;
if ($status === 'active') { if ($status === 'active') {
if (!$isRejoin && ($interview->call_status !== 'active' || !$interview->call_started_at)) { if (! $isRejoin && ($interview->call_status !== 'active' || ! $interview->call_started_at)) {
$updateData['call_started_by'] = Auth::id(); $updateData['call_started_by'] = Auth::id();
$updateData['call_started_at'] = now(); $updateData['call_started_at'] = now();
$isNewCall = true; $isNewCall = true;
@ -820,23 +834,23 @@ public function uploadTabScreenshot(Request $request, $id)
->orWhere('submission_unique_id', $id) ->orWhere('submission_unique_id', $id)
->firstOrFail(); ->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; $interviewEnabled = ! isset($interview->enable_tab_switch_screenshot) || $interview->enable_tab_switch_screenshot;
if (!$globalEnabled || !$interviewEnabled) { if (! $globalEnabled || ! $interviewEnabled) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Candidate system screenshot capture is disabled by admin setting.' 'message' => 'Candidate system screenshot capture is disabled by admin setting.',
]); ]);
} }
$reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch' $reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch'
$filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg'; $filename = 'screenshot_'.$reason.'_'.time().'_'.rand(100, 999).'.jpg';
$uniqueFolder = $interview->submission_unique_id ?: $interview->id; $uniqueFolder = $interview->submission_unique_id ?: $interview->id;
$subDir = 'uploads/candidate_screenshots/' . $uniqueFolder; $subDir = 'uploads/candidate_screenshots/'.$uniqueFolder;
$destinationPath = public_path($subDir); $destinationPath = public_path($subDir);
if (!file_exists($destinationPath)) { if (! file_exists($destinationPath)) {
mkdir($destinationPath, 0777, true); mkdir($destinationPath, 0777, true);
} }
@ -844,7 +858,7 @@ public function uploadTabScreenshot(Request $request, $id)
if ($request->hasFile('screenshot')) { if ($request->hasFile('screenshot')) {
$file = $request->file('screenshot'); $file = $request->file('screenshot');
$file->move($destinationPath, $filename); $file->move($destinationPath, $filename);
$url = asset($subDir . '/' . $filename); $url = asset($subDir.'/'.$filename);
} elseif ($request->input('image')) { } elseif ($request->input('image')) {
$base64Image = $request->input('image'); $base64Image = $request->input('image');
if (str_contains($base64Image, ',')) { if (str_contains($base64Image, ',')) {
@ -853,14 +867,14 @@ public function uploadTabScreenshot(Request $request, $id)
$base64Image = str_replace(' ', '+', $base64Image); $base64Image = str_replace(' ', '+', $base64Image);
$imageData = base64_decode($base64Image); $imageData = base64_decode($base64Image);
if ($imageData !== false && strlen($imageData) > 0) { if ($imageData !== false && strlen($imageData) > 0) {
file_put_contents($destinationPath . '/' . $filename, $imageData); file_put_contents($destinationPath.'/'.$filename, $imageData);
$url = asset($subDir . '/' . $filename); $url = asset($subDir.'/'.$filename);
} }
} }
if ($url) { if ($url) {
$relativePath = '/' . $subDir . '/' . $filename; $relativePath = '/'.$subDir.'/'.$filename;
$fullFilePath = $destinationPath . '/' . $filename; $fullFilePath = $destinationPath.'/'.$filename;
// Automatically analyze candidate system screen capture using AI Vision Engine (disabled for now) // Automatically analyze candidate system screen capture using AI Vision Engine (disabled for now)
$aiVerdict = null; $aiVerdict = null;
@ -881,7 +895,7 @@ public function uploadTabScreenshot(Request $request, $id)
$screenshots = $interview->tab_switch_screenshots ?? []; $screenshots = $interview->tab_switch_screenshots ?? [];
$screenshots[] = $screenshotEntry; $screenshots[] = $screenshotEntry;
$hasAiTool = !empty($aiVerdict['is_cheating']) && !empty($aiVerdict['ai_tool_detected']); $hasAiTool = ! empty($aiVerdict['is_cheating']) && ! empty($aiVerdict['ai_tool_detected']);
if ($reason === 'call_start') { if ($reason === 'call_start') {
$logType = 'call_start_screenshot'; $logType = 'call_start_screenshot';
@ -889,7 +903,7 @@ public function uploadTabScreenshot(Request $request, $id)
} elseif ($hasAiTool) { } elseif ($hasAiTool) {
$logType = 'external_ai_detected'; $logType = 'external_ai_detected';
$toolName = $aiVerdict['ai_tool_detected']; $toolName = $aiVerdict['ai_tool_detected'];
$logMsg = "🤖 candidate might use external ai ({$toolName}): " . $aiVerdict['summary']; $logMsg = "🤖 candidate might use external ai ({$toolName}): ".$aiVerdict['summary'];
} else { } else {
$logType = 'tab_switch'; $logType = 'tab_switch';
$logMsg = '📸 Candidate switched browser tab or window: System screen screenshot captured for interviewer & admin review.'; $logMsg = '📸 Candidate switched browser tab or window: System screen screenshot captured for interviewer & admin review.';
@ -936,7 +950,7 @@ public function toggleTabScreenshot(Request $request, $id)
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'enable_tab_switch_screenshot' => $enable, '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.',
]); ]);
} }
@ -945,31 +959,37 @@ public function toggleTabScreenshot(Request $request, $id)
*/ */
public function uploadRecording(Request $request, $id) public function uploadRecording(Request $request, $id)
{ {
try {
$interview = Interview::where('id', $id) $interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id) ->orWhere('submission_unique_id', $id)
->firstOrFail(); ->first();
if (! $interview) {
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
}
if ($request->hasFile('video')) { if ($request->hasFile('video')) {
$file = $request->file('video'); $file = $request->file('video');
$ext = strtolower($file->getClientOriginalExtension() ?: 'webm'); $ext = strtolower($file->getClientOriginalExtension() ?: 'webm');
if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) { if (! in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) {
$ext = 'webm'; $ext = 'webm';
} }
$baseFolder = config('interview.recordings.base_folder', 'uploads/candidate_recordings');
$uniqueFolder = $interview->submission_unique_id ?: $interview->id; $uniqueFolder = $interview->submission_unique_id ?: $interview->id;
$subDir = 'uploads/candidate_recordings/' . $uniqueFolder; $subDir = trim($baseFolder, '/').'/'.$uniqueFolder;
$destinationPath = public_path($subDir); $destinationPath = public_path($subDir);
if (!file_exists($destinationPath)) { if (! file_exists($destinationPath)) {
mkdir($destinationPath, 0777, true); mkdir($destinationPath, 0777, true);
} }
$filename = 'recording_' . time() . '.' . $ext; $filename = 'recording_'.time().'.'.$ext;
$file->move($destinationPath, $filename); $file->move($destinationPath, $filename);
$url = asset($subDir . '/' . $filename); $url = asset($subDir.'/'.$filename);
$relativePath = '/' . $subDir . '/' . $filename; $relativePath = '/'.$subDir.'/'.$filename;
$recordings = $interview->recordings ?? []; $recordings = $interview->recordings ?? [];
if (!is_array($recordings)) { if (! is_array($recordings)) {
$recordings = $interview->recording_path ? [$interview->recording_path] : []; $recordings = $interview->recording_path ? [$interview->recording_path] : [];
} }
$recordings[] = [ $recordings[] = [
@ -987,7 +1007,67 @@ public function uploadRecording(Request $request, $id)
return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]); return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]);
} }
return response()->json(['success' => false, 'message' => 'No video payload received']); 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);
}
}
/**
* Handle live chunked video recording upload from the interviewer proctoring session.
*
* @param int|string $id
* @return JsonResponse|Responsable
*/
public function uploadRecordingChunk(
UploadRecordingChunkRequest $request,
$id,
StoreRecordingChunkAction $storeChunkAction,
FinalizeRecordingAction $finalizeRecordingAction
) {
try {
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->first();
if (! $interview) {
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
}
$validated = $request->validated();
$sessionId = $validated['session_id'];
$chunkIndex = (int) $validated['chunk_index'];
$isFinal = filter_var($validated['is_final'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($request->hasFile('chunk')) {
$file = $request->file('chunk');
$storeChunkAction->execute($interview, $sessionId, $chunkIndex, $file);
}
if ($isFinal) {
$totalChunks = (int) ($validated['total_chunks'] ?? ($chunkIndex + 1));
$duration = isset($validated['duration']) ? (int) $validated['duration'] : null;
$finalizeRecordingAction->execute($interview, $sessionId, $totalChunks, null, null, $duration);
return new RecordingFinalizedResponse($sessionId);
}
return new RecordingChunkReceivedResponse($sessionId, $chunkIndex);
} catch (\Throwable $e) {
\Log::error('Recording chunk upload failed: '.$e->getMessage(), [
'id' => $id,
'session_id' => $request->input('session_id'),
'chunk_index' => $request->input('chunk_index'),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'message' => 'Error processing recording chunk: '.$e->getMessage(),
], 500);
}
} }
/** /**
@ -1000,16 +1080,16 @@ public function downloadRecording(Request $request, $id)
$recordings = $interview->recordings ?? []; $recordings = $interview->recordings ?? [];
$targetUrl = null; $targetUrl = null;
if (!empty($recordings) && isset($recordings[$index])) { if (! empty($recordings) && isset($recordings[$index])) {
$rec = $recordings[$index]; $rec = $recordings[$index];
$targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec; $targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec;
} }
if (!$targetUrl) { if (! $targetUrl) {
$targetUrl = $interview->recording_path; $targetUrl = $interview->recording_path;
} }
if (!$targetUrl) { if (! $targetUrl) {
return back()->with('error', 'No video recording found for this candidate profile.'); return back()->with('error', 'No video recording found for this candidate profile.');
} }
@ -1017,20 +1097,20 @@ public function downloadRecording(Request $request, $id)
$sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); $sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
$candidatePaths = [ $candidatePaths = [
storage_path('app/public/' . $sanitizedPath), storage_path('app/public/'.$sanitizedPath),
storage_path('app/private/public/' . $sanitizedPath), storage_path('app/private/public/'.$sanitizedPath),
storage_path('app/private/' . $sanitizedPath), storage_path('app/private/'.$sanitizedPath),
storage_path('app/' . $sanitizedPath), storage_path('app/'.$sanitizedPath),
public_path('storage/' . $sanitizedPath), public_path('storage/'.$sanitizedPath),
public_path($sanitizedPath), public_path($sanitizedPath),
]; ];
$baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath); $baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath);
if ($baseNoExt !== $sanitizedPath) { if ($baseNoExt !== $sanitizedPath) {
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/public/'.$baseNoExt.'.webm');
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/public/'.$baseNoExt.'.mp4');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/private/public/'.$baseNoExt.'.webm');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/private/public/'.$baseNoExt.'.mp4');
} }
$filePath = null; $filePath = null;
@ -1044,13 +1124,16 @@ public function downloadRecording(Request $request, $id)
if ($filePath && file_exists($filePath)) { if ($filePath && file_exists($filePath)) {
$handle = @fopen($filePath, 'rb'); $handle = @fopen($filePath, 'rb');
$header = $handle ? fread($handle, 12) : ''; $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'); $isWebm = str_starts_with($header, "\x1A\x45\xDF\xA3") || str_ends_with($filePath, '.webm');
$ext = $isWebm ? 'webm' : 'mp4'; $ext = $isWebm ? 'webm' : 'mp4';
$mimeType = $isWebm ? 'video/webm' : 'video/mp4'; $mimeType = $isWebm ? 'video/webm' : 'video/mp4';
$downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.' . $ext; $downloadFilename = 'candidate_'.Str::slug($interview->candidate_name).'_recording_'.($index + 1).'.'.$ext;
return response()->download($filePath, $downloadFilename, [ return response()->download($filePath, $downloadFilename, [
'Content-Type' => $mimeType, 'Content-Type' => $mimeType,
]); ]);
@ -1076,12 +1159,24 @@ public function generateReport($id)
foreach ($logs as $l) { foreach ($logs as $l) {
$type = $l['type'] ?? ''; $type = $l['type'] ?? '';
if ($type === 'tab_switch') $tabSwitches++; if ($type === 'tab_switch') {
if ($type === 'focus_lost') $focusLosses++; $tabSwitches++;
if (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) $gazeAnomalies++; }
if ($type === 'paste_event') $pasteEvents++; if ($type === 'focus_lost') {
if ($type === 'gaze_lower_device') $lowerGazeViolations++; $focusLosses++;
if (in_array($type, ['question_repeat_lower', 'question_repetition'])) $questionRepeatViolations++; }
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); $totalViolations = count($logs);
@ -1107,14 +1202,14 @@ public function generateReport($id)
// Technical Code Score // Technical Code Score
$codeScore = 0; $codeScore = 0;
if (!empty($interview->submitted_code)) { if (! empty($interview->submitted_code)) {
$codeScore += 50; $codeScore += 50;
if (!empty($interview->code_output) && !str_contains(strtolower($interview->code_output), 'error')) { if (! empty($interview->code_output) && ! str_contains(strtolower($interview->code_output), 'error')) {
$codeScore += 40; $codeScore += 40;
} else { } else {
$codeScore += 20; $codeScore += 20;
} }
if (!empty($interview->candidate_notes) || !empty($interview->candidate_drawing)) { if (! empty($interview->candidate_notes) || ! empty($interview->candidate_drawing)) {
$codeScore += 10; $codeScore += 10;
} }
} }
@ -1135,4 +1230,25 @@ public function generateReport($id)
'codeScore' 'codeScore'
)); ));
} }
/**
* Serve / stream candidate resume file.
*/
public function viewResume($id)
{
$interview = Interview::findOrFail($id);
if (! $interview->resume_path || ! Storage::disk('public')->exists($interview->resume_path)) {
abort(404, 'Candidate resume file not found.');
}
$filePath = Storage::disk('public')->path($interview->resume_path);
$mimeType = Storage::disk('public')->mimeType($interview->resume_path) ?: 'application/pdf';
return response()->file($filePath, [
'Content-Type' => $mimeType,
'Content-Disposition' => 'inline; filename="'.basename($filePath).'"',
'Cache-Control' => 'no-cache, private',
]);
}
} }

View File

@ -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.',
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Interview;
use Illuminate\Foundation\Http\FormRequest;
class UploadRecordingChunkRequest 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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'session_id' => ['required', 'string', 'max:255'],
'chunk_index' => ['required', 'integer', 'min:0'],
'is_final' => ['nullable', 'boolean'],
'total_chunks' => ['nullable', 'integer', 'min:0'],
'duration' => ['nullable', 'integer', 'min:0'],
'chunk' => ['nullable', 'file', 'max:25600'], // 25MB max per chunk
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'session_id.required' => 'A valid recording session identifier is required.',
'chunk_index.required' => 'Chunk index must be provided.',
];
}
}

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

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

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Responses\Interview;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
class RecordingChunkReceivedResponse implements Responsable
{
/**
* Create a new response instance.
*
* @param string $sessionId
* @param int $chunkIndex
*/
public function __construct(
protected string $sessionId,
protected int $chunkIndex
) {}
/**
* Create an HTTP response that represents the object.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function toResponse($request): JsonResponse
{
return response()->json([
'success' => true,
'chunk_index' => $this->chunkIndex,
'is_final' => false,
'session_id' => $this->sessionId,
], 200);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Responses\Interview;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
class RecordingFinalizedResponse implements Responsable
{
/**
* Create a new response instance.
*
* @param string $sessionId
* @param string $message
*/
public function __construct(
protected string $sessionId,
protected string $message = 'Final recording chunk received. Video assembly queued in background.'
) {}
/**
* Create an HTTP response that represents the object.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function toResponse($request): JsonResponse
{
return response()->json([
'success' => true,
'is_final' => true,
'processing' => true,
'session_id' => $this->sessionId,
'message' => $this->message,
], 200);
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Jobs;
use App\Actions\Interview\MergeRecordingChunksAction;
use App\Models\Interview;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Attributes\UniqueFor;
use Illuminate\Queue\Attributes\WithoutRelations;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
#[Timeout(300)]
#[Tries(3)]
#[Backoff(5, 15, 30)]
#[UniqueFor(1800)]
#[WithoutRelations]
class MergeRecordingChunksJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @param int|string $interviewId
* @param string $sessionId
* @param string|null $tempDir
* @param string|null $folderName
* @param int $totalChunks
* @param int|null $duration
*/
public function __construct(
public int|string $interviewId,
public string $sessionId,
public ?string $tempDir = null,
public ?string $folderName = null,
public int $totalChunks = 0,
public ?int $duration = null
) {}
/**
* The unique ID of the job.
*/
public function uniqueId(): string
{
return 'merge_rec_' . $this->interviewId . '_' . $this->sessionId;
}
/**
* Execute the job.
*/
public function handle(MergeRecordingChunksAction $mergeAction): void
{
Cache::lock('interview_merge_lock_' . $this->interviewId, 60)->block(15, function () use ($mergeAction) {
$interview = Interview::where('id', $this->interviewId)
->orWhere('submission_unique_id', $this->interviewId)
->first();
if (!$interview) {
Log::warning('[MergeRecordingChunksJob] Interview not found', [
'interview_id' => $this->interviewId,
'session_id' => $this->sessionId,
]);
return;
}
$mergeAction->execute(
$interview,
$this->sessionId,
$this->tempDir,
$this->folderName,
$this->duration
);
});
}
/**
* Handle a job failure.
*/
public function failed(?Throwable $exception): void
{
Log::error('[MergeRecordingChunksJob] Recording merge job failed', [
'interview_id' => $this->interviewId,
'session_id' => $this->sessionId,
'error' => $exception?->getMessage(),
'trace' => $exception?->getTraceAsString(),
]);
}
}

View 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'),
];
}
}

View 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',
);
}
}

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

View File

@ -13,9 +13,14 @@ class Interview extends Model
'candidate_name', 'candidate_name',
'candidate_email', 'candidate_email',
'candidate_phone', 'candidate_phone',
'job_title',
'round',
'description',
'resume_path',
'temp_password', 'temp_password',
'scheduled_at', 'scheduled_at',
'expires_at', 'expires_at',
'reminder_sent_at',
'language', 'language',
'status', 'status',
'assigned_interviewers', 'assigned_interviewers',
@ -41,6 +46,7 @@ class Interview extends Model
protected $casts = [ protected $casts = [
'scheduled_at' => 'datetime', 'scheduled_at' => 'datetime',
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'reminder_sent_at' => 'datetime',
'call_started_at' => 'datetime', 'call_started_at' => 'datetime',
'enable_tab_switch_screenshot' => 'boolean', 'enable_tab_switch_screenshot' => 'boolean',
'assigned_interviewers' => 'array', 'assigned_interviewers' => 'array',
@ -70,6 +76,18 @@ public function isExpired(): bool
return now()->greaterThan($this->expires_at); return now()->greaterThan($this->expires_at);
} }
/**
* Check if the interview call is active and unexpired.
*/
public function isCallActive(): bool
{
if ($this->isExpired() || in_array($this->status, ['completed', 'expired'])) {
return false;
}
return $this->call_status === 'active';
}
/** /**
* Check if the current time is within the interview timeline. * Check if the current time is within the interview timeline.
*/ */
@ -86,7 +104,7 @@ public function isActiveTimeline(): bool
return false; return false;
} }
return !in_array($this->status, ['completed', 'expired']); return ! in_array($this->status, ['completed', 'expired']);
} }
/** /**
@ -99,6 +117,91 @@ public function isAssignedInterviewer($userId): bool
} }
$interviewers = array_map('strval', (array) $this->assigned_interviewers); $interviewers = array_map('strval', (array) $this->assigned_interviewers);
return in_array((string) $userId, $interviewers, true); return in_array((string) $userId, $interviewers, true);
} }
/**
* Get computed initials for candidate (e.g. "John Doe" -> "JD").
*/
public function getCandidateInitialsAttribute(): string
{
$parts = preg_split('/\s+/', trim($this->candidate_name ?? ''));
if (count($parts) >= 2) {
return strtoupper(substr($parts[0], 0, 1).substr(end($parts), 0, 1));
}
$nameStr = trim($this->candidate_name ?? '');
return strtoupper(substr($nameStr, 0, 1).(strlen($nameStr) > 1 ? substr($nameStr, -1) : ''));
}
/**
* Get proctoring alert metrics summary.
*/
public function getProctorMetricsAttribute(): array
{
$logs = $this->proctor_logs ?? [];
return [
'tab_switches' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'tab_switch')),
'focus_lost' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'focus_lost')),
'gaze_alerts' => count(array_filter($logs, fn ($l) => in_array($l['type'] ?? '', ['gaze_anomaly', 'gaze_fixed_staring', 'looking_away']))),
'lower_gaze' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'gaze_lower_device')),
'question_repeat' => count(array_filter($logs, fn ($l) => in_array($l['type'] ?? '', ['question_repeat_lower', 'question_repetition']))),
'external_ai' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'external_ai_detected')),
'paste_events' => count(array_filter($logs, fn ($l) => ($l['type'] ?? '') === 'paste_event')),
'total_incidents' => count($logs),
];
}
/**
* Format timeline string: "M d, H:i H:i".
*/
public function getFormattedTimelineAttribute(): string
{
$start = $this->scheduled_at ?? $this->created_at;
return $start->format('M d, H:i').' '.$this->expires_at->format('H:i');
}
/**
* Get the standardized iCalendar event title / name.
* Pattern: Interview Invitation_{Round}_{Candidate First & Last Name}_{Job Title}_{Date}_{Time}
* Example: Interview Invitation_R1_Soumyadeep Mondal_AIML (Computer Vision & Agentic AI) Engineer_11/8/2026_1700
*/
public function getEventTitleAttribute(): string
{
$round = ! empty($this->round) ? $this->round : 'R1';
$candidateName = ! empty($this->candidate_name) ? $this->candidate_name : 'Candidate';
$jobTitle = ! empty($this->job_title) ? $this->job_title : 'Candidate Assessment';
$scheduled = $this->scheduled_at ?? $this->created_at ?? now();
$dateStr = $scheduled->format('j/n/Y');
$timeStr = $scheduled->format('Hi');
return "Interview Invitation_{$round}_{$candidateName}_{$jobTitle}_{$dateStr}_{$timeStr}";
}
/**
* Get the User models corresponding to the assigned interviewers.
*/
public function assignedUsers()
{
if (empty($this->assigned_interviewers)) {
return collect();
}
return User::whereIn('id', (array) $this->assigned_interviewers)->get();
}
/**
* Get the URL to view the candidate's resume.
*/
public function getResumeUrlAttribute(): ?string
{
if (! $this->resume_path) {
return null;
}
return route('interview.resume', $this->id);
}
} }

View File

@ -5,6 +5,7 @@
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use SocialiteProviders\Manager\SocialiteWasCalled; use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Microsoft\MicrosoftExtendSocialite; use SocialiteProviders\Microsoft\MicrosoftExtendSocialite;

View 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,
];
}
}

View 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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

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

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

17
boost.json Normal file
View File

@ -0,0 +1,17 @@
{
"agents": [
"antigravity",
"zed"
],
"cloud": false,
"guidelines": true,
"mcp": true,
"nightwatch": false,
"sail": false,
"skills": [
"infer-conventions",
"laravel-best-practices",
"socialite-development",
"tailwindcss-development"
]
}

View File

@ -1,7 +1,9 @@
<?php <?php
use App\Providers\AppServiceProvider; use App\Providers\AppServiceProvider;
use App\Providers\IceCandidateServiceProvider;
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
IceCandidateServiceProvider::class,
]; ];

View File

@ -10,13 +10,16 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"gehrisandro/tailwind-merge-laravel": "^1.4",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/socialite": "^5.28", "laravel/socialite": "^5.28",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"socialiteproviders/microsoft": "^4.9" "socialiteproviders/microsoft": "^4.9",
"spatie/icalendar-generator": "^3.3"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/boost": "^2.5",
"laravel/pail": "^1.2.5", "laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6", "laravel/pao": "^1.0.6",
"laravel/pint": "^1.27", "laravel/pint": "^1.27",

559
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c4355def664b0181bc293f22335a84f1", "content-hash": "80d788c07c363103e99cadbacd821b17",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@ -644,6 +644,147 @@
], ],
"time": "2025-12-03T09:33:47+00:00" "time": "2025-12-03T09:33:47+00:00"
}, },
{
"name": "gehrisandro/tailwind-merge-laravel",
"version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/gehrisandro/tailwind-merge-laravel.git",
"reference": "4dfc54d2f1c148b87a7f9803b3bae74c3437e7d9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/gehrisandro/tailwind-merge-laravel/zipball/4dfc54d2f1c148b87a7f9803b3bae74c3437e7d9",
"reference": "4dfc54d2f1c148b87a7f9803b3bae74c3437e7d9",
"shasum": ""
},
"require": {
"gehrisandro/tailwind-merge-php": "^v1.1.0",
"guzzlehttp/guzzle": "^7.5.1",
"laravel/framework": "^12.0|^13.0",
"php": "^8.2.0"
},
"require-dev": {
"laravel/pint": "^1.13.8",
"orchestra/testbench": "^10.0|^11.0",
"pestphp/pest": "^3.7|^4.4",
"pestphp/pest-plugin-arch": "^3.0|^4.0",
"pestphp/pest-plugin-type-coverage": "^3.3|^4.0",
"phpstan/phpstan": "^1.10.55|^2.1",
"rector/rector": "^0.19|^2.0",
"symfony/var-dumper": "^6.4.2|^7.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"TailwindMerge\\Laravel\\TailwindMergeServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"TailwindMerge\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sandro Gehri",
"email": "sandrogehri@gmail.com"
}
],
"description": "TailwindMerge for Laravel merges multiple Tailwind CSS classes by automatically resolving conflicts between them",
"keywords": [
"classes",
"laravel",
"merge",
"php",
"tailwindcss"
],
"support": {
"issues": "https://github.com/gehrisandro/tailwind-merge-laravel/issues",
"source": "https://github.com/gehrisandro/tailwind-merge-laravel/tree/v1.4.0"
},
"funding": [
{
"url": "https://github.com/gehrisandro",
"type": "github"
}
],
"time": "2026-03-21T06:54:49+00:00"
},
{
"name": "gehrisandro/tailwind-merge-php",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/gehrisandro/tailwind-merge-php.git",
"reference": "400040c89bc958ed130699ee4db0213f689cd498"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/gehrisandro/tailwind-merge-php/zipball/400040c89bc958ed130699ee4db0213f689cd498",
"reference": "400040c89bc958ed130699ee4db0213f689cd498",
"shasum": ""
},
"require": {
"php": "^8.2.0",
"psr/simple-cache": "^3.0"
},
"require-dev": {
"laravel/pint": "^1.13.8",
"nunomaduro/collision": "^v8.9.1",
"pestphp/pest": "^v4.4.2",
"pestphp/pest-plugin-type-coverage": "^v4.0.3",
"phpstan/phpstan": "^2.1.42",
"rector/rector": "^2.3.9",
"symfony/var-dumper": "^v7.4.6"
},
"type": "library",
"autoload": {
"files": [
"src/TailwindMerge.php"
],
"psr-4": {
"TailwindMerge\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sandro Gehri",
"email": "sandrogehri@gmail.com"
}
],
"description": "TailwindMerge for PHP merges multiple Tailwind CSS classes by automatically resolving conflicts between them",
"keywords": [
"classes",
"merge",
"php",
"tailwindcss"
],
"support": {
"issues": "https://github.com/gehrisandro/tailwind-merge-php/issues",
"source": "https://github.com/gehrisandro/tailwind-merge-php/tree/v1.2.0"
},
"funding": [
{
"url": "https://github.com/gehrisandro",
"type": "github"
}
],
"time": "2026-03-21T06:39:15+00:00"
},
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.4", "version": "v1.1.4",
@ -3871,6 +4012,65 @@
}, },
"time": "2026-03-26T00:32:34+00:00" "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", "name": "symfony/clock",
"version": "v8.1.0", "version": "v8.1.0",
@ -6565,6 +6765,83 @@
} }
], ],
"packages-dev": [ "packages-dev": [
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{ {
"name": "fakerphp/faker", "name": "fakerphp/faker",
"version": "v1.24.1", "version": "v1.24.1",
@ -6812,6 +7089,146 @@
}, },
"time": "2026-04-29T18:32:34+00:00" "time": "2026-04-29T18:32:34+00:00"
}, },
{
"name": "laravel/boost",
"version": "v2.5.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
"reference": "f5f9297225aba9857d014b3140f55a7c50cb1a92"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/f5f9297225aba9857d014b3140f55a7c50cb1a92",
"reference": "f5f9297225aba9857d014b3140f55a7c50cb1a92",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"laravel/mcp": "^0.7.1|^0.8.0|^0.9.0",
"laravel/prompts": "^0.3.10",
"laravel/roster": "^1.0.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.27.0",
"mockery/mockery": "^1.6.12",
"orchestra/testbench": "^9.15.0|^10.6|^11.0",
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Boost\\BoostServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Boost\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
"homepage": "https://github.com/laravel/boost",
"keywords": [
"ai",
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
"time": "2026-08-07T05:46:02+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.9.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "7ca5b923630118696602d14348cd0466a5e853ec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/7ca5b923630118696602d14348cd0466a5e853ec",
"reference": "7ca5b923630118696602d14348cd0466a5e853ec",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2",
"symfony/process": "^7.4.5|^8.0.5"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-08-13T15:01:07+00:00"
},
{ {
"name": "laravel/pail", "name": "laravel/pail",
"version": "v1.2.7", "version": "v1.2.7",
@ -7045,6 +7462,68 @@
}, },
"time": "2026-06-16T15:34:04+00:00" "time": "2026-06-16T15:34:04+00:00"
}, },
{
"name": "laravel/roster",
"version": "v1.0.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/roster.git",
"reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa",
"reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa",
"shasum": ""
},
"require": {
"composer/semver": "^3.0",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/yaml": "^7.2|^8.0"
},
"require-dev": {
"laravel/pint": "^1.29",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"pestphp/pest": "^3.0|^4.1",
"phpstan/phpstan": "^2.0",
"rector/rector": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Roster\\RosterServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Roster\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Detect packages & approaches in use within a Laravel project",
"homepage": "https://github.com/laravel/roster",
"keywords": [
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/roster/issues",
"source": "https://github.com/laravel/roster"
},
"time": "2026-07-18T17:53:15+00:00"
},
{ {
"name": "mockery/mockery", "name": "mockery/mockery",
"version": "1.6.12", "version": "1.6.12",
@ -8798,6 +9277,82 @@
], ],
"time": "2024-10-20T05:08:20+00:00" "time": "2024-10-20T05:08:20+00:00"
}, },
{
"name": "symfony/yaml",
"version": "v8.1.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "faabdbe998e8c5c599dceffa27aa265b185c0736"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/faabdbe998e8c5c599dceffa27aa265b185c0736",
"reference": "faabdbe998e8c5c599dceffa27aa265b185c0736",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<7.4"
},
"require-dev": {
"symfony/console": "^7.4|^8.0",
"yaml/yaml-test-suite": "*"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v8.1.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-22T15:42:13+00:00"
},
{ {
"name": "theseer/tokenizer", "name": "theseer/tokenizer",
"version": "2.0.1", "version": "2.0.1",
@ -8858,5 +9413,5 @@
"php": "^8.3" "php": "^8.3"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }

View File

@ -65,7 +65,7 @@
| |
*/ */
'timezone' => 'UTC', 'timezone' => env('APP_TIMEZONE', 'UTC'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -7,5 +7,18 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
*/ */
'max_interviewer' => (int) env('MAX_INTERVIEWER', 2) 'max_interviewer' => (int) env('MAX_INTERVIEWER', 2),
/*
|--------------------------------------------------------------------------
| Candidate Recordings Storage Configuration
|--------------------------------------------------------------------------
|
| Base folder and temporary chunk storage locations for session recordings.
|
*/
'recordings' => [
'base_folder' => env('INTERVIEW_RECORDINGS_FOLDER', 'uploads/candidate_recordings'),
'temp_folder' => env('INTERVIEW_RECORDINGS_TEMP_FOLDER', 'app/temp_recordings'),
],
]; ];

View File

@ -38,7 +38,7 @@
'microsoft' => [ 'microsoft' => [
'client_id' => env('MICROSOFT_CLIENT_ID', env('MICROSOFT_GRAPH_CLIENT_ID')), 'client_id' => env('MICROSOFT_CLIENT_ID', env('MICROSOFT_GRAPH_CLIENT_ID')),
'client_secret' => env('MICROSOFT_CLIENT_SECRET', env('MICROSOFT_GRAPH_CLIENT_SECRET')), 'client_secret' => env('MICROSOFT_CLIENT_SECRET', env('MICROSOFT_GRAPH_CLIENT_SECRET')),
'redirect' => env('MICROSOFT_REDIRECT_URI', env('APP_URL', 'http://localhost:8000') . '/auth/microsoft/callback'), 'redirect' => env('MICROSOFT_REDIRECT_URI', env('APP_URL', 'http://localhost:8000').'/auth/microsoft/callback'),
'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')), 'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')),
], ],
@ -47,4 +47,32 @@
'key' => env('METERED_KEY'), '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),
],
],
],
]; ];

View File

@ -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']);
});
}
};

View File

@ -0,0 +1,28 @@
<?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('resume_path')->nullable()->after('description');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('interviews', function (Blueprint $table) {
$table->dropColumn('resume_path');
});
}
};

15
package-lock.json generated
View File

@ -1,9 +1,12 @@
{ {
"name": "singlelogin", "name": "sls",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"@sapphi-red/web-noise-suppressor": "^0.4.0"
},
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
@ -407,6 +410,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sapphi-red/web-noise-suppressor": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@sapphi-red/web-noise-suppressor/-/web-noise-suppressor-0.4.0.tgz",
"integrity": "sha512-vkBEL/VDkbeP3qqSRQFRtPLGa19a38JUzO6J0r5D/MQSumrlERy671DAMaETgY6etXjDCoJcD7JBIaXnuGVtVw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/sapphi-red"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",

View File

@ -12,5 +12,8 @@
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^8.0.0" "vite": "^8.0.0"
},
"dependencies": {
"@sapphi-red/web-noise-suppressor": "^0.4.0"
} }
} }

View File

@ -54,3 +54,28 @@ #interviewer-screen-video {
transform: scaleX(1) !important; 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;
}

View File

@ -11,3 +11,179 @@ window.initMeteredIceServers = initMeteredIceServers;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.getMeteredIceServers(); 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');
};

View File

@ -0,0 +1,105 @@
/**
* Real-Time Voice Activity Detector (VAD) & Active Speaking Border Engine
*/
// Easily configurable Tailwind CSS classes applied when a participant is actively speaking
export const SPEAKING_ACTIVE_CLASSES = [
'border-emerald-500/80',
];
export class AudioActivityMonitor {
constructor(speakingClasses = SPEAKING_ACTIVE_CLASSES) {
this.audioCtx = null;
this.monitors = new Map(); // key -> { source, analyser, animId, targetElemId }
this.speakingClasses = speakingClasses;
}
getAudioContext() {
if (!this.audioCtx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) {
this.audioCtx = new AudioCtx();
}
}
if (this.audioCtx && this.audioCtx.state === 'suspended') {
this.audioCtx.resume().catch(() => {});
}
return this.audioCtx;
}
attach(key, mediaStream, targetElemId, threshold = 12) {
this.detach(key);
if (!mediaStream || !mediaStream.getAudioTracks || mediaStream.getAudioTracks().length === 0) return;
const ctx = this.getAudioContext();
if (!ctx) return;
try {
const source = ctx.createMediaStreamSource(mediaStream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.3;
source.connect(analyser);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
let isSpeaking = false;
let silenceTimer = null;
const checkAudio = () => {
const entry = this.monitors.get(key);
if (!entry) return;
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const average = sum / bufferLength;
const elem = document.getElementById(targetElemId);
if (average > threshold) {
if (!isSpeaking) {
isSpeaking = true;
if (silenceTimer) clearTimeout(silenceTimer);
if (elem) {
elem.classList.add(...this.speakingClasses);
}
} else {
if (silenceTimer) clearTimeout(silenceTimer);
}
silenceTimer = setTimeout(() => {
isSpeaking = false;
if (elem) {
elem.classList.remove(...this.speakingClasses);
}
}, 350);
}
entry.animId = requestAnimationFrame(checkAudio);
};
const animId = requestAnimationFrame(checkAudio);
this.monitors.set(key, { source, analyser, animId, targetElemId });
} catch (e) {
console.log('VAD attach notice:', e);
}
}
detach(key) {
if (this.monitors.has(key)) {
const m = this.monitors.get(key);
if (m.animId) cancelAnimationFrame(m.animId);
try { if (m.source) m.source.disconnect(); } catch(e) {}
try { if (m.analyser) m.analyser.disconnect(); } catch(e) {}
const elem = document.getElementById(m.targetElemId);
if (elem) {
elem.classList.remove(...this.speakingClasses);
}
this.monitors.delete(key);
}
}
}
export const globalAudioMonitor = new AudioActivityMonitor();

View File

@ -446,19 +446,18 @@ export function broadcastCandidateScreenStream(stream) {
const targetPeerIds = new Set(); const targetPeerIds = new Set();
// Collect dedicated screen receiver targets from active interviewers // Collect dedicated screen receiver targets and main interviewer peers
if (typeof window.latestActivePeers !== 'undefined' && Array.isArray(window.latestActivePeers)) { if (typeof window.latestActivePeers !== 'undefined' && Array.isArray(window.latestActivePeers)) {
window.latestActivePeers.forEach(p => { window.latestActivePeers.forEach(p => {
if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) { if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) {
const screenTargetId = p.peer_id.replace('interviewer_', 'interviewer_screen_'); const screenTargetId = p.peer_id.replace('interviewer_', 'interviewer_screen_');
targetPeerIds.add(screenTargetId); targetPeerIds.add(screenTargetId);
targetPeerIds.add(p.peer_id);
} }
}); });
} }
if (targetPeerIds.size === 0) {
targetPeerIds.add(legacyScreenPeerId); targetPeerIds.add(legacyScreenPeerId);
}
const makeCalls = (peer) => { const makeCalls = (peer) => {
targetPeerIds.forEach(targetId => { targetPeerIds.forEach(targetId => {
@ -626,12 +625,12 @@ export function updateScreenShareButton(active) {
const btn = document.getElementById('share-screen-btn'); const btn = document.getElementById('share-screen-btn');
if (!btn) return; if (!btn) return;
if (active) { if (active) {
btn.innerHTML = '🛑 Stop Screen Sharing'; btn.innerHTML = '<i class="fa-solid fa-stop mr-1.5"></i> <span>Stop Screen Sharing</span>';
btn.style.background = '#ef4444'; btn.style.background = '#ef4444';
btn.style.borderColor = '#dc2626'; btn.style.borderColor = '#dc2626';
btn.onclick = stopScreenShare; btn.onclick = stopScreenShare;
} else { } else {
btn.innerHTML = '🖥️ Share Screen with Interviewer'; btn.innerHTML = '<i class="fa-solid fa-desktop mr-1.5"></i> <span>Share Screen with Interviewer</span>';
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)'; btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
btn.style.borderColor = '#6366f1'; btn.style.borderColor = '#6366f1';
btn.onclick = startScreenShare; btn.onclick = startScreenShare;

View File

@ -5,10 +5,13 @@
*/ */
import { safePlayMediaStream, getInitials } from './interview-call.js'; import { safePlayMediaStream, getInitials } from './interview-call.js';
import { globalAudioMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js';
import { initMeteredIceServers } from './metered.js'; import { initMeteredIceServers } from './metered.js';
import { createNoiseSuppressedStream, getRNNoisePreference, setRNNoisePreference } from './noise-suppressor.js';
let editor = null; let editor = null;
let candidateLocalStream = null; let candidateLocalStream = null;
let candidateNoiseSuppressor = null;
let candidatePeerInstance = null; let candidatePeerInstance = null;
let candidatePeerConnection = null; let candidatePeerConnection = null;
let liveSyncChannel = null; let liveSyncChannel = null;
@ -437,22 +440,20 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
if (!tile) { if (!tile) {
tile = document.createElement('div'); tile = document.createElement('div');
tile.id = 'cand-iv-tile-' + peerId; tile.id = 'cand-iv-tile-' + peerId;
tile.className = 'webcam-box'; tile.className = 'w-full aspect-[16/10] bg-black rounded-xl border-2 border-indigo-500/40 overflow-hidden relative shrink-0';
tile.style.position = 'relative';
tile.style.borderColor = '#6366f1';
tile.innerHTML = ` tile.innerHTML = `
<video id="cand-iv-video-${peerId}" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811;"></video> <video id="cand-iv-video-${peerId}" autoplay playsinline class="w-full h-full object-cover bg-slate-950 -scale-x-100"></video>
<div id="cand-iv-placeholder-${peerId}" style="position: absolute; inset: 0; display: ${camOnVal ? 'none' : 'flex'}; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; color: white; z-index: 5;"> <div id="cand-iv-placeholder-${peerId}" class="absolute inset-0 ${camOnVal ? 'hidden' : 'flex'} flex-col items-center justify-center bg-slate-900/95 text-slate-400 text-xs text-center p-2.5 z-10">
<div style="width: 58px; height: 58px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 800; color: white; box-shadow: 0 0 15px rgba(56,189,248,0.4);"> <div class="w-14 h-14 rounded-full bg-gradient-to-br from-sky-400 to-indigo-500 flex items-center justify-center text-xl font-bold font-outfit text-white mb-1.5 shadow-lg shadow-sky-500/30">
${initials} ${initials}
</div> </div>
<div style="font-size: 0.8rem; font-weight: 700; color: white; margin-top: 6px;">${labelName}</div> <div class="text-xs text-slate-200 font-semibold">${labelName}</div>
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 2px;">Camera Turned Off</div> <div class="text-[10.5px] text-slate-400 mt-0.5 font-medium">Camera Turned Off</div>
</div> </div>
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.85); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10; display: flex; align-items: center; gap: 6px;"> <div class="absolute bottom-2 left-2 bg-indigo-600/90 px-2 py-0.5 rounded text-[10.5px] text-white font-semibold z-10 backdrop-blur-xs flex items-center gap-1.5">
<span>🎙 ${labelName}</span> <span><i class="fa-solid fa-microphone-lines mr-1 text-[9px]"></i>${labelName}</span>
<span style="opacity: 0.4;">|</span> <span class="opacity-40">|</span>
<i id="cand-iv-mic-${peerId}" class="${micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}" style="color: ${micOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${micOnVal ? 'Mic On' : 'Mic Muted'}"></i> <i id="cand-iv-mic-${peerId}" class="${micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}" style="color: ${micOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${micOnVal ? 'Mic On' : 'Mic Muted'}"></i>
<i id="cand-iv-cam-${peerId}" class="${camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}" style="color: ${camOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${camOnVal ? 'Camera On' : 'Camera Off'}"></i> <i id="cand-iv-cam-${peerId}" class="${camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}" style="color: ${camOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${camOnVal ? 'Camera On' : 'Camera Off'}"></i>
</div> </div>
@ -477,7 +478,13 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
camIcon.title = camOnVal ? 'Camera On' : 'Camera Off'; camIcon.title = camOnVal ? 'Camera On' : 'Camera Off';
} }
if (placeholderEl) { if (placeholderEl) {
placeholderEl.style.display = camOnVal ? 'none' : 'flex'; if (camOnVal) {
placeholderEl.classList.add('hidden');
placeholderEl.classList.remove('flex');
} else {
placeholderEl.classList.remove('hidden');
placeholderEl.classList.add('flex');
}
} }
} }
@ -492,7 +499,7 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
const badge = document.getElementById('call-status-badge'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge) {
badge.innerText = '🟢 CALL CONNECTED (LIVE)'; badge.innerHTML = '<i class="fa-solid fa-circle text-[8px] mr-1 text-emerald-400"></i> CALL LIVE';
badge.style.background = 'rgba(16,185,129,0.3)'; badge.style.background = 'rgba(16,185,129,0.3)';
badge.style.color = '#34d399'; badge.style.color = '#34d399';
} }
@ -500,6 +507,7 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
} }
export function removeCandidateInterviewerTile(peerId) { export function removeCandidateInterviewerTile(peerId) {
globalAudioMonitor.detach('cand-iv-video-' + peerId);
const stream = candidateInterviewerTiles.get(peerId); const stream = candidateInterviewerTiles.get(peerId);
if (stream && stream.active && latestCallStatus !== 'ended') { if (stream && stream.active && latestCallStatus !== 'ended') {
return; return;
@ -516,11 +524,11 @@ export function removeCandidateInterviewerTile(peerId) {
const badge = document.getElementById('call-status-badge'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge) {
if (latestCallStatus === 'ended') { if (latestCallStatus === 'ended') {
badge.innerText = 'CALL ENDED BY HOST'; badge.innerText = 'CALL ENDED';
badge.style.background = 'rgba(239,68,68,0.2)'; badge.style.background = 'rgba(239,68,68,0.2)';
badge.style.color = '#fca5a5'; badge.style.color = '#fca5a5';
} else { } else {
badge.innerText = 'READY (Waiting for Interviewer)'; badge.innerText = 'READY';
badge.style.background = 'rgba(16,185,129,0.2)'; badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399'; badge.style.color = '#34d399';
} }
@ -550,7 +558,7 @@ export function triggerCandidateRing(offer = null, callStartedAt = null) {
const badge = document.getElementById('call-status-badge'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge) {
badge.innerText = '🔔 INCOMING CALL... RINGING'; badge.innerHTML = '<i class="fa-solid fa-bell text-[9px] mr-1 text-amber-300 animate-bounce"></i> INCOMING CALL... RINGING';
badge.style.background = 'rgba(234,179,8,0.3)'; badge.style.background = 'rgba(234,179,8,0.3)';
badge.style.color = '#fde047'; badge.style.color = '#fde047';
} }
@ -618,7 +626,7 @@ export function showCandidateJoinOption() {
} }
const badge = document.getElementById('call-status-badge'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge) {
badge.innerText = '📞 CALL IN PROGRESS'; badge.innerText = 'CALL IN PROGRESS';
badge.style.background = 'rgba(59,130,246,0.3)'; badge.style.background = 'rgba(59,130,246,0.3)';
badge.style.color = '#93c5fd'; badge.style.color = '#93c5fd';
} }
@ -626,7 +634,7 @@ export function showCandidateJoinOption() {
if (joinBtn) { if (joinBtn) {
joinBtn.style.display = 'block'; joinBtn.style.display = 'block';
joinBtn.disabled = false; joinBtn.disabled = false;
joinBtn.innerText = '🟢 📞 Call in Progress • Join Call Now'; joinBtn.innerHTML = '<i class="fa-solid fa-phone-volume mr-1.5"></i> <span>Join Call Now</span>';
} }
} }
@ -679,7 +687,7 @@ export async function acceptCandidateCall() {
const badge = document.getElementById('call-status-badge'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge) {
badge.innerText = '🟢 CALL CONNECTED (LIVE)'; badge.innerText = 'CALL CONNECTED';
badge.style.background = 'rgba(16,185,129,0.3)'; badge.style.background = 'rgba(16,185,129,0.3)';
badge.style.color = '#34d399'; badge.style.color = '#34d399';
} }
@ -759,6 +767,11 @@ export function candidateLeaveCall(isEndedByHost = false) {
candidatePeerConnection = null; candidatePeerConnection = null;
} }
if (candidateNoiseSuppressor) {
try { candidateNoiseSuppressor.dispose(); } catch(e) {}
candidateNoiseSuppressor = null;
}
sendCandidatePeerHeartbeat('leave'); sendCandidatePeerHeartbeat('leave');
const interviewId = document.body.dataset.interviewId; const interviewId = document.body.dataset.interviewId;
@ -791,7 +804,7 @@ export function candidateLeaveCall(isEndedByHost = false) {
} }
} else { } else {
if (joinBtn) { if (joinBtn) {
joinBtn.innerText = '🟢 📞 Rejoin Call Now'; joinBtn.innerHTML = '<i class="fa-solid fa-phone-volume mr-1.5"></i> <span>Rejoin Call Now</span>';
joinBtn.style.display = 'block'; joinBtn.style.display = 'block';
joinBtn.disabled = false; joinBtn.disabled = false;
} }
@ -962,8 +975,28 @@ export function startCandidateCameraStream() {
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: true })) .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: true }))
.catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true })) .catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true }))
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false })) .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false }))
.then(stream => { .then(async rawStream => {
if (window.endNativePrompt) window.endNativePrompt(); if (window.endNativePrompt) window.endNativePrompt();
if (candidateNoiseSuppressor) {
try { candidateNoiseSuppressor.dispose(); } catch(e) {}
candidateNoiseSuppressor = null;
}
let stream = rawStream;
if (rawStream && rawStream.getAudioTracks().length > 0) {
candidateNoiseSuppressor = await createNoiseSuppressedStream(rawStream);
const processedTrack = candidateNoiseSuppressor.getProcessedAudioTrack();
const videoTracks = rawStream.getVideoTracks();
const tracks = [...videoTracks];
if (processedTrack) {
processedTrack.enabled = isCandidateMicEnabled;
tracks.push(processedTrack);
}
stream = new MediaStream(tracks);
updateCandidateNoiseSuppressionUI(candidateNoiseSuppressor.isEnabled());
}
candidateLocalStream = stream; candidateLocalStream = stream;
const vid = document.getElementById('webcam'); const vid = document.getElementById('webcam');
if (vid) { if (vid) {
@ -1080,17 +1113,55 @@ function debouncedCandidatePeerHeartbeat() {
}, 300); }, 300);
} }
export function updateCandidateNoiseSuppressionUI(isEnabled) {
const btn = document.getElementById('toggle-candidate-noise-btn');
if (btn) {
const span = btn.querySelector('span');
if (span) span.textContent = isEnabled ? 'Noise Cancellation Active' : 'Noise Cancellation Deactivated';
if (isEnabled) {
btn.className = 'w-full mb-2.5 py-1.5 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border-none text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5';
btn.title = 'Noise Cancellation Active - Click to disable';
} else {
btn.className = 'w-full mb-2.5 py-1.5 px-2 text-xs font-semibold rounded-lg bg-slate-800/80 border-none text-slate-400 hover:bg-slate-700/80 hover:text-slate-300 transition-all cursor-pointer flex items-center justify-center gap-1.5';
btn.title = 'Noise Cancellation Disabled - Click to enable';
}
}
}
export function toggleCandidateNoiseSuppression() {
let isEnabled = false;
if (candidateNoiseSuppressor) {
isEnabled = candidateNoiseSuppressor.toggle();
} else {
isEnabled = !getRNNoisePreference();
setRNNoisePreference(isEnabled);
}
updateCandidateNoiseSuppressionUI(isEnabled);
return isEnabled;
}
export function toggleMic() { export function toggleMic() {
if (!candidateLocalStream) return; isCandidateMicEnabled = !isCandidateMicEnabled;
if (candidateNoiseSuppressor) {
const processedTrack = candidateNoiseSuppressor.getProcessedAudioTrack();
if (processedTrack) processedTrack.enabled = isCandidateMicEnabled;
const origTrack = candidateNoiseSuppressor.getOriginalAudioTrack();
if (origTrack) origTrack.enabled = isCandidateMicEnabled;
}
if (candidateLocalStream) {
const audioTracks = candidateLocalStream.getAudioTracks(); const audioTracks = candidateLocalStream.getAudioTracks();
if (audioTracks.length > 0) { if (audioTracks.length > 0) {
isCandidateMicEnabled = !isCandidateMicEnabled;
audioTracks[0].enabled = isCandidateMicEnabled; audioTracks[0].enabled = isCandidateMicEnabled;
}
}
const btn = document.getElementById('toggle-mic-btn'); const btn = document.getElementById('toggle-mic-btn');
if (btn) { if (btn) {
btn.innerText = isCandidateMicEnabled ? '🎤 Mic On' : '🔇 Mic Muted'; btn.innerHTML = `<i class="${isCandidateMicEnabled ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}"></i> <span>${isCandidateMicEnabled ? 'Mic On' : 'Mic Off'}</span>`;
btn.style.background = isCandidateMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; btn.style.background = isCandidateMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
btn.style.borderColor = isCandidateMicEnabled ? '#10b981' : '#ef4444'; btn.className = `flex-1 py-2 px-2 text-xs font-semibold rounded-lg ${isCandidateMicEnabled ? 'bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30' : 'bg-red-500/20 text-red-300 hover:bg-red-500/30'} border-none transition-all cursor-pointer flex items-center justify-center gap-1.5`;
} }
const icon = document.getElementById('cand-self-mic-icon'); const icon = document.getElementById('cand-self-mic-icon');
if (icon) { if (icon) {
@ -1099,7 +1170,6 @@ export function toggleMic() {
icon.title = isCandidateMicEnabled ? 'Mic On' : 'Mic Muted'; icon.title = isCandidateMicEnabled ? 'Mic On' : 'Mic Muted';
} }
debouncedCandidatePeerHeartbeat(); debouncedCandidatePeerHeartbeat();
}
} }
export function toggleCam() { export function toggleCam() {
@ -1111,9 +1181,9 @@ export function toggleCam() {
videoTracks[0].enabled = isCandidateCamEnabled; videoTracks[0].enabled = isCandidateCamEnabled;
const btn = document.getElementById('toggle-cam-btn'); const btn = document.getElementById('toggle-cam-btn');
if (btn) { if (btn) {
btn.innerText = isCandidateCamEnabled ? '📷 Cam On' : '🚫 Cam Off'; btn.innerHTML = `<i class="${isCandidateCamEnabled ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}"></i> <span>${isCandidateCamEnabled ? 'Cam On' : 'Cam Off'}</span>`;
btn.style.background = isCandidateCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; btn.style.background = isCandidateCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
btn.style.borderColor = isCandidateCamEnabled ? '#10b981' : '#ef4444'; btn.className = `flex-1 py-2 px-2 text-xs font-semibold rounded-lg ${isCandidateCamEnabled ? 'bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30' : 'bg-red-500/20 text-red-300 hover:bg-red-500/30'} border-none transition-all cursor-pointer flex items-center justify-center gap-1.5`;
} }
if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex'; if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex';
} }
@ -1471,6 +1541,8 @@ if (typeof window !== 'undefined') {
window.candRingtone = candRingtone; window.candRingtone = candRingtone;
window.toggleMic = toggleMic; window.toggleMic = toggleMic;
window.toggleCam = toggleCam; window.toggleCam = toggleCam;
window.toggleCandidateNoiseSuppression = toggleCandidateNoiseSuppression;
window.updateCandidateNoiseSuppressionUI = updateCandidateNoiseSuppressionUI;
window.sendCandidateMessage = sendCandidateMessage; window.sendCandidateMessage = sendCandidateMessage;
window.pollCandidateChat = pollCandidateChat; window.pollCandidateChat = pollCandidateChat;
window.initCandidateRoom = initCandidateRoom; window.initCandidateRoom = initCandidateRoom;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,297 @@
/**
* RNNoise WebAssembly & AudioWorklet Real-Time Noise Suppression Engine
*
* Provides neural-network-powered speech enhancement and background noise suppression
* for WebRTC audio streams (candidate and interviewer calls) using Xiph's RNNoise.
*/
import { RnnoiseWorkletNode, loadRnnoise } from '@sapphi-red/web-noise-suppressor';
import rnnoiseWorkletUrl from '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url';
import rnnoiseWasmUrl from '@sapphi-red/web-noise-suppressor/rnnoise.wasm?url';
import rnnoiseSimdWasmUrl from '@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url';
// Cache loaded WASM binary and worklet registration across audio contexts
let cachedWasmBinaryPromise = null;
const registeredAudioContexts = new WeakSet();
/**
* Check if the browser environment supports AudioWorklet & WebAssembly
*/
export function isRNNoiseSupported() {
return typeof window !== 'undefined' &&
typeof window.AudioContext !== 'undefined' &&
typeof window.WebAssembly !== 'undefined' &&
typeof AudioWorkletNode !== 'undefined';
}
/**
* Get user preference for RNNoise noise suppression (default: enabled)
*/
export function getRNNoisePreference() {
if (typeof localStorage === 'undefined') return true;
const pref = localStorage.getItem('sls_rnnoise_enabled');
return pref === null ? true : pref === 'true';
}
/**
* Save user preference for RNNoise noise suppression
*/
export function setRNNoisePreference(enabled) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('sls_rnnoise_enabled', enabled ? 'true' : 'false');
}
}
/**
* Load the RNNoise WASM binary (with SIMD detection fallback)
*/
async function getWasmBinary() {
if (!cachedWasmBinaryPromise) {
cachedWasmBinaryPromise = loadRnnoise({
url: rnnoiseWasmUrl,
simdUrl: rnnoiseSimdWasmUrl,
}).catch((err) => {
console.warn('[RNNoise] Failed to load WASM binary:', err);
cachedWasmBinaryPromise = null;
throw err;
});
}
return cachedWasmBinaryPromise;
}
/**
* Register the RNNoise AudioWorklet module in the given AudioContext
*/
async function registerWorkletModule(audioCtx) {
if (!registeredAudioContexts.has(audioCtx)) {
await audioCtx.audioWorklet.addModule(rnnoiseWorkletUrl);
registeredAudioContexts.add(audioCtx);
}
}
/**
* Noise Suppressor Controller for an active MediaStream
*/
export class NoiseSuppressorController {
constructor({
audioCtx,
sourceNode,
rnnoiseNode,
noiseGain,
bypassGain,
destinationNode,
rawStream,
processedStream,
initialEnabled = true,
}) {
this.audioCtx = audioCtx;
this.sourceNode = sourceNode;
this.rnnoiseNode = rnnoiseNode;
this.noiseGain = noiseGain;
this.bypassGain = bypassGain;
this.destinationNode = destinationNode;
this.rawStream = rawStream;
this.processedStream = processedStream;
this._enabled = initialEnabled;
this._disposed = false;
this.applyState(initialEnabled, true);
}
/**
* Enable or disable noise suppression with smooth audio gain transition
*/
setEnabled(enabled) {
if (this._disposed) return;
this._enabled = Boolean(enabled);
setRNNoisePreference(this._enabled);
this.applyState(this._enabled, false);
}
applyState(enabled, immediate = false) {
if (!this.noiseGain || !this.bypassGain || !this.audioCtx) return;
const now = this.audioCtx.currentTime;
const transitionDuration = immediate ? 0 : 0.02; // 20ms click-free cross-fade
if (enabled) {
// Enable RNNoise path, disable raw bypass
this.bypassGain.gain.setValueAtTime(this.bypassGain.gain.value, now);
this.bypassGain.gain.linearRampToValueAtTime(0.0, now + transitionDuration);
this.noiseGain.gain.setValueAtTime(this.noiseGain.gain.value, now);
this.noiseGain.gain.linearRampToValueAtTime(1.0, now + transitionDuration);
} else {
// Disable RNNoise path, enable raw bypass
this.noiseGain.gain.setValueAtTime(this.noiseGain.gain.value, now);
this.noiseGain.gain.linearRampToValueAtTime(0.0, now + transitionDuration);
this.bypassGain.gain.setValueAtTime(this.bypassGain.gain.value, now);
this.bypassGain.gain.linearRampToValueAtTime(1.0, now + transitionDuration);
}
}
toggle() {
this.setEnabled(!this._enabled);
return this._enabled;
}
isEnabled() {
return this._enabled;
}
getProcessedStream() {
return this.processedStream || this.rawStream;
}
getProcessedAudioTrack() {
if (this.processedStream) {
const tracks = this.processedStream.getAudioTracks();
if (tracks.length > 0) return tracks[0];
}
if (this.rawStream) {
const tracks = this.rawStream.getAudioTracks();
if (tracks.length > 0) return tracks[0];
}
return null;
}
getOriginalAudioTrack() {
if (this.rawStream) {
const tracks = this.rawStream.getAudioTracks();
if (tracks.length > 0) return tracks[0];
}
return null;
}
dispose() {
if (this._disposed) return;
this._disposed = true;
try {
if (this.sourceNode) this.sourceNode.disconnect();
if (this.rnnoiseNode) {
this.rnnoiseNode.disconnect();
if (typeof this.rnnoiseNode.destroy === 'function') {
this.rnnoiseNode.destroy();
}
}
if (this.noiseGain) this.noiseGain.disconnect();
if (this.bypassGain) this.bypassGain.disconnect();
if (this.destinationNode) this.destinationNode.disconnect();
} catch (e) {
console.warn('[RNNoise] Cleanup warning:', e);
}
if (this.processedStream) {
this.processedStream.getTracks().forEach((t) => {
try { t.stop(); } catch (e) {}
});
}
if (this.audioCtx && this.audioCtx.state !== 'closed') {
try { this.audioCtx.close(); } catch (e) {}
}
}
}
/**
* Fallback Controller when RNNoise is unavailable or fails
*/
class FallbackNoiseSuppressorController {
constructor(rawStream) {
this.rawStream = rawStream;
this._enabled = false;
}
setEnabled(enabled) { this._enabled = Boolean(enabled); }
toggle() { this._enabled = !this._enabled; return this._enabled; }
isEnabled() { return this._enabled; }
getProcessedStream() { return this.rawStream; }
getProcessedAudioTrack() {
return this.rawStream?.getAudioTracks()[0] || null;
}
getOriginalAudioTrack() {
return this.rawStream?.getAudioTracks()[0] || null;
}
dispose() {}
}
/**
* Create a noise-suppressed MediaStream from a raw microphone MediaStream
*
* @param {MediaStream} rawStream - Input media stream with microphone audio
* @param {Object} options - Optional configuration
* @returns {Promise<NoiseSuppressorController>} Controller with processed stream
*/
export async function createNoiseSuppressedStream(rawStream, options = {}) {
if (!rawStream || !rawStream.getAudioTracks || rawStream.getAudioTracks().length === 0) {
return new FallbackNoiseSuppressorController(rawStream);
}
if (!isRNNoiseSupported()) {
console.warn('[RNNoise] WebAudio AudioWorklet / WASM not supported in this browser, using standard audio');
return new FallbackNoiseSuppressorController(rawStream);
}
try {
const AudioCtxClass = window.AudioContext || window.webkitAudioContext;
// RNNoise is trained on 48,000Hz (48kHz) audio. Create a 48kHz audio context.
const audioCtx = new AudioCtxClass({ sampleRate: 48000 });
if (audioCtx.state === 'suspended') {
await audioCtx.resume().catch(() => {});
}
// Parallelize WASM binary loading & worklet module registration with timeout
const wasmPromise = getWasmBinary();
const workletPromise = registerWorkletModule(audioCtx);
// Fail-safe 3500ms timeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('RNNoise init timeout')), 3500)
);
const [wasmBinary] = await Promise.race([
Promise.all([wasmPromise, workletPromise]),
timeoutPromise,
]);
const sourceNode = audioCtx.createMediaStreamSource(rawStream);
const rnnoiseNode = new RnnoiseWorkletNode(audioCtx, {
maxChannels: 1,
wasmBinary,
});
const noiseGain = audioCtx.createGain();
const bypassGain = audioCtx.createGain();
const destinationNode = audioCtx.createMediaStreamDestination();
// Connect RNNoise path: Source -> RNNoise -> NoiseGain -> Destination
sourceNode.connect(rnnoiseNode);
rnnoiseNode.connect(noiseGain);
noiseGain.connect(destinationNode);
// Connect Bypass path: Source -> BypassGain -> Destination
sourceNode.connect(bypassGain);
bypassGain.connect(destinationNode);
const processedStream = destinationNode.stream;
const initialEnabled = options.enabled !== undefined ? options.enabled : getRNNoisePreference();
console.log('[RNNoise] Audio suppressor initialized successfully (enabled:', initialEnabled, ')');
return new NoiseSuppressorController({
audioCtx,
sourceNode,
rnnoiseNode,
noiseGain,
bypassGain,
destinationNode,
rawStream,
processedStream,
initialEnabled,
});
} catch (err) {
console.warn('[RNNoise] Failed to initialize noise suppression worklet, falling back to raw audio:', err);
return new FallbackNoiseSuppressorController(rawStream);
}
}

View File

@ -0,0 +1,740 @@
/**
* Recording Compositor & Multi-Track Audio Mixer Engine
*
* Creates a unified 1920x1080 composite video stream from multiple WebRTC video sources
* (Candidate Webcam, Screen Share, Self Interviewer, Remote Mesh Panelists)
* matching the interviewer layout:
* - Large Main Area: Candidate Webcam (or Screen Share when active)
* - Right Sidebar: Stacked Peers (and Candidate Webcam at top when screen share is active)
* - Camera-off Avatar Placeholders with initials and badges
* - Bottom Overflow Row: Placed horizontally below main area if peers > 3
* - AudioContext multi-track audio mixing
*/
export class RecordingCompositor {
/**
* @param {{width:number, height:number, fps: number, getParticipantsData: () => {}}} options
*/
constructor(options = {}) {
this.width = options.width || 1280;
this.height = options.height || (options.width ? Math.round((options.width * 9) / 16) : 720);
this.fps = options.fps || 30;
this.canvas = document.createElement("canvas");
this.canvas.width = this.width;
this.canvas.height = this.height;
this.ctx = this.canvas.getContext("2d", { alpha: false });
this.audioCtx = null;
this.audioDestination = null;
this.audioSourceNodes = [];
this.mixedAudioTrack = null;
this.animationFrameId = null;
this.isRunning = false;
this.startTime = null;
this.speakingStates = new Map();
// Metadata provider function
this.getParticipantsData = options.getParticipantsData || (() => ({}));
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.startTime = Date.now();
this.initAudioMixer();
this.renderLoop();
}
stop() {
this.isRunning = false;
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.audioSourceNodes.length > 0) {
this.audioSourceNodes.forEach((node) => {
try {
node.disconnect();
} catch (e) {}
});
this.audioSourceNodes = [];
}
if (this.audioCtx) {
try {
this.audioCtx.close();
} catch (e) {}
this.audioCtx = null;
}
this.audioDestination = null;
this.mixedAudioTrack = null;
}
getStream() {
const videoTrack = this.canvas
.captureStream(this.fps)
.getVideoTracks()[0];
const tracks = [];
if (videoTrack) tracks.push(videoTrack);
if (this.mixedAudioTrack) tracks.push(this.mixedAudioTrack);
return new MediaStream(tracks);
}
setSpeakingState(key, isSpeaking) {
this.speakingStates.set(key, Boolean(isSpeaking));
}
initAudioMixer() {
try {
const AudioCtxClass =
window.AudioContext || window.webkitAudioContext;
if (!AudioCtxClass) return;
this.audioCtx = new AudioCtxClass();
this.audioDestination =
this.audioCtx.createMediaStreamDestination();
const data = this.getParticipantsData();
const audioStreams = [];
// 1. Candidate Audio
if (
data.candidateStream &&
data.candidateStream.getAudioTracks().length > 0
) {
audioStreams.push(data.candidateStream);
}
// 2. Interviewer Self Audio
if (
data.selfStream &&
data.selfStream.getAudioTracks().length > 0
) {
audioStreams.push(data.selfStream);
}
// 3. Panelist Mesh Audios
if (data.panelistStreams && Array.isArray(data.panelistStreams)) {
data.panelistStreams.forEach((ps) => {
if (
ps &&
ps.stream &&
ps.stream.getAudioTracks().length > 0
) {
audioStreams.push(ps.stream);
}
});
}
audioStreams.forEach((stream) => {
try {
const source =
this.audioCtx.createMediaStreamSource(stream);
source.connect(this.audioDestination);
this.audioSourceNodes.push(source);
} catch (e) {}
});
this.mixedAudioTrack =
this.audioDestination.stream.getAudioTracks()[0] || null;
} catch (e) {
console.warn("[RecordingCompositor] Audio mixing error:", e);
}
}
renderLoop() {
if (!this.isRunning) return;
this.renderFrame();
this.animationFrameId = requestAnimationFrame(() => this.renderLoop());
}
renderFrame() {
const ctx = this.ctx;
const W = this.width;
const H = this.height;
// Dark background
ctx.fillStyle = "#090d16";
ctx.fillRect(0, 0, W, H);
const data = this.getParticipantsData();
// 1. Candidate Feed
const candVideo = document.getElementById("interviewer-cand-video");
const isCandCamOn = data.candidateCamOn !== false;
const isCandMicOn = data.candidateMicOn !== false;
const candidateFeed = {
id: "candidate",
type: "candidate",
name: data.candidateName || "Candidate",
initials: this.extractInitials(data.candidateName || "Candidate"),
video: candVideo,
hasVideo: isCandCamOn && this.isVideoPlaying(candVideo),
isCamOn: isCandCamOn,
isMicOn: isCandMicOn,
isSpeaking:
this.speakingStates.get("interviewer-cand-video") || false,
badge: "Candidate",
};
// 2. Screen Share Feed
const screenVideo = document.getElementById("interviewer-screen-video");
const isScreenSharing = this.isScreenShareActive(screenVideo);
const screenFeed = {
id: "screen",
type: "screen",
name: "Candidate Live Screen",
initials: "SCR",
video: screenVideo,
hasVideo: isScreenSharing,
isCamOn: true,
isMicOn: false,
isSpeaking: false,
badge: "Shared Screen",
};
// 3. Self Interviewer Feed
const selfVideo = document.getElementById("interviewer-self-video");
const isSelfCamOn = data.selfCamOn !== false;
const isSelfMicOn = data.selfMicOn !== false;
const selfFeed = {
id: "self",
type: "interviewer",
name: data.selfName
? `${data.selfName} (You)`
: "Interviewer (You)",
initials: this.extractInitials(data.selfName || "IV"),
video: selfVideo,
hasVideo: isSelfCamOn && this.isVideoPlaying(selfVideo),
isCamOn: isSelfCamOn,
isMicOn: isSelfMicOn,
isSpeaking: false,
badge: "Interviewer",
};
// 4. Panelist Feeds
const panelistFeeds = [];
if (data.panelists && Array.isArray(data.panelists)) {
data.panelists.forEach((p) => {
const pVid = document.getElementById(
"panelist-video-" + p.peerId,
);
const isCamOn = p.camOn !== false;
const isMicOn = p.micOn !== false;
panelistFeeds.push({
id: p.peerId,
type: "panelist",
name: p.name || "Panelist",
initials: this.extractInitials(p.name || "Panelist"),
video: pVid,
hasVideo: isCamOn && this.isVideoPlaying(pVid),
isCamOn: isCamOn,
isMicOn: isMicOn,
isSpeaking: false,
badge: p.role || "Panelist",
});
});
}
// Layout determination
let mainFeed;
let secondaryFeeds = [];
if (isScreenSharing) {
// When screen share is active:
// Main = Screen Share
// Right Sidebar Top = Candidate Webcam
// Followed by Self and Panelists
mainFeed = screenFeed;
secondaryFeeds = [candidateFeed, selfFeed, ...panelistFeeds];
} else {
// Normal Call:
// Main = Candidate Webcam
// Right Sidebar = Self and Panelists
mainFeed = candidateFeed;
secondaryFeeds = [selfFeed, ...panelistFeeds];
}
// Compute dynamic geometry with strict 16:9 aspect ratio preservation for every tile
const padX = Math.max(8, Math.round(W * 0.015));
const padY = Math.max(8, Math.round(H * 0.02));
const gap = Math.max(6, Math.round(W * 0.01));
const totalSecondary = secondaryFeeds.length;
if (totalSecondary === 0) {
// Case 0: Only Main Feed (Full Canvas 16:9 with padding)
const maxAvailW = W - 2 * padX;
const maxAvailH = H - 2 * padY;
let mainW, mainH;
if (maxAvailW / maxAvailH > 16 / 9) {
mainH = maxAvailH;
mainW = Math.round(mainH * (16 / 9));
} else {
mainW = maxAvailW;
mainH = Math.round(mainW * (9 / 16));
}
const mainX = Math.round((W - mainW) / 2);
const mainY = Math.round((H - mainH) / 2);
this.renderTile(
ctx,
mainFeed,
{ x: mainX, y: mainY, w: mainW, h: mainH },
true,
);
} else if (totalSecondary === 1) {
// Case 1: Main + 1 Sidebar Tile (Both 16:9)
const availW = W - 2 * padX - gap;
const availH = H - 2 * padY;
let sideW = Math.round(availW * 0.26);
let sideH = Math.round(sideW * (9 / 16));
let mainW = availW - sideW;
let mainH = Math.round(mainW * (9 / 16));
if (mainH > availH) {
mainH = availH;
mainW = Math.round(mainH * (16 / 9));
sideW = Math.min(availW - mainW, Math.round(availH * (16 / 9)));
sideH = Math.round(sideW * (9 / 16));
}
const mainX = padX;
const mainY = Math.round((H - mainH) / 2);
const sideX = padX + mainW + gap;
const sideY = Math.round((H - sideH) / 2);
this.renderTile(
ctx,
mainFeed,
{ x: mainX, y: mainY, w: mainW, h: mainH },
true,
);
this.renderTile(
ctx,
secondaryFeeds[0],
{ x: sideX, y: sideY, w: sideW, h: sideH },
false,
);
} else if (totalSecondary === 2) {
// Case 2: Main + 2 Sidebar Tiles stacked vertically (All 16:9)
const availW = W - 2 * padX - gap;
const availH = H - 2 * padY;
let sideW = Math.round(availW * 0.28);
let sideH = Math.round(sideW * (9 / 16));
if (2 * sideH + gap > availH) {
sideH = Math.floor((availH - gap) / 2);
sideW = Math.round(sideH * (16 / 9));
}
const totalSideH = 2 * sideH + gap;
let mainW = availW - sideW;
let mainH = Math.round(mainW * (9 / 16));
if (mainH > availH) {
mainH = availH;
mainW = Math.round(mainH * (16 / 9));
}
const mainX = padX;
const mainY = Math.round((H - mainH) / 2);
const sideX = W - padX - sideW;
const sideYStart = Math.round((H - totalSideH) / 2);
this.renderTile(
ctx,
mainFeed,
{ x: mainX, y: mainY, w: mainW, h: mainH },
true,
);
this.renderTile(
ctx,
secondaryFeeds[0],
{ x: sideX, y: sideYStart, w: sideW, h: sideH },
false,
);
this.renderTile(
ctx,
secondaryFeeds[1],
{ x: sideX, y: sideYStart + sideH + gap, w: sideW, h: sideH },
false,
);
} else if (totalSecondary === 3) {
// Case 3: Main + 3 Sidebar Tiles stacked vertically (All 16:9)
const availH = H - 2 * padY;
const sideH = Math.floor((availH - 2 * gap) / 3);
const sideW = Math.round(sideH * (16 / 9));
const totalSideH = 3 * sideH + 2 * gap;
const topY = Math.round((H - totalSideH) / 2);
const sideX = W - padX - sideW;
let mainW = sideX - gap - padX;
let mainH = Math.round(mainW * (9 / 16));
if (mainH > totalSideH) {
mainH = totalSideH;
mainW = Math.round(mainH * (16 / 9));
}
const mainX = padX;
const mainY = Math.round((H - mainH) / 2);
this.renderTile(
ctx,
mainFeed,
{ x: mainX, y: mainY, w: mainW, h: mainH },
true,
);
for (let i = 0; i < 3; i++) {
const feed = secondaryFeeds[i];
const tileY = topY + i * (sideH + gap);
this.renderTile(
ctx,
feed,
{ x: sideX, y: tileY, w: sideW, h: sideH },
false,
);
}
} else {
// Case 4: Main + 3 Sidebar Tiles + Bottom Overflow Row (All 16:9)
const availH = H - 2 * padY;
const sideH = Math.floor((availH - 2 * gap) / 3);
const sideW = Math.round(sideH * (16 / 9));
const totalSideH = 3 * sideH + 2 * gap;
const topY = Math.round((H - totalSideH) / 2);
const sideX = W - padX - sideW;
const mainAreaW = sideX - gap - padX;
const targetMainH = Math.round((totalSideH - gap) * 0.68);
let mainW = Math.round(targetMainH * (16 / 9));
let mainH = targetMainH;
if (mainW > mainAreaW) {
mainW = mainAreaW;
mainH = Math.round(mainW * (9 / 16));
}
const mainX = padX + Math.round((mainAreaW - mainW) / 2);
const mainY = topY;
// 1. Render Main
this.renderTile(
ctx,
mainFeed,
{ x: mainX, y: mainY, w: mainW, h: mainH },
true,
);
// 2. Render 3 Sidebar Tiles
for (let i = 0; i < 3; i++) {
const feed = secondaryFeeds[i];
const tileY = topY + i * (sideH + gap);
this.renderTile(
ctx,
feed,
{ x: sideX, y: tileY, w: sideW, h: sideH },
false,
);
}
// 3. Render Bottom Overflow Tiles (each 16:9)
const overflowFeeds = secondaryFeeds.slice(3);
const numOverflow = overflowFeeds.length;
const botMaxH = totalSideH - mainH - gap;
const botYBase = topY + mainH + gap;
const candBotW = Math.floor(
(mainAreaW - (numOverflow - 1) * gap) / numOverflow,
);
const candBotH = Math.round(candBotW * (9 / 16));
let botW, botH;
if (candBotH > botMaxH) {
botH = botMaxH;
botW = Math.round(botH * (16 / 9));
} else {
botW = candBotW;
botH = candBotH;
}
const totalBotW = numOverflow * botW + (numOverflow - 1) * gap;
const botXStart = padX + Math.round((mainAreaW - totalBotW) / 2);
const botY = botYBase + Math.round((botMaxH - botH) / 2);
overflowFeeds.forEach((feed, j) => {
const tileX = botXStart + j * (botW + gap);
this.renderTile(
ctx,
feed,
{ x: tileX, y: botY, w: botW, h: botH },
false,
);
});
}
}
renderTile(ctx, feed, rect, isMain = false) {
const { x, y, w, h } = rect;
const radius = Math.max(6, Math.round(Math.min(w, h) * 0.035));
ctx.save();
this.drawRoundedClip(ctx, x, y, w, h, radius);
// Base tile background
ctx.fillStyle = "#0d1220";
ctx.fillRect(x, y, w, h);
if (feed.hasVideo && feed.video) {
// Draw video frame
if (feed.type === "screen") {
this.drawVideoContain(ctx, feed.video, x, y, w, h);
} else {
this.drawVideoCover(ctx, feed.video, x, y, w, h);
}
} else {
// Draw avatar placeholder
this.drawPlaceholder(ctx, feed, x, y, w, h, isMain);
}
ctx.restore();
// Sleek subtle border
ctx.save();
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
this.drawRoundedStroke(ctx, x, y, w, h, radius);
ctx.restore();
// Overlay Pill Tag (Bottom-Left)
this.drawPillTag(ctx, feed, x, y, w, h);
}
drawVideoCover(ctx, video, x, y, w, h) {
try {
const vw = video.videoWidth || 640;
const vh = video.videoHeight || 480;
const targetRatio = w / h;
const videoRatio = vw / vh;
let sx = 0,
sy = 0,
sw = vw,
sh = vh;
if (videoRatio > targetRatio) {
// Video is wider than 16:9 target: crop horizontally
sw = Math.round(vh * targetRatio);
sx = Math.round((vw - sw) / 2);
} else {
// Video is taller than 16:9 target: crop vertically
sh = Math.round(vw / targetRatio);
sy = Math.round((vh - sh) / 2);
}
ctx.drawImage(video, sx, sy, sw, sh, x, y, w, h);
} catch (e) {}
}
drawVideoContain(ctx, video, x, y, w, h) {
try {
const vw = video.videoWidth || 1920;
const vh = video.videoHeight || 1080;
const targetRatio = w / h;
const videoRatio = vw / vh;
let dw = w;
let dh = h;
let dx = x;
let dy = y;
if (videoRatio > targetRatio) {
// Video is wider than tile
dh = Math.round(w / videoRatio);
dy = Math.round(y + (h - dh) / 2);
} else {
// Video is taller than tile
dw = Math.round(h * videoRatio);
dx = Math.round(x + (w - dw) / 2);
}
ctx.drawImage(video, 0, 0, vw, vh, dx, dy, dw, dh);
} catch (e) {}
}
drawPlaceholder(ctx, feed, x, y, w, h, isMain = false) {
const cx = x + w / 2;
const cy = y + h / 2 - (isMain ? Math.round(h * 0.03) : Math.round(h * 0.02));
const circleRadius = isMain
? Math.min(Math.round(h * 0.18), Math.round(w * 0.12), 54)
: Math.min(Math.round(h * 0.2), Math.round(w * 0.14), 36);
// Circular gradient avatar
const gradient = ctx.createLinearGradient(
cx - circleRadius,
cy - circleRadius,
cx + circleRadius,
cy + circleRadius,
);
if (feed.type === "candidate") {
gradient.addColorStop(0, "#10b981");
gradient.addColorStop(1, "#059669");
} else {
gradient.addColorStop(0, "#5b8bff");
gradient.addColorStop(1, "#3b63e0");
}
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(cx, cy, Math.max(1, circleRadius), 0, Math.PI * 2);
ctx.fill();
// Avatar Initials Text
const initialsFont = Math.max(10, Math.round(circleRadius * 0.85));
ctx.fillStyle = "#ffffff";
ctx.font = `bold ${initialsFont}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(feed.initials || "IV", cx, cy);
// Participant Name
const nameFont = Math.max(10, Math.round(isMain ? Math.min(20, h * 0.045) : Math.min(14, h * 0.06)));
ctx.fillStyle = "#ffffff";
ctx.font = `600 ${nameFont}px sans-serif`;
ctx.textBaseline = "top";
ctx.fillText(feed.name, cx, cy + circleRadius + Math.max(6, Math.round(h * 0.02)));
// Subtitle status
const subFont = Math.max(9, Math.round(isMain ? Math.min(14, h * 0.032) : Math.min(11, h * 0.045)));
ctx.fillStyle = "#94a3b8";
ctx.font = `400 ${subFont}px sans-serif`;
ctx.fillText(
feed.isCamOn ? "Connecting video..." : "Camera turned off",
cx,
cy + circleRadius + Math.max(6, Math.round(h * 0.02)) + nameFont + Math.max(4, Math.round(h * 0.01)),
);
}
drawPillTag(ctx, feed, tileX, tileY, tileW, tileH) {
const text = feed.name;
const fontSize = Math.max(9, Math.min(12, Math.round(tileH * 0.045)));
ctx.font = `600 ${fontSize}px sans-serif`;
const textMetrics = ctx.measureText(text);
const tagPaddingX = Math.max(6, Math.round(fontSize * 0.7));
const tagW = textMetrics.width + tagPaddingX * 2;
const tagH = Math.max(16, Math.round(fontSize * 1.8));
const marginX = Math.max(6, Math.round(tileW * 0.02));
const marginY = Math.max(6, Math.round(tileH * 0.03));
const tagX = tileX + marginX;
const tagY = tileY + tileH - marginY - tagH;
const tagRadius = Math.max(3, Math.round(tagH * 0.25));
ctx.save();
// Background
ctx.fillStyle = "rgba(9, 13, 22, 0.8)";
this.drawRoundedFill(ctx, tagX, tagY, tagW, tagH, tagRadius);
// Border
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
ctx.lineWidth = 1;
this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, tagRadius);
// Name text
ctx.fillStyle = "#e2e8f0";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(text, tagX + tagPaddingX, tagY + tagH / 2);
ctx.restore();
}
isScreenShareActive(video) {
if (!video) return false;
const stream = video.srcObject;
if (!stream || !stream.active) return false;
const tracks = stream.getVideoTracks ? stream.getVideoTracks() : [];
if (tracks.length === 0) return false;
const hasLiveTrack = tracks.some(
(t) => t.readyState === "live" && t.enabled,
);
if (!hasLiveTrack) return false;
if (video.paused && typeof video.play === "function") {
video.play().catch(() => {});
}
return true;
}
isVideoPlaying(video) {
if (!video) return false;
const stream = video.srcObject;
if (!stream || !stream.active) return false;
const tracks = stream.getVideoTracks ? stream.getVideoTracks() : [];
if (tracks.length === 0) return false;
const hasLiveTrack = tracks.some(
(t) => t.readyState === "live" && t.enabled,
);
if (!hasLiveTrack) return false;
if (video.paused && typeof video.play === "function") {
video.play().catch(() => {});
}
return Boolean(
video.videoWidth > 0 || video.readyState >= 1 || hasLiveTrack,
);
}
extractInitials(name) {
if (!name) return "CD";
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
roundedRectPath(ctx, x, y, w, h, r) {
if (typeof ctx.roundRect === "function") {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
return;
}
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
drawRoundedClip(ctx, x, y, w, h, r) {
this.roundedRectPath(ctx, x, y, w, h, r);
ctx.clip();
}
drawRoundedStroke(ctx, x, y, w, h, r) {
this.roundedRectPath(ctx, x, y, w, h, r);
ctx.stroke();
}
drawRoundedFill(ctx, x, y, w, h, r) {
this.roundedRectPath(ctx, x, y, w, h, r);
ctx.fill();
}
}

View File

@ -0,0 +1,32 @@
@props([
'variant' => 'info', // success, danger, warning, info
'icon' => null,
])
@php
$variants = [
'success' => 'bg-emerald-500/15 border-emerald-500/30 text-emerald-300',
'danger' => 'bg-red-500/15 border-red-500/30 text-red-300',
'warning' => 'bg-amber-500/15 border-amber-500/30 text-amber-300',
'info' => 'bg-sky-500/15 border-sky-500/30 text-sky-300',
];
$defaultIcons = [
'success' => 'fa-solid fa-circle-check',
'danger' => 'fa-solid fa-triangle-exclamation',
'warning' => 'fa-solid fa-circle-exclamation',
'info' => 'fa-solid fa-circle-info',
];
$selectedIcon = $icon ?? ($defaultIcons[$variant] ?? $defaultIcons['info']);
$variantClass = $variants[$variant] ?? $variants['info'];
@endphp
<div {{ $attributes->merge(['class' => "flex items-start gap-2.5 px-4 py-3 border rounded-xl text-xs font-medium {$variantClass}"]) }}>
@if($selectedIcon)
<i class="{{ $selectedIcon }} text-sm mt-0.5 shrink-0"></i>
@endif
<div class="flex-1 leading-relaxed">
{{ $slot }}
</div>
</div>

View File

@ -2,7 +2,7 @@
'logs' => [], 'logs' => [],
]) ])
<div {{ $attributes->merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] max-h-[260px] overflow-y-auto']) }}> <div {{ $attributes->twMerge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] overflow-y-auto']) }}>
@if(is_array($logs) && count($logs) > 0) @if(is_array($logs) && count($logs) > 0)
@foreach($logs as $log) @foreach($logs as $log)
<x-audit-log-item <x-audit-log-item

View File

@ -3,10 +3,14 @@
'size' => 'md', // sm, md, lg 'size' => 'md', // sm, md, lg
'type' => 'button', 'type' => 'button',
'icon' => null, 'icon' => null,
'showLoader' => false,
'loading' => false,
'loadingText' => null,
]) ])
@php @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 = [ $variants = [
'default' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]', 'default' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]',
@ -31,11 +35,23 @@
]); ]);
@endphp @endphp
<button type="{{ $type }}" {{ $attributes->merge(['class' => $classes]) }}> <button
@if($icon) type="{{ $type }}"
<i class="{{ $icon }}"></i> @if($showLoader)
data-show-loader="true"
@if($loadingText) data-loading-text="{{ $loadingText }}" @endif
@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)) @if(trim($slot))
<span>{{ $slot }}</span> <span class="btn-text">{{ $slot }}</span>
@endif @endif
</button> </button>

View File

@ -4,18 +4,22 @@
]) ])
<div {{ $attributes->merge(['class' => 'bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden']) }}> <div {{ $attributes->merge(['class' => 'bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden']) }}>
@if($title || $icon || isset($headerExtra)) @if($title || $icon || isset($header) || isset($headerExtra))
<div class="flex items-center justify-between px-5 py-3.5 border-b border-[#1b2233]"> <div class="flex items-center justify-between px-5 py-3 border-b border-[#1b2233] gap-4 flex-wrap">
<div class="flex items-center gap-2.5 font-semibold text-[14.5px] tracking-tight text-white"> <div class="flex items-center gap-2.5 font-semibold text-[14.5px] tracking-tight text-white min-w-0">
@if(isset($header))
{{ $header }}
@else
@if($icon) @if($icon)
<i class="{{ $icon }} text-[#4b82f7] text-[13.5px]"></i> <i class="{{ $icon }} text-[#4b82f7] text-[13.5px]"></i>
@endif @endif
@if($title) @if($title)
<span>{{ $title }}</span> <span>{{ $title }}</span>
@endif @endif
@endif
</div> </div>
@if(isset($headerExtra)) @if(isset($headerExtra))
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 shrink-0">
{{ $headerExtra }} {{ $headerExtra }}
</div> </div>
@endif @endif

View File

@ -25,7 +25,7 @@
$borderRgba = "rgba({$r}, {$g}, {$b}, 0.35)"; $borderRgba = "rgba({$r}, {$g}, {$b}, 0.35)";
@endphp @endphp
<div {{ $attributes->merge(['class' => 'chat-message mb-2 p-2.5 rounded-lg border-l-2 transition-all']) }} style="background: {{ $bgRgba }}; border-color: {{ $resolvedColor }};"> <div {{ $attributes->merge(['class' => 'chat-message mb-2 p-2.5 rounded-lg transition-all']) }} style="background: {{ $bgRgba }}; border-color: {{ $resolvedColor }};">
<div class="flex justify-between items-center mb-1 gap-2"> <div class="flex justify-between items-center mb-1 gap-2">
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<span class="w-1.5 h-1.5 rounded-full shrink-0" style="background: {{ $resolvedColor }};"></span> <span class="w-1.5 h-1.5 rounded-full shrink-0" style="background: {{ $resolvedColor }};"></span>

View File

@ -15,7 +15,7 @@
]; ];
@endphp @endphp
<div {{ $attributes->merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2']) }}> <div {{ $attributes->twMerge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] overflow-y-auto p-3 flex flex-col gap-2']) }}>
@if(count($warnings) > 0) @if(count($warnings) > 0)
@foreach($warnings as $w) @foreach($warnings as $w)
@php @php
@ -30,7 +30,7 @@
@endphp @endphp
<x-chat-message <x-chat-message
:senderName="$senderName" :senderName="$senderName"
:isCandidate="$isCand" :isCandidate="
:color="$color" :color="$color"
:message="$w->message" :message="$w->message"
:timestamp="$w->created_at" :timestamp="$w->created_at"

View File

@ -0,0 +1,61 @@
@props([
'id',
'title' => null,
'description' => null,
'width' => 'lg',
'side' => 'right',
'onClose' => null,
])
@php
$widths = [
'sm' => 'max-w-sm',
'md' => 'max-w-md',
'lg' => 'max-w-lg',
'xl' => 'max-w-xl',
'2xl' => 'max-w-2xl',
'3xl' => 'max-w-3xl',
'full' => 'max-w-full',
];
$widthClass = $widths[$width] ?? $widths['lg'];
$isLeft = $side === 'left';
$justifyClass = $isLeft ? 'justify-start' : 'justify-end';
$translateClosed = $isLeft ? '-translate-x-full' : 'translate-x-full';
$borderClass = $isLeft ? 'border-r' : 'border-l';
@endphp
<div id="{{ $id }}" {{ $attributes->merge(['class' => "admin-drawer fixed inset-0 w-screen h-screen z-[1000] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto flex $justifyClass"]) }}>
<!-- Backdrop Overlay Target -->
<div class="fixed inset-0" onclick="document.getElementById('{{ $id }}').classList.remove('active'); {{ $onClose ? $onClose . '();' : '' }}"></div>
<!-- Slide-Over Drawer Content Container -->
<div class="drawer-panel relative z-10 w-full {{ $widthClass }} h-full bg-slate-900 {{ $borderClass }} border-slate-700/60 shadow-2xl flex flex-col text-white transform {{ $translateClosed }} transition-transform duration-300 ease-out [.active_&]:translate-x-0">
<!-- Header -->
<div class="flex items-start justify-between p-6 border-b border-white/10 shrink-0">
<div class="pr-4">
@if($title)
<h3 class="font-outfit text-xl font-bold text-white m-0 leading-snug">{{ $title }}</h3>
@endif
@if($description)
<p class="text-xs text-slate-400 mt-1 mb-0 leading-relaxed">{{ $description }}</p>
@endif
</div>
<button type="button" onclick="document.getElementById('{{ $id }}').classList.remove('active'); {{ $onClose ? $onClose . '();' : '' }}" class="w-8 h-8 rounded-lg bg-slate-800/80 border border-slate-700/60 text-slate-400 hover:text-white hover:bg-slate-700/60 transition-colors flex items-center justify-center cursor-pointer shrink-0 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Scrollable Body Content -->
<div class="flex-1 overflow-y-auto p-6 space-y-4">
{{ $slot }}
</div>
<!-- Optional Fixed Footer -->
@if(isset($footer))
<div class="p-6 border-t border-white/10 bg-slate-950/40 shrink-0">
{{ $footer }}
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,25 @@
@props([
'align' => 'left', // left, right
'width' => 'w-80 sm:w-96',
])
@php
$alignmentClasses = $align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left';
@endphp
<details class="group relative inline-block text-left select-none">
<summary class="list-none cursor-pointer p-1.5 rounded-lg bg-slate-800/80 border border-slate-700/60 text-slate-300 hover:text-white hover:bg-slate-700/60 transition-colors flex items-center justify-center w-8 h-8 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{{ $trigger ?? '' }}
@if(!isset($trigger))
<i class="fa-solid fa-bars text-sm"></i>
@endif
</summary>
<!-- Backdrop for click-outside dismissal -->
<div class="fixed inset-0 z-40" onclick="this.closest('details').removeAttribute('open')"></div>
<!-- Dropdown Content Panel -->
<div class="absolute {{ $alignmentClasses }} mt-2 {{ $width }} bg-slate-900 border border-slate-700/80 rounded-xl shadow-2xl p-3.5 z-50 backdrop-blur-xl text-white divide-y divide-white/10 font-normal">
{{ $slot }}
</div>
</details>

View File

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

View File

@ -0,0 +1,42 @@
@props([
'type' => 'text',
'name' => null,
'value' => null,
'placeholder' => null,
'required' => false,
'disabled' => false,
'size' => 'md', // sm, md, lg
'error' => null,
])
@php
$sizeClasses = [
'sm' => 'px-2.5 py-1.5 text-xs',
'md' => 'px-3 py-2 text-xs',
'lg' => 'px-3.5 py-2.5 text-sm',
];
$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
type="{{ $type }}"
@if($name) name="{{ $name }}" @endif
@if($value !== null) value="{{ $value }}" @endif
@if($placeholder) placeholder="{{ $placeholder }}" @endif
@if($required) required @endif
@if($disabled) disabled @endif
{{ $attributes->twMerge(['class' => $classes]) }}
/>

View File

@ -0,0 +1,75 @@
@php
$screenshotActive = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1';
$onboardingActive = \App\Models\Setting::get('onboarding_enabled', '1') === '1';
$isAdmin = Auth::check() && Auth::user()->isAdmin();
@endphp
<div class="mb-6 grid grid-cols-1 md:grid-cols-2 gap-3.5">
<!-- 1. Candidate System Screenshot Capture Option Card -->
<div class="bg-[#0e1422] border border-[#1b2233] rounded-xl p-4 flex flex-col justify-between hover:border-slate-700/60 transition-colors">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/25 flex items-center justify-center text-emerald-400 shrink-0 mt-0.5">
<i class="fa-solid fa-camera text-sm"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-1">
<h4 class="text-xs font-bold text-white m-0">Screenshot Capture</h4>
@if($screenshotActive)
<x-pill variant="success" size="sm" icon="fa-solid fa-circle-check">Active</x-pill>
@else
<x-pill variant="danger" size="sm" icon="fa-solid fa-circle-xmark">Stopped</x-pill>
@endif
</div>
<p class="text-[11.5px] text-slate-400 m-0 leading-relaxed">
Captures candidate's desktop automatically on call start & tab switches.
</p>
</div>
@if($isAdmin)
<div class="flex items-center justify-end pt-2 border-t border-white/5">
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST" class="flex items-center gap-2">
@csrf
<label class="relative inline-flex items-center cursor-pointer" title="{{ $screenshotActive ? 'Turn off screenshots' : 'Turn on screenshots' }}">
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ $screenshotActive ? 'checked' : '' }} class="sr-only peer">
<div class="w-8 h-4.5 bg-slate-800 border border-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-indigo-600 peer-checked:border-indigo-500"></div>
</label>
</form>
</div>
@endif
</div>
</div>
<!-- 2. Candidate Onboarding Option Card -->
<div class="bg-[#0e1422] border border-[#1b2233] rounded-xl p-3.5 flex flex-col justify-between gap-3 hover:border-slate-700/60 transition-colors">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-indigo-500/10 border border-indigo-500/25 flex items-center justify-center text-indigo-400 shrink-0 mt-0.5">
<i class="fa-solid fa-user-check text-sm"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-1">
<h4 class="text-xs font-bold text-white m-0">Candidate Onboarding</h4>
@if($onboardingActive)
<x-pill variant="success" size="sm" icon="fa-solid fa-circle-check">Active</x-pill>
@else
<x-pill variant="danger" size="sm" icon="fa-solid fa-circle-xmark">Disabled</x-pill>
@endif
</div>
<p class="text-[11.5px] text-slate-400 m-0 leading-relaxed">
Automatic provisioning checklist and one-click revocation.
</p>
</div>
@if($isAdmin)
<div class="flex items-center justify-end pt-2 border-t border-white/5">
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST" class="flex items-center gap-2">
@csrf
<label class="relative inline-flex items-center cursor-pointer" title="{{ $onboardingActive ? 'Turn off onboarding' : 'Turn on onboarding' }}">
<input type="checkbox" name="onboarding_enabled" value="1" onchange="this.form.submit()" {{ $onboardingActive ? 'checked' : '' }} class="sr-only peer">
<div class="w-8 h-4.5 bg-slate-800 border border-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-indigo-600 peer-checked:border-indigo-500"></div>
</label>
</form>
</div>
@endif
</div>
</div>
</div>

View File

@ -0,0 +1,43 @@
@props([
'interview',
])
<div class="flex items-center gap-2">
<!-- 1. Mic Toggle (Icon Only) -->
<x-button id="btn-toggle-interviewer-mic" onclick="toggleInterviewerMic()" variant="ghost" icon="fa-solid fa-microphone" class="hidden" title="Mic On / Mute"></x-button>
<!-- 2. Cam Toggle (Icon Only) -->
<x-button id="btn-toggle-interviewer-cam" onclick="toggleInterviewerCam()" variant="ghost" icon="fa-solid fa-video" class="hidden" title="Cam On / Off"></x-button>
<!-- 3. Start / Join / Restart Call -->
@if($interview->isExpired() || $interview->call_status === 'ended')
<x-button id="btn-start-call" disabled variant="primary" icon="fa-solid fa-phone-slash" class="opacity-50 cursor-not-allowed pointer-events-none">
Call Ended
</x-button>
@elseif(!$interview->isExpired() && $interview->call_status === 'active')
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
Join Call
</x-button>
@else
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
Start Call
</x-button>
@endif
<!-- 4. Record Call (Silent) -->
<x-button id="btn-start-recording" onclick="startCallRecording()" variant="secondary" icon="fa-solid fa-circle-dot" class="hidden">
Record Call (Silent)
</x-button>
<x-button id="btn-stop-recording" onclick="stopCallRecording()" variant="danger" class="hidden animate-pulse" title="Stop &amp; Save Recording">
<i class="fa-solid fa-square text-xs mr-1.5"></i>
<span>Recording <span id="rec-live-timer" class="font-mono text-xs font-bold">(00:00)</span> - Stop</span>
</x-button>
<!-- 5. Leave Call -->
<x-button id="btn-leave-call" onclick="leaveInterviewerCall()" variant="secondary" icon="fa-solid fa-arrow-right-from-bracket" class="hidden">
Leave Call
</x-button>
<!-- 6. End Call for All (Icon Only, Far Right) -->
<x-button id="btn-end-all-call" onclick="endCallForAll()" variant="danger" icon="fa-solid fa-phone-slash" class="hidden" title="End Call for All"></x-button>
</div>

View File

@ -0,0 +1,87 @@
@props([
'interview',
])
<div class="flex items-center gap-3 min-w-0 flex-wrap sm:flex-nowrap">
<x-hamburger-menu align="left">
<div class="pb-3">
<div class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<i class="fa-solid fa-circle-info text-indigo-400"></i> Session Details
</div>
<div class="grid grid-cols-2 gap-2">
<div class="bg-slate-950/60 border border-slate-800 rounded-lg p-2">
<div class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Submission ID</div>
<div id="modal-cand-uid" class="text-[11px] font-mono text-slate-300 break-all">{{ $interview->submission_unique_id }}</div>
</div>
<div class="bg-slate-950/60 border border-slate-800 rounded-lg p-2">
<div class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Temp Pass</div>
<div class="text-[11px] font-mono text-emerald-400 font-bold">{{ $interview->temp_password }}</div>
</div>
<div class="bg-slate-950/60 border border-slate-800 rounded-lg p-2">
<div class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Language</div>
<div class="text-[11px] font-mono text-slate-300 uppercase font-bold">{{ $interview->language }}</div>
</div>
<div class="bg-slate-950/60 border border-slate-800 rounded-lg p-2">
<div class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Timeline</div>
<div class="text-[11px] font-mono text-slate-300">{{ $interview->formatted_timeline }}</div>
</div>
</div>
</div>
<div class="pt-3 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1 text-xs text-slate-300 cursor-pointer select-none" onclick="toggleSosAutoTrigger()">
<span class="flex items-center gap-2 font-medium">
<span>SOS auto-trigger alert</span>
</span>
<span class="toggle relative inline-flex h-4 w-8 shrink-0 cursor-pointer rounded-full border border-slate-700 bg-slate-900 transition-colors duration-200 ease-in-out [&.on]:bg-emerald-500/20 [&.on]:border-emerald-500/50 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-2.5 after:w-2.5 after:rounded-full after:bg-slate-500 after:transition-all [&.on]:after:left-4.25 [&.on]:after:bg-emerald-400" id="sosToggle"></span>
</div>
<div class="flex items-center justify-between px-1 text-xs text-slate-300 cursor-pointer select-none" onclick="toggleInterviewerNoiseSuppression()">
<span class="flex items-center gap-2 font-medium">
<span>Noise Cancellation</span>
</span>
<span class="toggle relative inline-flex h-4 w-8 shrink-0 cursor-pointer rounded-full border border-slate-700 bg-slate-900 transition-colors duration-200 ease-in-out [&.on]:bg-emerald-500/20 [&.on]:border-emerald-500/50 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-2.5 after:w-2.5 after:rounded-full after:bg-slate-500 after:transition-all [&.on]:after:left-4.25 [&.on]:after:bg-emerald-400 on" id="noiseCancellationToggle"></span>
</div>
<div class="grid grid-cols-3 gap-1.5 pt-1">
<x-button onclick="regenerateCandidatePassword()" variant="secondary" size="sm" icon="fa-solid fa-key" class="w-full justify-center text-[11px]">Password</x-button>
<x-button onclick="openReportPage()" variant="secondary" size="sm" icon="fa-solid fa-file-pdf" class="w-full justify-center text-[11px]">PDF Report</x-button>
<x-button onclick="blockCandidateAction()" variant="danger" size="sm" icon="fa-solid fa-ban" class="w-full justify-center bg-red-500/20 text-red-300 border-red-500/40 text-[11px]">Block</x-button>
</div>
</div>
</x-hamburger-menu>
<!-- Candidate Avatar Circle -->
<div class="w-8 h-8 rounded-full bg-linear-to-br from-indigo-500 to-sky-600 flex items-center justify-center text-white font-bold text-xs shrink-0 shadow-md shadow-indigo-500/20">
{{ $interview->candidate_initials }}
</div>
<!-- Candidate Name & Email -->
<div class="min-w-0">
<h2 id="modal-cand-name" class="text-sm font-semibold text-white m-0 leading-tight truncate">
{{ $interview->candidate_name }}
</h2>
<div class="text-[11px] text-slate-400 font-mono truncate">
{{ $interview->candidate_email }} &middot; {{ $interview->candidate_phone }}
</div>
</div>
<!-- Status Pills (Connected Status & Timeline Status) -->
<div class="flex items-center gap-1.5 shrink-0 ml-1">
<span id="candidate-joined-pill" class="pill gray inline-flex items-center gap-1.5 font-semibold text-[10.5px] px-2 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60">
<i class="fa-solid fa-circle text-[7px] text-slate-500"></i> Not Joined
</span>
@if($interview->isExpired())
<x-pill id="timeline-status-pill" variant="danger" size="sm" icon="fa-solid fa-circle">Expired</x-pill>
@elseif($interview->status === 'completed')
<x-pill id="timeline-status-pill" variant="success" size="sm" icon="fa-solid fa-circle">Completed</x-pill>
@elseif(!$interview->isExpired() && $interview->call_status === 'active')
<x-pill id="timeline-status-pill" variant="info" size="sm" class="animate-pulse" icon="fa-solid fa-circle">Call in progress</x-pill>
@elseif($interview->isActiveTimeline())
<x-pill id="timeline-status-pill" variant="info" size="sm" icon="fa-solid fa-bolt">Timeline live</x-pill>
@else
<x-pill id="timeline-status-pill" variant="warning" size="sm" icon="fa-solid fa-clock">Scheduled</x-pill>
@endif
</div>
</div>

View File

@ -0,0 +1,25 @@
<div id="drawing-modal" class="fixed top-[70px] right-[360px] w-[550px] h-[420px] bg-slate-900 border-2 border-cyan-500 rounded-2xl shadow-2xl hidden flex-col z-50 overflow-hidden">
<div class="bg-slate-800 px-4 py-2.5 flex justify-between items-center border-b border-white/10">
<div class="font-bold text-xs text-white flex items-center gap-2">
<i class="fa-solid fa-pen-ruler text-cyan-400"></i>
<span>Candidate Live Drawing Board</span>
</div>
<div class="flex items-center gap-2">
<input type="color" id="draw-color" value="#38bdf8" class="w-6 h-6 border-none rounded-full cursor-pointer bg-transparent">
<button type="button" onclick="clearCanvasDraw()" class="px-2.5 py-1 text-[11px] font-semibold bg-white/10 hover:bg-white/20 text-white rounded-md border-none cursor-pointer transition-colors flex items-center gap-1">
<i class="fa-solid fa-eraser text-[10px]"></i>
<span>Clear</span>
</button>
<button type="button" onclick="toggleDrawingModal()" class="w-6 h-6 rounded-md bg-slate-700/60 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors border-none">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div class="flex-1 bg-[#050811] relative">
<canvas id="drawing-canvas" width="550" height="340" class="cursor-crosshair block w-full h-full"></canvas>
</div>
<div class="px-3 py-1.5 bg-slate-900 text-[10.5px] text-cyan-400 text-right border-t border-white/5 font-medium flex items-center justify-end gap-1.5">
<i class="fa-solid fa-diagram-project text-[10px]"></i>
<span>Draw architecture / logic diagrams (Auto-synced to Interviewer)</span>
</div>
</div>

View File

@ -0,0 +1,49 @@
@props([
'interview',
])
<header class="h-[60px] bg-slate-900 border-b border-white/10 flex items-center justify-between px-5 z-20 shrink-0">
<div class="flex items-center gap-3">
<div class="font-outfit font-bold text-base text-white flex items-center gap-2">
<i class="fa-solid fa-laptop-code text-indigo-400"></i>
<span>Candidate Assessment Room</span>
</div>
<code class="bg-white/10 px-2.5 py-1 rounded-md text-sky-400 text-xs font-mono">
ID: {{ $interview->submission_unique_id }}
</code>
</div>
<div class="flex items-center gap-2.5">
<x-button type="button" onclick="toggleNotepadModal()" variant="secondary" size="sm" icon="fa-solid fa-note-sticky" class="bg-indigo-500/20 text-indigo-300 border-none hover:bg-indigo-500/30">
Notepad
</x-button>
<x-button type="button" onclick="toggleDrawingModal()" variant="secondary" size="sm" icon="fa-solid fa-pen-ruler" class="bg-cyan-500/20 text-cyan-300 border-none hover:bg-cyan-500/30">
Drawing Board
</x-button>
<div class="flex items-center gap-1.5">
<label class="text-xs text-slate-400 font-medium">Language:</label>
<select id="language-select" class="px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 border border-white/10 text-white outline-none focus:border-indigo-500 transition-all cursor-pointer">
<option value="python" {{ strtolower($interview->language) === 'python' ? 'selected' : '' }}>Python 3</option>
<option value="cpp" {{ strtolower($interview->language) === 'cpp' ? 'selected' : '' }}>C++ (GCC)</option>
<option value="c" {{ strtolower($interview->language) === 'c' ? 'selected' : '' }}>C (GCC)</option>
<option value="java" {{ strtolower($interview->language) === 'java' ? 'selected' : '' }}>Java 15</option>
<option value="php" {{ strtolower($interview->language) === 'php' ? 'selected' : '' }}>PHP 8.2 / Laravel</option>
<option value="javascript" {{ strtolower($interview->language) === 'javascript' ? 'selected' : '' }}>JavaScript (Node.js)</option>
</select>
</div>
<x-button type="button" onclick="runCode()" variant="primary" size="sm" icon="fa-solid fa-play" class="border-none shadow-md shadow-indigo-600/30">
Run Code
</x-button>
<x-button type="button" onclick="submitSolution()" variant="success" size="sm" icon="fa-solid fa-check" class="border-none shadow-md shadow-emerald-600/30">
Submit Solution
</x-button>
<x-button type="button" onclick="candidateLogoutAction()" variant="danger" size="sm" icon="fa-solid fa-right-from-bracket" class="bg-red-500/20 border-none text-red-300 hover:bg-red-500/30">
Logout
</x-button>
</div>
</header>

View File

@ -0,0 +1,35 @@
<div id="candidate-incoming-modal" class="fixed inset-0 w-screen h-screen bg-[#050811]/85 backdrop-blur-md flex items-center justify-center z-[10000] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto">
<div class="max-w-[440px] w-[90%] bg-gradient-to-br from-slate-900 via-slate-900 to-indigo-950 border-2 border-emerald-500 rounded-3xl p-8 text-center text-white shadow-2xl shadow-emerald-500/30">
<div class="relative w-20 h-20 mx-auto mb-5 flex items-center justify-center">
<div class="absolute -inset-3 rounded-full border-2 border-emerald-500/60 animate-ping"></div>
<div class="absolute -inset-6 rounded-full border-2 border-indigo-500/40 animate-pulse"></div>
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-sky-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg shadow-emerald-500/50 z-10">
<i class="fa-solid fa-phone"></i>
</div>
</div>
<div class="text-[11px] uppercase tracking-widest text-emerald-400 font-extrabold mb-1.5 flex items-center justify-center gap-1.5">
<i class="fa-solid fa-bolt"></i>
<span>INCOMING INTERVIEW CALL</span>
</div>
<h3 class="font-outfit text-2xl font-extrabold text-white mb-1">
Interviewer Panel Calling...
</h3>
<div class="text-xs text-indigo-300 mb-6">
The interviewer panel has initiated a live 2-way call.
</div>
<div class="flex gap-3 justify-center">
<button type="button" onclick="declineCandidateCall()" class="flex-1 py-3 px-4 bg-red-500/20 border-none text-red-300 font-bold text-xs rounded-xl hover:bg-red-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
<i class="fa-solid fa-phone-slash"></i>
<span>Decline / Busy</span>
</button>
<button type="button" onclick="acceptCandidateCall()" class="flex-[1.4] py-3 px-4 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white font-extrabold text-xs rounded-xl border-none shadow-lg shadow-emerald-500/50 hover:from-emerald-600 hover:to-emerald-700 transition-all cursor-pointer animate-pulse flex items-center justify-center gap-1.5">
<i class="fa-solid fa-phone"></i>
<span>Accept Call</span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,20 @@
@props([
'notes' => '',
])
<div id="notepad-modal" class="fixed top-[70px] right-[360px] w-[420px] h-[350px] bg-slate-900 border-2 border-indigo-500 rounded-2xl shadow-2xl hidden flex-col z-50 overflow-hidden">
<div class="bg-slate-800 px-4 py-2.5 flex justify-between items-center border-b border-white/10">
<div class="font-bold text-xs text-white flex items-center gap-2">
<i class="fa-solid fa-note-sticky text-indigo-400"></i>
<span>Candidate Notepad (Synced)</span>
</div>
<button type="button" onclick="toggleNotepadModal()" class="w-6 h-6 rounded-md bg-slate-700/60 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors border-none">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<textarea id="notepad-textarea" oninput="saveNotepadContent()" placeholder="Type rough notes, pseudo-code, or thoughts here..." class="flex-1 w-full p-3.5 bg-slate-950 text-slate-200 border-none outline-none font-mono text-xs resize-none placeholder-slate-600 leading-relaxed">{{ $notes }}</textarea>
<div class="px-3 py-1.5 bg-slate-900 text-[10.5px] text-emerald-400 text-right border-t border-white/5 font-medium flex items-center justify-end gap-1.5">
<i class="fa-solid fa-circle-check text-[10px]"></i>
<span>Auto-saved to interviewer portal</span>
</div>
</div>

View File

@ -0,0 +1,112 @@
@props([
'interview',
])
<aside class="w-[340px] bg-slate-900/90 border-l border-white/10 p-4 flex flex-col gap-4 overflow-y-auto shrink-0 select-none">
<!-- Candidate Camera Feed -->
<div id="cand-self-video-box" class="w-full aspect-[16/10] bg-black rounded-xl border-2 border-white/15 overflow-hidden relative shrink-0 transition-all duration-200">
<video id="webcam" autoplay muted playsinline class="w-full h-full object-cover -scale-x-100"></video>
<div id="webcam-avatar-placeholder" class="absolute inset-0 hidden flex-col items-center justify-center bg-slate-900 text-white z-10">
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-indigo-500 to-sky-600 flex items-center justify-center text-xl font-bold font-outfit shadow-lg shadow-indigo-500/40">
{{ $interview->candidate_initials }}
</div>
<div class="text-[11px] text-slate-400 mt-1.5 font-medium">Camera Disabled</div>
</div>
<div class="absolute bottom-2 left-2 bg-black/70 px-2 py-0.5 rounded text-[10.5px] text-emerald-400 font-semibold z-10 flex items-center gap-1.5 backdrop-blur-xs">
<span>Candidate (You)</span>
<span class="opacity-40">|</span>
<i id="cand-self-mic-icon" class="fa-solid fa-microphone text-emerald-400 text-[10px]" title="Mic On"></i>
<i id="cand-self-cam-icon" class="fa-solid fa-video text-emerald-400 text-[10px]" title="Camera On"></i>
</div>
</div>
<!-- Remote Interviewer Panelist Video Grid (Multi-Party WebRTC Mesh) -->
<div id="interviewer-mesh-grid" class="flex flex-col gap-2.5 w-full">
<div id="interviewer-placeholder-box" class="w-full aspect-[16/10] bg-black rounded-xl border-2 border-indigo-500/40 overflow-hidden relative shrink-0 transition-all duration-200">
<video id="interviewer-video-default" autoplay playsinline class="w-full h-full object-cover bg-slate-950 hidden -scale-x-100"></video>
<div id="interviewer-video-placeholder" class="absolute inset-0 flex flex-col items-center justify-center bg-slate-900/95 text-slate-400 text-xs text-center p-2.5 z-10">
<div id="interviewer-avatar-circle" class="w-14 h-14 rounded-full bg-gradient-to-br from-sky-400 to-indigo-500 flex items-center justify-center text-xl font-bold font-outfit text-white mb-1.5 shadow-lg shadow-sky-500/30">
IV
</div>
<div id="call-status-sub" class="text-xs text-slate-200 font-semibold">Interviewer Panel</div>
<div class="text-[10.5px] text-sky-400 mt-0.5 font-medium">Waiting for interviewer panelist to join call...</div>
</div>
<div class="absolute bottom-2 left-2 bg-indigo-600/90 px-2 py-0.5 rounded text-[10.5px] text-white font-semibold z-10 backdrop-blur-xs flex items-center gap-1">
<i class="fa-solid fa-microphone-lines text-[9px]"></i>
<span>Interviewer / Panelist</span>
</div>
</div>
</div>
<!-- Proctor Canvas for Hidden AI Analysis -->
<canvas id="proctor-canvas" class="hidden" width="320" height="240"></canvas>
<!-- Candidate Profile Info -->
<div class="bg-white/[0.03] border border-white/10 rounded-xl p-3.5 text-xs leading-relaxed">
<div class="font-bold text-white mb-1.5 flex items-center gap-1.5">
<i class="fa-solid fa-user-tie text-indigo-400"></i>
<span>Candidate Profile</span>
</div>
<div class="text-slate-400">Name: <strong class="text-white">{{ $interview->candidate_name }}</strong></div>
<div class="text-slate-400">Email: <span class="text-white font-mono text-[11.5px]">{{ $interview->candidate_email }}</span></div>
<div class="text-slate-400 mt-1.5">Access Expires: <span class="text-red-400 font-semibold">{{ $interview->expires_at->format('H:i:s T') }}</span></div>
</div>
<!-- Real-Time Call Controls (Mic, Camera, Screen Sharing) -->
<div class="bg-indigo-500/10 border border-indigo-500/30 rounded-xl p-3.5">
<div class="text-xs font-bold text-white mb-2.5 flex items-center justify-between">
<span class="flex items-center gap-1.5">
<i class="fa-solid fa-phone text-indigo-400"></i>
<span>Real-Time Interview Call</span>
</span>
<span id="call-status-badge" class="text-[10px] font-bold bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded-full">READY</span>
</div>
<button type="button" id="candidate-join-active-call-btn" onclick="acceptCandidateCall()" class="hidden w-full p-2.5 mb-2.5 text-xs font-bold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-lg border-none shadow-lg shadow-emerald-500/30 cursor-pointer animate-pulse">
<i class="fa-solid fa-phone-volume mr-1.5"></i> Call in Progress &bull; Join Call Now
</button>
<div class="flex gap-2 mb-2.5">
<button type="button" id="toggle-mic-btn" onclick="toggleMic()" class="flex-1 py-2 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border-none text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
<i class="fa-solid fa-microphone"></i>
<span>Mic On</span>
</button>
<button type="button" id="toggle-cam-btn" onclick="toggleCam()" class="flex-1 py-2 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border-none text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
<i class="fa-solid fa-video"></i>
<span>Cam On</span>
</button>
<button type="button" id="leave-call-btn" onclick="candidateLeaveCall()" class="py-2 px-2.5 text-xs font-semibold rounded-lg bg-red-500/20 border-none text-red-300 hover:bg-red-500/30 transition-all cursor-pointer flex items-center justify-center gap-1" title="Leave 2-Way Call">
<i class="fa-solid fa-phone-slash"></i>
<span>Leave</span>
</button>
</div>
<!-- Noise Cancellation Toggle -->
<button type="button" id="toggle-candidate-noise-btn" onclick="toggleCandidateNoiseSuppression()" class="w-full mb-2.5 py-1.5 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border-none text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5" title="Noise Cancellation - Filter background keyboard and fan noise">
<i class="fa-solid fa-wand-magic-sparkles text-[11px]"></i>
<span>Noise Cancellation: ON</span>
</button>
<button type="button" id="share-screen-btn" onclick="startScreenShare()" class="w-full py-2.5 px-3 text-xs font-bold bg-indigo-500/30 text-white rounded-lg border-none transition-all cursor-pointer flex items-center justify-center gap-1.5">
<i class="fa-solid fa-desktop"></i>
<span>Share Screen with Interviewer</span>
</button>
</div>
<!-- 2-Way Real-Time Chat Container with Interviewer/Panelist -->
<div class="bg-slate-900/80 border border-white/10 rounded-xl p-3">
<div class="text-xs font-bold text-white mb-2 flex items-center gap-1.5">
<i class="fa-solid fa-comments text-indigo-400"></i>
<span>Live Chat with Interviewer</span>
</div>
<div id="candidate-chat-history" class="bg-[#050811] border border-white/10 rounded-lg p-2 h-[130px] overflow-y-auto text-xs mb-2 flex flex-col gap-1">
<div class="text-slate-500 italic text-center pt-10 text-[11px]">No messages yet. Send a message below.</div>
</div>
<div class="flex gap-1.5">
<input type="text" id="candidate-chat-input" placeholder="Type message to panelist..." onkeydown="if(event.key==='Enter') sendCandidateMessage()" class="flex-1 text-xs bg-slate-950 text-white border border-white/10 rounded-lg px-2.5 py-1.5 outline-none focus:border-indigo-500 placeholder-slate-500">
<x-button type="button" onclick="sendCandidateMessage()" variant="primary" size="sm" icon="fa-solid fa-paper-plane" class="border-none">
Send
</x-button>
</div>
</div>
</aside>

View File

@ -0,0 +1,10 @@
<div class="h-[220px] bg-[#050811] border-t border-white/10 flex flex-col shrink-0">
<div class="h-9 bg-[#090e1a] border-b border-white/10 flex items-center justify-between px-4 text-xs font-semibold text-slate-400">
<span class="flex items-center gap-1.5">
<i class="fa-solid fa-terminal text-slate-500"></i>
<span>EXECUTION OUTPUT TERMINAL (STDOUT / STDERR)</span>
</span>
<span id="run-status" class="text-slate-400">Ready</span>
</div>
<div id="terminal-output" class="flex-1 p-3.5 font-['Fira_Code',monospace] text-xs text-emerald-400 overflow-y-auto whitespace-pre-wrap leading-relaxed">// Press 'Run Code' to execute candidate solution live...</div>
</div>

View File

@ -0,0 +1,36 @@
<x-modal id="incoming-call-modal" maxWidth="md">
<div class="p-4 sm:p-6 text-center">
<div class="relative w-20 h-20 mx-auto mb-5 flex items-center justify-center">
<div class="absolute -inset-3 rounded-full border-2 border-emerald-500/60 animate-ping"></div>
<div class="absolute -inset-6 rounded-full border-2 border-indigo-500/40 animate-pulse"></div>
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-sky-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg shadow-emerald-500/50 z-10">
<i class="fa-solid fa-phone"></i>
</div>
</div>
<div class="text-[11px] uppercase tracking-widest text-emerald-400 font-bold mb-1.5">
Incoming Assessment Call
</div>
<h3 id="incoming-cand-name" class="font-outfit text-2xl font-extrabold text-white mb-1">
Candidate Name
</h3>
<div id="incoming-starter-info" class="text-xs text-sky-400 mb-4">
Initiated by Panelist
</div>
<div class="bg-white/5 border border-white/10 rounded-xl p-3 text-xs text-slate-300 mb-6 leading-relaxed">
Another interviewer has started calling the candidate. Click <strong>Accept Call</strong> to join live, or <strong>Decline</strong> if busy.
</div>
<div class="flex gap-3 justify-center">
<x-button id="incoming-decline-btn" onclick="declineIncomingCall()" variant="danger" size="md" icon="fa-solid fa-xmark" class="flex-1">
Decline / Miss
</x-button>
<x-button id="incoming-accept-btn" onclick="acceptIncomingCall()" variant="success" size="md" icon="fa-solid fa-phone" class="flex-[1.3] font-extrabold animate-pulse">
Accept Call
</x-button>
</div>
</div>
</x-modal>

View File

@ -0,0 +1,116 @@
@props([
'interview',
])
<details id="inspector-details" class="relative group select-none">
<!-- Summary Header Bar -->
<summary class="list-none [&::-webkit-details-marker]:hidden bg-[#0e1422] border border-[#1b2233] rounded-xl p-3 flex items-center justify-between gap-2 cursor-pointer transition-all hover:border-indigo-500/40">
<div id="inspector-active-tab-title" class="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-2">
<i id="inspector-active-tab-icon" class="fa-solid fa-layer-group text-indigo-400"></i>
<span id="inspector-active-tab-text">Inspector Panel</span>
</div>
<div class="flex items-center gap-2">
<!-- Button Toolbar -->
<div class="inline-flex items-center gap-1 p-0.5 bg-[#090d16] border border-[#1b2233] rounded-lg">
<button
type="button"
id="tab-btn-logs"
title="Proctoring Logs"
onclick="switchIdeTab('logs', 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"
>
<i class="fa-solid fa-shield-halved"></i>
</button>
<button
type="button"
id="tab-btn-notes"
title="Candidate Notepad"
onclick="switchIdeTab('notes', 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"
>
<i class="fa-solid fa-note-sticky"></i>
</button>
<button
type="button"
id="tab-btn-drawing"
title="Candidate Drawing Board"
onclick="switchIdeTab('drawing', 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"
>
<i class="fa-solid fa-pen"></i>
</button>
<button
type="button"
id="tab-btn-recordings"
title="Saved Video Recordings"
onclick="switchIdeTab('recordings', 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"
>
<i class="fa-solid fa-film"></i>
</button>
<button
type="button"
id="tab-btn-screenshots"
title="Tab Switch Screenshots"
onclick="switchIdeTab('screenshots', 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"
>
<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>
<!-- Floating Inspector Content Overlay -->
<div id="inspector-content-container" class="absolute top-[calc(100%+8px)] right-0 left-0 lg:left-auto lg:w-112.5 bg-[#0e1422] border border-[#1b2233] rounded-xl p-3 shadow-2xl shadow-black/80 backdrop-blur-md z-50 flex flex-col max-h-125 overflow-hidden">
<!-- Floating Header -->
<div class="flex items-center justify-between mb-2">
<div id="inspector-floating-title" class="text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-2">
<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"
title="Close Inspector"
onclick="closeInspectorPanel()"
class="w-6 h-6 rounded-md bg-white/5 hover:bg-white/15 text-slate-400 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors"
>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<!-- Floating Tab Content Panes -->
<div class="flex-1 overflow-y-auto">
<div id="tab-content-logs" class="hidden">
<x-audit-log id="modal-logs-display" :logs="$interview->proctor_logs ?? []" class="h-75 overflow-y-auto" />
</div>
<x-interview.tabs.notes-tab />
<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>

View File

@ -0,0 +1,35 @@
@props([
'activeNav' => 'interview',
])
<header class="h-17.5 bg-slate-900/80 backdrop-blur-md border-b border-slate-700/60 flex items-center justify-between px-6">
<div class="flex items-center gap-3">
<a href="{{ route('interview.index') }}" class="w-9 h-9 rounded-lg bg-linear-to-br from-indigo-500 to-sky-600 flex items-center justify-center font-bold text-white shadow-md shadow-indigo-500/30 no-underline">
<i class="fa-solid fa-bolt text-lg"></i>
</a>
<div>
<h1 class="font-outfit text-lg font-bold text-white m-0 leading-tight">Candidate Live Assessment Portal</h1>
<div class="text-xs text-slate-400">Proctored IDE & Code Submissions</div>
</div>
</div>
<div class="flex items-center gap-3">
@if(Auth::check() && Auth::user()->isAdmin())
<a href="{{ route('admin.dashboard') }}">
<x-button variant="ghost" size="sm" icon="fa-solid fa-sliders" class="text-indigo-400 border-indigo-500/40">Control Panel</x-button>
</a>
@endif
<a href="{{ route('onboarding.index') }}">
<x-button variant="secondary" size="sm" icon="fa-solid fa-rocket">Onboarding Hub</x-button>
</a>
<a href="{{ route('dashboard') }}">
<x-button variant="secondary" size="sm" icon="fa-solid fa-user">User Portal</x-button>
</a>
<form action="{{ route('logout') }}" method="POST" id="logout-form" class="inline">
@csrf
<x-button type="button" onclick="interviewerLogoutAction()" variant="danger" size="sm" icon="fa-solid fa-right-from-bracket" class="bg-red-500/20 text-red-300 border-red-500/40 font-semibold">
Logout
</x-button>
</form>
</div>
</header>

Some files were not shown because too many files have changed in this diff Show More