Initial Ladill Frontdesk release with deploy pipeline.
Deploy Ladill Frontdesk / deploy (push) Failing after 35s
Test / test (push) Failing after 2m45s

Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 20:37:15 +00:00
co-authored by Cursor
commit 9e2d79936c
284 changed files with 29134 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
use BelongsToOwner;
protected $table = 'frontdesk_audit_logs';
public $timestamps = false;
protected $fillable = [
'owner_ref', 'organization_id', 'actor_ref', 'action',
'subject_type', 'subject_id', 'metadata', 'ip_address', 'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): self {
return static::create([
'owner_ref' => $ownerRef,
'organization_id' => $organizationId,
'actor_ref' => $actorRef,
'action' => $action,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'metadata' => $metadata,
'ip_address' => request()?->ip(),
'created_at' => now(),
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Branch extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_branches';
protected $fillable = [
'owner_ref', 'organization_id', 'name', 'code', 'address', 'phone', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function buildings(): HasMany
{
return $this->hasMany(Building::class, 'branch_id');
}
public function hosts(): HasMany
{
return $this->hasMany(Host::class, 'branch_id');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Building extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_buildings';
protected $fillable = ['owner_ref', 'branch_id', 'name', 'floor_count'];
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function receptionDesks(): HasMany
{
return $this->hasMany(ReceptionDesk::class, 'building_id');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToOwner
{
public function scopeOwned(Builder $query, string $ownerRef): Builder
{
return $query->where($this->getTable().'.owner_ref', $ownerRef);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Device extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_devices';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'reception_desk_id',
'name', 'type', 'status', 'device_token', 'config', 'last_online_at',
];
protected function casts(): array
{
return [
'config' => 'array',
'last_online_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function receptionDesk(): BelongsTo
{
return $this->belongsTo(ReceptionDesk::class, 'reception_desk_id');
}
public function isOnline(int $staleMinutes = 10): bool
{
return $this->status === 'online'
&& $this->last_online_at
&& $this->last_online_at->gte(now()->subMinutes($staleMinutes));
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Host extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_hosts';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'name', 'department',
'office', 'phone', 'email', 'extension', 'is_available', 'user_ref',
];
protected function casts(): array
{
return ['is_available' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function visits(): HasMany
{
return $this->hasMany(Visit::class, 'host_id');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Member extends Model
{
use BelongsToOwner;
protected $table = 'frontdesk_members';
protected $fillable = ['owner_ref', 'organization_id', 'user_ref', 'role', 'branch_id'];
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function hasRole(string ...$roles): bool
{
return in_array($this->role, $roles, true);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OfflineCheckIn extends Model
{
protected $table = 'frontdesk_offline_checkins';
protected $fillable = ['device_id', 'client_id', 'payload', 'synced_at', 'visit_id'];
protected function casts(): array
{
return [
'payload' => 'array',
'synced_at' => 'datetime',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class, 'device_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Organization extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_organizations';
protected $fillable = [
'owner_ref', 'name', 'slug', 'logo_path', 'timezone', 'settings',
];
protected function casts(): array
{
return ['settings' => 'array'];
}
public function branches(): HasMany
{
return $this->hasMany(Branch::class, 'organization_id');
}
public function hosts(): HasMany
{
return $this->hasMany(Host::class, 'organization_id');
}
public function visitors(): HasMany
{
return $this->hasMany(Visitor::class, 'organization_id');
}
public function members(): HasMany
{
return $this->hasMany(Member::class, 'organization_id');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ReceptionDesk extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_reception_desks';
protected $fillable = ['owner_ref', 'building_id', 'name', 'location', 'is_active'];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function building(): BelongsTo
{
return $this->belongsTo(Building::class, 'building_id');
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
* Keyed by the OIDC `sub` (public_id); every CRM record is scoped to it.
*/
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_app_active_at' => 'datetime',
'password' => 'hashed',
];
}
/** The owner reference used to scope every CRM record to this account. */
public function ownerRef(): string
{
return (string) $this->public_id;
}
public function customers(): HasMany
{
return $this->hasMany(Customer::class, 'owner_ref', 'public_id');
}
public function leads(): HasMany
{
return $this->hasMany(Lead::class, 'owner_ref', 'public_id');
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
return $url !== '' ? $url : null;
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Visit extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_visits';
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_EXPECTED = 'expected';
public const STATUS_WAITING = 'waiting';
public const STATUS_CHECKED_IN = 'checked_in';
public const STATUS_CHECKED_OUT = 'checked_out';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_OVERDUE = 'overdue';
public const ACTIVE_STATUSES = [
self::STATUS_EXPECTED,
self::STATUS_WAITING,
self::STATUS_CHECKED_IN,
self::STATUS_OVERDUE,
];
protected $fillable = [
'public_id', 'external_ref', 'source', 'owner_ref', 'organization_id', 'branch_id', 'reception_desk_id',
'visitor_id', 'host_id', 'visitor_type', 'status', 'purpose',
'expected_duration_minutes', 'scheduled_at', 'checked_in_at', 'checked_out_at',
'badge_expires_at', 'badge_code', 'qr_token', 'photo_path', 'signature_path',
'policies_accepted', 'vehicle_info', 'contractor_details', 'delivery_details',
'allowed_areas', 'notes', 'integration_metadata', 'checked_in_by', 'checked_out_by',
];
protected function casts(): array
{
return [
'scheduled_at' => 'datetime',
'checked_in_at' => 'datetime',
'checked_out_at' => 'datetime',
'badge_expires_at' => 'datetime',
'policies_accepted' => 'boolean',
'vehicle_info' => 'array',
'contractor_details' => 'array',
'delivery_details' => 'array',
'allowed_areas' => 'array',
'integration_metadata' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (Visit $visit) {
if (! $visit->public_id) {
$visit->public_id = (string) Str::uuid();
}
if (! $visit->qr_token) {
$visit->qr_token = Str::random(32);
}
if (! $visit->badge_code) {
$visit->badge_code = strtoupper(Str::random(8));
}
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function receptionDesk(): BelongsTo
{
return $this->belongsTo(ReceptionDesk::class, 'reception_desk_id');
}
public function visitor(): BelongsTo
{
return $this->belongsTo(Visitor::class, 'visitor_id');
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class, 'host_id');
}
public function isInside(): bool
{
return $this->status === self::STATUS_CHECKED_IN;
}
public function isPending(): bool
{
return in_array($this->status, [
self::STATUS_EXPECTED,
self::STATUS_SCHEDULED,
self::STATUS_WAITING,
self::STATUS_OVERDUE,
], true);
}
public function awaitingApproval(): bool
{
if ($this->status !== self::STATUS_WAITING || $this->checked_in_at !== null) {
return false;
}
return (bool) (
data_get($this->contractor_details, '_awaiting_approval')
|| data_get($this->delivery_details, '_awaiting_approval')
);
}
/** @return array<string, string|null> */
public function typeDetailEntries(): array
{
$entries = [];
$labels = collect(config('frontdesk.visitor_type_config', []))
->flatMap(fn (array $config) => collect($config['fields'] ?? [])
->mapWithKeys(fn (array $field) => [$field['name'] => $field['label']]));
foreach (array_filter([$this->contractor_details, $this->delivery_details]) as $details) {
foreach ($details as $key => $value) {
if (str_starts_with((string) $key, '_') || $value === null || $value === '') {
continue;
}
$label = $labels->get($key, str_replace('_', ' ', (string) $key));
if (str_contains((string) $key, 'photo') || str_contains((string) $key, 'signature')) {
$entries[$label] = 'On file';
} elseif ($key === 'received_at') {
$entries[$label] = \Illuminate\Support\Carbon::parse($value)->format('M j, Y g:i A');
} else {
$entries[$label] = is_bool($value) ? ($value ? 'Yes' : 'No') : (string) $value;
}
}
}
return $entries;
}
public function canActivateCheckIn(): bool
{
return $this->isPending();
}
public function isBadgeExpired(): bool
{
return $this->badge_expires_at && $this->badge_expires_at->isPast();
}
public function scopeToday($query)
{
return $query->whereDate('checked_in_at', today())
->orWhereDate('scheduled_at', today());
}
public function scopeCurrentlyInside($query)
{
return $query->where('status', self::STATUS_CHECKED_IN);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Visitor extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_visitors';
public const WATCHLIST_ALLOWED = 'allowed';
public const WATCHLIST_REQUIRES_APPROVAL = 'requires_approval';
public const WATCHLIST_BLACKLISTED = 'blacklisted';
protected $fillable = [
'owner_ref', 'organization_id', 'full_name', 'company', 'phone', 'email',
'photo_path', 'id_document_path', 'watchlist_status', 'notes',
'visit_count', 'is_frequent',
];
protected function casts(): array
{
return ['is_frequent' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function visits(): HasMany
{
return $this->hasMany(Visit::class, 'visitor_id');
}
public function isBlacklisted(): bool
{
return $this->watchlist_status === self::WATCHLIST_BLACKLISTED;
}
public function requiresApproval(): bool
{
return $this->watchlist_status === self::WATCHLIST_REQUIRES_APPROVAL;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class WatchlistEntry extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'frontdesk_watchlist_entries';
protected $fillable = [
'owner_ref', 'organization_id', 'visitor_id', 'full_name',
'company', 'status', 'reason', 'created_by',
];
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function visitor(): BelongsTo
{
return $this->belongsTo(Visitor::class, 'visitor_id');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebhookEndpoint extends Model
{
use BelongsToOwner;
protected $table = 'frontdesk_webhook_endpoints';
protected $fillable = [
'owner_ref', 'organization_id', 'url', 'secret', 'events', 'is_active',
];
protected function casts(): array
{
return [
'events' => 'array',
'is_active' => 'boolean',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function subscribesTo(string $event): bool
{
$events = $this->events ?? config('frontdesk.webhook_events', []);
return in_array($event, $events, true) || in_array('*', $events, true);
}
}