Initial Ladill Frontdesk release with deploy pipeline.
Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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)]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user