Initial Ladill Frontdesk release with deploy pipeline.
Deploy Ladill Frontdesk / deploy (push) Failing after 35s
Test / test (push) Failing after 2m45s

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:
isaacclad
2026-06-27 20:37:15 +00:00
co-authored by Cursor
commit 9e2d79936c
284 changed files with 29134 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
* platform; CRM only consumes it. Amounts are integer minor units; the user is
* identified by public_id. Authenticates with the per-consumer config('billing.api_key').
*/
class BillingClient
{
private function base(): string
{
return rtrim((string) config('billing.api_url'), '/');
}
private function token(): string
{
return (string) (config('billing.api_key') ?? '');
}
public function balanceMinor(string $publicId): int
{
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
->get($this->base().'/balance', ['user' => $publicId]);
$res->throw();
return (int) ($res->json('balance_minor') ?? 0);
}
public function canAfford(string $publicId, int $amountMinor): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]);
$res->throw();
return (bool) ($res->json('affordable') ?? false);
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
*/
public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => (string) config('billing.service', 'crm'),
'source' => $source,
'reference' => $reference,
'description' => $description,
], static fn ($v) => $v !== null));
if ($res->status() === 402) {
return false;
}
$res->throw();
return true;
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Services\Billing;
use App\Models\CrmPurchase;
use Illuminate\Support\Str;
class OneTimePurchaseService
{
public function __construct(private readonly BillingClient $billing)
{
}
/** @return array<string, mixed>|null */
public function product(string $productKey): ?array
{
$product = config('crm_products.'.$productKey);
return is_array($product) ? $product : null;
}
public function hasPurchased(string $ownerRef, string $productKey): bool
{
return CrmPurchase::has($ownerRef, $productKey);
}
public function priceMinor(string $productKey): int
{
return (int) ($this->product($productKey)['price_minor'] ?? 0);
}
public function purchase(string $ownerRef, string $productKey): bool
{
if ($this->hasPurchased($ownerRef, $productKey)) {
return true;
}
$product = $this->product($productKey);
if (! $product) {
return false;
}
$costMinor = (int) ($product['price_minor'] ?? 0);
if ($costMinor > 0) {
try {
if (! $this->billing->canAfford($ownerRef, $costMinor)) {
return false;
}
$charged = $this->billing->debit(
$ownerRef,
$costMinor,
'purchase',
'crm-purchase-'.$productKey.'-'.$ownerRef,
'CRM: '.($product['name'] ?? $productKey),
);
if (! $charged) {
return false;
}
} catch (\Throwable) {
return false;
}
}
CrmPurchase::create([
'owner_ref' => $ownerRef,
'product_key' => $productKey,
'cost_minor' => $costMinor,
'purchased_at' => now(),
]);
return true;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Comms;
use App\Mail\ContactMessageMail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Outbound email to contacts. Uses the app's configured mailer (set MAIL_* to
* a Ladill Bird SMTP credential in production so mail leaves as the account's
* sender). Failures are swallowed + logged; the caller decides what to record.
*/
class EmailService
{
public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool
{
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return false;
}
try {
Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo));
return true;
} catch (\Throwable $e) {
Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]);
return false;
}
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Services\Comms;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Outbound SMS to contacts via Arkesel (the platform SMS provider). Low-volume,
* best-effort transactional send mirrors the platform's direct Arkesel calls;
* uses the shared ARKESEL_API_KEY. Failures are swallowed + logged.
*/
class SmsService
{
public function send(string $to, string $message): bool
{
$apiKey = (string) config('arkesel.api_key', '');
$sender = (string) config('arkesel.sender_id', 'Ladill');
$msisdn = $this->normalise($to);
if ($apiKey === '' || $msisdn === null) {
return false;
}
try {
$res = Http::timeout(20)
->withHeaders(['api-key' => $apiKey])
->acceptJson()
->post(rtrim((string) config('arkesel.base_url', 'https://sms.arkesel.com'), '/').'/api/v2/sms/send', [
'sender' => $sender,
'message' => $message,
'recipients' => [$msisdn],
]);
return $res->successful() && ($res->json('status') === 'success');
} catch (\Throwable $e) {
Log::warning('CRM SMS send failed', ['to' => $msisdn, 'error' => $e->getMessage()]);
return false;
}
}
/** Normalise to E.164-without-plus (e.g. 233XXXXXXXXX). */
private function normalise(string $to): ?string
{
$digits = preg_replace('/\D+/', '', $to) ?? '';
$country = (string) config('arkesel.default_country_code', '233');
if (strlen($digits) < 9) {
return null;
}
if (str_starts_with($digits, $country)) {
return $digits;
}
if (str_starts_with($digits, '0')) {
return $country.substr($digits, 1);
}
if (strlen($digits) === 9) {
return $country.$digits;
}
return $digits;
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Services\CrossApp;
use App\Models\Customer;
use App\Models\Deal;
use App\Support\CrmPrefillCodec;
use App\Support\LadillAppUrl;
class CrossAppLinkService
{
public function invoiceFromDeal(Deal $deal): string
{
$deal->loadMissing(['customer', 'lines']);
$customer = $deal->customer;
$lines = $deal->lines->map(fn ($line) => [
'description' => $line->description,
'quantity' => (float) $line->quantity,
'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''),
])->values()->all();
if ($lines === [] && (int) $deal->value_minor > 0) {
$lines = [[
'description' => $deal->title,
'quantity' => 1,
'unit_price' => number_format($deal->value_minor / 100, 2, '.', ''),
]];
}
$prefill = CrmPrefillCodec::encode([
'kind' => 'invoice',
'crm_customer_id' => $customer?->id,
'client_name' => $customer?->name ?: $deal->title,
'client_email' => $customer?->email,
'client_address' => $customer ? $this->formatAddress($customer) : null,
'notes' => 'Invoice for CRM deal: '.$deal->title,
'payment_enabled' => true,
'lines' => $lines,
]);
return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill));
}
public function merchantStorefrontFromDeal(Deal $deal): string
{
$deal->loadMissing('customer');
$amount = number_format(((int) $deal->value_minor) / 100, 2, '.', '');
$itemName = $deal->title;
$prefill = CrmPrefillCodec::encode([
'kind' => 'merchant_storefront',
'type' => 'shop',
'label' => 'Payment — '.$deal->title,
'shop_title' => $deal->customer?->company ?: $deal->title,
'sections' => [[
'name' => 'Payment',
'items' => [[
'name' => $itemName,
'description' => $deal->notes ?: 'Payment for '.$deal->title,
'price' => $amount,
'image_path' => '',
]],
]],
'accepts_payment' => true,
]);
return LadillAppUrl::connect('merchant', '/storefronts/create?prefill='.urlencode($prefill));
}
public function businessQrFromContact(Customer $contact): string
{
$prefill = CrmPrefillCodec::encode([
'kind' => 'qr_business',
'type' => 'business',
'label' => 'Card — '.$contact->name,
'name' => $contact->company ?: $contact->name,
'phone' => $contact->phone,
'email' => $contact->email,
'address' => $this->formatAddress($contact, singleLine: true),
]);
return LadillAppUrl::connect('qrplus', '/qr-codes/create?prefill='.urlencode($prefill));
}
public function eventsHubForContact(Customer $contact): string
{
$query = http_build_query(array_filter([
'search' => $contact->company ?: $contact->name,
]));
return LadillAppUrl::connect('events', '/attendees'.($query !== '' ? '?'.$query : ''));
}
private function formatAddress(Customer $contact, bool $singleLine = false): ?string
{
$parts = array_filter([
$contact->address_line1,
$contact->address_line2,
trim(implode(', ', array_filter([$contact->city, $contact->region, $contact->country]))),
]);
if ($parts === []) {
return null;
}
return $singleLine
? implode(', ', $parts)
: implode("\n", $parts);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Organization;
use App\Models\Visit;
use Illuminate\Support\Facades\Storage;
class BadgeRenderService
{
public function __construct(
protected QrCodeService $qrCodes,
) {}
/** @return array<string, mixed> */
public function templateFor(Organization $organization): array
{
return array_merge(config('frontdesk.default_badge_template', []), $organization->settings['badge_template'] ?? []);
}
public function renderHtml(Visit $visit, bool $autoPrint = false): string
{
$visit->load(['visitor', 'host', 'organization']);
return view('frontdesk.badges.render', [
'visit' => $visit,
'template' => $this->templateFor($visit->organization),
'photoUrl' => $this->photoUrl($visit),
'qrSvg' => $visit->qr_token ? $this->qrCodes->svg($visit, 120) : null,
'autoPrint' => $autoPrint,
'preview' => false,
])->render();
}
public function photoUrl(Visit $visit): ?string
{
$path = $visit->photo_path ?? $visit->visitor?->photo_path;
return $path && Storage::disk('public')->exists($path)
? Storage::disk('public')->url($path)
: null;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Device;
use Illuminate\Support\Str;
class DeviceService
{
public function generateToken(): string
{
return Str::random(48);
}
public function findByToken(string $token): ?Device
{
return Device::query()
->where('device_token', $token)
->first();
}
public function recordHeartbeat(Device $device): Device
{
$device->update([
'status' => 'online',
'last_online_at' => now(),
]);
return $device->fresh();
}
public function markStaleDevicesOffline(int $minutes = 10): int
{
return Device::query()
->where('status', 'online')
->where(function ($q) use ($minutes) {
$q->whereNull('last_online_at')
->orWhere('last_online_at', '<', now()->subMinutes($minutes));
})
->update(['status' => 'offline']);
}
/** @param array<string, mixed> $config */
public function defaultConfigForType(string $type): array
{
return match ($type) {
'badge_printer' => ['driver' => config('frontdesk.printers.default_driver', 'pdf')],
'kiosk' => ['mode' => 'self_service', 'allow_checkout' => false],
default => [],
};
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Member;
class FrontdeskPermissions
{
/** @var array<string, list<string>> */
protected array $roleAbilities = [
'super_admin' => ['*'],
'org_admin' => ['*'],
'branch_admin' => [
'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage',
'hosts.view', 'hosts.manage', 'kiosk.use', 'security.view', 'security.checkout', 'security.verify',
'settings.view', 'admin.branches.view', 'admin.desks.view', 'admin.desks.manage',
'watchlist.view', 'watchlist.manage', 'audit.view', 'audit.export',
'devices.view', 'devices.manage', 'reports.view', 'reports.export',
],
'receptionist' => [
'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage',
'hosts.view', 'kiosk.use', 'watchlist.view', 'devices.view',
],
'security_officer' => [
'dashboard.view', 'visits.view', 'visitors.view', 'security.view', 'security.checkout', 'security.verify',
'watchlist.view', 'audit.view',
],
'host' => [
'dashboard.view', 'visits.view', 'visitors.view', 'host.portal',
],
'auditor' => [
'dashboard.view', 'visits.view', 'visitors.view', 'security.view', 'settings.view',
'audit.view', 'audit.export', 'watchlist.view', 'compliance.restore',
'reports.view', 'reports.export',
],
];
public function can(?Member $member, string $ability): bool
{
if ($member === null) {
return false;
}
$abilities = $this->roleAbilities[$member->role] ?? [];
if (in_array('*', $abilities, true)) {
return true;
}
return in_array($ability, $abilities, true);
}
public function isAdmin(?Member $member): bool
{
return $member !== null && in_array($member->role, ['super_admin', 'org_admin'], true);
}
public function managesBranches(?Member $member): bool
{
return $this->can($member, 'admin.branches.manage');
}
}
@@ -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',
]));
}
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Organization;
class NotificationPreferenceService
{
/** @return list<string> */
public function channelsForEvent(Organization $organization, string $event): array
{
if (! $this->isEventEnabled($organization, $event)) {
return [];
}
return array_values(array_filter(
$organization->settings['notification_channels'] ?? ['email'],
fn (string $channel) => in_array($channel, array_keys(config('frontdesk.notification_channels', [])), true),
));
}
public function isEventEnabled(Organization $organization, string $event): bool
{
$events = $organization->settings['notification_events']
?? config('frontdesk.default_notification_events', []);
return (bool) ($events[$event] ?? true);
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Branch;
use App\Models\Host;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Str;
class OrganizationResolver
{
public function resolveForUser(User $user): ?Organization
{
$ref = $user->ownerRef();
$member = Member::where('user_ref', $ref)->first();
if ($member) {
return Organization::find($member->organization_id);
}
return Organization::owned($ref)->first();
}
public function forUser(User $user): Organization
{
return $this->resolveForUser($user)
?? throw new \RuntimeException('No organization for user.');
}
public function isOnboarded(User $user): bool
{
$organization = $this->resolveForUser($user);
return $organization !== null
&& (bool) data_get($organization->settings, 'onboarded', false);
}
public function memberFor(User $user, ?Organization $organization = null): ?Member
{
$organization ??= $this->resolveForUser($user);
if (! $organization) {
return null;
}
return Member::where('organization_id', $organization->id)
->where('user_ref', $user->ownerRef())
->first();
}
public function ensureOwnerMember(User $user, Organization $organization): Member
{
return Member::firstOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $user->ownerRef(),
],
[
'owner_ref' => $user->ownerRef(),
'role' => 'org_admin',
],
);
}
/**
* @param array<string, mixed> $data
*/
public function completeOnboarding(User $user, array $data): Organization
{
$ref = $user->ownerRef();
$organization = Organization::create([
'owner_ref' => $ref,
'name' => $data['organization_name'],
'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6),
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'badge_expiry_hours' => (int) ($data['badge_expiry_hours'] ?? config('frontdesk.badge.default_expiry_hours', 8)),
'kiosk_reset_seconds' => (int) ($data['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
'notification_channels' => ['email'],
'visitor_policy' => $data['visitor_policy'] ?? null,
],
]);
$this->ensureOwnerMember($user, $organization);
Branch::create([
'owner_ref' => $ref,
'organization_id' => $organization->id,
'name' => $data['branch_name'],
'address' => $data['branch_address'] ?? null,
'is_active' => true,
]);
return $organization;
}
public function hostFor(User $user): ?Host
{
$organization = $this->resolveForUser($user);
if (! $organization) {
return null;
}
return Host::where('organization_id', $organization->id)
->where('user_ref', $user->ownerRef())
->first();
}
/** Branch ID the member may access; null = all branches. */
public function branchScope(?Member $member): ?int
{
if ($member === null) {
return null;
}
if (in_array($member->role, ['super_admin', 'org_admin', 'auditor'], true)) {
return null;
}
return $member->branch_id;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Visit;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
class QrCodeService
{
public function visitUrl(Visit $visit): string
{
$path = config('frontdesk.badge.qr_url_path', '/q');
return url($path.'/'.$visit->qr_token);
}
public function svg(Visit $visit, int $size = 200): string
{
$options = new QROptions([
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
'scale' => 5,
'imageBase64' => false,
]);
return (new QRCode($options))->render($this->visitUrl($visit));
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\AuditLog;
use App\Models\Host;
use App\Models\Organization;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class ReportService
{
/**
* @return array<string, mixed>
*/
public function summary(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$visits = $this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId);
$checkedIn = (clone $visits)->whereNotNull('checked_in_at');
$durations = (clone $checkedIn)
->whereNotNull('checked_out_at')
->get(['checked_in_at', 'checked_out_at'])
->map(fn (Visit $v) => $v->checked_in_at->diffInMinutes($v->checked_out_at));
return [
'total_visits' => (clone $visits)->count(),
'checked_in' => (clone $checkedIn)->count(),
'checked_out' => (clone $visits)->where('status', Visit::STATUS_CHECKED_OUT)->count(),
'cancelled' => (clone $visits)->where('status', Visit::STATUS_CANCELLED)->count(),
'contractors' => (clone $checkedIn)->where('visitor_type', 'contractor')->count(),
'deliveries' => (clone $checkedIn)->where('visitor_type', 'delivery')->count(),
'avg_duration_minutes' => $durations->isEmpty() ? 0 : (int) round($durations->avg()),
'unique_visitors' => (clone $checkedIn)->distinct('visitor_id')->count('visitor_id'),
];
}
/** @return array<int, array{hour: int, count: int}> */
public function peakHours(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$counts = array_fill(0, 24, 0);
$this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
->whereNotNull('checked_in_at')
->pluck('checked_in_at')
->each(function ($checkedInAt) use (&$counts) {
$counts[(int) Carbon::parse($checkedInAt)->format('G')]++;
});
$hours = [];
for ($h = 0; $h < 24; $h++) {
$hours[] = ['hour' => $h, 'count' => $counts[$h]];
}
return $hours;
}
/** @return Collection<int, object{department: string, count: int}> */
public function visitsByDepartment(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): Collection
{
return $this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
->whereNotNull('frontdesk_visits.checked_in_at')
->join('frontdesk_hosts', 'frontdesk_visits.host_id', '=', 'frontdesk_hosts.id')
->selectRaw("coalesce(frontdesk_hosts.department, 'Unassigned') as department, count(*) as total")
->groupBy('department')
->orderByDesc('total')
->get()
->map(fn ($row) => (object) ['department' => $row->department, 'count' => (int) $row->total]);
}
/** @return Collection<int, Visitor> */
public function frequentVisitors(string $ownerRef, Organization $organization, int $limit = 10): Collection
{
return Visitor::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_frequent', true)
->orderByDesc('visit_count')
->limit($limit)
->get();
}
/** @return array<string, int> */
public function securityIncidents(string $ownerRef, Organization $organization, Carbon $from, Carbon $to): array
{
$query = AuditLog::owned($ownerRef)
->where('organization_id', $organization->id)
->whereBetween('created_at', [$from, $to])
->whereIn('action', [
'watchlist.blocked_attempt',
'watchlist.flagged_checkin',
'badge.expired',
]);
return [
'blocked_attempts' => (clone $query)->where('action', 'watchlist.blocked_attempt')->count(),
'flagged_checkins' => (clone $query)->where('action', 'watchlist.flagged_checkin')->count(),
'expired_badges' => (clone $query)->where('action', 'badge.expired')->count(),
];
}
/** @return array<int, array{date: string, count: int}> */
public function dailyCounts(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$rows = [];
$this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
->whereNotNull('checked_in_at')
->pluck('checked_in_at')
->each(function ($checkedInAt) use (&$rows) {
$key = Carbon::parse($checkedInAt)->toDateString();
$rows[$key] = ($rows[$key] ?? 0) + 1;
});
$days = [];
for ($date = $from->copy()->startOfDay(); $date->lte($to); $date->addDay()) {
$key = $date->toDateString();
$days[] = ['date' => $key, 'count' => (int) ($rows[$key] ?? 0)];
}
return $days;
}
protected function visitQuery(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): Builder
{
return Visit::query()
->from('frontdesk_visits')
->where('frontdesk_visits.owner_ref', $ownerRef)
->where('frontdesk_visits.organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('frontdesk_visits.branch_id', $branchId))
->where(function ($q) use ($from, $to) {
$q->whereBetween('frontdesk_visits.checked_in_at', [$from, $to])
->orWhereBetween('frontdesk_visits.scheduled_at', [$from, $to]);
});
}
}
@@ -0,0 +1,221 @@
<?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],
);
$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,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\AuditLog;
use App\Models\Visit;
class VisitCheckOutService
{
public function __construct(
protected NotificationDispatcher $notifications,
protected \App\Services\Integrations\WebhookDispatcher $webhooks,
) {}
public function checkOut(Visit $visit, ?string $actorRef = null): Visit
{
abort_unless($visit->isInside(), 422, 'Visitor is not currently checked in.');
$visit->update([
'status' => Visit::STATUS_CHECKED_OUT,
'checked_out_at' => now(),
'checked_out_by' => $actorRef,
]);
AuditLog::record(
$visit->owner_ref,
'visit.checked_out',
$visit->organization_id,
$actorRef,
Visit::class,
$visit->id,
['visitor' => $visit->visitor->full_name],
);
$this->notifications->visitorCheckedOut($visit);
$this->webhooks->dispatch('visit.checked_out', $visit->fresh(['visitor', 'host']));
return $visit->fresh(['visitor', 'host']);
}
public function checkOutByQrToken(string $ownerRef, string $qrToken, ?string $actorRef = null): Visit
{
$visit = Visit::owned($ownerRef)
->where('qr_token', $qrToken)
->currentlyInside()
->firstOrFail();
return $this->checkOut($visit, $actorRef);
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\AuditLog;
use App\Models\Organization;
use App\Models\Visit;
use Illuminate\Support\Facades\DB;
class VisitLifecycleService
{
public function __construct(
protected VisitCheckInService $checkIns,
protected NotificationDispatcher $notifications,
) {}
public function checkInFromSchedule(Visit $visit, ?string $actorRef = null, bool $policiesAccepted = true): Visit
{
abort_unless($visit->canActivateCheckIn(), 422, 'This visit cannot be checked in.');
$visit->load('visitor');
app(WatchlistService::class)->assertCanCheckIn($visit->visitor);
return $this->checkIns->activateCheckIn($visit, $actorRef, $policiesAccepted);
}
public function markWaiting(Visit $visit, ?string $actorRef = null): Visit
{
abort_unless(in_array($visit->status, [
Visit::STATUS_EXPECTED,
Visit::STATUS_SCHEDULED,
Visit::STATUS_OVERDUE,
], true), 422, 'Visit is not awaiting arrival.');
$visit->update(['status' => Visit::STATUS_WAITING]);
AuditLog::record(
$visit->owner_ref,
'visit.waiting',
$visit->organization_id,
$actorRef,
Visit::class,
$visit->id,
['visitor' => $visit->visitor->full_name],
);
$this->notifications->visitorWaiting($visit);
return $visit->fresh(['visitor', 'host']);
}
public function cancel(Visit $visit, ?string $actorRef = null, ?string $reason = null): Visit
{
abort_if($visit->isInside(), 422, 'Checked-in visits must be checked out, not cancelled.');
abort_if($visit->status === Visit::STATUS_CANCELLED, 422, 'Visit is already cancelled.');
$visit->update([
'status' => Visit::STATUS_CANCELLED,
'notes' => trim(($visit->notes ?? '').($reason ? "\nCancelled: {$reason}" : '')),
]);
AuditLog::record(
$visit->owner_ref,
'visit.cancelled',
$visit->organization_id,
$actorRef,
Visit::class,
$visit->id,
['visitor' => $visit->visitor->full_name, 'reason' => $reason],
);
$this->notifications->visitCancelled($visit);
return $visit->fresh(['visitor', 'host']);
}
/** Mark expected/scheduled visits past their scheduled time as overdue. */
public function markOverdueVisits(?Organization $organization = null): int
{
$query = Visit::query()
->whereIn('status', [Visit::STATUS_EXPECTED, Visit::STATUS_SCHEDULED])
->whereNotNull('scheduled_at')
->where('scheduled_at', '<', now());
if ($organization) {
$query->where('organization_id', $organization->id);
}
return $query->update(['status' => Visit::STATUS_OVERDUE]);
}
public function approve(Visit $visit, ?string $actorRef = null): Visit
{
abort_unless($visit->awaitingApproval(), 422, 'This visit is not awaiting approval.');
$visit->load('visitor');
app(WatchlistService::class)->assertCanCheckIn($visit->visitor);
return $this->checkIns->activateCheckIn($visit, $actorRef, (bool) $visit->policies_accepted);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\AuditLog;
use App\Models\Organization;
use App\Models\Visit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class VisitScheduleService
{
public function __construct(
protected VisitCheckInService $checkIns,
protected WatchlistService $watchlist,
protected NotificationDispatcher $notifications,
) {}
/**
* Pre-register or schedule a future visit without checking in.
*
* @param array<string, mixed> $data
*/
public function schedule(string $ownerRef, Organization $organization, array $data, ?string $actorRef = null): Visit
{
return DB::transaction(function () use ($ownerRef, $organization, $data, $actorRef) {
$visitor = $this->checkIns->resolveVisitorForSchedule($ownerRef, $organization, $data);
$this->watchlist->assertCanCheckIn($visitor);
$scheduledAt = isset($data['scheduled_at'])
? Carbon::parse($data['scheduled_at'])
: now();
$status = $scheduledAt->startOfDay()->isAfter(now()->startOfDay())
? Visit::STATUS_SCHEDULED
: Visit::STATUS_EXPECTED;
$visit = Visit::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $data['branch_id'] ?? null,
'visitor_id' => $visitor->id,
'host_id' => $data['host_id'] ?? null,
'visitor_type' => $data['visitor_type'] ?? 'visitor',
'status' => $status,
'purpose' => $data['purpose'] ?? null,
'expected_duration_minutes' => (int) ($data['expected_duration_minutes']
?? config('frontdesk.kiosk.default_visit_duration_minutes', 60)),
'scheduled_at' => $scheduledAt,
'notes' => $data['notes'] ?? null,
'external_ref' => $data['external_ref'] ?? null,
'source' => $data['source'] ?? null,
'integration_metadata' => $data['integration_metadata'] ?? null,
]);
AuditLog::record(
$ownerRef,
'visit.scheduled',
$organization->id,
$actorRef,
Visit::class,
$visit->id,
['visitor' => $visitor->full_name, 'scheduled_at' => $scheduledAt->toIso8601String()],
);
if ($status === Visit::STATUS_EXPECTED) {
$this->notifications->visitorExpected($visit);
}
return $visit->load(['visitor', 'host']);
});
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Visitor;
use Illuminate\Database\Eloquent\Collection;
class VisitorSearchService
{
/** @return Collection<int, Visitor> */
public function search(string $ownerRef, int $organizationId, string $query, int $limit = 20): Collection
{
$query = trim($query);
if ($query === '') {
return new Collection;
}
return Visitor::owned($ownerRef)
->where('organization_id', $organizationId)
->where(function ($q) use ($query) {
$q->where('full_name', 'like', "%{$query}%")
->orWhere('company', 'like', "%{$query}%")
->orWhere('phone', 'like', "%{$query}%")
->orWhere('email', 'like', "%{$query}%");
})
->orderByDesc('is_frequent')
->orderBy('full_name')
->limit($limit)
->get();
}
}
@@ -0,0 +1,197 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\Organization;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class VisitorTypeService
{
/** @return array<string, mixed> */
public function configFor(string $type): array
{
return config("frontdesk.visitor_type_config.{$type}", config('frontdesk.visitor_type_config.visitor', []));
}
/** @return list<array<string, mixed>> */
public function fieldsFor(string $type): array
{
return $this->configFor($type)['fields'] ?? [];
}
public function requiresApproval(string $type, Organization $organization): bool
{
$settings = $organization->settings ?? [];
$overrides = $settings['type_requires_approval'] ?? [];
if (array_key_exists($type, $overrides)) {
return (bool) $overrides[$type];
}
return (bool) ($this->configFor($type)['requires_approval'] ?? false);
}
public function badgeExpiryHours(string $type, Organization $organization): int
{
$settings = $organization->settings ?? [];
$overrides = $settings['type_badge_expiry_hours'] ?? [];
if (isset($overrides[$type])) {
return (int) $overrides[$type];
}
$typeHours = $this->configFor($type)['badge_expiry_hours'] ?? null;
if ($typeHours !== null) {
return (int) $typeHours;
}
return (int) data_get($settings, 'badge_expiry_hours', config('frontdesk.badge.default_expiry_hours', 8));
}
/** @return array<string, mixed> */
public function validationRules(string $type): array
{
$rules = ['type_fields' => ['nullable', 'array']];
foreach ($this->fieldsFor($type) as $field) {
$key = 'type_fields.'.$field['name'];
if ($field['type'] === 'checkbox') {
$rules[$key] = [($field['required'] ?? false) ? 'accepted' : 'nullable'];
continue;
}
$fieldRules = [($field['required'] ?? false) ? 'required' : 'nullable'];
$fieldRules[] = match ($field['type']) {
'photo' => 'file',
'signature' => 'string',
'date' => 'date',
'datetime-local' => 'date',
'textarea' => 'string',
default => 'string',
};
if ($field['type'] === 'photo') {
$fieldRules[] = 'image';
$fieldRules[] = 'max:5120';
}
if (in_array($field['type'], ['text', 'textarea'], true)) {
$fieldRules[] = 'max:500';
}
$rules[$key] = $fieldRules;
}
return $rules;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function enrichCheckInData(string $type, array $data, Organization $organization, ?Request $request = null): array
{
if ($request) {
$typeFields = $data['type_fields'] ?? [];
foreach ($this->fieldsFor($type) as $field) {
if ($field['type'] === 'photo' && $request->hasFile("type_fields.{$field['name']}")) {
$typeFields[$field['name']] = $this->storeUpload(
$request->file("type_fields.{$field['name']}"),
'photos',
);
}
}
$data['type_fields'] = $typeFields;
}
return $this->mergeTypeDetailsFromArray($type, $data, $organization);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function mergeTypeDetailsFromArray(string $type, array $data, Organization $organization): array
{
$detailsKey = $this->configFor($type)['details_key'] ?? null;
if ($detailsKey === null) {
return $data;
}
$details = [];
$typeFields = $data['type_fields'] ?? [];
foreach ($this->fieldsFor($type) as $field) {
$name = $field['name'];
$value = $typeFields[$name] ?? null;
if ($field['type'] === 'signature' && is_string($value) && str_starts_with($value, 'data:image')) {
$value = $this->storeBase64Image($value, 'signatures');
}
if ($field['type'] === 'checkbox') {
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN) || in_array($value, ['1', 'on', 1], true);
}
if ($value !== null && $value !== '' && $value !== false) {
$details[$name] = $value;
}
}
if ($type === 'delivery') {
$details['received_at'] = now()->toIso8601String();
}
if ($this->requiresApproval($type, $organization)) {
$details['_awaiting_approval'] = true;
}
$data[$detailsKey] = $details;
unset($data['type_fields']);
return $data;
}
/** @return array<string, array<string, mixed>> */
public function configsForFrontend(): array
{
$types = array_keys(config('frontdesk.visitor_types', []));
return collect($types)
->mapWithKeys(fn (string $type) => [$type => [
'label' => config("frontdesk.visitor_types.{$type}"),
'fields' => $this->fieldsFor($type),
'requires_approval' => (bool) ($this->configFor($type)['requires_approval'] ?? false),
]])
->all();
}
public function storeUpload(UploadedFile $file, string $folder): string
{
return $file->store("frontdesk/{$folder}", 'public');
}
public function storeBase64Image(string $dataUrl, string $folder): string
{
if (! preg_match('/^data:image\/(\w+);base64,/', $dataUrl, $matches)) {
throw new \InvalidArgumentException('Invalid image data.');
}
$extension = $matches[1] === 'jpeg' ? 'jpg' : $matches[1];
$contents = base64_decode(substr($dataUrl, strpos($dataUrl, ',') + 1));
$path = "frontdesk/{$folder}/".Str::uuid().".{$extension}";
Storage::disk('public')->put($path, $contents);
return $path;
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\AuditLog;
use App\Models\Organization;
use App\Models\Visitor;
use App\Models\WatchlistEntry;
use Symfony\Component\HttpKernel\Exception\HttpException;
class WatchlistService
{
public function __construct(
protected NotificationDispatcher $notifications,
) {}
public function assertCanCheckIn(Visitor $visitor, ?Organization $organization = null, ?string $actorRef = null): void
{
if ($visitor->isBlacklisted()) {
if ($organization) {
$this->recordBlockedAttempt($visitor, $organization, $actorRef);
}
throw new HttpException(403, 'This visitor is blacklisted and cannot check in.');
}
}
public function visitorNeedsApprovalQueue(Visitor $visitor): bool
{
return $visitor->requiresApproval();
}
/** @param array<string, mixed> $context */
public function recordBlockedAttempt(
Visitor $visitor,
Organization $organization,
?string $actorRef = null,
array $context = [],
): void {
AuditLog::record(
$organization->owner_ref,
'watchlist.blocked_attempt',
$organization->id,
$actorRef,
Visitor::class,
$visitor->id,
array_merge([
'visitor' => $visitor->full_name,
'watchlist_status' => $visitor->watchlist_status,
], $context),
);
$this->notifications->watchlistBlockedAttempt($visitor, $organization);
}
/** @param array<string, mixed> $context */
public function recordFlaggedCheckIn(
Visitor $visitor,
Organization $organization,
?string $actorRef = null,
array $context = [],
): void {
AuditLog::record(
$organization->owner_ref,
'watchlist.flagged_checkin',
$organization->id,
$actorRef,
Visitor::class,
$visitor->id,
array_merge([
'visitor' => $visitor->full_name,
'watchlist_status' => $visitor->watchlist_status,
], $context),
);
$this->notifications->watchlistFlaggedCheckIn($visitor, $organization);
}
public function syncEntryForVisitor(Visitor $visitor, string $status, ?string $reason, ?string $actorRef): WatchlistEntry
{
$entry = WatchlistEntry::query()
->where('owner_ref', $visitor->owner_ref)
->where('organization_id', $visitor->organization_id)
->where('visitor_id', $visitor->id)
->first();
if ($status === Visitor::WATCHLIST_ALLOWED) {
$entry?->delete();
return $entry ?? new WatchlistEntry;
}
return WatchlistEntry::updateOrCreate(
[
'owner_ref' => $visitor->owner_ref,
'organization_id' => $visitor->organization_id,
'visitor_id' => $visitor->id,
],
[
'full_name' => $visitor->full_name,
'company' => $visitor->company,
'status' => $status,
'reason' => $reason,
'created_by' => $actorRef,
],
);
}
public function applyApprovalMetadata(array $data, Visitor $visitor): array
{
if (! $this->visitorNeedsApprovalQueue($visitor)) {
return $data;
}
$key = isset($data['delivery_details']) ? 'delivery_details' : 'contractor_details';
$details = $data[$key] ?? [];
$details['_awaiting_approval'] = true;
$details['_watchlist_flagged'] = true;
$data[$key] = $details;
return $data;
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Services\Integrations;
use App\Models\Visit;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WebhookDispatcher
{
public function dispatch(string $event, Visit $visit): void
{
$visit->loadMissing('organization');
$endpoints = WebhookEndpoint::query()
->where('organization_id', $visit->organization_id)
->where('is_active', true)
->get()
->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event));
if ($endpoints->isEmpty()) {
return;
}
$payload = [
'event' => $event,
'visit' => [
'id' => $visit->id,
'public_id' => $visit->public_id,
'external_ref' => $visit->external_ref,
'source' => $visit->source,
'status' => $visit->status,
'visitor_type' => $visit->visitor_type,
'badge_code' => $visit->badge_code,
'checked_in_at' => $visit->checked_in_at?->toIso8601String(),
'checked_out_at' => $visit->checked_out_at?->toIso8601String(),
'visitor_name' => $visit->visitor?->full_name,
'host_name' => $visit->host?->name,
],
'timestamp' => now()->toIso8601String(),
];
foreach ($endpoints as $endpoint) {
$this->send($endpoint, $payload);
}
}
/** @param array<string, mixed> $payload */
protected function send(WebhookEndpoint $endpoint, array $payload): void
{
$body = json_encode($payload);
$headers = ['Content-Type' => 'application/json'];
if ($endpoint->secret) {
$headers['X-Frontdesk-Signature'] = hash_hmac('sha256', $body, $endpoint->secret);
}
try {
Http::timeout(10)->withHeaders($headers)->withBody($body, 'application/json')->post($endpoint->url);
} catch (\Throwable $e) {
Log::warning('Frontdesk webhook delivery failed', [
'endpoint_id' => $endpoint->id,
'url' => $endpoint->url,
'error' => $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Printers;
use App\Contracts\Printers\PrinterDriverInterface;
use App\Models\Visit;
class BrotherPrinterDriver implements PrinterDriverInterface
{
public function name(): string
{
return 'brother';
}
public function renderBadge(Visit $visit): array
{
return (new PdfPrinterDriver)->renderBadge($visit);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Printers;
use App\Contracts\Printers\PrinterDriverInterface;
use App\Models\Visit;
class DymoPrinterDriver implements PrinterDriverInterface
{
public function name(): string
{
return 'dymo';
}
public function renderBadge(Visit $visit): array
{
return (new PdfPrinterDriver)->renderBadge($visit);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Printers;
use App\Contracts\Printers\PrinterDriverInterface;
use App\Models\Visit;
use App\Services\Frontdesk\BadgeRenderService;
class PdfPrinterDriver implements PrinterDriverInterface
{
public function name(): string
{
return 'pdf';
}
public function renderBadge(Visit $visit): array
{
$visit->load(['visitor', 'host', 'organization']);
$html = app(BadgeRenderService::class)->renderHtml($visit, true);
return [
'format' => 'html',
'content' => $html,
'filename' => 'badge-'.$visit->badge_code.'.html',
];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Printers;
use App\Contracts\Printers\PrinterDriverInterface;
use App\Models\Visit;
use InvalidArgumentException;
class PrinterManager
{
/** @var array<string, PrinterDriverInterface> */
protected array $drivers = [];
public function __construct()
{
$this->register(new PdfPrinterDriver);
$this->register(new ZebraPrinterDriver);
$this->register(new BrotherPrinterDriver);
$this->register(new DymoPrinterDriver);
}
public function register(PrinterDriverInterface $driver): void
{
$this->drivers[$driver->name()] = $driver;
}
public function driver(?string $name = null): PrinterDriverInterface
{
$name = $name ?? config('frontdesk.printers.default_driver', 'pdf');
if (! isset($this->drivers[$name])) {
throw new InvalidArgumentException("Unknown printer driver: {$name}");
}
return $this->drivers[$name];
}
public function renderBadge(Visit $visit, ?string $driver = null): array
{
return $this->driver($driver)->renderBadge($visit);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Services\Printers;
use App\Contracts\Printers\PrinterDriverInterface;
use App\Models\Visit;
class ZebraPrinterDriver implements PrinterDriverInterface
{
public function name(): string
{
return 'zebra';
}
public function renderBadge(Visit $visit): array
{
$visit->load(['visitor', 'host']);
$name = strtoupper(substr($visit->visitor->full_name, 0, 24));
$company = strtoupper(substr($visit->visitor->company ?? '', 0, 20));
$host = strtoupper(substr($visit->host?->name ?? '', 0, 20));
$code = $visit->badge_code;
$zpl = "^XA^FO50,50^A0N,40,40^FD{$name}^FS";
$zpl .= "^FO50,100^A0N,25,25^FD{$company}^FS";
$zpl .= "^FO50,140^A0N,25,25^FDHost: {$host}^FS";
$zpl .= "^FO50,180^BQN,2,5^FDQA,{$code}^FS";
$zpl .= "^XZ";
return [
'format' => 'zpl',
'content' => $zpl,
'filename' => 'badge-'.$visit->badge_code.'.zpl',
];
}
}