From f1fe74993418a1f095c9d0cb797c42577cc0d5e5 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 27 Jun 2026 13:26:16 +0000 Subject: [PATCH] Aggregate ladl.link slugs from all platform apps in the link list. 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 --- app/Http/Controllers/Link/LinkController.php | 13 ++++ .../Controllers/Link/OverviewController.php | 6 ++ app/Models/ShortLink.php | 21 +++++ app/Services/Link/LinkCatalogSyncService.php | 64 +++++++++++++++ app/Services/Link/LinkManagerService.php | 3 + .../Link/PlatformLinkCatalogClient.php | 49 ++++++++++++ ...000_add_source_metadata_to_short_links.php | 35 +++++++++ resources/views/links/index.blade.php | 4 + resources/views/links/show.blade.php | 77 ++++++++++++------- 9 files changed, 245 insertions(+), 27 deletions(-) create mode 100644 app/Services/Link/LinkCatalogSyncService.php create mode 100644 app/Services/Link/PlatformLinkCatalogClient.php create mode 100644 database/migrations/2026_06_27_160000_add_source_metadata_to_short_links.php diff --git a/app/Http/Controllers/Link/LinkController.php b/app/Http/Controllers/Link/LinkController.php index 53a8229..b4b00d6 100644 --- a/app/Http/Controllers/Link/LinkController.php +++ b/app/Http/Controllers/Link/LinkController.php @@ -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.'); diff --git a/app/Http/Controllers/Link/OverviewController.php b/app/Http/Controllers/Link/OverviewController.php index 438fe77..61c7454 100644 --- a/app/Http/Controllers/Link/OverviewController.php +++ b/app/Http/Controllers/Link/OverviewController.php @@ -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() diff --git a/app/Models/ShortLink.php b/app/Models/ShortLink.php index c871ccc..55121e7 100644 --- a/app/Models/ShortLink.php +++ b/app/Models/ShortLink.php @@ -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), + }; + } } diff --git a/app/Services/Link/LinkCatalogSyncService.php b/app/Services/Link/LinkCatalogSyncService.php new file mode 100644 index 0000000..49e16fa --- /dev/null +++ b/app/Services/Link/LinkCatalogSyncService.php @@ -0,0 +1,64 @@ +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(); + } + }); + } +} diff --git a/app/Services/Link/LinkManagerService.php b/app/Services/Link/LinkManagerService.php index 2d7a6cc..bde8148 100644 --- a/app/Services/Link/LinkManagerService.php +++ b/app/Services/Link/LinkManagerService.php @@ -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']), diff --git a/app/Services/Link/PlatformLinkCatalogClient.php b/app/Services/Link/PlatformLinkCatalogClient.php new file mode 100644 index 0000000..d867e52 --- /dev/null +++ b/app/Services/Link/PlatformLinkCatalogClient.php @@ -0,0 +1,49 @@ +> */ + 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 []; + } + } +} diff --git a/database/migrations/2026_06_27_160000_add_source_metadata_to_short_links.php b/database/migrations/2026_06_27_160000_add_source_metadata_to_short_links.php new file mode 100644 index 0000000..a622736 --- /dev/null +++ b/database/migrations/2026_06_27_160000_add_source_metadata_to_short_links.php @@ -0,0 +1,35 @@ +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', + ]); + }); + } +}; diff --git a/resources/views/links/index.blade.php b/resources/views/links/index.blade.php index e89febc..d518282 100644 --- a/resources/views/links/index.blade.php +++ b/resources/views/links/index.blade.php @@ -23,6 +23,7 @@ Link Destination + App Clicks Status @@ -37,6 +38,9 @@

{{ $link->publicUrl() }}

{{ $link->destination_url }} + + {{ $link->sourceAppLabel() }} + {{ number_format($link->clicks_total) }} @if($link->is_active && ! $link->isExpired()) diff --git a/resources/views/links/show.blade.php b/resources/views/links/show.blade.php index 9d8c230..71ddaa0 100644 --- a/resources/views/links/show.blade.php +++ b/resources/views/links/show.blade.php @@ -3,19 +3,35 @@
-

{{ $link->label ?: $link->slug }}

+
+

{{ $link->label ?: $link->slug }}

+ {{ $link->sourceAppLabel() }} +
{{ $link->publicUrl() }}
-
- @csrf @method('DELETE') - -
+ @if ($link->is_managed_here) +
+ @csrf @method('DELETE') + +
+ @elseif ($link->manage_url) + + Manage in {{ $link->sourceAppLabel() }} + + @endif
+ @if (! $link->is_managed_here) +
+ This ladl.link slug was created in {{ $link->sourceAppLabel() }}. Edit it there — changes sync back here automatically. +
+ @endif +

Total clicks

@@ -31,29 +47,36 @@
-
- @csrf @method('PATCH') + @if ($link->is_managed_here) + + @csrf @method('PATCH') -
- - +
+ + +
+ +
+ + +
+ + + + + + @else +
+

Destination

+

{{ $link->destination_url }}

- -
- - -
- - - - - + @endif