visitor->full_name} has checked in and is here to see you."; if ($visit->purpose) { $message .= " Purpose: {$visit->purpose}."; } return $this->dispatchVisitEvent( $visit, 'visitor_arrived', 'Visitor here to see you', $message, ['icon' => 'user-check'], ); } public function visitorCheckedOut(Visit $visit): void { $this->dispatchVisitEvent( $visit, 'visitor_checked_out', 'Visitor checked out', "{$visit->visitor->full_name} has checked out.", ['icon' => 'logout'], ); } public function visitorExpected(Visit $visit): void { $when = $visit->scheduled_at?->format('M j, g:i A') ?? 'today'; $this->dispatchVisitEvent( $visit, 'visitor_expected', 'Visitor expected', "{$visit->visitor->full_name} is expected on {$when}.", ['icon' => 'calendar'], ); } public function visitorWaiting(Visit $visit): void { $this->dispatchVisitEvent( $visit, 'visitor_waiting', 'Visitor waiting to see you', "{$visit->visitor->full_name} is waiting in reception to see you.", ['icon' => 'clock'], ); } public function visitCancelled(Visit $visit): void { $this->dispatchVisitEvent( $visit, 'visit_cancelled', 'Visit cancelled', "The visit for {$visit->visitor->full_name} has been cancelled.", ['icon' => 'x-circle'], ); } public function approvalNeeded(Visit $visit): void { $visit->load(['visitor', 'host', 'organization']); $this->dispatchVisitEvent( $visit, 'approval_needed', 'Visitor awaiting approval', "{$visit->visitor->full_name} ({$visit->visitor_type}) is waiting for approval.", ['icon' => 'shield-alert'], ); $this->notifyStaffUsers( $visit->organization, 'approval_needed', 'Approval required', "{$visit->visitor->full_name} needs reception approval.", $visit, ); } public function watchlistBlockedAttempt(Visitor $visitor, Organization $organization): void { Log::alert('Watchlist blocked check-in attempt', [ 'visitor_id' => $visitor->id, 'visitor' => $visitor->full_name, 'organization_id' => $organization->id, ]); $this->notifyStaffUsers( $organization, 'watchlist_alert', 'Blocked check-in attempt', "{$visitor->full_name} (blacklisted) attempted to check in.", ); } public function watchlistFlaggedCheckIn(Visitor $visitor, Organization $organization): void { Log::warning('Watchlist flagged visitor submitted for approval', [ 'visitor_id' => $visitor->id, 'visitor' => $visitor->full_name, ]); $this->notifyStaffUsers( $organization, 'watchlist_alert', 'Flagged visitor check-in', "{$visitor->full_name} requires approval before badge issue.", ); } public function badgeExpired(Visit $visit): void { $visit->load(['visitor', 'organization']); $this->notifyStaffUsers( $visit->organization, 'badge_expired', 'Expired badge', "{$visit->visitor->full_name}'s badge expired while still checked in.", $visit, ); } 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 $meta */ protected function dispatchVisitEvent( Visit $visit, string $event, string $title, string $message, array $meta = [], ): bool { $visit->load(['visitor', 'host', 'organization']); $channels = $this->preferences->channelsForEvent($visit->organization, $event); if ($channels === [] || ! $visit->host) { return false; } return $this->notifyHost($visit->host, $visit->organization, $channels, $title, $message, $visit, $event, $meta); } /** @param list $channels */ protected function notifyHost( Host $host, Organization $organization, array $channels, string $title, string $message, ?Visit $visit = null, ?string $event = null, array $meta = [], ): bool { $notified = false; $reference = $visit ? 'visit-'.$visit->id.'-'.($event ?? 'alert') : 'host-'.$host->id; $credential = $this->credentials->forOrganization($organization); if (in_array('email', $channels, true) && $host->email) { if (! $credential->hasValidBird()) { Log::info('Frontdesk host email skipped — Ladill Bird not connected', [ 'host_id' => $host->id, 'organization_id' => $organization->id, ]); } else { $apiKey = $credential->birdApiKey(); if (! $apiKey) { Log::info('Frontdesk host email skipped — Bird credentials could not be decrypted', [ 'host_id' => $host->id, 'organization_id' => $organization->id, ]); } elseif (! $this->billing->canAffordEmail($organization)) { Log::info('Frontdesk host email skipped — plan allowance exhausted or insufficient wallet balance', [ 'host_id' => $host->id, 'organization_id' => $organization->id, ]); } else { try { $html = OrganizationBranding::wrapEmailHtml(nl2br(e($message)), $organization); $fromName = trim((string) ($credential->bird_from_name ?: $organization->name)) ?: null; $sent = $this->customerEmail->send( $apiKey, (string) $credential->bird_from_email, $fromName, (string) $host->email, $title, $html, $message, ); if (! $sent) { Log::warning('Frontdesk host email failed', [ 'host_id' => $host->id, 'error' => $this->customerEmail->lastError(), ]); } elseif ($this->billing->chargeEmail($organization, $reference, "Host alert: {$title}")) { $notified = true; } } catch (\Throwable $e) { Log::warning('Frontdesk host email failed', ['host_id' => $host->id, 'error' => $e->getMessage()]); } } } } if (in_array('sms', $channels, true) && $host->phone) { $smsBody = "{$title}: {$message}"; if (! $credential->hasValidSms()) { Log::info('Frontdesk host SMS skipped — Ladill SMS not connected', [ 'host_id' => $host->id, 'organization_id' => $organization->id, ]); } else { $apiKey = $credential->smsApiKey(); if (! $apiKey) { Log::info('Frontdesk host SMS skipped — SMS credentials could not be decrypted', [ 'host_id' => $host->id, 'organization_id' => $organization->id, ]); } elseif (! $this->billing->canAffordSms($organization, $smsBody)) { Log::info('Frontdesk host SMS skipped — plan allowance exhausted or insufficient wallet balance', [ 'host_id' => $host->id, 'organization_id' => $organization->id, ]); } else { try { $sent = $this->customerSms->send( $apiKey, (string) $host->phone, $smsBody, (string) $credential->sms_sender_id, ); if (! $sent) { Log::warning('Frontdesk host SMS failed', [ 'host_id' => $host->id, 'error' => $this->customerSms->lastError(), ]); } elseif ($this->billing->chargeSms($organization, $smsBody, $reference, "Host SMS: {$title}")) { $notified = true; } } catch (\Throwable $e) { Log::warning('Frontdesk host SMS failed', ['host_id' => $host->id, 'error' => $e->getMessage()]); } } } } if ($host->user_ref) { $user = User::where('public_id', $host->user_ref)->first(); if ($user) { $user->notify(new FrontdeskAlertNotification($title, $message, array_merge($meta, [ 'event' => $event, 'visit_id' => $visit?->id, 'url' => $visit ? route('frontdesk.visits.show', $visit) : null, ]))); } } return $notified; } protected function notifyStaffUsers( Organization $organization, string $event, string $title, string $message, ?Visit $visit = null, ): void { if (! $this->preferences->isEventEnabled($organization, $event)) { return; } $roles = ['org_admin', 'branch_admin', 'receptionist', 'security_officer']; $userRefs = Member::query() ->where('organization_id', $organization->id) ->whereIn('role', $roles) ->pluck('user_ref') ->unique(); foreach ($userRefs as $userRef) { $user = User::where('public_id', $userRef)->first(); if (! $user) { continue; } $user->notify(new FrontdeskAlertNotification($title, $message, [ 'event' => $event, 'visit_id' => $visit?->id, 'url' => $visit ? route('frontdesk.visits.show', $visit) : route('frontdesk.dashboard'), 'icon' => 'bell', ])); } } }