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>
315 lines
11 KiB
PHP
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]);
|
|
}
|
|
}
|