Add employee presence with kiosk sign-in, QR badges, and mobile step-out.
Deploy Ladill Frontdesk / deploy (push) Failing after 26s

Enables staff roster, PIN/code kiosk flow, attendance reports, evacuation staff roll call, webhooks, branch mismatch warnings, and a linked-user My presence portal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-28 21:11:18 +00:00
co-authored by Cursor
parent 905d7268e4
commit 890c2c71e3
40 changed files with 2613 additions and 53 deletions
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\EmployeePresence;
use App\Services\Frontdesk\NotificationDispatcher;
use Illuminate\Console\Command;
class EmployeeReturnAlertsCommand extends Command
{
protected $signature = 'frontdesk:employee-return-alerts';
protected $description = 'Alert staff when employees are overdue returning from step-out';
public function handle(NotificationDispatcher $notifications): int
{
$overdue = EmployeePresence::query()
->where('status', EmployeePresence::STATUS_STEPPED_OUT)
->whereNotNull('expected_return_at')
->where('expected_return_at', '<', now())
->whereNull('return_alert_sent_at')
->with(['employee.organization'])
->get();
$count = 0;
foreach ($overdue as $presence) {
$notifications->employeeOverdueReturn($presence);
$presence->update(['return_alert_sent_at' => now()]);
$count++;
}
$this->info("Sent {$count} overdue return alert(s).");
return self::SUCCESS;
}
}
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Employee;
use App\Models\EmployeePresence;
use App\Models\Visit;
use App\Services\Frontdesk\OrganizationResolver;
use Illuminate\Http\Request;
@@ -26,15 +28,27 @@ class DashboardController extends Controller
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$cacheKey = "fd:dashboard:{$owner}:{$organization->id}:".($branchScope ?? 'all');
$stats = Cache::remember($cacheKey, 60, function () use ($visitQuery) {
$stats = Cache::remember($cacheKey, 60, function () use ($visitQuery, $owner, $organization, $branchScope) {
$today = (clone $visitQuery)->where(function ($q) {
$q->whereDate('checked_in_at', today())
->orWhereDate('scheduled_at', today());
});
$employeeIds = Employee::owned($owner)
->where('organization_id', $organization->id)
->where('active', true);
if ($branchScope !== null) {
$employeeIds->where('branch_id', $branchScope);
}
$employeeIds = $employeeIds->pluck('id');
$presenceQuery = EmployeePresence::query()->whereIn('employee_id', $employeeIds);
return [
'visitors_today' => (clone $today)->count(),
'currently_inside' => (clone $visitQuery)->currentlyInside()->count(),
'staff_on_site' => (clone $presenceQuery)->where('status', EmployeePresence::STATUS_ON_SITE)->count(),
'staff_stepped_out' => (clone $presenceQuery)->where('status', EmployeePresence::STATUS_STEPPED_OUT)->count(),
'expected_arrivals' => (clone $visitQuery)->whereIn('status', [
Visit::STATUS_EXPECTED,
Visit::STATUS_SCHEDULED,
@@ -83,6 +97,19 @@ class DashboardController extends Controller
->limit(10)
->get();
return view('frontdesk.dashboard', compact('stats', 'currentVisitors', 'expectedVisitors', 'pendingApprovals', 'organization'));
$staffSteppedOut = EmployeePresence::query()
->where('status', EmployeePresence::STATUS_STEPPED_OUT)
->whereHas('employee', function ($q) use ($owner, $organization, $request) {
$q->owned($owner)->where('organization_id', $organization->id)->where('active', true);
$this->scopeToBranch($request, $q, 'branch_id');
})
->with('employee')
->orderByDesc('last_event_at')
->limit(10)
->get();
return view('frontdesk.dashboard', compact(
'stats', 'currentVisitors', 'expectedVisitors', 'pendingApprovals', 'staffSteppedOut', 'organization',
));
}
}
@@ -0,0 +1,323 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Employee;
use App\Models\EmployeePresenceEvent;
use App\Models\Host;
use App\Services\Frontdesk\EmployeePresenceService;
use App\Services\Frontdesk\QrCodeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EmployeeController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'employees.view');
$organization = $this->organization($request);
$employees = Employee::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with(['branch', 'presence'])
->orderBy('full_name');
$this->scopeToBranch($request, $employees);
$employees = $employees->paginate(25);
return view('frontdesk.employees.index', compact('employees', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'employees.manage');
$organization = $this->organization($request);
return view('frontdesk.employees.create', [
'organization' => $organization,
'branches' => $this->branches($request, $organization->id),
'hosts' => $this->hosts($request, $organization->id),
]);
}
public function store(Request $request, EmployeePresenceService $presence): RedirectResponse
{
$this->authorizeAbility($request, 'employees.manage');
$organization = $this->organization($request);
$validated = $this->validatedEmployee($request);
$pin = $validated['pin'];
unset($validated['pin']);
$employee = Employee::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
'pin_hash' => $presence->hashPin($pin),
...$this->employeePayload($validated),
]);
$presence->presenceFor($employee);
return redirect()->route('frontdesk.employees.index')->with('success', 'Employee added.');
}
public function edit(Request $request, Employee $employee): View
{
$this->authorizeAbility($request, 'employees.manage');
$this->authorizeOwner($request, $employee);
return view('frontdesk.employees.edit', [
'employee' => $employee->load('presence'),
'branches' => $this->branches($request, $employee->organization_id),
'hosts' => $this->hosts($request, $employee->organization_id),
]);
}
public function update(Request $request, Employee $employee, EmployeePresenceService $presence): RedirectResponse
{
$this->authorizeAbility($request, 'employees.manage');
$this->authorizeOwner($request, $employee);
$validated = $this->validatedEmployee($request, updating: true);
$payload = $this->employeePayload($validated);
if (! empty($validated['pin'])) {
$payload['pin_hash'] = $presence->hashPin($validated['pin']);
}
$employee->update([
...$payload,
'active' => $request->boolean('active', true),
]);
return redirect()->route('frontdesk.employees.index')->with('success', 'Employee updated.');
}
public function destroy(Request $request, Employee $employee): RedirectResponse
{
$this->authorizeAbility($request, 'employees.manage');
$this->authorizeOwner($request, $employee);
$employee->delete();
return redirect()->route('frontdesk.employees.index')->with('success', 'Employee removed.');
}
public function importForm(Request $request): View
{
$this->authorizeAbility($request, 'employees.manage');
$organization = $this->organization($request);
return view('frontdesk.employees.import', compact('organization'));
}
public function import(Request $request, EmployeePresenceService $presence): RedirectResponse
{
$this->authorizeAbility($request, 'employees.manage');
$organization = $this->organization($request);
$request->validate([
'csv' => ['required', 'file', 'mimes:csv,txt', 'max:2048'],
]);
$handle = fopen($request->file('csv')->getRealPath(), 'r');
if ($handle === false) {
return back()->with('error', 'Could not read the CSV file.');
}
$header = fgetcsv($handle);
if ($header === false) {
fclose($handle);
return back()->with('error', 'CSV file is empty.');
}
$header = array_map(fn ($col) => strtolower(trim((string) $col)), $header);
$required = ['employee_code', 'full_name', 'pin'];
foreach ($required as $column) {
if (! in_array($column, $header, true)) {
fclose($handle);
return back()->with('error', "CSV must include columns: employee_code, full_name, pin.");
}
}
$ownerRef = $this->ownerRef($request);
$imported = 0;
$rowNumber = 1;
while (($row = fgetcsv($handle)) !== false) {
$rowNumber++;
if ($row === [null] || $row === []) {
continue;
}
$data = array_combine($header, array_pad($row, count($header), null));
if ($data === false) {
continue;
}
$code = trim((string) ($data['employee_code'] ?? ''));
$name = trim((string) ($data['full_name'] ?? ''));
$pin = trim((string) ($data['pin'] ?? ''));
if ($code === '' || $name === '' || $pin === '') {
continue;
}
if (! preg_match('/^\d{4,6}$/', $pin)) {
continue;
}
$branchId = null;
if (! empty($data['branch'])) {
$branchId = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->where('name', trim((string) $data['branch']))
->value('id');
}
$employee = Employee::query()->updateOrCreate(
[
'organization_id' => $organization->id,
'employee_code' => $code,
],
[
'owner_ref' => $ownerRef,
'full_name' => $name,
'department' => trim((string) ($data['department'] ?? '')) ?: null,
'email' => trim((string) ($data['email'] ?? '')) ?: null,
'phone' => trim((string) ($data['phone'] ?? '')) ?: null,
'branch_id' => $branchId,
'pin_hash' => $presence->hashPin($pin),
'active' => true,
],
);
$presence->presenceFor($employee);
$imported++;
}
fclose($handle);
return redirect()->route('frontdesk.employees.index')->with('success', "{$imported} employee(s) imported.");
}
public function badge(Request $request, Employee $employee, QrCodeService $qr): View
{
$this->authorizeAbility($request, 'employees.view');
$this->authorizeOwner($request, $employee);
return view('frontdesk.employees.badge', [
'employee' => $employee->load('organization'),
'qrSvg' => $qr->employeeSvg($employee),
]);
}
public function exportAttendance(Request $request): StreamedResponse
{
$this->authorizeAbility($request, 'reports.export');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
[$from, $to] = $this->dateRange($request);
$branchId = app(\App\Services\Frontdesk\OrganizationResolver::class)->branchScope($this->member($request));
$query = EmployeePresenceEvent::owned($owner)
->where('organization_id', $organization->id)
->whereBetween('created_at', [$from, $to])
->with(['employee'])
->orderBy('created_at');
if ($branchId) {
$query->where('branch_id', $branchId);
}
$events = $query->get();
$filename = 'employee-attendance-'.$from->format('Y-m-d').'-'.$to->format('Y-m-d').'.csv';
return response()->streamDownload(function () use ($events) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Date', 'Time', 'Employee', 'Code', 'Department', 'Event', 'Destination', 'Notes']);
foreach ($events as $event) {
fputcsv($handle, [
$event->created_at->toDateString(),
$event->created_at->format('H:i'),
$event->employee->full_name,
$event->employee->employee_code,
$event->employee->department ?? '',
config('frontdesk.employee_presence_events.'.$event->event, $event->event),
$event->destination ?? '',
$event->notes ?? '',
]);
}
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
/** @return array{0: Carbon, 1: Carbon} */
protected function dateRange(Request $request): array
{
$from = $request->filled('from')
? Carbon::parse($request->string('from'))->startOfDay()
: now()->subDays(30)->startOfDay();
$to = $request->filled('to')
? Carbon::parse($request->string('to'))->endOfDay()
: now()->endOfDay();
return [$from, $to];
}
/** @return array<string, mixed> */
protected function validatedEmployee(Request $request, bool $updating = false): array
{
$pinRules = $updating
? ['nullable', 'string', 'regex:/^\d{4,6}$/']
: ['required', 'string', 'regex:/^\d{4,6}$/'];
return $request->validate([
'employee_code' => ['required', 'string', 'max:32'],
'full_name' => ['required', 'string', 'max:255'],
'department' => ['nullable', 'string', 'max:255'],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'pin' => $pinRules,
'branch_id' => ['nullable', 'integer'],
'host_id' => ['nullable', 'integer'],
'user_ref' => ['nullable', 'string', 'max:64'],
]);
}
protected function employeePayload(array $validated): array
{
$payload = $validated;
unset($payload['pin']);
return array_filter($payload, fn ($value) => $value !== null && $value !== '');
}
protected function branches(Request $request, int $organizationId)
{
return Branch::owned($this->ownerRef($request))
->where('organization_id', $organizationId)
->where('is_active', true)
->orderBy('name')
->get();
}
protected function hosts(Request $request, int $organizationId)
{
return Host::owned($this->ownerRef($request))
->where('organization_id', $organizationId)
->orderBy('name')
->get();
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Employee;
use App\Services\Frontdesk\EmployeePresenceService;
use App\Services\Frontdesk\OrganizationResolver;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmployeePortalController extends Controller
{
use ScopesToAccount;
public function index(Request $request, EmployeePresenceService $presence): View
{
$employee = $this->resolveLinkedEmployee($request);
$organization = $this->organization($request);
$presence->presenceFor($employee);
$employee->load(['presence', 'branch']);
return view('frontdesk.employee-portal.index', [
'employee' => $employee,
'organization' => $organization,
'stepOutReasons' => config('frontdesk.employee_step_out_reasons'),
'statusLabel' => config('frontdesk.employee_presence_statuses.'.$employee->presence->status, $employee->presence->status),
]);
}
public function action(Request $request, EmployeePresenceService $presence): RedirectResponse
{
$employee = $this->resolveLinkedEmployee($request);
$validated = $request->validate([
'action' => ['required', 'string', 'in:step_out,step_in,sign_out'],
'step_out_reason' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('frontdesk.employee_step_out_reasons')))],
'destination' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:500'],
'expected_return_at' => ['nullable', 'date'],
]);
match ($validated['action']) {
'step_out' => $presence->stepOut($employee, [
'step_out_reason' => $validated['step_out_reason'] ?? 'other',
'destination' => $validated['destination'] ?? null,
'notes' => $validated['notes'] ?? null,
'expected_return_at' => $validated['expected_return_at'] ?? null,
]),
'step_in' => $presence->stepIn($employee),
'sign_out' => $presence->signOut($employee),
default => null,
};
$message = match ($validated['action']) {
'step_out' => 'Stepped out recorded.',
'step_in' => 'Welcome back!',
'sign_out' => 'Signed out for the day.',
default => 'Done.',
};
return redirect()->route('frontdesk.employee.portal')->with('success', $message);
}
protected function resolveLinkedEmployee(Request $request): Employee
{
$employee = app(OrganizationResolver::class)->employeeFor($request->user());
if (! $employee) {
throw new HttpResponseException(
redirect()
->route('frontdesk.dashboard')
->with('error', 'No employee profile is linked to your account. Ask an administrator to link your Ladill user on the Employees screen.'),
);
}
$this->authorizeOwner($request, $employee);
return $employee;
}
}
@@ -36,6 +36,11 @@ class KioskController extends Controller
'typeConfigs' => $visitorTypes->configsForFrontend(),
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
'visitorPolicy' => $settings['visitor_policy'] ?? null,
// Staff sign-in requires a registered kiosk device (/kiosk/d/{token}).
'employeeKioskEnabled' => false,
'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons'))
->map(fn ($label, $key) => ['key' => $key, 'label' => $label])
->values(),
]);
}
@@ -6,11 +6,13 @@ use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\Host;
use App\Models\Organization;
use App\Services\Frontdesk\EmployeePresenceService;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitorTypeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Validation\ValidationException;
class KioskDeviceController extends Controller
{
@@ -32,6 +34,8 @@ class KioskDeviceController extends Controller
$hosts = $hosts->orderBy('name')->get();
$employeeKioskEnabled = (bool) ($settings['employee_kiosk_enabled'] ?? true);
return view('frontdesk.kiosk.index', [
'organization' => $organization,
'hosts' => $hosts,
@@ -40,9 +44,76 @@ class KioskDeviceController extends Controller
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
'visitorPolicy' => $settings['visitor_policy'] ?? null,
'checkInUrl' => route('frontdesk.kiosk.device.check-in', $device->device_token),
'employeeKioskEnabled' => $employeeKioskEnabled,
'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons'))
->map(fn ($label, $key) => ['key' => $key, 'label' => $label])
->values(),
'staffActionUrl' => route('frontdesk.kiosk.device.staff.action', $device->device_token),
]);
}
public function staffAction(Request $request, EmployeePresenceService $presence): JsonResponse
{
$device = $this->device($request);
$organization = Organization::findOrFail($device->organization_id);
$validated = $request->validate([
'employee_code' => ['nullable', 'string', 'max:32'],
'qr_token' => ['nullable', 'string', 'max:500'],
'pin' => ['required', 'string', 'regex:/^\d{4,6}$/'],
'action' => ['required', 'string', 'in:identify,sign_in,sign_out,step_out,step_in'],
'step_out_reason' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('frontdesk.employee_step_out_reasons')))],
'destination' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:500'],
'expected_return_at' => ['nullable', 'date'],
'confirm_branch_mismatch' => ['boolean'],
]);
if (empty($validated['employee_code']) && empty($validated['qr_token'])) {
throw ValidationException::withMessages([
'credentials' => 'Enter your employee code or scan your badge.',
]);
}
$employee = $presence->resolveEmployee($organization, $validated);
if ($validated['action'] === 'identify') {
return response()->json([
'employee' => $presence->employeePayload($employee),
]);
}
match ($validated['action']) {
'sign_in' => $result = $presence->signIn($employee, $device, (bool) ($validated['confirm_branch_mismatch'] ?? false)),
'sign_out' => $presence->signOut($employee, $device),
'step_out' => $presence->stepOut($employee, [
'step_out_reason' => $validated['step_out_reason'] ?? 'other',
'destination' => $validated['destination'] ?? null,
'notes' => $validated['notes'] ?? null,
'expected_return_at' => $validated['expected_return_at'] ?? null,
], $device),
'step_in' => $presence->stepIn($employee, $device),
default => null,
};
$response = [
'employee' => $presence->employeePayload($employee->fresh()),
'message' => match ($validated['action']) {
'sign_in' => 'Signed in successfully.',
'sign_out' => 'Signed out for the day.',
'step_out' => 'Stepped out recorded.',
'step_in' => 'Welcome back!',
default => 'Done.',
},
];
if ($validated['action'] === 'sign_in' && isset($result['warning']) && $result['warning']) {
$response['warning'] = $result['warning'];
}
return response()->json($response);
}
public function checkIn(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): JsonResponse
{
$device = $this->device($request);
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Visit;
use App\Services\Frontdesk\BadgeRenderService;
use App\Services\Frontdesk\EmployeeReportService;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\OrganizationResolver;
use App\Services\Frontdesk\ReportService;
@@ -18,7 +19,7 @@ class ReportController extends Controller
{
use ScopesToAccount;
public function index(Request $request, ReportService $reports): View
public function index(Request $request, ReportService $reports, EmployeeReportService $employeeReports): View
{
$this->authorizeAbility($request, 'reports.view');
$organization = $this->organization($request);
@@ -37,6 +38,9 @@ class ReportController extends Controller
'frequentVisitors' => $reports->frequentVisitors($owner, $organization),
'security' => $reports->securityIncidents($owner, $organization, $from, $to),
'dailyCounts' => $reports->dailyCounts($owner, $organization, $from, $to, $branchId),
'employeeSummary' => $employeeReports->summary($owner, $organization, $from, $to, $branchId),
'employeeAttendance' => $employeeReports->attendanceLog($owner, $organization, $from, $to, $branchId),
'employeeStepOuts' => $employeeReports->stepOutLog($owner, $organization, $from, $to, $branchId),
'canExport' => app(FrontdeskPermissions::class)->can($this->member($request), 'reports.export'),
]);
}
@@ -4,11 +4,13 @@ namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\EmployeePresence;
use App\Models\Visit;
use App\Services\Frontdesk\VisitCheckOutService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SecurityController extends Controller
{
@@ -27,10 +29,13 @@ class SecurityController extends Controller
$this->scopeToBranch($request, $occupancy);
$occupancy = $occupancy->orderBy('checked_in_at')->get();
$staffOnSite = $this->staffOccupancy($request, $owner, $organization);
$expiredBadges = $occupancy->filter(fn (Visit $v) => $v->isBadgeExpired());
return view('frontdesk.security.index', [
'occupancy' => $occupancy,
'staffOnSite' => $staffOnSite,
'expiredBadges' => $expiredBadges,
'organization' => $organization,
'canCheckout' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
@@ -51,7 +56,59 @@ class SecurityController extends Controller
$this->scopeToBranch($request, $occupancy);
$occupancy = $occupancy->orderBy('checked_in_at')->get();
return view('frontdesk.security.evacuation', compact('occupancy', 'organization'));
$staffOnSite = $this->staffOccupancy($request, $owner, $organization, withBranch: true);
return view('frontdesk.security.evacuation', compact('occupancy', 'staffOnSite', 'organization'));
}
public function evacuationExport(Request $request): StreamedResponse
{
$this->authorizeAbility($request, 'security.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$occupancy = Visit::owned($owner)
->where('organization_id', $organization->id)
->currentlyInside()
->with(['visitor', 'host', 'branch']);
$this->scopeToBranch($request, $occupancy);
$occupancy = $occupancy->orderBy('checked_in_at')->get();
$staffOnSite = $this->staffOccupancy($request, $owner, $organization, withBranch: true);
$filename = 'evacuation-'.now()->format('Y-m-d-His').'.csv';
return response()->streamDownload(function () use ($occupancy, $staffOnSite) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Type', 'Name', 'Detail', 'Branch', 'Since', 'Phone', 'Status']);
foreach ($occupancy as $visit) {
fputcsv($handle, [
'Visitor',
$visit->visitor->full_name,
ucfirst(str_replace('_', ' ', $visit->visitor_type)).($visit->host ? ' · Host: '.$visit->host->name : ''),
$visit->branch?->name ?? '—',
$visit->checked_in_at?->format('Y-m-d g:i A') ?? '—',
$visit->visitor->phone ?? '—',
'On site',
]);
}
foreach ($staffOnSite as $presence) {
fputcsv($handle, [
'Staff',
$presence->employee->full_name,
$presence->employee->department ?? '—',
$presence->branch?->name ?? $presence->employee->branch?->name ?? '—',
$presence->signed_in_at?->format('Y-m-d g:i A') ?? '—',
$presence->employee->phone ?? '—',
config('frontdesk.employee_presence_statuses.'.$presence->status, $presence->status)
.($presence->destination ? ' · '.$presence->destination : ''),
]);
}
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
public function evacuationBadges(Request $request, \App\Services\Frontdesk\BadgeRenderService $badges): View
@@ -118,4 +175,21 @@ class SecurityController extends Controller
'visit' => $visit,
]);
}
protected function staffOccupancy(Request $request, string $owner, $organization, bool $withBranch = false)
{
$query = EmployeePresence::query()
->whereIn('status', [EmployeePresence::STATUS_ON_SITE, EmployeePresence::STATUS_STEPPED_OUT])
->whereHas('employee', function ($q) use ($owner, $organization, $request) {
$q->owned($owner)->where('organization_id', $organization->id)->where('active', true);
$this->scopeToBranch($request, $q, 'branch_id');
})
->with(['employee']);
if ($withBranch) {
$query->with(['branch', 'employee.branch']);
}
return $query->orderBy('employee_id')->get();
}
}
@@ -70,6 +70,7 @@ class SettingsController extends Controller
'report_daily_recipients' => ['nullable', 'string', 'max:2000'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
'remove_logo' => ['nullable', 'boolean'],
'employee_kiosk_enabled' => ['nullable', 'boolean'],
]);
$settings = $organization->settings ?? [];
@@ -77,6 +78,7 @@ class SettingsController extends Controller
$settings['badge_expiry_hours'] = $validated['badge_expiry_hours'];
$settings['kiosk_reset_seconds'] = $validated['kiosk_reset_seconds'];
$settings['visitor_policy'] = $validated['visitor_policy'] ?? null;
$settings['employee_kiosk_enabled'] = $request->boolean('employee_kiosk_enabled');
$settings['notification_channels'] = $validated['notification_channels'] ?? ['email'];
$eventKeys = array_keys(config('frontdesk.notification_events', []));
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Employee extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_employees';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'employee_code', 'qr_token', 'full_name',
'department', 'email', 'phone', 'pin_hash', 'host_id', 'user_ref', 'active',
];
protected $hidden = ['pin_hash'];
protected function casts(): array
{
return ['active' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class, 'host_id');
}
public function presence(): HasOne
{
return $this->hasOne(EmployeePresence::class, 'employee_id');
}
protected static function booted(): void
{
static::creating(function (Employee $employee) {
if (! $employee->qr_token) {
$employee->qr_token = Str::random(32);
}
});
}
public static function findByQrLookup(string $lookup, int $organizationId): ?self
{
$token = self::parseQrLookup($lookup);
if ($token === null) {
return null;
}
return self::query()
->where('organization_id', $organizationId)
->where('qr_token', $token)
->where('active', true)
->first();
}
public static function parseQrLookup(string $lookup): ?string
{
$lookup = trim($lookup);
if ($lookup === '') {
return null;
}
if (preg_match('#/eq/([A-Za-z0-9]+)#', $lookup, $matches)) {
return $matches[1];
}
if (preg_match('/^[A-Za-z0-9]{16,64}$/', $lookup)) {
return $lookup;
}
return null;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmployeePresence extends Model
{
public const STATUS_OFF_SITE = 'off_site';
public const STATUS_ON_SITE = 'on_site';
public const STATUS_STEPPED_OUT = 'stepped_out';
protected $table = 'frontdesk_employee_presence';
protected $fillable = [
'employee_id', 'status', 'branch_id', 'device_id', 'destination',
'step_out_reason', 'notes', 'expected_return_at', 'return_alert_sent_at',
'signed_in_at', 'last_event_at',
];
protected function casts(): array
{
return [
'signed_in_at' => 'datetime',
'last_event_at' => 'datetime',
'expected_return_at' => 'datetime',
'return_alert_sent_at' => 'datetime',
];
}
public function employee(): BelongsTo
{
return $this->belongsTo(Employee::class, 'employee_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class, 'device_id');
}
public function isOnSite(): bool
{
return $this->status === self::STATUS_ON_SITE;
}
public function isSteppedOut(): bool
{
return $this->status === self::STATUS_STEPPED_OUT;
}
public function isOffSite(): bool
{
return $this->status === self::STATUS_OFF_SITE;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmployeePresenceEvent extends Model
{
use BelongsToOwner;
public const EVENT_SIGN_IN = 'sign_in';
public const EVENT_SIGN_OUT = 'sign_out';
public const EVENT_STEP_OUT = 'step_out';
public const EVENT_STEP_IN = 'step_in';
protected $table = 'frontdesk_employee_presence_events';
protected $fillable = [
'owner_ref', 'organization_id', 'employee_id', 'event', 'status_after',
'branch_id', 'device_id', 'destination', 'step_out_reason', 'notes',
];
public function employee(): BelongsTo
{
return $this->belongsTo(Employee::class, 'employee_id');
}
}
@@ -0,0 +1,314 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Device;
use App\Models\Employee;
use App\Models\EmployeePresence;
use App\Models\EmployeePresenceEvent;
use App\Models\Host;
use App\Models\Organization;
use App\Services\Integrations\WebhookDispatcher;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class EmployeePresenceService
{
public function __construct(
protected WebhookDispatcher $webhooks,
) {}
public function hashPin(string $pin): string
{
return Hash::make($pin);
}
public function verifyPin(Employee $employee, string $pin): bool
{
return Hash::check($pin, $employee->pin_hash);
}
public function findActiveByCode(Organization $organization, string $employeeCode): ?Employee
{
return Employee::query()
->where('organization_id', $organization->id)
->where('employee_code', $employeeCode)
->where('active', true)
->first();
}
public function authenticate(Organization $organization, string $employeeCode, string $pin): Employee
{
$employee = $this->findActiveByCode($organization, $employeeCode);
if ($employee === null || ! $this->verifyPin($employee, $pin)) {
throw ValidationException::withMessages([
'credentials' => 'Invalid employee code or PIN.',
]);
}
return $employee;
}
public function authenticateByQr(Organization $organization, string $qrLookup, string $pin): Employee
{
$employee = Employee::findByQrLookup($qrLookup, $organization->id);
if ($employee === null || ! $this->verifyPin($employee, $pin)) {
throw ValidationException::withMessages([
'credentials' => 'Invalid badge or PIN.',
]);
}
return $employee;
}
public function resolveEmployee(Organization $organization, array $credentials): Employee
{
$qrLookup = trim((string) ($credentials['qr_token'] ?? ''));
if ($qrLookup !== '') {
return $this->authenticateByQr($organization, $qrLookup, (string) ($credentials['pin'] ?? ''));
}
return $this->authenticate($organization, (string) ($credentials['employee_code'] ?? ''), (string) ($credentials['pin'] ?? ''));
}
/** @return list<string> */
public function availableActions(EmployeePresence $presence): array
{
return match ($presence->status) {
EmployeePresence::STATUS_OFF_SITE => ['sign_in'],
EmployeePresence::STATUS_ON_SITE => ['sign_out', 'step_out'],
EmployeePresence::STATUS_STEPPED_OUT => ['step_in', 'sign_out'],
default => [],
};
}
public function presenceFor(Employee $employee): EmployeePresence
{
return EmployeePresence::query()->firstOrCreate(
['employee_id' => $employee->id],
['status' => EmployeePresence::STATUS_OFF_SITE],
);
}
/** @return array{presence: EmployeePresence, warning: string|null} */
public function signIn(Employee $employee, ?Device $device = null, bool $confirmBranchMismatch = false): array
{
$presence = $this->presenceFor($employee);
if ($presence->status === EmployeePresence::STATUS_ON_SITE) {
throw ValidationException::withMessages([
'action' => 'You are already signed in.',
]);
}
$branchId = $device?->branch_id ?? $employee->branch_id;
$warning = $this->branchMismatchWarning($employee, $device);
if ($warning !== null && ! $confirmBranchMismatch) {
throw ValidationException::withMessages([
'branch_mismatch' => $warning,
]);
}
$now = now();
$presence->update([
'status' => EmployeePresence::STATUS_ON_SITE,
'branch_id' => $branchId,
'device_id' => $device?->id,
'destination' => null,
'step_out_reason' => null,
'notes' => null,
'expected_return_at' => null,
'return_alert_sent_at' => null,
'signed_in_at' => $now,
'last_event_at' => $now,
]);
$this->recordEvent($employee, EmployeePresenceEvent::EVENT_SIGN_IN, EmployeePresence::STATUS_ON_SITE, $presence);
$this->syncHostAvailability($employee, true);
$this->webhooks->dispatchEmployee('employee.signed_in', $employee, $presence->fresh());
return ['presence' => $presence->fresh(), 'warning' => $warning];
}
public function signOut(Employee $employee, ?Device $device = null): EmployeePresence
{
$presence = $this->presenceFor($employee);
if ($presence->status === EmployeePresence::STATUS_OFF_SITE) {
throw ValidationException::withMessages([
'action' => 'You are not signed in.',
]);
}
$now = now();
$presence->update([
'status' => EmployeePresence::STATUS_OFF_SITE,
'device_id' => $device?->id,
'destination' => null,
'step_out_reason' => null,
'notes' => null,
'expected_return_at' => null,
'return_alert_sent_at' => null,
'signed_in_at' => null,
'last_event_at' => $now,
]);
$this->recordEvent($employee, EmployeePresenceEvent::EVENT_SIGN_OUT, EmployeePresence::STATUS_OFF_SITE, $presence);
$this->syncHostAvailability($employee, true);
$this->webhooks->dispatchEmployee('employee.signed_out', $employee, $presence->fresh());
return $presence->fresh();
}
/** @param array{step_out_reason: string, destination?: string|null, notes?: string|null, expected_return_at?: string|null} $payload */
public function stepOut(Employee $employee, array $payload, ?Device $device = null): EmployeePresence
{
$presence = $this->presenceFor($employee);
if ($presence->status !== EmployeePresence::STATUS_ON_SITE) {
throw ValidationException::withMessages([
'action' => 'You must be signed in to step out.',
]);
}
$destination = trim((string) ($payload['destination'] ?? ''));
$reasonKey = $payload['step_out_reason'];
$reasonLabel = config('frontdesk.employee_step_out_reasons.'.$reasonKey, $reasonKey);
if ($reasonKey === 'other' && $destination === '') {
throw ValidationException::withMessages([
'destination' => 'Please say where you are going.',
]);
}
$displayDestination = $destination !== '' ? $destination : $reasonLabel;
$now = now();
$expectedReturn = ! empty($payload['expected_return_at'])
? Carbon::parse($payload['expected_return_at'])
: null;
$presence->update([
'status' => EmployeePresence::STATUS_STEPPED_OUT,
'device_id' => $device?->id,
'destination' => $displayDestination,
'step_out_reason' => $reasonKey,
'notes' => $payload['notes'] ?? null,
'expected_return_at' => $expectedReturn,
'return_alert_sent_at' => null,
'last_event_at' => $now,
]);
$this->recordEvent($employee, EmployeePresenceEvent::EVENT_STEP_OUT, EmployeePresence::STATUS_STEPPED_OUT, $presence);
$this->syncHostAvailability($employee, false);
$this->webhooks->dispatchEmployee('employee.stepped_out', $employee, $presence->fresh());
return $presence->fresh();
}
public function stepIn(Employee $employee, ?Device $device = null): EmployeePresence
{
$presence = $this->presenceFor($employee);
if ($presence->status !== EmployeePresence::STATUS_STEPPED_OUT) {
throw ValidationException::withMessages([
'action' => 'You are not marked as stepped out.',
]);
}
$now = now();
$presence->update([
'status' => EmployeePresence::STATUS_ON_SITE,
'device_id' => $device?->id,
'destination' => null,
'step_out_reason' => null,
'notes' => null,
'expected_return_at' => null,
'return_alert_sent_at' => null,
'last_event_at' => $now,
]);
$this->recordEvent($employee, EmployeePresenceEvent::EVENT_STEP_IN, EmployeePresence::STATUS_ON_SITE, $presence);
$this->syncHostAvailability($employee, true);
$this->webhooks->dispatchEmployee('employee.stepped_in', $employee, $presence->fresh());
return $presence->fresh();
}
/** @return array<string, mixed> */
public function employeePayload(Employee $employee): array
{
$presence = $this->presenceFor($employee);
return [
'id' => $employee->id,
'full_name' => $employee->full_name,
'department' => $employee->department,
'employee_code' => $employee->employee_code,
'status' => $presence->status,
'status_label' => config('frontdesk.employee_presence_statuses.'.$presence->status, $presence->status),
'destination' => $presence->destination,
'expected_return_at' => $presence->expected_return_at?->toIso8601String(),
'signed_in_at' => $presence->signed_in_at?->toIso8601String(),
'available_actions' => $this->availableActions($presence),
];
}
protected function branchMismatchWarning(Employee $employee, ?Device $device): ?string
{
if ($employee->branch_id === null || $device?->branch_id === null) {
return null;
}
if ((int) $employee->branch_id === (int) $device->branch_id) {
return null;
}
$employee->loadMissing('branch');
$device->loadMissing('branch');
$homeBranch = $employee->branch?->name ?? 'your home branch';
$kioskBranch = $device->branch?->name ?? 'this branch';
return "Your home branch is {$homeBranch}, but this kiosk is at {$kioskBranch}. Confirm to sign in here anyway.";
}
protected function recordEvent(
Employee $employee,
string $event,
string $statusAfter,
EmployeePresence $presence,
): void {
EmployeePresenceEvent::create([
'owner_ref' => $employee->owner_ref,
'organization_id' => $employee->organization_id,
'employee_id' => $employee->id,
'event' => $event,
'status_after' => $statusAfter,
'branch_id' => $presence->branch_id,
'device_id' => $presence->device_id,
'destination' => $presence->destination,
'step_out_reason' => $presence->step_out_reason,
'notes' => $presence->notes,
]);
}
protected function syncHostAvailability(Employee $employee, bool $available): void
{
if ($employee->host_id === null) {
return;
}
Host::query()
->where('id', $employee->host_id)
->where('organization_id', $employee->organization_id)
->update(['is_available' => $available]);
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\EmployeePresence;
use App\Models\EmployeePresenceEvent;
use App\Models\Organization;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class EmployeeReportService
{
/** @return array<string, int> */
public function summary(
string $ownerRef,
Organization $organization,
Carbon $from,
Carbon $to,
?int $branchId = null,
): array {
$events = $this->eventsQuery($ownerRef, $organization->id, $from, $to, $branchId);
return [
'sign_ins' => (clone $events)->where('event', EmployeePresenceEvent::EVENT_SIGN_IN)->count(),
'sign_outs' => (clone $events)->where('event', EmployeePresenceEvent::EVENT_SIGN_OUT)->count(),
'step_outs' => (clone $events)->where('event', EmployeePresenceEvent::EVENT_STEP_OUT)->count(),
'staff_on_site_now' => EmployeePresence::query()
->where('status', EmployeePresence::STATUS_ON_SITE)
->whereHas('employee', function ($q) use ($ownerRef, $organization, $branchId) {
$q->owned($ownerRef)->where('organization_id', $organization->id)->where('active', true);
if ($branchId) {
$q->where('branch_id', $branchId);
}
})
->count(),
];
}
/** @return Collection<int, EmployeePresenceEvent> */
public function attendanceLog(
string $ownerRef,
Organization $organization,
Carbon $from,
Carbon $to,
?int $branchId = null,
): Collection {
return $this->eventsQuery($ownerRef, $organization->id, $from, $to, $branchId)
->with(['employee'])
->orderByDesc('created_at')
->limit(200)
->get();
}
/** @return Collection<int, EmployeePresenceEvent> */
public function stepOutLog(
string $ownerRef,
Organization $organization,
Carbon $from,
Carbon $to,
?int $branchId = null,
): Collection {
return $this->eventsQuery($ownerRef, $organization->id, $from, $to, $branchId)
->where('event', EmployeePresenceEvent::EVENT_STEP_OUT)
->with(['employee'])
->orderByDesc('created_at')
->limit(100)
->get();
}
protected function eventsQuery(
string $ownerRef,
int $organizationId,
Carbon $from,
Carbon $to,
?int $branchId,
) {
$query = EmployeePresenceEvent::owned($ownerRef)
->where('organization_id', $organizationId)
->whereBetween('created_at', [$from, $to]);
if ($branchId) {
$query->where('branch_id', $branchId);
}
return $query;
}
}
@@ -12,24 +12,26 @@ class FrontdeskPermissions
'org_admin' => ['*'],
'branch_admin' => [
'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage',
'hosts.view', 'hosts.manage', 'kiosk.use', 'security.view', 'security.checkout', 'security.verify',
'hosts.view', 'hosts.manage', 'employees.view', 'employees.manage', 'kiosk.use',
'security.view', 'security.checkout', 'security.verify',
'settings.view', 'admin.branches.view', 'admin.desks.view', 'admin.desks.manage',
'watchlist.view', 'watchlist.manage', 'audit.view', 'audit.export',
'devices.view', 'devices.manage', 'reports.view', 'reports.export',
],
'receptionist' => [
'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage',
'hosts.view', 'kiosk.use', 'watchlist.view', 'devices.view',
'hosts.view', 'employees.view', 'kiosk.use', 'watchlist.view', 'devices.view',
],
'security_officer' => [
'dashboard.view', 'visits.view', 'visitors.view', 'security.view', 'security.checkout', 'security.verify',
'dashboard.view', 'visits.view', 'visitors.view', 'employees.view',
'security.view', 'security.checkout', 'security.verify',
'watchlist.view', 'audit.view',
],
'host' => [
'dashboard.view', 'visits.view', 'visitors.view', 'host.portal',
],
'auditor' => [
'dashboard.view', 'visits.view', 'visitors.view', 'security.view', 'settings.view',
'dashboard.view', 'visits.view', 'visitors.view', 'employees.view', 'security.view', 'settings.view',
'audit.view', 'audit.export', 'watchlist.view', 'compliance.restore',
'reports.view', 'reports.export',
],
@@ -5,6 +5,7 @@ namespace App\Services\Frontdesk;
use App\Models\Host;
use App\Models\Member;
use App\Models\Organization;
use App\Models\EmployeePresence;
use App\Models\User;
use App\Models\Visitor;
use App\Models\Visit;
@@ -143,6 +144,20 @@ class NotificationDispatcher
);
}
public function employeeOverdueReturn(EmployeePresence $presence): void
{
$presence->load(['employee.organization']);
$employee = $presence->employee;
$expected = $presence->expected_return_at?->format('g:i A') ?? 'the expected time';
$this->notifyStaffUsers(
$employee->organization,
'employee_overdue_return',
'Employee overdue to return',
"{$employee->full_name} was expected back by {$expected} ({$presence->destination}).",
);
}
/** @param array<string, mixed> $meta */
protected function dispatchVisitEvent(
Visit $visit,
@@ -3,6 +3,7 @@
namespace App\Services\Frontdesk;
use App\Models\Branch;
use App\Models\Employee;
use App\Models\Host;
use App\Models\Member;
use App\Models\Organization;
@@ -109,6 +110,19 @@ class OrganizationResolver
->first();
}
public function employeeFor(User $user): ?Employee
{
$organization = $this->resolveForUser($user);
if (! $organization) {
return null;
}
return Employee::where('organization_id', $organization->id)
->where('user_ref', $user->ownerRef())
->where('active', true)
->first();
}
/** Branch ID the member may access; null = all branches. */
public function branchScope(?Member $member): ?int
{
+19 -1
View File
@@ -2,6 +2,7 @@
namespace App\Services\Frontdesk;
use App\Models\Employee;
use App\Models\Visit;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
@@ -15,7 +16,24 @@ class QrCodeService
return url($path.'/'.$visit->qr_token);
}
public function employeeUrl(Employee $employee): string
{
$path = config('frontdesk.badge.employee_qr_url_path', '/eq');
return url($path.'/'.$employee->qr_token);
}
public function svg(Visit $visit, int $size = 200): string
{
return $this->renderSvg($this->visitUrl($visit), $size);
}
public function employeeSvg(Employee $employee, int $size = 200): string
{
return $this->renderSvg($this->employeeUrl($employee), $size);
}
protected function renderSvg(string $url, int $size = 200): string
{
$options = new QROptions([
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
@@ -23,6 +41,6 @@ class QrCodeService
'imageBase64' => false,
]);
return (new QRCode($options))->render($this->visitUrl($visit));
return (new QRCode($options))->render($url);
}
}
+37 -10
View File
@@ -2,6 +2,8 @@
namespace App\Services\Integrations;
use App\Models\Employee;
use App\Models\EmployeePresence;
use App\Models\Visit;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Http;
@@ -13,16 +15,6 @@ class WebhookDispatcher
{
$visit->loadMissing('organization');
$endpoints = WebhookEndpoint::query()
->where('organization_id', $visit->organization_id)
->where('is_active', true)
->get()
->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event));
if ($endpoints->isEmpty()) {
return;
}
$payload = [
'event' => $event,
'visit' => [
@@ -41,6 +33,41 @@ class WebhookDispatcher
'timestamp' => now()->toIso8601String(),
];
$this->dispatchPayload($visit->organization_id, $event, $payload);
}
public function dispatchEmployee(string $event, Employee $employee, ?EmployeePresence $presence = null): void
{
$presence ??= $employee->presence;
$payload = [
'event' => $event,
'employee' => [
'id' => $employee->id,
'employee_code' => $employee->employee_code,
'full_name' => $employee->full_name,
'department' => $employee->department,
'branch_id' => $employee->branch_id,
'status' => $presence?->status,
'destination' => $presence?->destination,
'signed_in_at' => $presence?->signed_in_at?->toIso8601String(),
'expected_return_at' => $presence?->expected_return_at?->toIso8601String(),
],
'timestamp' => now()->toIso8601String(),
];
$this->dispatchPayload($employee->organization_id, $event, $payload);
}
/** @param array<string, mixed> $payload */
protected function dispatchPayload(int $organizationId, string $event, array $payload): void
{
$endpoints = WebhookEndpoint::query()
->where('organization_id', $organizationId)
->where('is_active', true)
->get()
->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event));
foreach ($endpoints as $endpoint) {
$this->send($endpoint, $payload);
}