Aggregate ladl.link slugs from all platform apps in the link list.
Deploy Ladill Link / deploy (push) Successful in 29s

Sync QR and storefront short codes from sibling apps via a platform catalog API so My Links shows every ladl.link URL in one place.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 13:26:16 +00:00
co-authored by Cursor
parent b4ad797c38
commit f1fe749934
9 changed files with 245 additions and 27 deletions
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\ShortLink;
use App\Services\Link\LinkBillingService;
use App\Services\Link\LinkCatalogSyncService;
use App\Services\Link\LinkManagerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -16,11 +17,14 @@ class LinkController extends Controller
public function __construct(
private LinkManagerService $links,
private LinkBillingService $billing,
private LinkCatalogSyncService $catalogSync,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$this->catalogSync->syncForUser($account);
$links = ShortLink::query()
->where('user_id', $account->id)
->latest()
@@ -77,6 +81,10 @@ class LinkController extends Controller
{
$this->authorizeLink($link);
if (! $link->is_managed_here) {
return back()->withErrors(['link' => 'This link is managed in '.$link->sourceAppLabel().'.']);
}
$validated = $request->validate([
'destination_url' => ['sometimes', 'required', 'string', 'max:2048'],
'label' => ['nullable', 'string', 'max:120'],
@@ -96,6 +104,11 @@ class LinkController extends Controller
public function destroy(ShortLink $link): RedirectResponse
{
$this->authorizeLink($link);
if (! $link->is_managed_here) {
return back()->withErrors(['link' => 'This link is managed in '.$link->sourceAppLabel().'.']);
}
$link->delete();
return redirect()->route('user.links.index')->with('success', 'Link deleted.');
@@ -4,13 +4,19 @@ namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\ShortLink;
use App\Services\Link\LinkCatalogSyncService;
use Illuminate\View\View;
class OverviewController extends Controller
{
public function __construct(
private LinkCatalogSyncService $catalogSync,
) {}
public function index(): View
{
$account = ladill_account();
$this->catalogSync->syncForUser($account);
$wallet = $account->getOrCreateLinkWallet();
$recentLinks = ShortLink::query()
+21
View File
@@ -10,9 +10,14 @@ class ShortLink extends Model
{
protected $fillable = [
'user_id',
'source_app',
'source_kind',
'source_ref',
'slug',
'label',
'destination_url',
'manage_url',
'is_managed_here',
'is_active',
'clicks_total',
'unique_clicks_total',
@@ -21,6 +26,7 @@ class ShortLink extends Model
];
protected $casts = [
'is_managed_here' => 'boolean',
'is_active' => 'boolean',
'clicks_total' => 'integer',
'unique_clicks_total' => 'integer',
@@ -57,4 +63,19 @@ class ShortLink extends Model
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
public function sourceAppLabel(): string
{
return match ($this->source_app) {
'link' => 'Ladill Link',
'qrplus' => 'QR Plus',
'merchant' => 'Merchant',
'events' => 'Events',
'mini' => 'Mini',
'give' => 'Give',
'transfer' => 'Transfer',
'platform' => 'Ladill',
default => ucfirst((string) $this->source_app),
};
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Link;
use App\Models\ShortLink;
use App\Models\User;
use App\Support\LadillLink;
use Illuminate\Support\Facades\DB;
/**
* Mirrors platform-wide ladl.link slugs into the local catalog for unified listing.
*/
class LinkCatalogSyncService
{
public function __construct(
private PlatformLinkCatalogClient $catalog,
) {}
public function syncForUser(User $user): void
{
$remote = $this->catalog->linksForUser((string) $user->public_id);
$remoteSlugs = collect($remote)->pluck('slug')->filter()->all();
DB::transaction(function () use ($user, $remote, $remoteSlugs): void {
foreach ($remote as $row) {
$slug = (string) ($row['slug'] ?? '');
if ($slug === '') {
continue;
}
if (ShortLink::query()
->where('slug', $slug)
->where('is_managed_here', true)
->exists()) {
continue;
}
ShortLink::query()->updateOrCreate(
['slug' => $slug],
[
'user_id' => $user->id,
'label' => $row['label'] ?? null,
'destination_url' => (string) ($row['destination_url'] ?? LadillLink::url($slug)),
'source_app' => (string) ($row['source_app'] ?? 'platform'),
'source_kind' => (string) ($row['source_kind'] ?? 'qr'),
'source_ref' => isset($row['source_ref']) ? (string) $row['source_ref'] : null,
'manage_url' => $row['manage_url'] ?? null,
'is_managed_here' => false,
'is_active' => (bool) ($row['is_active'] ?? true),
'clicks_total' => (int) ($row['clicks_total'] ?? 0),
],
);
}
if ($remoteSlugs !== []) {
ShortLink::query()
->where('user_id', $user->id)
->where('is_managed_here', false)
->whereNotIn('slug', $remoteSlugs)
->delete();
}
});
}
}
+3
View File
@@ -30,6 +30,9 @@ class LinkManagerService
$link = ShortLink::create([
'user_id' => $user->id,
'source_app' => 'link',
'source_kind' => 'redirect',
'is_managed_here' => true,
'slug' => $slug,
'label' => $data['label'] ?? null,
'destination_url' => $this->normalizeUrl($data['destination_url']),
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Link;
use Illuminate\Support\Facades\Http;
/**
* Fetches ladl.link slugs created in other Ladill apps from the platform catalog API.
*/
class PlatformLinkCatalogClient
{
private function base(): string
{
$platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
return $platform.'/api/link-catalog';
}
private function token(): string
{
return (string) (config('billing.api_key') ?? '');
}
/** @return list<array<string, mixed>> */
public function linksForUser(string $publicId): array
{
if ($this->token() === '') {
return [];
}
try {
$response = Http::withToken($this->token())
->acceptJson()
->timeout(12)
->get($this->base().'/links', ['user' => $publicId]);
if (! $response->successful()) {
return [];
}
return array_values(array_filter(
(array) ($response->json('links') ?? []),
fn ($row) => is_array($row) && filled($row['slug'] ?? null),
));
} catch (\Throwable) {
return [];
}
}
}
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_links', function (Blueprint $table) {
$table->string('source_app', 32)->default('link')->after('user_id');
$table->string('source_kind', 16)->default('redirect')->after('source_app');
$table->string('source_ref', 64)->nullable()->after('source_kind');
$table->string('manage_url', 512)->nullable()->after('destination_url');
$table->boolean('is_managed_here')->default(true)->after('manage_url');
$table->index(['user_id', 'source_app']);
});
}
public function down(): void
{
Schema::table('short_links', function (Blueprint $table) {
$table->dropIndex(['user_id', 'source_app']);
$table->dropColumn([
'source_app',
'source_kind',
'source_ref',
'manage_url',
'is_managed_here',
]);
});
}
};
+4
View File
@@ -23,6 +23,7 @@
<tr>
<th class="px-5 py-3">Link</th>
<th class="px-5 py-3">Destination</th>
<th class="px-5 py-3">App</th>
<th class="px-5 py-3">Clicks</th>
<th class="px-5 py-3">Status</th>
</tr>
@@ -37,6 +38,9 @@
<p class="text-emerald-600">{{ $link->publicUrl() }}</p>
</td>
<td class="max-w-xs truncate px-5 py-3 text-slate-500">{{ $link->destination_url }}</td>
<td class="px-5 py-3">
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">{{ $link->sourceAppLabel() }}</span>
</td>
<td class="px-5 py-3 text-slate-700">{{ number_format($link->clicks_total) }}</td>
<td class="px-5 py-3">
@if($link->is_active && ! $link->isExpired())
+23
View File
@@ -3,19 +3,35 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<h1 class="text-2xl font-semibold text-slate-900">{{ $link->label ?: $link->slug }}</h1>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">{{ $link->sourceAppLabel() }}</span>
</div>
<a href="{{ $link->publicUrl() }}" target="_blank" rel="noopener"
class="mt-1 inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700">
{{ $link->publicUrl() }}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
</a>
</div>
@if ($link->is_managed_here)
<form method="POST" action="{{ route('user.links.destroy', $link) }}" onsubmit="return confirm('Delete this link?')">
@csrf @method('DELETE')
<button type="submit" class="text-sm text-red-600 hover:text-red-700">Delete</button>
</form>
@elseif ($link->manage_url)
<a href="{{ $link->manage_url }}" target="_blank" rel="noopener"
class="inline-flex items-center rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">
Manage in {{ $link->sourceAppLabel() }}
</a>
@endif
</div>
@if (! $link->is_managed_here)
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
This ladl.link slug was created in {{ $link->sourceAppLabel() }}. Edit it there changes sync back here automatically.
</div>
@endif
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-sm text-slate-500">Total clicks</p>
@@ -31,6 +47,7 @@
</div>
</div>
@if ($link->is_managed_here)
<form method="POST" action="{{ route('user.links.update', $link) }}" class="space-y-4 rounded-xl border border-slate-200 bg-white p-6">
@csrf @method('PATCH')
@@ -55,5 +72,11 @@
<button type="submit" class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Save changes</button>
</form>
@else
<div class="rounded-xl border border-slate-200 bg-white p-6">
<p class="text-sm font-medium text-slate-700">Destination</p>
<p class="mt-1 text-sm text-slate-600">{{ $link->destination_url }}</p>
</div>
@endif
</div>
</x-user-layout>