Deploy Ladill Frontdesk / deploy (push) Successful in 44s
Host alerts use email/phone on the host record without requiring a Ladill account link; SMS and over-quota emails debit the org wallet at platform rates. Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
8.9 KiB
PHP
222 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Frontdesk;
|
|
|
|
use App\Models\AuditLog;
|
|
use App\Models\Organization;
|
|
use App\Models\Visitor;
|
|
use App\Models\Visit;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class VisitCheckInService
|
|
{
|
|
public function __construct(
|
|
protected WatchlistService $watchlist,
|
|
protected NotificationDispatcher $notifications,
|
|
protected QrCodeService $qrCodes,
|
|
protected VisitorTypeService $visitorTypes,
|
|
protected \App\Services\Integrations\WebhookDispatcher $webhooks,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function checkIn(string $ownerRef, Organization $organization, array $data, ?string $actorRef = null): Visit
|
|
{
|
|
$data = $this->processMediaFields($data);
|
|
$visitorType = $data['visitor_type'] ?? 'visitor';
|
|
|
|
if (! empty($data['type_fields'])) {
|
|
$data = $this->visitorTypes->mergeTypeDetailsFromArray($visitorType, $data, $organization);
|
|
}
|
|
|
|
$visitor = $this->resolveVisitor($ownerRef, $organization, $data);
|
|
|
|
$this->watchlist->assertCanCheckIn($visitor, $organization, $actorRef);
|
|
|
|
$needsApproval = $this->visitorTypes->requiresApproval($visitorType, $organization)
|
|
|| $this->watchlist->visitorNeedsApprovalQueue($visitor);
|
|
|
|
if ($needsApproval) {
|
|
$data = $this->watchlist->applyApprovalMetadata($data, $visitor);
|
|
}
|
|
|
|
return DB::transaction(function () use ($ownerRef, $organization, $data, $actorRef, $visitor, $visitorType, $needsApproval) {
|
|
$duration = (int) ($data['expected_duration_minutes']
|
|
?? config('frontdesk.kiosk.default_visit_duration_minutes', 60));
|
|
$expiryHours = $this->visitorTypes->badgeExpiryHours($visitorType, $organization);
|
|
|
|
$visit = Visit::create([
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $data['branch_id'] ?? null,
|
|
'reception_desk_id' => $data['reception_desk_id'] ?? null,
|
|
'visitor_id' => $visitor->id,
|
|
'host_id' => $data['host_id'] ?? null,
|
|
'visitor_type' => $visitorType,
|
|
'status' => $needsApproval ? Visit::STATUS_WAITING : Visit::STATUS_CHECKED_IN,
|
|
'purpose' => $data['purpose'] ?? null,
|
|
'expected_duration_minutes' => $duration,
|
|
'scheduled_at' => $data['scheduled_at'] ?? null,
|
|
'checked_in_at' => $needsApproval ? null : now(),
|
|
'badge_expires_at' => $needsApproval ? null : now()->addHours($expiryHours),
|
|
'photo_path' => $data['photo_path'] ?? null,
|
|
'signature_path' => $data['signature_path'] ?? null,
|
|
'policies_accepted' => (bool) ($data['policies_accepted'] ?? false),
|
|
'vehicle_info' => $data['vehicle_info'] ?? null,
|
|
'contractor_details' => $data['contractor_details'] ?? null,
|
|
'delivery_details' => $data['delivery_details'] ?? null,
|
|
'allowed_areas' => $data['allowed_areas'] ?? null,
|
|
'notes' => $data['notes'] ?? null,
|
|
'checked_in_by' => $needsApproval ? null : $actorRef,
|
|
'external_ref' => $data['external_ref'] ?? null,
|
|
'source' => $data['source'] ?? null,
|
|
'integration_metadata' => $data['integration_metadata'] ?? null,
|
|
]);
|
|
|
|
if (! $needsApproval) {
|
|
$this->finalizeCheckIn($visit, $actorRef);
|
|
} else {
|
|
AuditLog::record(
|
|
$ownerRef,
|
|
'visit.awaiting_approval',
|
|
$organization->id,
|
|
$actorRef,
|
|
Visit::class,
|
|
$visit->id,
|
|
['visitor' => $visitor->full_name, 'visitor_type' => $visitorType],
|
|
);
|
|
|
|
$this->notifications->approvalNeeded($visit);
|
|
|
|
if ($this->watchlist->visitorNeedsApprovalQueue($visitor)) {
|
|
$this->watchlist->recordFlaggedCheckIn($visitor, $organization, $actorRef, [
|
|
'visit_id' => $visit->id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $visit->load(['visitor', 'host', 'organization']);
|
|
});
|
|
}
|
|
|
|
public function activateCheckIn(Visit $visit, ?string $actorRef = null, bool $policiesAccepted = true): Visit
|
|
{
|
|
return DB::transaction(function () use ($visit, $actorRef, $policiesAccepted) {
|
|
$visit->load(['visitor', 'organization']);
|
|
$expiryHours = $this->visitorTypes->badgeExpiryHours($visit->visitor_type, $visit->organization);
|
|
|
|
$contractorDetails = $visit->contractor_details ?? [];
|
|
$deliveryDetails = $visit->delivery_details ?? [];
|
|
unset($contractorDetails['_awaiting_approval'], $deliveryDetails['_awaiting_approval']);
|
|
|
|
$visit->update([
|
|
'status' => Visit::STATUS_CHECKED_IN,
|
|
'checked_in_at' => now(),
|
|
'badge_expires_at' => now()->addHours($expiryHours),
|
|
'policies_accepted' => $policiesAccepted,
|
|
'checked_in_by' => $actorRef,
|
|
'contractor_details' => $contractorDetails ?: null,
|
|
'delivery_details' => $deliveryDetails ?: null,
|
|
]);
|
|
|
|
$this->finalizeCheckIn($visit, $actorRef);
|
|
|
|
return $visit->fresh(['visitor', 'host', 'organization']);
|
|
});
|
|
}
|
|
|
|
protected function finalizeCheckIn(Visit $visit, ?string $actorRef): void
|
|
{
|
|
$visitor = $visit->visitor;
|
|
$visitor->increment('visit_count');
|
|
if ($visitor->visit_count >= 5) {
|
|
$visitor->update(['is_frequent' => true]);
|
|
}
|
|
|
|
AuditLog::record(
|
|
$visit->owner_ref,
|
|
'visit.checked_in',
|
|
$visit->organization_id,
|
|
$actorRef,
|
|
Visit::class,
|
|
$visit->id,
|
|
['visitor' => $visitor->full_name, 'badge_code' => $visit->badge_code],
|
|
);
|
|
|
|
$visit->host_notified = $this->notifications->visitorArrived($visit);
|
|
$this->webhooks->dispatch('visit.checked_in', $visit);
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
protected function processMediaFields(array $data): array
|
|
{
|
|
foreach (['photo_data' => 'photo_path', 'signature_data' => 'signature_path'] as $input => $target) {
|
|
if (! empty($data[$input])) {
|
|
$data[$target] = $this->visitorTypes->storeBase64Image($data[$input], $input === 'photo_data' ? 'photos' : 'signatures');
|
|
unset($data[$input]);
|
|
} elseif (! empty($data[$target]) && is_string($data[$target]) && str_starts_with($data[$target], 'data:image')) {
|
|
$data[$target] = $this->visitorTypes->storeBase64Image(
|
|
$data[$target],
|
|
$target === 'photo_path' ? 'photos' : 'signatures',
|
|
);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function resolveVisitorForSchedule(string $ownerRef, Organization $organization, array $data): Visitor
|
|
{
|
|
return $this->resolveVisitor($ownerRef, $organization, $data);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
protected function resolveVisitor(string $ownerRef, Organization $organization, array $data): Visitor
|
|
{
|
|
if (! empty($data['visitor_id'])) {
|
|
return Visitor::owned($ownerRef)->findOrFail($data['visitor_id']);
|
|
}
|
|
|
|
$existing = Visitor::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where(function ($q) use ($data) {
|
|
if (! empty($data['email'])) {
|
|
$q->where('email', $data['email']);
|
|
}
|
|
if (! empty($data['phone'])) {
|
|
$q->orWhere('phone', $data['phone']);
|
|
}
|
|
})
|
|
->when(empty($data['email']) && empty($data['phone']), fn ($q) => $q->whereRaw('0 = 1'))
|
|
->first();
|
|
|
|
if ($existing) {
|
|
$existing->update(array_filter([
|
|
'full_name' => $data['full_name'] ?? $existing->full_name,
|
|
'company' => $data['company'] ?? $existing->company,
|
|
'phone' => $data['phone'] ?? $existing->phone,
|
|
'email' => $data['email'] ?? $existing->email,
|
|
'photo_path' => $data['photo_path'] ?? $existing->photo_path,
|
|
]));
|
|
|
|
return $existing->fresh();
|
|
}
|
|
|
|
return Visitor::create([
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'full_name' => $data['full_name'],
|
|
'company' => $data['company'] ?? null,
|
|
'phone' => $data['phone'] ?? null,
|
|
'email' => $data['email'] ?? null,
|
|
'photo_path' => $data['photo_path'] ?? null,
|
|
]);
|
|
}
|
|
}
|