Aggregate ladl.link slugs from all platform apps in the link list.
Deploy Ladill Link / deploy (push) Successful in 29s
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:
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Link;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ShortLink;
|
use App\Models\ShortLink;
|
||||||
use App\Services\Link\LinkBillingService;
|
use App\Services\Link\LinkBillingService;
|
||||||
|
use App\Services\Link\LinkCatalogSyncService;
|
||||||
use App\Services\Link\LinkManagerService;
|
use App\Services\Link\LinkManagerService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -16,11 +17,14 @@ class LinkController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private LinkManagerService $links,
|
private LinkManagerService $links,
|
||||||
private LinkBillingService $billing,
|
private LinkBillingService $billing,
|
||||||
|
private LinkCatalogSyncService $catalogSync,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): View
|
public function index(Request $request): View
|
||||||
{
|
{
|
||||||
$account = ladill_account();
|
$account = ladill_account();
|
||||||
|
$this->catalogSync->syncForUser($account);
|
||||||
|
|
||||||
$links = ShortLink::query()
|
$links = ShortLink::query()
|
||||||
->where('user_id', $account->id)
|
->where('user_id', $account->id)
|
||||||
->latest()
|
->latest()
|
||||||
@@ -77,6 +81,10 @@ class LinkController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeLink($link);
|
$this->authorizeLink($link);
|
||||||
|
|
||||||
|
if (! $link->is_managed_here) {
|
||||||
|
return back()->withErrors(['link' => 'This link is managed in '.$link->sourceAppLabel().'.']);
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'destination_url' => ['sometimes', 'required', 'string', 'max:2048'],
|
'destination_url' => ['sometimes', 'required', 'string', 'max:2048'],
|
||||||
'label' => ['nullable', 'string', 'max:120'],
|
'label' => ['nullable', 'string', 'max:120'],
|
||||||
@@ -96,6 +104,11 @@ class LinkController extends Controller
|
|||||||
public function destroy(ShortLink $link): RedirectResponse
|
public function destroy(ShortLink $link): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->authorizeLink($link);
|
$this->authorizeLink($link);
|
||||||
|
|
||||||
|
if (! $link->is_managed_here) {
|
||||||
|
return back()->withErrors(['link' => 'This link is managed in '.$link->sourceAppLabel().'.']);
|
||||||
|
}
|
||||||
|
|
||||||
$link->delete();
|
$link->delete();
|
||||||
|
|
||||||
return redirect()->route('user.links.index')->with('success', 'Link deleted.');
|
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\Http\Controllers\Controller;
|
||||||
use App\Models\ShortLink;
|
use App\Models\ShortLink;
|
||||||
|
use App\Services\Link\LinkCatalogSyncService;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class OverviewController extends Controller
|
class OverviewController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private LinkCatalogSyncService $catalogSync,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(): View
|
public function index(): View
|
||||||
{
|
{
|
||||||
$account = ladill_account();
|
$account = ladill_account();
|
||||||
|
$this->catalogSync->syncForUser($account);
|
||||||
$wallet = $account->getOrCreateLinkWallet();
|
$wallet = $account->getOrCreateLinkWallet();
|
||||||
|
|
||||||
$recentLinks = ShortLink::query()
|
$recentLinks = ShortLink::query()
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ class ShortLink extends Model
|
|||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'source_app',
|
||||||
|
'source_kind',
|
||||||
|
'source_ref',
|
||||||
'slug',
|
'slug',
|
||||||
'label',
|
'label',
|
||||||
'destination_url',
|
'destination_url',
|
||||||
|
'manage_url',
|
||||||
|
'is_managed_here',
|
||||||
'is_active',
|
'is_active',
|
||||||
'clicks_total',
|
'clicks_total',
|
||||||
'unique_clicks_total',
|
'unique_clicks_total',
|
||||||
@@ -21,6 +26,7 @@ class ShortLink extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'is_managed_here' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'clicks_total' => 'integer',
|
'clicks_total' => 'integer',
|
||||||
'unique_clicks_total' => 'integer',
|
'unique_clicks_total' => 'integer',
|
||||||
@@ -57,4 +63,19 @@ class ShortLink extends Model
|
|||||||
{
|
{
|
||||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ class LinkManagerService
|
|||||||
|
|
||||||
$link = ShortLink::create([
|
$link = ShortLink::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
|
'source_app' => 'link',
|
||||||
|
'source_kind' => 'redirect',
|
||||||
|
'is_managed_here' => true,
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'label' => $data['label'] ?? null,
|
'label' => $data['label'] ?? null,
|
||||||
'destination_url' => $this->normalizeUrl($data['destination_url']),
|
'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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="px-5 py-3">Link</th>
|
<th class="px-5 py-3">Link</th>
|
||||||
<th class="px-5 py-3">Destination</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">Clicks</th>
|
||||||
<th class="px-5 py-3">Status</th>
|
<th class="px-5 py-3">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -37,6 +38,9 @@
|
|||||||
<p class="text-emerald-600">{{ $link->publicUrl() }}</p>
|
<p class="text-emerald-600">{{ $link->publicUrl() }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="max-w-xs truncate px-5 py-3 text-slate-500">{{ $link->destination_url }}</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 text-slate-700">{{ number_format($link->clicks_total) }}</td>
|
||||||
<td class="px-5 py-3">
|
<td class="px-5 py-3">
|
||||||
@if($link->is_active && ! $link->isExpired())
|
@if($link->is_active && ! $link->isExpired())
|
||||||
|
|||||||
@@ -3,19 +3,35 @@
|
|||||||
<div class="mx-auto max-w-3xl space-y-6">
|
<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 class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $link->label ?: $link->slug }}</h1>
|
<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"
|
<a href="{{ $link->publicUrl() }}" target="_blank" rel="noopener"
|
||||||
class="mt-1 inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700">
|
class="mt-1 inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700">
|
||||||
{{ $link->publicUrl() }}
|
{{ $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>
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="{{ route('user.links.destroy', $link) }}" onsubmit="return confirm('Delete this link?')">
|
@if ($link->is_managed_here)
|
||||||
@csrf @method('DELETE')
|
<form method="POST" action="{{ route('user.links.destroy', $link) }}" onsubmit="return confirm('Delete this link?')">
|
||||||
<button type="submit" class="text-sm text-red-600 hover:text-red-700">Delete</button>
|
@csrf @method('DELETE')
|
||||||
</form>
|
<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>
|
</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="grid gap-4 sm:grid-cols-3">
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||||
<p class="text-sm text-slate-500">Total clicks</p>
|
<p class="text-sm text-slate-500">Total clicks</p>
|
||||||
@@ -31,29 +47,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('user.links.update', $link) }}" class="space-y-4 rounded-xl border border-slate-200 bg-white p-6">
|
@if ($link->is_managed_here)
|
||||||
@csrf @method('PATCH')
|
<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')
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="destination_url" class="block text-sm font-medium text-slate-700">Destination URL</label>
|
<label for="destination_url" class="block text-sm font-medium text-slate-700">Destination URL</label>
|
||||||
<input type="url" name="destination_url" id="destination_url" value="{{ old('destination_url', $link->destination_url) }}" required
|
<input type="url" name="destination_url" id="destination_url" value="{{ old('destination_url', $link->destination_url) }}" required
|
||||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="label" class="block text-sm font-medium text-slate-700">Label</label>
|
||||||
|
<input type="text" name="label" id="label" value="{{ old('label', $link->label) }}"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="hidden" name="is_active" value="0">
|
||||||
|
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $link->is_active))
|
||||||
|
class="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500">
|
||||||
|
Link is active
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
@endif
|
||||||
<div>
|
|
||||||
<label for="label" class="block text-sm font-medium text-slate-700">Label</label>
|
|
||||||
<input type="text" name="label" id="label" value="{{ old('label', $link->label) }}"
|
|
||||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input type="hidden" name="is_active" value="0">
|
|
||||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $link->is_active))
|
|
||||||
class="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500">
|
|
||||||
Link is active
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</x-user-layout>
|
</x-user-layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user