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,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user