Files
ladill-link/app/Services/Link/LinkManagerService.php
T
isaaccladandCursor d9c91ad7d8 Add Ladill Link URL shortener on ladl.link.
Management UI at link.ladill.com with GHS 0.05 per link wallet billing,
click analytics, and legacy fallback to ladill.com/q for QR ecosystem codes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 10:58:41 +00:00

112 lines
3.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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,
'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 320 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;
}
}