Files
isaaccladandCursor 0c092b7711
Deploy Ladill Frontdesk / deploy (push) Successful in 46s
Add visitor self-checkout on the kiosk.
Let guests check out by badge code or QR scan before leaving, with device and staff kiosk API routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 09:07:39 +00:00

111 lines
3.1 KiB
PHP

<?php
namespace App\Services\Frontdesk;
use App\Models\AuditLog;
use App\Models\Organization;
use App\Models\Visit;
class VisitCheckOutService
{
public function __construct(
protected NotificationDispatcher $notifications,
protected \App\Services\Integrations\WebhookDispatcher $webhooks,
) {}
public function checkOut(Visit $visit, ?string $actorRef = null): Visit
{
abort_unless($visit->isInside(), 422, 'Visitor is not currently checked in.');
$visit->update([
'status' => Visit::STATUS_CHECKED_OUT,
'checked_out_at' => now(),
'checked_out_by' => $actorRef,
]);
AuditLog::record(
$visit->owner_ref,
'visit.checked_out',
$visit->organization_id,
$actorRef,
Visit::class,
$visit->id,
['visitor' => $visit->visitor->full_name],
);
$this->notifications->visitorCheckedOut($visit);
$this->webhooks->dispatch('visit.checked_out', $visit->fresh(['visitor', 'host']));
return $visit->fresh(['visitor', 'host']);
}
public function checkOutByQrToken(string $ownerRef, string $qrToken, ?string $actorRef = null): Visit
{
$visit = Visit::owned($ownerRef)
->where('qr_token', $qrToken)
->currentlyInside()
->firstOrFail();
return $this->checkOut($visit, $actorRef);
}
public function checkOutFromKiosk(
string $ownerRef,
Organization $organization,
?string $badgeCode = null,
?string $qrInput = null,
?int $branchId = null,
): Visit {
$visit = $this->resolveCheckedInVisitForKiosk($ownerRef, $organization, $badgeCode, $qrInput, $branchId);
return $this->checkOut($visit);
}
protected function resolveCheckedInVisitForKiosk(
string $ownerRef,
Organization $organization,
?string $badgeCode,
?string $qrInput,
?int $branchId,
): Visit {
$query = Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->currentlyInside()
->with('visitor');
if ($branchId !== null) {
$query->where(function ($q) use ($branchId) {
$q->whereNull('branch_id')->orWhere('branch_id', $branchId);
});
}
$badgeCode = strtoupper(trim((string) $badgeCode));
if ($badgeCode !== '') {
return (clone $query)->where('badge_code', $badgeCode)->firstOrFail();
}
$qrToken = $this->parseVisitQrToken((string) $qrInput);
abort_unless($qrToken !== null, 422, 'Invalid badge scan.');
return (clone $query)->where('qr_token', $qrToken)->firstOrFail();
}
protected function parseVisitQrToken(string $input): ?string
{
$trimmed = trim($input);
if ($trimmed === '') {
return null;
}
if (preg_match('#/q/([A-Za-z0-9]+)#', $trimmed, $matches)) {
return $matches[1];
}
if (preg_match('/^[A-Za-z0-9]{16,64}$/', $trimmed)) {
return $trimmed;
}
return null;
}
}