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