Files
ladill-frontdesk/app/Services/Frontdesk/EmployeePresenceService.php
T
isaaccladandCursor 890c2c71e3
Deploy Ladill Frontdesk / deploy (push) Failing after 26s
Add employee presence with kiosk sign-in, QR badges, and mobile step-out.
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>
2026-06-28 21:11:18 +00:00

315 lines
11 KiB
PHP

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