Files
ladill-frontdesk/app/Services/Frontdesk/NotificationDispatcher.php
T
isaacclad b6f229ab9a
Deploy Ladill Frontdesk / deploy (push) Has been cancelled
Tighten Pro alert caps and move custom badges to Enterprise.
Pro includes 5,000 email and 5,000 SMS host alerts per month with hard
monthly caps; Enterprise is unlimited. Custom badge templates are
Enterprise-only, and plan benefit copy matches the new limits.
2026-07-16 08:37:06 +00:00

340 lines
12 KiB
PHP

<?php
namespace App\Services\Frontdesk;
use App\Models\Host;
use App\Models\Member;
use App\Models\Organization;
use App\Models\EmployeePresence;
use App\Models\User;
use App\Models\Visitor;
use App\Models\Visit;
use App\Notifications\FrontdeskAlertNotification;
use App\Services\Messaging\CustomerEmailClient;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
use App\Support\OrganizationBranding;
use Illuminate\Support\Facades\Log;
class NotificationDispatcher
{
public function __construct(
protected MessagingCredentialsService $credentials,
protected CustomerEmailClient $customerEmail,
protected CustomerSmsClient $customerSms,
protected NotificationPreferenceService $preferences,
protected NotificationBillingService $billing,
) {}
public function visitorArrived(Visit $visit): bool
{
$message = "{$visit->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<string, mixed> $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<string> $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);
$sent = $this->customerEmail->send(
$apiKey,
(string) $credential->bird_from_email,
$credential->bird_from_name,
(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',
]));
}
}
}