Add per-customer SMS and Bird credentials to Frontdesk Integrations.
Deploy Ladill Frontdesk / deploy (push) Successful in 45s

NotificationDispatcher sends via customer relay and skips Frontdesk wallet debit when tenant keys are configured.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-12 17:05:54 +00:00
co-authored by Cursor
parent 0b45e08016
commit 8300cafc36
17 changed files with 1110 additions and 40 deletions
@@ -1,6 +1,10 @@
<x-app-layout title="Integrations">
<h1 class="text-xl font-semibold text-slate-900 dark:text-slate-100">Integrations</h1>
<p class="text-sm text-slate-500 dark:text-slate-400">Connect Ladill apps and external systems.</p>
<p class="text-sm text-slate-500 dark:text-slate-400">Connect Ladill apps, messaging, and external systems.</p>
@if (! $canManage)
<p class="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">You can view integrations, but only admins can change credentials.</p>
@endif
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Supported Ladill apps</h2>
@@ -12,6 +16,111 @@
<p class="mt-3 text-xs text-slate-500">Sibling apps authenticate with service API keys and can create visits via <code class="text-xs">POST /api/visits</code> with <code class="text-xs">external_ref</code> for idempotency.</p>
</section>
<div class="mt-6 space-y-6 max-w-2xl">
<section class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Ladill SMS</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
Connect your <a href="https://sms.ladill.com" target="_blank" rel="noopener" class="text-indigo-600 underline dark:text-indigo-400">Ladill SMS</a>
API key so host SMS alerts bill your SMS wallet and send under your sender ID.
</p>
@if ($credential->hasValidSms())
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
Connected · key {{ $credential->sms_api_key_prefix }} · sender <strong>{{ $credential->sms_sender_id }}</strong>
@if ($credential->sms_validated_at)
· validated {{ $credential->sms_validated_at->diffForHumans() }}
@endif
</div>
@elseif ($credential->sms_status === \App\Models\MessagingCredential::STATUS_INVALID)
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-200">
{{ $credential->sms_last_error ?: 'SMS credentials are invalid.' }}
</div>
@endif
@if ($canManage)
<form method="POST" action="{{ route('frontdesk.integrations.sms.save') }}" class="mt-4 space-y-4">
@csrf
<div>
<label class="block text-sm font-medium">SMS API key</label>
<input type="password" name="sms_api_key" autocomplete="off" required
placeholder="lsk_sms_live_…"
class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm dark:border-slate-600 dark:bg-slate-800">
</div>
<div>
<label class="block text-sm font-medium">Sender ID</label>
<input type="text" name="sms_sender_id" maxlength="11" required
value="{{ old('sms_sender_id', $credential->sms_sender_id) }}"
placeholder="Approved sender ID"
class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
<p class="mt-1 text-xs text-slate-500">Must match an approved sender ID on your SMS service.</p>
</div>
<button type="submit" class="btn-primary">Validate &amp; save SMS</button>
</form>
@if ($credential->sms_api_key_encrypted || $credential->hasValidSms())
<form method="POST" action="{{ route('frontdesk.integrations.sms.disconnect') }}" class="mt-3" onsubmit="return confirm('Disconnect Ladill SMS?')">
@csrf
<button type="submit" class="text-sm text-rose-700 hover:underline dark:text-rose-400">Disconnect SMS</button>
</form>
@endif
@endif
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Ladill Bird</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
Connect your <a href="https://bird.ladill.com" target="_blank" rel="noopener" class="text-indigo-600 underline dark:text-indigo-400">Ladill Bird</a>
API key so host email alerts bill your Bird wallet and send from your domain.
</p>
@if ($credential->hasValidBird())
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
Connected · key {{ $credential->bird_api_key_prefix }} · from
<strong>{{ $credential->bird_from_name ? $credential->bird_from_name.' <'.$credential->bird_from_email.'>' : $credential->bird_from_email }}</strong>
@if ($credential->bird_validated_at)
· validated {{ $credential->bird_validated_at->diffForHumans() }}
@endif
</div>
@elseif ($credential->bird_status === \App\Models\MessagingCredential::STATUS_INVALID)
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-200">
{{ $credential->bird_last_error ?: 'Bird credentials are invalid.' }}
</div>
@endif
@if ($canManage)
<form method="POST" action="{{ route('frontdesk.integrations.bird.save') }}" class="mt-4 space-y-4">
@csrf
<div>
<label class="block text-sm font-medium">Bird API key</label>
<input type="password" name="bird_api_key" autocomplete="off" required
placeholder="lsk_live_…"
class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm dark:border-slate-600 dark:bg-slate-800">
</div>
<div>
<label class="block text-sm font-medium">From email</label>
<input type="email" name="bird_from_email" required
value="{{ old('bird_from_email', $credential->bird_from_email) }}"
placeholder="frontdesk@yourdomain.com"
class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
</div>
<div>
<label class="block text-sm font-medium">From name</label>
<input type="text" name="bird_from_name" maxlength="100"
value="{{ old('bird_from_name', $credential->bird_from_name) }}"
placeholder="{{ $organization->name }}"
class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
</div>
<button type="submit" class="btn-primary">Validate &amp; save Bird</button>
</form>
@if ($credential->bird_api_key_encrypted || $credential->hasValidBird())
<form method="POST" action="{{ route('frontdesk.integrations.bird.disconnect') }}" class="mt-3" onsubmit="return confirm('Disconnect Ladill Bird?')">
@csrf
<button type="submit" class="text-sm text-rose-700 hover:underline dark:text-rose-400">Disconnect Bird</button>
</form>
@endif
@endif
</section>
</div>
@if ($canManage)
<form method="POST" action="{{ route('frontdesk.integrations.update') }}" class="mt-6 max-w-2xl space-y-6">
@csrf @method('PUT')
@@ -39,12 +148,12 @@
</div>
</div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="webhook_active" value="1" @checked(old('webhook_active', $webhook?->is_active ?? true))> Active</label>
<button type="submit" class="btn-primary">Save integrations</button>
<button type="submit" class="btn-primary">Save webhooks</button>
</section>
</form>
@endif
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
<section class="mt-6 max-w-2xl rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Calendar feed (iCal)</h2>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">Subscribe to scheduled visits in Outlook, Google Calendar, or Apple Calendar.</p>
<code class="mt-3 block break-all rounded-lg bg-slate-50 p-3 text-xs dark:bg-slate-800">{{ $icalUrl }}</code>