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\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)]));
}
}