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>
115 lines
3.5 KiB
PHP
115 lines
3.5 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Link;
|
||
|
||
use App\Models\LinkClick;
|
||
use App\Models\LinkWallet;
|
||
use App\Models\ShortLink;
|
||
use App\Models\User;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
use RuntimeException;
|
||
|
||
class LinkManagerService
|
||
{
|
||
public function __construct(
|
||
private LinkBillingService $billing,
|
||
private LinkClickRecorder $clickRecorder,
|
||
) {}
|
||
|
||
public function create(User $user, array $data): ShortLink
|
||
{
|
||
if (! $this->billing->canCreate($user)) {
|
||
throw new RuntimeException('Insufficient wallet balance. Top up to create links.');
|
||
}
|
||
|
||
return DB::transaction(function () use ($user, $data) {
|
||
$slug = $this->resolveSlug($data['custom_slug'] ?? null);
|
||
$wallet = $user->getOrCreateLinkWallet();
|
||
|
||
$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']),
|
||
'expires_at' => $data['expires_at'] ?? null,
|
||
]);
|
||
|
||
$this->billing->debitForLinkCreation($wallet, $link);
|
||
|
||
return $link;
|
||
});
|
||
}
|
||
|
||
public function update(ShortLink $link, array $data): ShortLink
|
||
{
|
||
$link->fill([
|
||
'label' => $data['label'] ?? $link->label,
|
||
'destination_url' => isset($data['destination_url'])
|
||
? $this->normalizeUrl($data['destination_url'])
|
||
: $link->destination_url,
|
||
'is_active' => $data['is_active'] ?? $link->is_active,
|
||
'expires_at' => array_key_exists('expires_at', $data) ? $data['expires_at'] : $link->expires_at,
|
||
])->save();
|
||
|
||
return $link->fresh();
|
||
}
|
||
|
||
public function resolve(Request $request, string $slug): ?ShortLink
|
||
{
|
||
$link = ShortLink::query()
|
||
->where('slug', $slug)
|
||
->where('is_active', true)
|
||
->first();
|
||
|
||
if ($link === null || $link->isExpired()) {
|
||
return null;
|
||
}
|
||
|
||
$this->clickRecorder->record($link, $request);
|
||
|
||
return $link;
|
||
}
|
||
|
||
private function resolveSlug(?string $custom): string
|
||
{
|
||
if ($custom !== null && $custom !== '') {
|
||
$custom = strtolower(trim($custom));
|
||
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $custom)) {
|
||
throw new RuntimeException('Custom slug must be 3–20 characters: lowercase letters, numbers, and hyphens.');
|
||
}
|
||
if (ShortLink::where('slug', $custom)->exists()) {
|
||
throw new RuntimeException('That slug is already taken.');
|
||
}
|
||
|
||
return $custom;
|
||
}
|
||
|
||
do {
|
||
$slug = Str::lower(Str::random((int) config('link.slug_length', 8)));
|
||
} while (ShortLink::where('slug', $slug)->exists());
|
||
|
||
return $slug;
|
||
}
|
||
|
||
private function normalizeUrl(string $url): string
|
||
{
|
||
$url = trim($url);
|
||
if ($url === '') {
|
||
throw new RuntimeException('Destination URL is required.');
|
||
}
|
||
if (! preg_match('#^https?://#i', $url)) {
|
||
$url = 'https://'.$url;
|
||
}
|
||
if (! filter_var($url, FILTER_VALIDATE_URL)) {
|
||
throw new RuntimeException('Enter a valid URL.');
|
||
}
|
||
|
||
return $url;
|
||
}
|
||
}
|