Initial Ladill Frontdesk release with deploy pipeline.
Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\Visit;
|
||||
use App\Notifications\FrontdeskAlertNotification;
|
||||
use App\Services\Comms\EmailService;
|
||||
use App\Services\Comms\SmsService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotificationDispatcher
|
||||
{
|
||||
public function __construct(
|
||||
protected EmailService $email,
|
||||
protected SmsService $sms,
|
||||
protected NotificationPreferenceService $preferences,
|
||||
) {}
|
||||
|
||||
public function visitorArrived(Visit $visit): void
|
||||
{
|
||||
$this->dispatchVisitEvent(
|
||||
$visit,
|
||||
'visitor_arrived',
|
||||
'Visitor has arrived',
|
||||
"{$visit->visitor->full_name} has checked in.",
|
||||
['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',
|
||||
"{$visit->visitor->full_name} is waiting in reception.",
|
||||
['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,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $meta */
|
||||
protected function dispatchVisitEvent(
|
||||
Visit $visit,
|
||||
string $event,
|
||||
string $title,
|
||||
string $message,
|
||||
array $meta = [],
|
||||
): void {
|
||||
$visit->load(['visitor', 'host', 'organization']);
|
||||
$channels = $this->preferences->channelsForEvent($visit->organization, $event);
|
||||
|
||||
if ($channels === [] || ! $visit->host) {
|
||||
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 = [],
|
||||
): void {
|
||||
if (in_array('email', $channels, true) && $host->email) {
|
||||
try {
|
||||
$this->email->send($host->email, $title, $message);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Frontdesk host email failed', ['host_id' => $host->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('sms', $channels, true) && $host->phone) {
|
||||
$this->sms->send($host->phone, "{$title}: {$message}");
|
||||
}
|
||||
|
||||
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,
|
||||
])));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user