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->publicUrl() }}
Total clicks
@@ -31,29 +47,36 @@