Deploy Ladill Frontdesk / deploy (push) Successful in 51s
Wrap Frontdesk Bird host notifications in the customer-branded email layout. Co-authored-by: Cursor <cursoragent@cursor.com>
340 lines
12 KiB
PHP
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 — 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 — 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',
|
|
]));
|
|
}
|
|
}
|
|
}
|