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
@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Services\Frontdesk\DeviceService;
use Illuminate\Console\Command;
class MarkDevicesOfflineCommand extends Command
{
protected $signature = 'frontdesk:mark-devices-offline {--minutes=10 : Minutes since last heartbeat}';
protected $description = 'Mark devices without recent heartbeat as offline';
public function handle(DeviceService $devices): int
{
$count = $devices->markStaleDevicesOffline((int) $this->option('minutes'));
$this->info("Marked {$count} device(s) as offline.");
return self::SUCCESS;
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditLog;
use App\Models\Visit;
use App\Services\Frontdesk\NotificationDispatcher;
use Illuminate\Console\Command;
class MarkExpiredBadgesCommand extends Command
{
protected $signature = 'frontdesk:mark-expired-badges';
protected $description = 'Log and alert on checked-in visits with expired badges';
public function handle(NotificationDispatcher $notifications): int
{
$visits = Visit::query()
->where('status', Visit::STATUS_CHECKED_IN)
->whereNotNull('badge_expires_at')
->where('badge_expires_at', '<', now())
->with(['visitor', 'organization'])
->get();
$count = 0;
foreach ($visits as $visit) {
$alreadyLogged = AuditLog::query()
->where('subject_type', Visit::class)
->where('subject_id', $visit->id)
->where('action', 'badge.expired')
->where('created_at', '>=', now()->subDay())
->exists();
if ($alreadyLogged) {
continue;
}
AuditLog::record(
$visit->owner_ref,
'badge.expired',
$visit->organization_id,
null,
Visit::class,
$visit->id,
[
'visitor' => $visit->visitor->full_name,
'badge_code' => $visit->badge_code,
'expired_at' => $visit->badge_expires_at?->toIso8601String(),
],
);
$notifications->badgeExpired($visit);
$count++;
}
$this->info("Recorded {$count} expired badge alert(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Services\Frontdesk\VisitLifecycleService;
use Illuminate\Console\Command;
class MarkOverdueVisitsCommand extends Command
{
protected $signature = 'frontdesk:mark-overdue-visits';
protected $description = 'Mark expected and scheduled visits past their scheduled time as overdue';
public function handle(VisitLifecycleService $lifecycle): int
{
$count = $lifecycle->markOverdueVisits();
$this->info("Marked {$count} visit(s) as overdue.");
return self::SUCCESS;
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Comms\EmailService;
use App\Services\Frontdesk\ReportService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class SendDailyReportCommand extends Command
{
protected $signature = 'frontdesk:send-daily-reports';
protected $description = 'Email daily visitor summary reports to configured org admins';
public function handle(ReportService $reports, EmailService $email): int
{
$from = now()->subDay()->startOfDay();
$to = now()->subDay()->endOfDay();
$sent = 0;
Organization::query()->each(function (Organization $organization) use ($reports, $email, $from, $to, &$sent) {
$recipients = data_get($organization->settings, 'report_daily_recipients', []);
if ($recipients === []) {
return;
}
$summary = $reports->summary($organization->owner_ref, $organization, $from, $to);
$subject = "Frontdesk daily summary — {$organization->name} ({$from->toDateString()})";
$body = implode("\n", [
"Visitors checked in: {$summary['checked_in']}",
"Currently unique visitors: {$summary['unique_visitors']}",
"Contractors: {$summary['contractors']}",
"Deliveries: {$summary['deliveries']}",
"Average visit duration (min): {$summary['avg_duration_minutes']}",
]);
foreach ($recipients as $address) {
if (is_string($address) && filter_var($address, FILTER_VALIDATE_EMAIL)) {
$email->send($address, $subject, $body);
$sent++;
}
}
});
$this->info("Sent {$sent} daily report email(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Contracts\Printers;
use App\Models\Visit;
interface PrinterDriverInterface
{
public function name(): string;
/** @return array{format: string, content: string, filename?: string} */
public function renderBadge(Visit $visit): array;
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
trait ScopesToOwner
{
protected function ownerRef(Request $request): string
{
$owner = trim((string) ($request->input('owner') ?? $request->query('owner', '')));
abort_if($owner === '', 422, 'The owner parameter is required.');
return $owner;
}
protected function authorizeOwner(Request $request, Model $model): void
{
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Frontdesk\DeviceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DeviceHeartbeatController extends Controller
{
public function __invoke(Request $request, DeviceService $devices): JsonResponse
{
$token = $request->header('X-Device-Token') ?? $request->input('token');
abort_unless(is_string($token) && $token !== '', 422, 'Device token required.');
$device = $devices->findByToken($token);
abort_unless($device, 404);
$devices->recordHeartbeat($device);
return response()->json([
'status' => 'online',
'last_online_at' => $device->fresh()->last_online_at?->toIso8601String(),
]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesToOwner;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\OfflineCheckIn;
use App\Models\Organization;
use App\Services\Frontdesk\DeviceService;
use App\Services\Frontdesk\VisitCheckInService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OfflineSyncController extends Controller
{
public function store(Request $request, DeviceService $devices, VisitCheckInService $checkIn): JsonResponse
{
$token = $request->header('X-Device-Token') ?? $request->input('device_token');
abort_unless(is_string($token) && $token !== '', 422);
$device = $devices->findByToken($token);
abort_unless($device, 404);
$validated = $request->validate([
'client_id' => ['required', 'uuid'],
'payload' => ['required', 'array'],
'payload.full_name' => ['required', 'string', 'max:255'],
'payload.visitor_type' => ['required', 'string'],
'payload.policies_accepted' => ['required', 'boolean'],
]);
$existing = OfflineCheckIn::where('client_id', $validated['client_id'])->first();
if ($existing?->visit_id) {
return response()->json([
'visit_id' => $existing->visit_id,
'status' => 'already_synced',
]);
}
$organization = Organization::findOrFail($device->organization_id);
$payload = array_merge($validated['payload'], [
'branch_id' => $device->branch_id,
'reception_desk_id' => $device->reception_desk_id,
]);
$visit = $checkIn->checkIn($device->owner_ref, $organization, $payload);
OfflineCheckIn::updateOrCreate(
['client_id' => $validated['client_id']],
[
'device_id' => $device->id,
'payload' => $validated['payload'],
'synced_at' => now(),
'visit_id' => $visit->id,
],
);
return response()->json([
'visit_id' => $visit->id,
'public_id' => $visit->public_id,
'status' => 'synced',
], 201);
}
}
@@ -0,0 +1,145 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesToOwner;
use App\Http\Controllers\Controller;
use App\Models\Organization;
use App\Models\Visit;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitCheckOutService;
use App\Services\Frontdesk\VisitLifecycleService;
use App\Services\Frontdesk\VisitScheduleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VisitController extends Controller
{
use ScopesToOwner;
public function index(Request $request): JsonResponse
{
$owner = $this->ownerRef($request);
$visits = Visit::owned($owner)
->with(['visitor', 'host'])
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->latest()
->paginate(min((int) $request->query('per_page', 25), 100));
return response()->json($visits);
}
public function store(Request $request, VisitCheckInService $checkIn, VisitScheduleService $scheduler): JsonResponse
{
$owner = $this->ownerRef($request);
$organization = Organization::owned($owner)->findOrFail($request->integer('organization_id'));
$source = (string) $request->attributes->get('service_caller', 'api');
if ($request->filled('external_ref')) {
$existing = Visit::owned($owner)
->where('organization_id', $organization->id)
->where('external_ref', $request->string('external_ref'))
->where('source', $source)
->with(['visitor', 'host'])
->first();
if ($existing) {
return response()->json($existing);
}
}
if ($request->boolean('schedule')) {
$validated = $request->validate([
'organization_id' => ['required', 'integer'],
'external_ref' => ['nullable', 'string', 'max:128'],
'visitor_id' => ['nullable', 'integer'],
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
'company' => ['nullable', 'string'],
'phone' => ['nullable', 'string'],
'email' => ['nullable', 'email'],
'host_id' => ['nullable', 'integer'],
'branch_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string'],
'purpose' => ['nullable', 'string'],
'scheduled_at' => ['required', 'date'],
'notes' => ['nullable', 'string'],
'integration_metadata' => ['nullable', 'array'],
]);
$visit = $scheduler->schedule($owner, $organization, [
...$validated,
'source' => $source,
]);
return response()->json($visit->load(['visitor', 'host']), 201);
}
$validated = $request->validate([
'organization_id' => ['required', 'integer'],
'external_ref' => ['nullable', 'string', 'max:128'],
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string'],
'phone' => ['nullable', 'string'],
'email' => ['nullable', 'email'],
'host_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string'],
'purpose' => ['nullable', 'string'],
'integration_metadata' => ['nullable', 'array'],
'policies_accepted' => ['nullable', 'boolean'],
]);
$visit = $checkIn->checkIn($owner, $organization, [
...$validated,
'source' => $source,
'policies_accepted' => $validated['policies_accepted'] ?? true,
]);
return response()->json($visit->load(['visitor', 'host']), 201);
}
public function show(Request $request, Visit $visit): JsonResponse
{
$this->authorizeOwner($request, $visit);
return response()->json($visit->load(['visitor', 'host']));
}
public function checkOut(Request $request, Visit $visit, VisitCheckOutService $checkOut): JsonResponse
{
$this->authorizeOwner($request, $visit);
return response()->json($checkOut->checkOut($visit));
}
public function activate(Request $request, Visit $visit, VisitLifecycleService $lifecycle): JsonResponse
{
$this->authorizeOwner($request, $visit);
$visit = $lifecycle->checkInFromSchedule($visit);
return response()->json($visit->load(['visitor', 'host']));
}
public function approve(Request $request, Visit $visit, VisitLifecycleService $lifecycle): JsonResponse
{
$this->authorizeOwner($request, $visit);
$visit = $lifecycle->approve($visit);
return response()->json($visit->load(['visitor', 'host']));
}
public function cancel(Request $request, Visit $visit, VisitLifecycleService $lifecycle): JsonResponse
{
$this->authorizeOwner($request, $visit);
$validated = $request->validate([
'reason' => ['nullable', 'string', 'max:500'],
]);
return response()->json(
$lifecycle->cancel($visit, null, $validated['reason'] ?? null)->load(['visitor', 'host']),
);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesToOwner;
use App\Http\Controllers\Controller;
use App\Models\Visitor;
use App\Services\Frontdesk\VisitorSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VisitorController extends Controller
{
use ScopesToOwner;
public function index(Request $request, VisitorSearchService $search): JsonResponse
{
$owner = $this->ownerRef($request);
$organizationId = (int) $request->query('organization_id');
if ($request->filled('q')) {
return response()->json(
$search->search($owner, $organizationId, $request->string('q')->toString())
);
}
$visitors = Visitor::owned($owner)
->when($organizationId, fn ($q) => $q->where('organization_id', $organizationId))
->orderBy('full_name')
->paginate(min((int) $request->query('per_page', 25), 100));
return response()->json($visitors);
}
public function show(Request $request, Visitor $visitor): JsonResponse
{
$this->authorizeOwner($request, $visitor);
return response()->json($visitor->load(['visits' => fn ($q) => $q->latest()->limit(10)]));
}
}
@@ -0,0 +1,293 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
/**
* Max consecutive failed callbacks before we stop bouncing back into the
* authorize flow and show an error page instead (prevents an infinite
* /sso/callback /sso/connect redirect loop on a persistent upstream error).
*/
private const MAX_SSO_ATTEMPTS = 3;
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('frontdesk.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('frontdesk.dashboard'));
}
if (! $request->boolean('fallback')) {
$request->session()->forget('sso.attempts');
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('frontdesk.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
$request->session()->put('sso.state', $state);
$request->session()->put('sso.intended', $intended);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = [
'response_type' => 'code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
];
$loginHint = (string) $request->session()->get('sso.login_hint', '');
if ($loginHint !== '') {
$query['login_hint'] = $loginHint;
}
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
return redirect()->away($authorizeUrl);
}
public function callback(Request $request): RedirectResponse
{
$intended = (string) $request->session()->get('sso.intended', route('frontdesk.dashboard'));
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return $this->finishCallback($request, $intended, 'invalid_state');
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'code' => (string) $request->query('code'),
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return $this->finishCallback($request, $intended, 'token_exchange_failed');
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed');
}
Auth::login($user, remember: true);
$request->session()->regenerate();
$request->session()->forget('sso.attempts');
return $this->finishCallback($request, $intended, null);
}
public function failed(Request $request): View
{
return view('auth.sso-error', [
'reason' => (string) $request->session()->get('sso.error', ''),
]);
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// Per-app sign-out: end only this app's session and keep the platform
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently.
return redirect()->route('frontdesk.signed-out');
}
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
'embedded' => 1,
'return' => $return,
]);
return view('auth.sso-logout-bridge', [
'hubUrl' => $hubUrl,
'return' => $return,
'authOrigin' => 'https://'.config('app.auth_domain'),
]);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$return = (string) $request->query('return', '');
$root = (string) config('app.platform_domain', 'ladill.com');
$host = parse_url($return, PHP_URL_HOST);
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($return);
}
return response('', 204);
}
private function attemptSilentRefresh(Request $request, string $intended): bool
{
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
if ($refreshToken === '') {
return false;
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
$request->session()->forget('sso.refresh_token');
return false;
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return false;
}
Auth::login($user, remember: true);
$request->session()->put('sso.intended', $intended);
return true;
}
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
{
$refreshToken = (string) $tokenRes->json('refresh_token', '');
if ($refreshToken !== '') {
$request->session()->put('sso.refresh_token', $refreshToken);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
if ($email !== '') {
$request->session()->put('sso.login_hint', $email);
}
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse
{
if ($error) {
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
$request->session()->flash('sso.error', $error);
return redirect()->route('sso.failed');
}
$request->session()->put('sso.attempts', $attempts);
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]);
}
return $this->safeRedirect($intended, route('frontdesk.dashboard'));
}
private function safeReturnUrl(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return $url;
}
return route('frontdesk.signed-out');
}
private function safeRedirect(string $url, string $fallback): RedirectResponse
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($url);
}
return redirect()->away($fallback);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AuditLogController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'audit.view');
$organization = $this->organization($request);
$logs = $this->query($request, $organization)->paginate(50)->withQueryString();
return view('frontdesk.audit.index', [
'logs' => $logs,
'organization' => $organization,
'actions' => config('frontdesk.audit_actions'),
'canExport' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->can($this->member($request), 'audit.export'),
]);
}
public function export(Request $request): StreamedResponse
{
$this->authorizeAbility($request, 'audit.export');
$organization = $this->organization($request);
$filename = 'frontdesk-audit-'.now()->format('Y-m-d-His').'.csv';
return response()->streamDownload(function () use ($request, $organization) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Time', 'Action', 'Actor', 'Subject', 'Metadata', 'IP']);
$this->query($request, $organization)->chunk(200, function ($logs) use ($handle) {
foreach ($logs as $log) {
fputcsv($handle, [
$log->created_at?->toDateTimeString(),
$log->action,
$log->actor_ref ?? '—',
$log->subject_type ? class_basename($log->subject_type).' #'.$log->subject_id : '—',
json_encode($log->metadata ?? []),
$log->ip_address ?? '—',
]);
}
});
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
protected function query(Request $request, $organization)
{
return AuditLog::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->when($request->action, fn ($q, $action) => $q->where('action', $action))
->when($request->from, fn ($q, $from) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay()))
->when($request->to, fn ($q, $to) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay()))
->when($request->q, function ($q, $search) {
$q->where(function ($inner) use ($search) {
$inner->where('action', 'like', "%{$search}%")
->orWhere('metadata', 'like', "%{$search}%");
});
})
->orderByDesc('created_at');
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Visit;
use App\Services\Frontdesk\BadgeRenderService;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Printers\PrinterManager;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BadgeController extends Controller
{
use ScopesToAccount;
public function editTemplate(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
return view('frontdesk.badges.template', [
'organization' => $organization,
'canManage' => $canManage,
'template' => array_merge(
config('frontdesk.default_badge_template', []),
$organization->settings['badge_template'] ?? [],
),
'sampleVisit' => Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('status', Visit::STATUS_CHECKED_IN)
->with(['visitor', 'host'])
->latest('checked_in_at')
->first(),
]);
}
public function updateTemplate(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'show_photo' => ['boolean'],
'show_qr' => ['boolean'],
'show_host' => ['boolean'],
'show_company' => ['boolean'],
'show_type' => ['boolean'],
'primary_color' => ['nullable', 'string', 'max:7'],
'footer_text' => ['nullable', 'string', 'max:255'],
]);
$settings = $organization->settings ?? [];
$settings['badge_template'] = [
'show_photo' => $request->boolean('show_photo'),
'show_qr' => $request->boolean('show_qr'),
'show_host' => $request->boolean('show_host'),
'show_company' => $request->boolean('show_company'),
'show_type' => $request->boolean('show_type'),
'primary_color' => $validated['primary_color'] ?? '#0d9488',
'footer_text' => $validated['footer_text'] ?? '',
];
$organization->update(['settings' => $settings]);
return back()->with('success', 'Badge template saved.');
}
public function preview(Request $request, Visit $visit, BadgeRenderService $badges): View
{
$this->authorizeAbility($request, 'visits.view');
$this->authorizeOwner($request, $visit);
$visit->load(['visitor', 'host', 'organization']);
return view('frontdesk.badges.render', [
'visit' => $visit,
'template' => $badges->templateFor($visit->organization),
'photoUrl' => $badges->photoUrl($visit),
'qrSvg' => $visit->qr_token ? app(\App\Services\Frontdesk\QrCodeService::class)->svg($visit, 120) : null,
'autoPrint' => false,
'preview' => true,
]);
}
public function print(Request $request, Visit $visit, PrinterManager $printers)
{
$this->authorizeAbility($request, 'visits.view');
$this->authorizeOwner($request, $visit);
$rendered = $printers->renderBadge($visit, $request->query('driver'));
return response($rendered['content'], 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BranchController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.view');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->withCount(['buildings'])
->orderBy('name')
->get();
return view('frontdesk.admin.branches.index', compact('branches', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
return view('frontdesk.admin.branches.create', ['organization' => $this->organization($request)]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
]);
Branch::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
...$validated,
'is_active' => true,
]);
return redirect()->route('frontdesk.branches.index')->with('success', 'Branch created.');
}
public function edit(Request $request, Branch $branch): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
return view('frontdesk.admin.branches.edit', compact('branch'));
}
public function update(Request $request, Branch $branch): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
'is_active' => ['boolean'],
]);
$branch->update([
...$validated,
'is_active' => $request->boolean('is_active'),
]);
return redirect()->route('frontdesk.branches.index')->with('success', 'Branch updated.');
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Building;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BuildingController extends Controller
{
use ScopesToAccount;
public function index(Request $request, Branch $branch): View
{
$this->authorizeAbility($request, 'admin.branches.view');
$this->authorizeOwner($request, $branch);
$buildings = $branch->buildings()->withCount('receptionDesks')->orderBy('name')->get();
return view('frontdesk.admin.buildings.index', compact('branch', 'buildings'));
}
public function store(Request $request, Branch $branch): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'floor_count' => ['nullable', 'string', 'max:20'],
]);
Building::create([
'owner_ref' => $this->ownerRef($request),
'branch_id' => $branch->id,
...$validated,
]);
return back()->with('success', 'Building added.');
}
public function destroy(Request $request, Branch $branch, Building $building): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $building);
abort_unless($building->branch_id === $branch->id, 404);
$building->delete();
return back()->with('success', 'Building removed.');
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ComplianceController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'compliance.restore');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$deletedVisitors = Visitor::owned($owner)
->onlyTrashed()
->where('organization_id', $organization->id)
->latest('deleted_at')
->limit(25)
->get();
$deletedVisits = Visit::owned($owner)
->onlyTrashed()
->where('organization_id', $organization->id)
->with('visitor')
->latest('deleted_at')
->limit(25)
->get();
return view('frontdesk.compliance.recovery', compact('deletedVisitors', 'deletedVisits', 'organization'));
}
public function restoreVisitor(Request $request, int $visitorId): RedirectResponse
{
$this->authorizeAbility($request, 'compliance.restore');
$organization = $this->organization($request);
$visitor = Visitor::owned($this->ownerRef($request))
->onlyTrashed()
->where('organization_id', $organization->id)
->findOrFail($visitorId);
$visitor->restore();
AuditLog::record(
$this->ownerRef($request),
'visitor.restored',
$organization->id,
$this->ownerRef($request),
Visitor::class,
$visitor->id,
['full_name' => $visitor->full_name],
);
return back()->with('success', 'Visitor restored.');
}
public function restoreVisit(Request $request, int $visitId): RedirectResponse
{
$this->authorizeAbility($request, 'compliance.restore');
$organization = $this->organization($request);
$visit = Visit::owned($this->ownerRef($request))
->onlyTrashed()
->where('organization_id', $organization->id)
->findOrFail($visitId);
$visit->restore();
AuditLog::record(
$this->ownerRef($request),
'visit.restored',
$organization->id,
$this->ownerRef($request),
Visit::class,
$visit->id,
['visitor_id' => $visit->visitor_id],
);
return back()->with('success', 'Visit restored.');
}
public function destroyVisitor(Request $request, Visitor $visitor): RedirectResponse
{
$this->authorizeAbility($request, 'visitors.manage');
$this->authorizeOwner($request, $visitor);
$organization = $this->organization($request);
$visitor->delete();
AuditLog::record(
$this->ownerRef($request),
'visitor.deleted',
$organization->id,
$this->ownerRef($request),
Visitor::class,
$visitor->id,
['full_name' => $visitor->full_name],
);
return redirect()
->route('frontdesk.visitors.index')
->with('success', 'Visitor archived.');
}
public function destroyVisit(Request $request, Visit $visit): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$organization = $this->organization($request);
abort_if($visit->isInside(), 422, 'Check out the visitor before archiving the visit.');
$visit->delete();
AuditLog::record(
$this->ownerRef($request),
'visit.deleted',
$organization->id,
$this->ownerRef($request),
Visit::class,
$visit->id,
['visitor_id' => $visit->visitor_id],
);
return redirect()
->route('frontdesk.visits.index')
->with('success', 'Visit archived.');
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Frontdesk\Concerns;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\OrganizationResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
trait ScopesToAccount
{
protected function ownerRef(Request $request): string
{
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
$organization = $request->attributes->get('frontdesk.organization')
?? app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
return $organization;
}
protected function member(Request $request): ?Member
{
return $request->attributes->get('frontdesk.member')
?? app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
abort_unless(
app(FrontdeskPermissions::class)->can($this->member($request), $ability),
403,
);
}
protected function authorizeOwner(Request $request, Model $model): void
{
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
}
protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder
{
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null) {
$query->where($column, $branchId);
}
return $query;
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Visit;
use App\Services\Frontdesk\OrganizationResolver;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class DashboardController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'dashboard.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$visitQuery = Visit::owned($owner)->where('organization_id', $organization->id);
$this->scopeToBranch($request, $visitQuery);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$cacheKey = "fd:dashboard:{$owner}:{$organization->id}:".($branchScope ?? 'all');
$stats = Cache::remember($cacheKey, 60, function () use ($visitQuery) {
$today = (clone $visitQuery)->where(function ($q) {
$q->whereDate('checked_in_at', today())
->orWhereDate('scheduled_at', today());
});
return [
'visitors_today' => (clone $today)->count(),
'currently_inside' => (clone $visitQuery)->currentlyInside()->count(),
'expected_arrivals' => (clone $visitQuery)->whereIn('status', [
Visit::STATUS_EXPECTED,
Visit::STATUS_SCHEDULED,
Visit::STATUS_OVERDUE,
])->whereDate('scheduled_at', today())->count(),
'waiting' => (clone $visitQuery)->where('status', Visit::STATUS_WAITING)->count(),
'overdue' => (clone $visitQuery)->where('status', Visit::STATUS_OVERDUE)->count(),
'checked_out_today' => (clone $visitQuery)->where('status', Visit::STATUS_CHECKED_OUT)
->whereDate('checked_out_at', today())->count(),
'deliveries_today' => (clone $visitQuery)->where('visitor_type', 'delivery')
->whereDate('checked_in_at', today())->count(),
'contractors_today' => (clone $visitQuery)->where('visitor_type', 'contractor')
->whereDate('checked_in_at', today())->count(),
'pending_approvals' => (clone $visitQuery)->where('status', Visit::STATUS_WAITING)
->whereNull('checked_in_at')
->where(function ($q) {
$q->whereJsonContains('contractor_details', ['_awaiting_approval' => true])
->orWhereJsonContains('delivery_details', ['_awaiting_approval' => true]);
})->count(),
];
});
$currentVisitors = (clone $visitQuery)->currentlyInside()
->with(['visitor', 'host'])
->latest('checked_in_at')
->limit(10)
->get();
$expectedVisitors = (clone $visitQuery)
->whereIn('status', [Visit::STATUS_EXPECTED, Visit::STATUS_SCHEDULED, Visit::STATUS_WAITING, Visit::STATUS_OVERDUE])
->whereDate('scheduled_at', today())
->with(['visitor', 'host'])
->orderBy('scheduled_at')
->limit(10)
->get();
$pendingApprovals = (clone $visitQuery)
->where('status', Visit::STATUS_WAITING)
->whereNull('checked_in_at')
->where(function ($q) {
$q->whereJsonContains('contractor_details', ['_awaiting_approval' => true])
->orWhereJsonContains('delivery_details', ['_awaiting_approval' => true]);
})
->with(['visitor', 'host'])
->latest()
->limit(10)
->get();
return view('frontdesk.dashboard', compact('stats', 'currentVisitors', 'expectedVisitors', 'pendingApprovals', 'organization'));
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Device;
use App\Models\ReceptionDesk;
use App\Services\Frontdesk\DeviceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DeviceController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'devices.view');
$organization = $this->organization($request);
$devices = Device::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with(['branch', 'receptionDesk'])
->orderBy('name')
->paginate(25);
return view('frontdesk.devices.index', [
'organization' => $organization,
'devices' => $devices,
'deviceTypes' => config('frontdesk.device_types'),
'canManage' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->can($this->member($request), 'devices.manage'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'devices.manage');
$organization = $this->organization($request);
return view('frontdesk.devices.create', [
'organization' => $organization,
'deviceTypes' => config('frontdesk.device_types'),
'branches' => $this->branches($request, $organization->id),
'desks' => $this->desks($request, $organization->id),
]);
}
public function store(Request $request, DeviceService $devices): RedirectResponse
{
$this->authorizeAbility($request, 'devices.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.device_types')))],
'branch_id' => ['nullable', 'integer'],
'reception_desk_id' => ['nullable', 'integer'],
]);
$token = in_array($validated['type'], ['kiosk', 'badge_printer', 'qr_scanner'], true)
? $devices->generateToken()
: null;
Device::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
'name' => $validated['name'],
'type' => $validated['type'],
'branch_id' => $validated['branch_id'] ?? null,
'reception_desk_id' => $validated['reception_desk_id'] ?? null,
'device_token' => $token,
'status' => 'offline',
'config' => $devices->defaultConfigForType($validated['type']),
]);
return redirect()->route('frontdesk.devices.index')->with('success', 'Device registered.');
}
public function edit(Request $request, Device $device): View
{
$this->authorizeAbility($request, 'devices.manage');
$this->authorizeOwner($request, $device);
return view('frontdesk.devices.edit', [
'device' => $device,
'deviceTypes' => config('frontdesk.device_types'),
'branches' => $this->branches($request, $device->organization_id),
'desks' => $this->desks($request, $device->organization_id),
]);
}
public function update(Request $request, Device $device): RedirectResponse
{
$this->authorizeAbility($request, 'devices.manage');
$this->authorizeOwner($request, $device);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.device_types')))],
'branch_id' => ['nullable', 'integer'],
'reception_desk_id' => ['nullable', 'integer'],
'status' => ['nullable', 'string', 'in:online,offline,maintenance'],
]);
$device->update($validated);
return redirect()->route('frontdesk.devices.index')->with('success', 'Device updated.');
}
public function destroy(Request $request, Device $device): RedirectResponse
{
$this->authorizeAbility($request, 'devices.manage');
$this->authorizeOwner($request, $device);
$device->delete();
return redirect()->route('frontdesk.devices.index')->with('success', 'Device removed.');
}
public function regenerateToken(Request $request, Device $device, DeviceService $devices): RedirectResponse
{
$this->authorizeAbility($request, 'devices.manage');
$this->authorizeOwner($request, $device);
$device->update(['device_token' => $devices->generateToken()]);
return back()->with('success', 'Device token regenerated.');
}
/** @return \Illuminate\Database\Eloquent\Collection<int, Branch> */
protected function branches(Request $request, int $organizationId)
{
return Branch::owned($this->ownerRef($request))
->where('organization_id', $organizationId)
->where('is_active', true)
->orderBy('name')
->get();
}
/** @return \Illuminate\Database\Eloquent\Collection<int, ReceptionDesk> */
protected function desks(Request $request, int $organizationId)
{
return ReceptionDesk::owned($this->ownerRef($request))
->whereHas('building.branch', fn ($q) => $q->where('organization_id', $organizationId))
->where('is_active', true)
->orderBy('name')
->get();
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Host;
use App\Models\Visitor;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class HostController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'hosts.view');
$organization = $this->organization($request);
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->paginate(25);
return view('frontdesk.hosts.index', compact('hosts', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'hosts.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('frontdesk.hosts.create', compact('organization', 'branches'));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'hosts.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'department' => ['nullable', 'string', 'max:255'],
'office' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'extension' => ['nullable', 'string', 'max:20'],
'user_ref' => ['nullable', 'string', 'max:64'],
'branch_id' => ['nullable', 'integer'],
]);
Host::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
...$validated,
]);
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host added.');
}
public function edit(Request $request, Host $host): View
{
$this->authorizeAbility($request, 'hosts.manage');
$this->authorizeOwner($request, $host);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $host->organization_id)
->where('is_active', true)
->orderBy('name')
->get();
return view('frontdesk.hosts.edit', compact('host', 'branches'));
}
public function update(Request $request, Host $host): RedirectResponse
{
$this->authorizeAbility($request, 'hosts.manage');
$this->authorizeOwner($request, $host);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'department' => ['nullable', 'string', 'max:255'],
'office' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'extension' => ['nullable', 'string', 'max:20'],
'user_ref' => ['nullable', 'string', 'max:64'],
'branch_id' => ['nullable', 'integer'],
'is_available' => ['boolean'],
]);
$host->update([
...$validated,
'is_available' => $request->boolean('is_available', true),
]);
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host updated.');
}
public function destroy(Request $request, Host $host): RedirectResponse
{
$this->authorizeAbility($request, 'hosts.manage');
$this->authorizeOwner($request, $host);
$host->delete();
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host removed.');
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Host;
use App\Models\Visit;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\OrganizationResolver;
use App\Services\Frontdesk\VisitLifecycleService;
use App\Services\Frontdesk\VisitScheduleService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class HostPortalController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$host = $this->resolveLinkedHost($request);
$organization = $this->organization($request);
$pending = Visit::owned($host->owner_ref)
->where('organization_id', $organization->id)
->where('host_id', $host->id)
->where('status', Visit::STATUS_WAITING)
->with('visitor')
->orderByDesc('updated_at')
->get()
->filter(fn (Visit $v) => $v->awaitingApproval());
$upcoming = Visit::owned($host->owner_ref)
->where('organization_id', $organization->id)
->where('host_id', $host->id)
->whereIn('status', [Visit::STATUS_SCHEDULED, Visit::STATUS_EXPECTED, Visit::STATUS_OVERDUE])
->with('visitor')
->orderBy('scheduled_at')
->limit(10)
->get();
$recent = Visit::owned($host->owner_ref)
->where('organization_id', $organization->id)
->where('host_id', $host->id)
->whereIn('status', [Visit::STATUS_CHECKED_IN, Visit::STATUS_CHECKED_OUT, Visit::STATUS_CANCELLED])
->with('visitor')
->orderByDesc('updated_at')
->limit(15)
->get();
return view('frontdesk.host-portal.index', compact('host', 'organization', 'pending', 'upcoming', 'recent'));
}
public function scheduleForm(Request $request): View
{
$host = $this->resolveLinkedHost($request);
$organization = $this->organization($request);
return view('frontdesk.host-portal.schedule', compact('host', 'organization'));
}
public function scheduleStore(Request $request, VisitScheduleService $scheduler): RedirectResponse
{
$host = $this->resolveLinkedHost($request);
$organization = $this->organization($request);
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
'scheduled_at' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$scheduler->schedule(
$host->owner_ref,
$organization,
[
...$validated,
'host_id' => $host->id,
'branch_id' => $host->branch_id,
],
$this->ownerRef($request),
);
return redirect()->route('frontdesk.host.index')->with('success', 'Visit scheduled.');
}
public function approve(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$host = $this->resolveLinkedHost($request);
$this->authorizeOwner($request, $visit);
abort_unless($visit->host_id === $host->id, 403);
abort_unless($visit->awaitingApproval(), 422);
$lifecycle->approve($visit, $this->ownerRef($request));
return redirect()->route('frontdesk.host.index')->with('success', 'Visit approved.');
}
public function toggleAvailability(Request $request): RedirectResponse
{
$host = $this->resolveLinkedHost($request);
$host->update(['is_available' => ! $host->is_available]);
return back()->with('success', $host->is_available ? 'You are now available.' : 'You are marked unavailable.');
}
protected function resolveLinkedHost(Request $request): Host
{
$permissions = app(FrontdeskPermissions::class);
$member = $this->member($request);
abort_unless(
$permissions->can($member, 'host.portal') || app(OrganizationResolver::class)->hostFor($request->user()) !== null,
403,
);
$host = app(OrganizationResolver::class)->hostFor($request->user());
abort_unless($host, 403, 'No host profile linked to your account.');
$this->authorizeOwner($request, $host);
return $host;
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\WebhookEndpoint;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class IntegrationController extends Controller
{
use ScopesToAccount;
public function edit(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->isAdmin($this->member($request));
$webhook = WebhookEndpoint::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->first();
return view('frontdesk.integrations.edit', [
'organization' => $organization,
'canManage' => $canManage,
'webhook' => $webhook,
'webhookEvents' => config('frontdesk.webhook_events', []),
'integrations' => config('frontdesk.integrations', []),
'icalUrl' => $this->signedIcalUrl($organization->id, $this->ownerRef($request)),
]);
}
public function update(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'webhook_url' => ['nullable', 'url', 'max:500'],
'webhook_secret' => ['nullable', 'string', 'max:128'],
'webhook_events' => ['nullable', 'array'],
'webhook_events.*' => ['string'],
'webhook_active' => ['boolean'],
]);
if (empty($validated['webhook_url'])) {
WebhookEndpoint::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->delete();
return back()->with('success', 'Integration settings saved.');
}
WebhookEndpoint::updateOrCreate(
[
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
],
[
'url' => $validated['webhook_url'],
'secret' => $validated['webhook_secret'] ?? null,
'events' => array_values($validated['webhook_events'] ?? config('frontdesk.webhook_events')),
'is_active' => $request->boolean('webhook_active', true),
],
);
return back()->with('success', 'Integration settings saved.');
}
protected function signedIcalUrl(int $organizationId, string $ownerRef): string
{
$token = hash_hmac('sha256', "{$organizationId}:{$ownerRef}", (string) config('app.key'));
return route('frontdesk.integrations.ical', [
'organization' => $organizationId,
'owner' => $ownerRef,
'token' => $token,
]);
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Host;
use App\Models\Visit;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitCheckOutService;
use App\Services\Frontdesk\VisitorTypeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class KioskController extends Controller
{
use ScopesToAccount;
public function show(Request $request, VisitorTypeService $visitorTypes): View
{
$this->authorizeAbility($request, 'kiosk.use');
$organization = $this->organization($request);
$settings = $organization->settings ?? [];
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_available', true);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->get();
return view('frontdesk.kiosk.index', [
'organization' => $organization,
'hosts' => $hosts,
'visitorTypes' => config('frontdesk.visitor_types'),
'typeConfigs' => $visitorTypes->configsForFrontend(),
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
'visitorPolicy' => $settings['visitor_policy'] ?? null,
]);
}
public function checkIn(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): JsonResponse
{
$this->authorizeAbility($request, 'kiosk.use');
$organization = $this->organization($request);
$visitorType = $request->input('visitor_type', 'visitor');
$validated = $request->validate(array_merge([
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'host_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer'],
'policies_accepted' => ['accepted'],
'photo_data' => ['nullable', 'string'],
'signature_path' => ['nullable', 'string'],
], $visitorTypes->validationRules($visitorType)));
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization);
if (! empty($validated['photo_data'])) {
$payload['photo_data'] = $validated['photo_data'];
}
$visit = $checkIn->checkIn($this->ownerRef($request), $organization, $payload);
return response()->json([
'visit' => [
'id' => $visit->id,
'public_id' => $visit->public_id,
'badge_code' => $visit->badge_code,
'visitor_name' => $visit->visitor->full_name,
'host_name' => $visit->host?->name,
'status' => $visit->status,
'awaiting_approval' => $visit->awaitingApproval(),
'checked_in_at' => $visit->checked_in_at?->toIso8601String(),
'badge_url' => $visit->isInside() ? route('frontdesk.visits.badge', $visit) : null,
],
]);
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\Host;
use App\Models\Organization;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitorTypeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class KioskDeviceController extends Controller
{
public function show(Request $request, VisitorTypeService $visitorTypes): View
{
$device = $this->device($request);
$organization = Organization::findOrFail($device->organization_id);
$settings = $organization->settings ?? [];
$hosts = Host::owned($device->owner_ref)
->where('organization_id', $organization->id)
->where('is_available', true);
if ($device->branch_id) {
$hosts->where(function ($q) use ($device) {
$q->whereNull('branch_id')->orWhere('branch_id', $device->branch_id);
});
}
$hosts = $hosts->orderBy('name')->get();
return view('frontdesk.kiosk.index', [
'organization' => $organization,
'hosts' => $hosts,
'visitorTypes' => config('frontdesk.visitor_types'),
'typeConfigs' => $visitorTypes->configsForFrontend(),
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
'visitorPolicy' => $settings['visitor_policy'] ?? null,
'checkInUrl' => route('frontdesk.kiosk.device.check-in', $device->device_token),
]);
}
public function checkIn(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): JsonResponse
{
$device = $this->device($request);
$organization = Organization::findOrFail($device->organization_id);
$visitorType = $request->input('visitor_type', 'visitor');
$validated = $request->validate(array_merge([
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'host_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer'],
'policies_accepted' => ['accepted'],
'photo_data' => ['nullable', 'string'],
'vehicle_info' => ['nullable', 'array'],
'signature_path' => ['nullable', 'string'],
], $visitorTypes->validationRules($visitorType)));
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization);
if (! empty($validated['photo_data'])) {
$payload['photo_data'] = $validated['photo_data'];
}
$payload['branch_id'] = $device->branch_id;
$payload['reception_desk_id'] = $device->reception_desk_id;
$visit = $checkIn->checkIn($device->owner_ref, $organization, $payload);
return response()->json([
'visit' => [
'id' => $visit->id,
'public_id' => $visit->public_id,
'badge_code' => $visit->badge_code,
'visitor_name' => $visit->visitor->full_name,
'host_name' => $visit->host?->name,
'status' => $visit->status,
'awaiting_approval' => $visit->awaitingApproval(),
'checked_in_at' => $visit->checked_in_at?->toIso8601String(),
'badge_url' => $visit->isInside() ? route('frontdesk.visits.badge', $visit) : null,
],
]);
}
protected function device(Request $request): Device
{
return $request->attributes->get('frontdesk.device')
?? abort(404);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Member;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MemberController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.view');
$organization = $this->organization($request);
$members = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with('branch')
->orderBy('created_at')
->get();
return view('frontdesk.admin.members.index', [
'members' => $members,
'organization' => $organization,
'roles' => config('frontdesk.roles'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('frontdesk.admin.members.create', [
'organization' => $organization,
'branches' => $branches,
'roles' => config('frontdesk.roles'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'user_ref' => ['required', 'string', 'max:255'],
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.roles')))],
'branch_id' => ['nullable', 'integer', 'exists:frontdesk_branches,id'],
]);
Member::updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $validated['user_ref'],
],
[
'owner_ref' => $this->ownerRef($request),
'role' => $validated['role'],
'branch_id' => $validated['branch_id'] ?? null,
],
);
return redirect()->route('frontdesk.members.index')->with('success', 'Member saved.');
}
public function destroy(Request $request, Member $member): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$this->authorizeOwner($request, $member);
abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
$member->delete();
return redirect()->route('frontdesk.members.index')->with('success', 'Member removed.');
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Services\Frontdesk\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OnboardingController extends Controller
{
use ScopesToAccount;
public function __construct(
protected OrganizationResolver $organizations,
) {}
public function show(Request $request): View|RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('frontdesk.dashboard');
}
return view('frontdesk.onboarding.show', [
'user' => $request->user(),
'timezones' => timezone_identifiers_list(),
]);
}
public function store(Request $request): RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('frontdesk.dashboard');
}
$validated = $request->validate([
'organization_name' => ['required', 'string', 'max:255'],
'branch_name' => ['required', 'string', 'max:255'],
'branch_address' => ['nullable', 'string', 'max:500'],
'timezone' => ['required', 'timezone'],
'visitor_policy' => ['nullable', 'string', 'max:5000'],
'badge_expiry_hours' => ['nullable', 'integer', 'min:1', 'max:24'],
'kiosk_reset_seconds' => ['nullable', 'integer', 'min:30', 'max:600'],
]);
$this->organizations->completeOnboarding($request->user(), $validated);
return redirect()->route('frontdesk.dashboard')->with('success', 'Welcome to Ladill Frontdesk!');
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Models\Visit;
use App\Services\Frontdesk\QrCodeService;
use App\Services\Frontdesk\VisitCheckOutService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class QrScanController extends Controller
{
public function show(string $token, QrCodeService $qr): View
{
$visit = Visit::where('qr_token', $token)->with(['visitor', 'host', 'organization'])->firstOrFail();
return view('frontdesk.qr.show', [
'visit' => $visit,
'qrSvg' => $qr->svg($visit),
]);
}
public function checkOut(string $token, VisitCheckOutService $checkOut): View
{
$visit = Visit::where('qr_token', $token)->firstOrFail();
$checkOut->checkOut($visit);
return view('frontdesk.qr.checked-out', ['visit' => $visit->load('visitor')]);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Building;
use App\Models\ReceptionDesk;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ReceptionDeskController extends Controller
{
use ScopesToAccount;
public function index(Request $request, Building $building): View
{
$this->authorizeAbility($request, 'admin.desks.view');
$this->authorizeOwner($request, $building);
$desks = $building->receptionDesks()->orderBy('name')->get();
return view('frontdesk.admin.desks.index', compact('building', 'desks'));
}
public function store(Request $request, Building $building): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $building);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'location' => ['nullable', 'string', 'max:255'],
]);
ReceptionDesk::create([
'owner_ref' => $this->ownerRef($request),
'building_id' => $building->id,
...$validated,
'is_active' => true,
]);
return back()->with('success', 'Reception desk added.');
}
public function destroy(Request $request, Building $building, ReceptionDesk $desk): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $desk);
abort_unless($desk->building_id === $building->id, 404);
$desk->delete();
return back()->with('success', 'Reception desk removed.');
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Visit;
use App\Services\Frontdesk\BadgeRenderService;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\OrganizationResolver;
use App\Services\Frontdesk\ReportService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReportController extends Controller
{
use ScopesToAccount;
public function index(Request $request, ReportService $reports): View
{
$this->authorizeAbility($request, 'reports.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
[$from, $to] = $this->dateRange($request);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
return view('frontdesk.reports.index', [
'organization' => $organization,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'summary' => $reports->summary($owner, $organization, $from, $to, $branchId),
'peakHours' => $reports->peakHours($owner, $organization, $from, $to, $branchId),
'departments' => $reports->visitsByDepartment($owner, $organization, $from, $to, $branchId),
'frequentVisitors' => $reports->frequentVisitors($owner, $organization),
'security' => $reports->securityIncidents($owner, $organization, $from, $to),
'dailyCounts' => $reports->dailyCounts($owner, $organization, $from, $to, $branchId),
'canExport' => app(FrontdeskPermissions::class)->can($this->member($request), 'reports.export'),
]);
}
public function export(Request $request, ReportService $reports): StreamedResponse
{
$this->authorizeAbility($request, 'reports.export');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
[$from, $to] = $this->dateRange($request);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
$summary = $reports->summary($owner, $organization, $from, $to, $branchId);
$departments = $reports->visitsByDepartment($owner, $organization, $from, $to, $branchId);
$filename = 'frontdesk-report-'.$from->format('Y-m-d').'-'.$to->format('Y-m-d').'.csv';
return response()->streamDownload(function () use ($summary, $departments, $from, $to) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Frontdesk report', $from->toDateString(), 'to', $to->toDateString()]);
fputcsv($handle, []);
fputcsv($handle, ['Metric', 'Value']);
foreach ($summary as $key => $value) {
fputcsv($handle, [str_replace('_', ' ', $key), $value]);
}
fputcsv($handle, []);
fputcsv($handle, ['Department', 'Visits']);
foreach ($departments as $row) {
fputcsv($handle, [$row->department, $row->count]);
}
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
/** @return array{0: Carbon, 1: Carbon} */
protected function dateRange(Request $request): array
{
$from = $request->filled('from')
? Carbon::parse($request->string('from'))->startOfDay()
: now()->subDays(30)->startOfDay();
$to = $request->filled('to')
? Carbon::parse($request->string('to'))->endOfDay()
: now()->endOfDay();
return [$from, $to];
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Visit;
use App\Services\Frontdesk\VisitCheckOutService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SecurityController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'security.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$occupancy = Visit::owned($owner)
->where('organization_id', $organization->id)
->currentlyInside()
->with(['visitor', 'host']);
$this->scopeToBranch($request, $occupancy);
$occupancy = $occupancy->orderBy('checked_in_at')->get();
$expiredBadges = $occupancy->filter(fn (Visit $v) => $v->isBadgeExpired());
return view('frontdesk.security.index', [
'occupancy' => $occupancy,
'expiredBadges' => $expiredBadges,
'organization' => $organization,
'canCheckout' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->can($this->member($request), 'security.checkout'),
]);
}
public function evacuation(Request $request): View
{
$this->authorizeAbility($request, 'security.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$occupancy = Visit::owned($owner)
->where('organization_id', $organization->id)
->currentlyInside()
->with(['visitor', 'host', 'branch']);
$this->scopeToBranch($request, $occupancy);
$occupancy = $occupancy->orderBy('checked_in_at')->get();
return view('frontdesk.security.evacuation', compact('occupancy', 'organization'));
}
public function evacuationBadges(Request $request, \App\Services\Frontdesk\BadgeRenderService $badges): View
{
$this->authorizeAbility($request, 'security.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visits = Visit::owned($owner)
->where('organization_id', $organization->id)
->currentlyInside()
->with(['visitor', 'host', 'organization']);
$this->scopeToBranch($request, $visits);
$visits = $visits->orderBy('checked_in_at')->get();
$rendered = $visits->map(fn (Visit $visit) => $badges->renderHtml($visit));
return view('frontdesk.security.evacuation-badges', compact('visits', 'rendered', 'organization'));
}
public function checkOut(Request $request, Visit $visit, VisitCheckOutService $checkOut): RedirectResponse
{
$this->authorizeAbility($request, 'security.checkout');
$this->authorizeOwner($request, $visit);
$checkOut->checkOut($visit, $this->ownerRef($request));
return back()->with('success', 'Visitor checked out.');
}
public function verifyForm(Request $request): View
{
$this->authorizeAbility($request, 'security.verify');
return view('frontdesk.security.verify', [
'organization' => $this->organization($request),
]);
}
public function verify(Request $request): View
{
$this->authorizeAbility($request, 'security.verify');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'lookup' => ['required', 'string', 'max:64'],
]);
$lookup = trim($validated['lookup']);
$visit = Visit::owned($owner)
->where('organization_id', $organization->id)
->where(function ($q) use ($lookup) {
$q->where('badge_code', strtoupper($lookup))
->orWhere('qr_token', $lookup);
})
->with(['visitor', 'host', 'branch'])
->latest('checked_in_at')
->first();
return view('frontdesk.security.verify', [
'organization' => $organization,
'lookup' => $lookup,
'visit' => $visit,
]);
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Frontdesk\FrontdeskPermissions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
use ScopesToAccount;
public function edit(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->count();
return view('frontdesk.settings.edit', [
'organization' => $organization,
'canManage' => $canManage,
'branchCount' => $branches,
'roles' => config('frontdesk.roles'),
'deviceTypes' => config('frontdesk.device_types'),
'notificationChannels' => config('frontdesk.notification_channels'),
'notificationEvents' => config('frontdesk.notification_events'),
]);
}
public function update(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'timezone'],
'badge_expiry_hours' => ['required', 'integer', 'min:1', 'max:24'],
'kiosk_reset_seconds' => ['required', 'integer', 'min:30', 'max:600'],
'contractor_badge_expiry_hours' => ['nullable', 'integer', 'min:1', 'max:24'],
'visitor_policy' => ['nullable', 'string', 'max:5000'],
'notification_channels' => ['nullable', 'array'],
'notification_channels.*' => ['string', 'in:'.implode(',', array_keys(config('frontdesk.notification_channels')))],
'notification_events' => ['nullable', 'array'],
'report_daily_recipients' => ['nullable', 'string', 'max:2000'],
]);
$settings = $organization->settings ?? [];
$settings['onboarded'] = true;
$settings['badge_expiry_hours'] = $validated['badge_expiry_hours'];
$settings['kiosk_reset_seconds'] = $validated['kiosk_reset_seconds'];
$settings['visitor_policy'] = $validated['visitor_policy'] ?? null;
$settings['notification_channels'] = $validated['notification_channels'] ?? ['email'];
$eventKeys = array_keys(config('frontdesk.notification_events', []));
$submittedEvents = $validated['notification_events'] ?? [];
$settings['notification_events'] = [];
foreach ($eventKeys as $key) {
$settings['notification_events'][$key] = (bool) ($submittedEvents[$key] ?? false);
}
if (array_key_exists('report_daily_recipients', $validated)) {
$settings['report_daily_recipients'] = array_values(array_filter(array_map(
'trim',
explode(',', (string) ($validated['report_daily_recipients'] ?? '')),
)));
}
if (! empty($validated['contractor_badge_expiry_hours'])) {
$settings['type_badge_expiry_hours'] = array_merge(
$settings['type_badge_expiry_hours'] ?? [],
['contractor' => (int) $validated['contractor_badge_expiry_hours']],
);
}
$organization->update([
'name' => $validated['name'],
'timezone' => $validated['timezone'],
'settings' => $settings,
]);
return back()->with('success', 'Settings saved.');
}
}
@@ -0,0 +1,300 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Host;
use App\Models\Visit;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitCheckOutService;
use App\Services\Frontdesk\VisitLifecycleService;
use App\Services\Frontdesk\VisitScheduleService;
use App\Services\Frontdesk\VisitorSearchService;
use App\Services\Frontdesk\VisitorTypeService;
use App\Services\Printers\PrinterManager;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class VisitController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'visits.view');
$organization = $this->organization($request);
$visits = Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with(['visitor', 'host']);
$this->scopeToBranch($request, $visits);
$visits = $visits
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->q, function ($q, $search) {
$q->whereHas('visitor', fn ($v) => $v->where('full_name', 'like', "%{$search}%"));
})
->latest()
->paginate(25)
->withQueryString();
return view('frontdesk.visits.index', compact('visits', 'organization'));
}
public function calendar(Request $request): View
{
$this->authorizeAbility($request, 'visits.view');
$organization = $this->organization($request);
$start = $request->filled('start')
? Carbon::parse($request->string('start')->toString())->startOfWeek()
: now()->startOfWeek();
$end = $start->copy()->endOfWeek();
$visits = Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->whereNotNull('scheduled_at')
->whereBetween('scheduled_at', [$start, $end])
->whereNotIn('status', [Visit::STATUS_CANCELLED])
->with(['visitor', 'host']);
$this->scopeToBranch($request, $visits);
$visits = $visits->orderBy('scheduled_at')->get()->groupBy(
fn (Visit $visit) => $visit->scheduled_at->toDateString(),
);
return view('frontdesk.visits.calendar', compact('visits', 'organization', 'start', 'end'));
}
public function create(Request $request, VisitorSearchService $search): View
{
$this->authorizeAbility($request, 'visits.manage');
$organization = $this->organization($request);
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->get();
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$returningVisitors = $request->filled('q')
? $search->search($this->ownerRef($request), $organization->id, $request->string('q')->toString())
: collect();
return view('frontdesk.visits.create', [
'organization' => $organization,
'hosts' => $hosts,
'branches' => $branches,
'returningVisitors' => $returningVisitors,
'typeConfigs' => app(VisitorTypeService::class)->configsForFrontend(),
]);
}
public function scheduleForm(Request $request, VisitorSearchService $search): View
{
$this->authorizeAbility($request, 'visits.manage');
$organization = $this->organization($request);
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->get();
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$returningVisitors = $request->filled('q')
? $search->search($this->ownerRef($request), $organization->id, $request->string('q')->toString())
: collect();
return view('frontdesk.visits.schedule', compact('organization', 'hosts', 'branches', 'returningVisitors'));
}
public function store(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$organization = $this->organization($request);
$visitorType = $request->input('visitor_type', 'visitor');
$validated = $request->validate(array_merge([
'visitor_id' => ['nullable', 'integer'],
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'host_id' => ['nullable', 'integer'],
'branch_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
'policies_accepted' => ['accepted'],
'photo_data' => ['nullable', 'string'],
], $visitorTypes->validationRules($visitorType)));
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
->branchScope($this->member($request));
if ($branchScope !== null) {
$validated['branch_id'] = $branchScope;
}
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization, $request);
$visit = $checkIn->checkIn(
$this->ownerRef($request),
$organization,
$payload,
$this->ownerRef($request),
);
$message = $visit->awaitingApproval()
? 'Visit submitted for approval.'
: 'Visitor checked in successfully.';
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', $message);
}
public function scheduleStore(Request $request, VisitScheduleService $scheduler): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'visitor_id' => ['nullable', 'integer'],
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'host_id' => ['nullable', 'integer'],
'branch_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
'scheduled_at' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
->branchScope($this->member($request));
if ($branchScope !== null) {
$validated['branch_id'] = $branchScope;
}
$visit = $scheduler->schedule(
$this->ownerRef($request),
$organization,
$validated,
$this->ownerRef($request),
);
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', 'Visit scheduled successfully.');
}
public function show(Request $request, Visit $visit): View
{
$this->authorizeAbility($request, 'visits.view');
$this->authorizeOwner($request, $visit);
$visit->load(['visitor', 'host', 'organization', 'branch']);
$canManage = app(FrontdeskPermissions::class)->can(
$this->member($request),
'visits.manage',
);
return view('frontdesk.visits.show', compact('visit', 'canManage'));
}
public function activate(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$request->validate(['policies_accepted' => ['accepted']]);
$lifecycle->checkInFromSchedule(
$visit,
$this->ownerRef($request),
true,
);
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', 'Visitor checked in successfully.');
}
public function markWaiting(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$lifecycle->markWaiting($visit, $this->ownerRef($request));
return back()->with('success', 'Visitor marked as waiting.');
}
public function cancel(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$validated = $request->validate([
'reason' => ['nullable', 'string', 'max:500'],
]);
$lifecycle->cancel($visit, $this->ownerRef($request), $validated['reason'] ?? null);
return redirect()
->route('frontdesk.visits.index', ['status' => 'cancelled'])
->with('success', 'Visit cancelled.');
}
public function approve(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$lifecycle->approve($visit, $this->ownerRef($request));
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', 'Visit approved and visitor checked in.');
}
public function checkOut(Request $request, Visit $visit, VisitCheckOutService $checkOut): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$checkOut->checkOut($visit, $this->ownerRef($request));
return back()->with('success', 'Visitor checked out.');
}
public function badge(Request $request, Visit $visit, PrinterManager $printers)
{
$this->authorizeAbility($request, 'visits.view');
$this->authorizeOwner($request, $visit);
$rendered = $printers->renderBadge($visit, $request->query('driver'));
if ($rendered['format'] === 'html') {
return response($rendered['content'])->header('Content-Type', 'text/html');
}
return response($rendered['content'])
->header('Content-Type', 'text/plain')
->header('Content-Disposition', 'attachment; filename="'.$rendered['filename'].'"');
}
}
@@ -0,0 +1,200 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Models\Branch;
use App\Models\Host;
use App\Models\Visitor;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\WatchlistService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class VisitorController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'visitors.view');
$organization = $this->organization($request);
$visitors = Visitor::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->when($request->q, fn ($q, $search) => $q->where('full_name', 'like', "%{$search}%"))
->orderByDesc('is_frequent')
->orderBy('full_name')
->paginate(25)
->withQueryString();
return view('frontdesk.visitors.index', compact('visitors', 'organization'));
}
public function show(Request $request, Visitor $visitor): View
{
$this->authorizeAbility($request, 'visitors.view');
$this->authorizeOwner($request, $visitor);
$visitor->load(['visits' => fn ($q) => $q->latest()->limit(20), 'visits.host']);
$visitIds = $visitor->visits()->pluck('id');
$activity = AuditLog::owned($this->ownerRef($request))
->where('organization_id', $visitor->organization_id)
->where(function ($q) use ($visitor, $visitIds) {
$q->where(function ($inner) use ($visitor) {
$inner->where('subject_type', Visitor::class)
->where('subject_id', $visitor->id);
});
if ($visitIds->isNotEmpty()) {
$q->orWhere(function ($inner) use ($visitIds) {
$inner->where('subject_type', Visit::class)
->whereIn('subject_id', $visitIds);
});
}
})
->orderByDesc('created_at')
->limit(30)
->get();
return view('frontdesk.visitors.show', [
'visitor' => $visitor,
'activity' => $activity,
'auditActions' => config('frontdesk.audit_actions'),
'watchlistStatuses' => config('frontdesk.watchlist_statuses'),
'canManage' => app(FrontdeskPermissions::class)->can(
$this->member($request),
'visitors.manage'
),
]);
}
public function checkInForm(Request $request, Visitor $visitor): View
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visitor);
$organization = $this->organization($request);
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->get();
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('frontdesk.visitors.check-in', compact('visitor', 'organization', 'hosts', 'branches'));
}
public function checkIn(Request $request, Visitor $visitor, VisitCheckInService $checkIn): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visitor);
$organization = $this->organization($request);
$validated = $request->validate([
'host_id' => ['nullable', 'integer'],
'branch_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'policies_accepted' => ['accepted'],
]);
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
->branchScope($this->member($request));
if ($branchScope !== null) {
$validated['branch_id'] = $branchScope;
}
$validated['visitor_id'] = $visitor->id;
$visit = $checkIn->checkIn(
$this->ownerRef($request),
$organization,
$validated,
$this->ownerRef($request),
);
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', 'Returning visitor checked in.');
}
public function update(Request $request, Visitor $visitor): RedirectResponse
{
$this->authorizeAbility($request, 'visitors.manage');
$this->authorizeOwner($request, $visitor);
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'notes' => ['nullable', 'string', 'max:2000'],
'photo' => ['nullable', 'image', 'max:5120'],
'id_document' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
]);
if ($request->hasFile('photo')) {
if ($visitor->photo_path) {
Storage::disk('public')->delete($visitor->photo_path);
}
$validated['photo_path'] = $request->file('photo')->store('frontdesk/visitors/photos', 'public');
}
if ($request->hasFile('id_document')) {
if ($visitor->id_document_path) {
Storage::disk('public')->delete($visitor->id_document_path);
}
$validated['id_document_path'] = $request->file('id_document')->store('frontdesk/visitors/documents', 'public');
}
unset($validated['photo'], $validated['id_document']);
$visitor->update($validated);
return back()->with('success', 'Visitor profile updated.');
}
public function updateWatchlist(Request $request, Visitor $visitor, WatchlistService $watchlist): RedirectResponse
{
$this->authorizeAbility($request, 'visitors.manage');
$this->authorizeOwner($request, $visitor);
$organization = $this->organization($request);
$validated = $request->validate([
'watchlist_status' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.watchlist_statuses')))],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$visitor->update($validated);
$watchlist->syncEntryForVisitor(
$visitor,
$validated['watchlist_status'],
$validated['notes'] ?? null,
$this->ownerRef($request),
);
AuditLog::record(
$this->ownerRef($request),
'watchlist.entry_created',
$organization->id,
$this->ownerRef($request),
Visitor::class,
$visitor->id,
['watchlist_status' => $validated['watchlist_status']],
);
return back()->with('success', 'Visitor watchlist status updated.');
}
}
@@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Models\Visitor;
use App\Models\WatchlistEntry;
use App\Services\Frontdesk\WatchlistService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class WatchlistController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'watchlist.view');
$organization = $this->organization($request);
$entries = WatchlistEntry::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with('visitor')
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->q, function ($q, $search) {
$q->where(function ($inner) use ($search) {
$inner->where('full_name', 'like', "%{$search}%")
->orWhere('company', 'like', "%{$search}%");
});
})
->latest()
->paginate(25)
->withQueryString();
return view('frontdesk.watchlist.index', [
'entries' => $entries,
'organization' => $organization,
'statuses' => config('frontdesk.watchlist_statuses'),
'canManage' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->can($this->member($request), 'watchlist.manage'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'watchlist.manage');
$organization = $this->organization($request);
$visitors = Visitor::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('full_name')
->limit(100)
->get();
return view('frontdesk.watchlist.create', [
'organization' => $organization,
'visitors' => $visitors,
'statuses' => config('frontdesk.watchlist_statuses'),
]);
}
public function store(Request $request, WatchlistService $watchlist): RedirectResponse
{
$this->authorizeAbility($request, 'watchlist.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'visitor_id' => ['nullable', 'integer'],
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'status' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.watchlist_statuses')))],
'reason' => ['nullable', 'string', 'max:2000'],
]);
if ($validated['status'] === Visitor::WATCHLIST_ALLOWED) {
return back()->withErrors(['status' => 'Choose requires approval or blacklisted for watchlist entries.']);
}
$visitor = null;
if (! empty($validated['visitor_id'])) {
$visitor = Visitor::owned($this->ownerRef($request))->findOrFail($validated['visitor_id']);
$validated['full_name'] = $visitor->full_name;
$validated['company'] = $visitor->company;
}
$entry = WatchlistEntry::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
'visitor_id' => $visitor?->id,
'full_name' => $validated['full_name'],
'company' => $validated['company'] ?? null,
'status' => $validated['status'],
'reason' => $validated['reason'] ?? null,
'created_by' => $this->ownerRef($request),
]);
if ($visitor) {
$visitor->update(['watchlist_status' => $validated['status'], 'notes' => $validated['reason'] ?? $visitor->notes]);
}
AuditLog::record(
$this->ownerRef($request),
'watchlist.entry_created',
$organization->id,
$this->ownerRef($request),
WatchlistEntry::class,
$entry->id,
['full_name' => $entry->full_name, 'status' => $entry->status],
);
return redirect()
->route('frontdesk.watchlist.index')
->with('success', 'Watchlist entry added.');
}
public function destroy(Request $request, WatchlistEntry $entry, WatchlistService $watchlist): RedirectResponse
{
$this->authorizeAbility($request, 'watchlist.manage');
$this->authorizeOwner($request, $entry);
$organization = $this->organization($request);
if ($entry->visitor_id) {
$visitor = Visitor::owned($this->ownerRef($request))->find($entry->visitor_id);
$visitor?->update(['watchlist_status' => Visitor::WATCHLIST_ALLOWED]);
}
AuditLog::record(
$this->ownerRef($request),
'watchlist.entry_removed',
$organization->id,
$this->ownerRef($request),
WatchlistEntry::class,
$entry->id,
['full_name' => $entry->full_name],
);
$entry->delete();
return back()->with('success', 'Watchlist entry removed.');
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers;
use App\Models\Organization;
use App\Models\Visit;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class IcalFeedController extends Controller
{
public function __invoke(Request $request, Organization $organization): Response
{
$owner = (string) $request->query('owner');
$token = (string) $request->query('token');
abort_unless($owner !== '' && $token !== '', 403);
abort_unless(
hash_equals(
hash_hmac('sha256', "{$organization->id}:{$owner}", (string) config('app.key')),
$token,
),
403,
);
abort_unless($organization->owner_ref === $owner, 404);
$visits = Visit::owned($owner)
->where('organization_id', $organization->id)
->whereIn('status', [
Visit::STATUS_SCHEDULED,
Visit::STATUS_EXPECTED,
Visit::STATUS_WAITING,
])
->whereNotNull('scheduled_at')
->where('scheduled_at', '>=', now()->subDay())
->with(['visitor', 'host'])
->orderBy('scheduled_at')
->limit(500)
->get();
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Ladill Frontdesk//EN',
'CALSCALE:GREGORIAN',
];
foreach ($visits as $visit) {
$start = $visit->scheduled_at->utc()->format('Ymd\THis\Z');
$end = $visit->scheduled_at->copy()->addHour()->utc()->format('Ymd\THis\Z');
$summary = $this->escape('Visit: '.$visit->visitor->full_name);
$description = $this->escape(trim(($visit->purpose ?? '').' Host: '.($visit->host?->name ?? '—')));
$lines[] = 'BEGIN:VEVENT';
$lines[] = 'UID:frontdesk-visit-'.$visit->id.'@ladill.com';
$lines[] = 'DTSTAMP:'.now()->utc()->format('Ymd\THis\Z');
$lines[] = 'DTSTART:'.$start;
$lines[] = 'DTEND:'.$end;
$lines[] = 'SUMMARY:'.$summary;
$lines[] = 'DESCRIPTION:'.$description;
$lines[] = 'END:VEVENT';
}
$lines[] = 'END:VCALENDAR';
return response(implode("\r\n", $lines), 200, [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Disposition' => 'attachment; filename="frontdesk-visits.ics"',
]);
}
protected function escape(string $value): string
{
return str_replace(["\n", ',', ';'], ['\\n', '\\,', '\\;'], $value);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->latest()
->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function unread(Request $request): JsonResponse
{
$notifications = $request->user()
->unreadNotifications()
->latest()
->take(10)
->get()
->map(fn ($n) => [
'id' => $n->id,
'type' => class_basename($n->type),
'title' => $n->data['title'] ?? 'Notification',
'message' => $n->data['message'] ?? '',
'icon' => $n->data['icon'] ?? 'bell',
'url' => $n->data['url'] ?? null,
'created_at' => $n->created_at->diffForHumans(),
]);
return response()->json([
'notifications' => $notifications,
'unread_count' => $request->user()->unreadNotifications()->count(),
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = $request->user()
->notifications()
->where('id', $id)
->first();
if ($notification) {
$notification->markAsRead();
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use App\Services\Frontdesk\DeviceService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateFrontdeskDevice
{
public function __construct(
protected DeviceService $devices,
) {}
public function handle(Request $request, Closure $next, string $type = 'kiosk'): Response
{
$token = $request->route('token') ?? $request->header('X-Device-Token');
abort_unless(is_string($token) && $token !== '', 404);
$device = $this->devices->findByToken($token);
abort_unless($device && $device->type === $type, 404);
$this->devices->recordHeartbeat($device);
$request->attributes->set('frontdesk.device', $device->fresh());
return $next($request);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Generic service-to-service auth for internal APIs. Validates the bearer token
* against per-consumer keys in config("{namespace}.service_api_keys") with a
* constant-time compare, and records the caller. Usage: auth.service:crm.
*/
class AuthenticateService
{
public function handle(Request $request, Closure $next, string $namespace = 'crm'): Response
{
$token = (string) $request->bearerToken();
$caller = null;
if ($token !== '') {
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
$caller = $name;
break;
}
}
}
if ($caller === null) {
return response()->json(['error' => 'Unauthorized.'], 401);
}
$request->attributes->set('service_caller', $caller);
return $next($request);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\OrganizationResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureFrontdeskAbility
{
public function __construct(
protected OrganizationResolver $organizations,
protected FrontdeskPermissions $permissions,
) {}
public function handle(Request $request, Closure $next, string $ability): Response
{
$user = $request->user();
abort_unless($user, 403);
$organization = $this->organizations->resolveForUser($user);
abort_unless($organization, 403);
$member = $this->organizations->memberFor($user, $organization);
abort_unless($this->permissions->can($member, $ability), 403);
$request->attributes->set('frontdesk.organization', $organization);
$request->attributes->set('frontdesk.member', $member);
return $next($request);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use App\Services\Frontdesk\OrganizationResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOrganizationSetup
{
public function __construct(
protected OrganizationResolver $organizations,
) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
if ($request->routeIs('frontdesk.onboarding*')) {
return $next($request);
}
if (! $this->organizations->isOnboarded($user)) {
return redirect()->route('frontdesk.onboarding.show');
}
return $next($request);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* App sessions are subordinate to the platform (auth.ladill.com) session.
* When the platform session ends, clear this app's local session too.
*/
class EnsurePlatformSession
{
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$authDomain = trim((string) config('app.auth_domain', ''));
if ($authDomain === '') {
return $next($request);
}
$cookieHeader = (string) $request->headers->get('Cookie', '');
if ($cookieHeader === '') {
return $this->clearAppSession($request);
}
try {
$response = Http::withHeaders(['Cookie' => $cookieHeader])
->timeout(3)
->get('https://'.$authDomain.'/sso/ping');
} catch (\Throwable) {
return $next($request);
}
if ($response->status() === 401) {
return $this->clearAppSession($request);
}
return $next($request);
}
private function clearAppSession(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
* pages. The splash shows once per browser session while the app loads, then
* fades out masking the client-side boot so the app feels instant.
*/
class InjectBootSplash
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->isMethod('GET') || ! Auth::check()) {
return $response;
}
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
$content = $response->getContent();
if (! is_string($content)
|| stripos($content, '<body') === false
|| str_contains($content, 'id="ladill-boot"')) {
return $response;
}
$splash = View::make('partials.boot-splash')->render();
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
if (is_string($content)) {
$response->setContent($content);
}
return $response;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Shares the signed-in account with views. CRM data is scoped per account by
* the user's public_id (owner_ref); today every user acts as their own
* account (team support graduates here later, like the platform pattern).
*/
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
$request->attributes->set('actingAccount', $user);
if (! $request->is('api/*')) {
View::share('actingAccount', $user);
}
}
return $next($request);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Jobs;
use App\Models\Campaign;
use App\Models\CampaignRecipient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class DispatchCampaignJob implements ShouldQueue
{
use Queueable;
public function __construct(public int $campaignId)
{
}
public function handle(): void
{
$campaign = Campaign::query()->find($this->campaignId);
if (! $campaign || ! in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING], true)) {
return;
}
if ($campaign->recipient_count === 0) {
$campaign->forceFill([
'status' => Campaign::STATUS_FAILED,
'completed_at' => now(),
])->save();
return;
}
$campaign->forceFill(['status' => Campaign::STATUS_SENDING])->save();
$campaign->recipients()
->where('status', CampaignRecipient::STATUS_PENDING)
->orderBy('id')
->pluck('id')
->each(fn (int $recipientId) => SendCampaignMessageJob::dispatch($recipientId));
}
}
+195
View File
@@ -0,0 +1,195 @@
<?php
namespace App\Jobs;
use App\Models\Activity;
use App\Models\Campaign;
use App\Models\CampaignRecipient;
use App\Models\User;
use App\Services\Campaigns\CampaignBillingService;
use App\Services\Campaigns\CampaignMessageService;
use App\Services\Campaigns\CampaignPricingService;
use App\Services\Comms\EmailService;
use App\Services\Comms\SmsService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class SendCampaignMessageJob implements ShouldQueue
{
use Queueable;
public function __construct(public int $recipientId)
{
}
public function handle(
EmailService $email,
SmsService $sms,
CampaignPricingService $pricing,
CampaignBillingService $billing,
CampaignMessageService $messages,
): void {
$recipient = CampaignRecipient::query()->with('campaign')->find($this->recipientId);
if (! $recipient || ! $recipient->campaign || $recipient->status !== CampaignRecipient::STATUS_PENDING) {
return;
}
$campaign = $recipient->campaign;
if (! in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING], true)) {
return;
}
$body = $messages->personalize($campaign->body, $recipient->recipient_name);
$subject = $messages->personalize((string) ($campaign->subject ?? ''), $recipient->recipient_name);
$costMinor = $pricing->recipientCostMinor($campaign, $campaign->body);
if (! $billing->canAfford($campaign->owner_ref, $costMinor)) {
$this->markRecipient($recipient, CampaignRecipient::STATUS_SKIPPED, 0, 'Insufficient wallet balance.');
return;
}
$sent = $campaign->channel === Campaign::CHANNEL_SMS
? $sms->send((string) $recipient->recipient_phone, $body)
: $email->send(
(string) $recipient->recipient_email,
$subject !== '' ? $subject : 'Message from '.config('app.name'),
$body,
$this->senderName($campaign),
$this->senderEmail($campaign),
);
if (! $sent) {
$this->markRecipient($recipient, CampaignRecipient::STATUS_FAILED, 0, 'Delivery could not be confirmed.');
return;
}
$charged = $billing->charge(
$campaign->owner_ref,
$costMinor,
$campaign->channel,
$campaign->id,
$recipient->id,
'CRM campaign "'.$campaign->name.'" to '.$recipient->recipient_name,
);
$this->markRecipient(
$recipient,
CampaignRecipient::STATUS_SENT,
$charged ? $costMinor : 0,
null,
now(),
);
$this->logActivity($campaign, $recipient, $subject, $body);
}
private function markRecipient(
CampaignRecipient $recipient,
string $status,
int $costMinor,
?string $error,
?\DateTimeInterface $sentAt = null,
): void {
$recipient->forceFill([
'status' => $status,
'cost_minor' => $costMinor,
'error_message' => $error,
'sent_at' => $sentAt,
])->save();
$this->refreshCampaignCounters($recipient->campaign()->first());
}
private function refreshCampaignCounters(?Campaign $campaign): void
{
if (! $campaign) {
return;
}
DB::transaction(function () use ($campaign) {
$campaign = Campaign::query()->lockForUpdate()->find($campaign->id);
if (! $campaign) {
return;
}
$counts = $campaign->recipients()
->selectRaw("
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as sent_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as failed_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as skipped_count,
SUM(cost_minor) as actual_cost_minor
", [
CampaignRecipient::STATUS_SENT,
CampaignRecipient::STATUS_FAILED,
CampaignRecipient::STATUS_SKIPPED,
])
->first();
$pending = $campaign->recipients()->where('status', CampaignRecipient::STATUS_PENDING)->count();
$campaign->forceFill([
'sent_count' => (int) ($counts->sent_count ?? 0),
'failed_count' => (int) ($counts->failed_count ?? 0),
'skipped_count' => (int) ($counts->skipped_count ?? 0),
'actual_cost_minor' => (int) ($counts->actual_cost_minor ?? 0),
]);
if ($pending === 0 && in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING], true)) {
$campaign->status = Campaign::STATUS_COMPLETED;
$campaign->completed_at = now();
} elseif ($campaign->status === Campaign::STATUS_QUEUED) {
$campaign->status = Campaign::STATUS_SENDING;
}
$campaign->save();
});
}
private function logActivity(Campaign $campaign, CampaignRecipient $recipient, string $subject, string $body): void
{
$title = $campaign->channel === Campaign::CHANNEL_SMS
? 'Campaign SMS: '.$campaign->name
: 'Campaign email: '.($subject !== '' ? $subject : $campaign->name);
$payload = [
'owner_ref' => $campaign->owner_ref,
'type' => $campaign->channel,
'direction' => 'out',
'title' => $title,
'body' => $body,
'completed_at' => now(),
];
if ($recipient->customer_id) {
Activity::create([
...$payload,
'subject_type' => 'contact',
'subject_id' => $recipient->customer_id,
]);
}
if ($recipient->lead_id) {
Activity::create([
...$payload,
'subject_type' => 'lead',
'subject_id' => $recipient->lead_id,
]);
}
}
private function senderName(Campaign $campaign): ?string
{
return User::query()->where('public_id', $campaign->owner_ref)->value('name');
}
private function senderEmail(Campaign $campaign): ?string
{
return User::query()->where('public_id', $campaign->owner_ref)->value('email');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactMessageMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $subjectLine,
public string $bodyText,
public ?string $fromName = null,
public ?string $replyToAddress = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->subjectLine,
replyTo: $this->replyToAddress ? [$this->replyToAddress] : [],
);
}
public function content(): Content
{
return new Content(
view: 'email.contact-message',
with: [
'bodyText' => $this->bodyText,
'fromName' => $this->fromName,
],
);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
use BelongsToOwner;
protected $table = 'frontdesk_audit_logs';
public $timestamps = false;
protected $fillable = [
'owner_ref', 'organization_id', 'actor_ref', 'action',
'subject_type', 'subject_id', 'metadata', 'ip_address', 'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): self {
return static::create([
'owner_ref' => $ownerRef,
'organization_id' => $organizationId,
'actor_ref' => $actorRef,
'action' => $action,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'metadata' => $metadata,
'ip_address' => request()?->ip(),
'created_at' => now(),
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Branch extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_branches';
protected $fillable = [
'owner_ref', 'organization_id', 'name', 'code', 'address', 'phone', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function buildings(): HasMany
{
return $this->hasMany(Building::class, 'branch_id');
}
public function hosts(): HasMany
{
return $this->hasMany(Host::class, 'branch_id');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Building extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_buildings';
protected $fillable = ['owner_ref', 'branch_id', 'name', 'floor_count'];
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function receptionDesks(): HasMany
{
return $this->hasMany(ReceptionDesk::class, 'building_id');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToOwner
{
public function scopeOwned(Builder $query, string $ownerRef): Builder
{
return $query->where($this->getTable().'.owner_ref', $ownerRef);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Device extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_devices';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'reception_desk_id',
'name', 'type', 'status', 'device_token', 'config', 'last_online_at',
];
protected function casts(): array
{
return [
'config' => 'array',
'last_online_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function receptionDesk(): BelongsTo
{
return $this->belongsTo(ReceptionDesk::class, 'reception_desk_id');
}
public function isOnline(int $staleMinutes = 10): bool
{
return $this->status === 'online'
&& $this->last_online_at
&& $this->last_online_at->gte(now()->subMinutes($staleMinutes));
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Host extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_hosts';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'name', 'department',
'office', 'phone', 'email', 'extension', 'is_available', 'user_ref',
];
protected function casts(): array
{
return ['is_available' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function visits(): HasMany
{
return $this->hasMany(Visit::class, 'host_id');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Member extends Model
{
use BelongsToOwner;
protected $table = 'frontdesk_members';
protected $fillable = ['owner_ref', 'organization_id', 'user_ref', 'role', 'branch_id'];
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function hasRole(string ...$roles): bool
{
return in_array($this->role, $roles, true);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OfflineCheckIn extends Model
{
protected $table = 'frontdesk_offline_checkins';
protected $fillable = ['device_id', 'client_id', 'payload', 'synced_at', 'visit_id'];
protected function casts(): array
{
return [
'payload' => 'array',
'synced_at' => 'datetime',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class, 'device_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Organization extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_organizations';
protected $fillable = [
'owner_ref', 'name', 'slug', 'logo_path', 'timezone', 'settings',
];
protected function casts(): array
{
return ['settings' => 'array'];
}
public function branches(): HasMany
{
return $this->hasMany(Branch::class, 'organization_id');
}
public function hosts(): HasMany
{
return $this->hasMany(Host::class, 'organization_id');
}
public function visitors(): HasMany
{
return $this->hasMany(Visitor::class, 'organization_id');
}
public function members(): HasMany
{
return $this->hasMany(Member::class, 'organization_id');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ReceptionDesk extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_reception_desks';
protected $fillable = ['owner_ref', 'building_id', 'name', 'location', 'is_active'];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function building(): BelongsTo
{
return $this->belongsTo(Building::class, 'building_id');
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
* Keyed by the OIDC `sub` (public_id); every CRM record is scoped to it.
*/
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_app_active_at' => 'datetime',
'password' => 'hashed',
];
}
/** The owner reference used to scope every CRM record to this account. */
public function ownerRef(): string
{
return (string) $this->public_id;
}
public function customers(): HasMany
{
return $this->hasMany(Customer::class, 'owner_ref', 'public_id');
}
public function leads(): HasMany
{
return $this->hasMany(Lead::class, 'owner_ref', 'public_id');
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
return $url !== '' ? $url : null;
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Visit extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_visits';
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_EXPECTED = 'expected';
public const STATUS_WAITING = 'waiting';
public const STATUS_CHECKED_IN = 'checked_in';
public const STATUS_CHECKED_OUT = 'checked_out';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_OVERDUE = 'overdue';
public const ACTIVE_STATUSES = [
self::STATUS_EXPECTED,
self::STATUS_WAITING,
self::STATUS_CHECKED_IN,
self::STATUS_OVERDUE,
];
protected $fillable = [
'public_id', 'external_ref', 'source', 'owner_ref', 'organization_id', 'branch_id', 'reception_desk_id',
'visitor_id', 'host_id', 'visitor_type', 'status', 'purpose',
'expected_duration_minutes', 'scheduled_at', 'checked_in_at', 'checked_out_at',
'badge_expires_at', 'badge_code', 'qr_token', 'photo_path', 'signature_path',
'policies_accepted', 'vehicle_info', 'contractor_details', 'delivery_details',
'allowed_areas', 'notes', 'integration_metadata', 'checked_in_by', 'checked_out_by',
];
protected function casts(): array
{
return [
'scheduled_at' => 'datetime',
'checked_in_at' => 'datetime',
'checked_out_at' => 'datetime',
'badge_expires_at' => 'datetime',
'policies_accepted' => 'boolean',
'vehicle_info' => 'array',
'contractor_details' => 'array',
'delivery_details' => 'array',
'allowed_areas' => 'array',
'integration_metadata' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (Visit $visit) {
if (! $visit->public_id) {
$visit->public_id = (string) Str::uuid();
}
if (! $visit->qr_token) {
$visit->qr_token = Str::random(32);
}
if (! $visit->badge_code) {
$visit->badge_code = strtoupper(Str::random(8));
}
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function receptionDesk(): BelongsTo
{
return $this->belongsTo(ReceptionDesk::class, 'reception_desk_id');
}
public function visitor(): BelongsTo
{
return $this->belongsTo(Visitor::class, 'visitor_id');
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class, 'host_id');
}
public function isInside(): bool
{
return $this->status === self::STATUS_CHECKED_IN;
}
public function isPending(): bool
{
return in_array($this->status, [
self::STATUS_EXPECTED,
self::STATUS_SCHEDULED,
self::STATUS_WAITING,
self::STATUS_OVERDUE,
], true);
}
public function awaitingApproval(): bool
{
if ($this->status !== self::STATUS_WAITING || $this->checked_in_at !== null) {
return false;
}
return (bool) (
data_get($this->contractor_details, '_awaiting_approval')
|| data_get($this->delivery_details, '_awaiting_approval')
);
}
/** @return array<string, string|null> */
public function typeDetailEntries(): array
{
$entries = [];
$labels = collect(config('frontdesk.visitor_type_config', []))
->flatMap(fn (array $config) => collect($config['fields'] ?? [])
->mapWithKeys(fn (array $field) => [$field['name'] => $field['label']]));
foreach (array_filter([$this->contractor_details, $this->delivery_details]) as $details) {
foreach ($details as $key => $value) {
if (str_starts_with((string) $key, '_') || $value === null || $value === '') {
continue;
}
$label = $labels->get($key, str_replace('_', ' ', (string) $key));
if (str_contains((string) $key, 'photo') || str_contains((string) $key, 'signature')) {
$entries[$label] = 'On file';
} elseif ($key === 'received_at') {
$entries[$label] = \Illuminate\Support\Carbon::parse($value)->format('M j, Y g:i A');
} else {
$entries[$label] = is_bool($value) ? ($value ? 'Yes' : 'No') : (string) $value;
}
}
}
return $entries;
}
public function canActivateCheckIn(): bool
{
return $this->isPending();
}
public function isBadgeExpired(): bool
{
return $this->badge_expires_at && $this->badge_expires_at->isPast();
}
public function scopeToday($query)
{
return $query->whereDate('checked_in_at', today())
->orWhereDate('scheduled_at', today());
}
public function scopeCurrentlyInside($query)
{
return $query->where('status', self::STATUS_CHECKED_IN);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Visitor extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_visitors';
public const WATCHLIST_ALLOWED = 'allowed';
public const WATCHLIST_REQUIRES_APPROVAL = 'requires_approval';
public const WATCHLIST_BLACKLISTED = 'blacklisted';
protected $fillable = [
'owner_ref', 'organization_id', 'full_name', 'company', 'phone', 'email',
'photo_path', 'id_document_path', 'watchlist_status', 'notes',
'visit_count', 'is_frequent',
];
protected function casts(): array
{
return ['is_frequent' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function visits(): HasMany
{
return $this->hasMany(Visit::class, 'visitor_id');
}
public function isBlacklisted(): bool
{
return $this->watchlist_status === self::WATCHLIST_BLACKLISTED;
}
public function requiresApproval(): bool
{
return $this->watchlist_status === self::WATCHLIST_REQUIRES_APPROVAL;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class WatchlistEntry extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_watchlist_entries';
protected $fillable = [
'owner_ref', 'organization_id', 'visitor_id', 'full_name',
'company', 'status', 'reason', 'created_by',
];
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function visitor(): BelongsTo
{
return $this->belongsTo(Visitor::class, 'visitor_id');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebhookEndpoint extends Model
{
use BelongsToOwner;
protected $table = 'frontdesk_webhook_endpoints';
protected $fillable = [
'owner_ref', 'organization_id', 'url', 'secret', 'events', 'is_active',
];
protected function casts(): array
{
return [
'events' => 'array',
'is_active' => 'boolean',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function subscribesTo(string $event): bool
{
$events = $this->events ?? config('frontdesk.webhook_events', []);
return in_array($event, $events, true) || in_array('*', $events, true);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class FrontdeskAlertNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public string $title,
public string $message,
public array $payload = [],
) {}
/** @return list<string> */
public function via(object $notifiable): array
{
return ['database'];
}
/** @return array<string, mixed> */
public function toDatabase(object $notifiable): array
{
return [
'title' => $this->title,
'message' => $this->message,
'icon' => $this->payload['icon'] ?? 'bell',
'url' => $this->payload['url'] ?? null,
'event' => $this->payload['event'] ?? null,
'visit_id' => $this->payload['visit_id'] ?? null,
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
RateLimiter::for('kiosk-device', function (Request $request) {
$key = $request->route('token')
?? $request->header('X-Device-Token')
?? $request->ip();
return Limit::perMinute(30)->by((string) $key);
});
RateLimiter::for('qr-scan', fn (Request $request) => Limit::perMinute(20)->by($request->ip()));
RateLimiter::for('device-heartbeat', function (Request $request) {
return Limit::perMinute(60)->by((string) ($request->header('X-Device-Token') ?? $request->ip()));
});
}
}
+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',
];
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Support;
final class LadillAppUrl
{
public static function connect(string $appSubdomain, string $path = '/'): string
{
$root = (string) config('app.platform_domain', 'ladill.com');
$path = '/'.ltrim($path, '/');
$target = "https://{$appSubdomain}.{$root}{$path}";
return "https://{$appSubdomain}.{$root}/sso/connect?redirect=".urlencode($target);
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace App\Support;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Route;
/**
* Standard signed-in profile links for Ladill product apps (identical across
* the suite). Points at the account/home portals, not app-local routes.
*
* @return list<array<string, mixed>>
*/
class UserProfileMenu
{
public static function items(?Authenticatable $user = null): array
{
$user ??= auth()->user();
if (! $user) {
return [];
}
$items = [];
foreach ([
['label' => 'Home', 'path' => '', 'host' => 'home'],
['label' => 'Profile', 'path' => 'profile'],
['label' => 'Account Settings', 'path' => 'account-settings'],
['label' => 'Dashboard', 'path' => 'dashboard'],
['label' => 'Billing', 'path' => 'billing'],
] as $link) {
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
if (self::isCurrentMenuLink($link, $href)) {
continue;
}
$items[] = [
'type' => 'link',
'label' => $link['label'],
'href' => $href,
];
}
// Wallet balance peek — sits directly after Billing (only when this
// app exposes a balance endpoint).
if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) {
$items[] = ['type' => 'wallet'];
}
if (self::userIsAdmin($user)) {
$adminHref = self::platformUrl('account', 'admin');
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
$items[] = [
'type' => 'link',
'label' => 'Admin',
'href' => $adminHref,
];
}
}
if (Route::has('logout')) {
$items[] = [
'type' => 'logout',
'label' => 'Logout',
'action' => route('logout'),
];
}
return $items;
}
private static function platformUrl(string $host, string $path = ''): string
{
if ($host === 'home') {
if (function_exists('ladill_home_url')) {
return ladill_home_url($path);
}
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
return self::absoluteUrl($domain, $path);
}
if (function_exists('ladill_account_url')) {
return ladill_account_url($path);
}
return self::absoluteUrl((string) config('app.account_domain'), $path);
}
/** @param array{label?: string, path?: string, host?: string} $link */
private static function isCurrentMenuLink(array $link, string $href): bool
{
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
return false;
}
$request = request();
if (! $request) {
return false;
}
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
$currentHost = strtolower($request->getHost());
if ($linkHost === '' || $linkHost !== $currentHost) {
return false;
}
$currentPath = trim($request->getPathInfo(), '/');
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
if (($link['host'] ?? 'account') === 'home') {
return $currentPath === '';
}
if (($link['path'] ?? '') === 'dashboard') {
return in_array($currentPath, ['dashboard', 'account'], true)
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
}
return self::pathMatchesSection($currentPath, $targetPath);
}
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
{
if ($targetPath === '') {
return $currentPath === '';
}
return $currentPath === $targetPath
|| str_starts_with($currentPath, $targetPath.'/');
}
private static function absoluteUrl(string $domain, string $path = ''): string
{
$base = 'https://'.trim($domain, '/');
return $path === '' ? $base : $base.'/'.ltrim($path, '/');
}
private static function userIsAdmin(Authenticatable $user): bool
{
if (method_exists($user, 'isAdmin')) {
return $user->isAdmin();
}
return (bool) ($user->is_admin ?? false);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
use Illuminate\Support\Facades\Config;
if (! function_exists('ladill_platform_url')) {
/** Absolute URL on the platform root (ladill.com). */
function ladill_platform_url(string $path = '/'): string
{
return 'https://'.Config::get('app.platform_domain', 'ladill.com').'/'.ltrim($path, '/');
}
}
if (! function_exists('ladill_account_url')) {
/** Absolute URL on the unified account portal (account.ladill.com). */
function ladill_account_url(string $path = '/'): string
{
return 'https://'.Config::get('app.account_domain', 'account.ladill.com').'/'.ltrim($path, '/');
}
}
if (! function_exists('ladill_home_url')) {
/** Absolute URL on the signed-in product hub (home.ladill.com). */
function ladill_home_url(string $path = '/'): string
{
$platform = (string) Config::get('app.platform_domain', 'ladill.com');
return 'https://home.'.$platform.'/'.ltrim($path, '/');
}
}
if (! function_exists('ladill_auth_url')) {
/** Absolute URL on the identity provider (auth.ladill.com). */
function ladill_auth_url(string $path = '/'): string
{
return 'https://'.Config::get('app.auth_domain', 'auth.ladill.com').'/'.ltrim($path, '/');
}
}
if (! function_exists('crm_money')) {
/** Format integer minor units as a human currency string (e.g. 12500 → GHS 125.00). */
function crm_money(?int $minor, ?string $currency = null): string
{
$currency = $currency ?: Config::get('crm.default_currency', 'GHS');
return $currency.' '.number_format(((int) $minor) / 100, 2);
}
}