'boolean', 'is_active' => 'boolean', 'clicks_total' => 'integer', 'unique_clicks_total' => 'integer', 'last_clicked_at' => 'datetime', 'expires_at' => 'datetime', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } public function campaign(): BelongsTo { return $this->belongsTo(Campaign::class); } public function clicks(): HasMany { return $this->hasMany(LinkClick::class); } /** * Shareable short URL for this slug — always derived at runtime from the * owner's default domain (custom or platform), never stored. */ public function publicUrl(?User $owner = null): string { $owner ??= $this->user; $base = $owner ? $owner->publicLinkBaseUrl() : self::publicBaseUrl(); return rtrim($base, '/').'/'.$this->slug; } public static function publicHost(): string { return strtolower((string) config('link.public_domain', 'ladl.link')); } public static function publicBaseUrl(): string { return 'https://'.self::publicHost(); } /** * Build the live redirect target: current destination_url plus any path * suffix and query string from the inbound request (query keys on the * short link override destination keys when both are set). */ public function resolveRedirectUrl(Request $request, ?string $path = null): string { $base = trim((string) $this->destination_url); if ($base === '') { return $this->publicUrl(); } $suffix = $path !== null ? trim($path, '/') : ''; if ($suffix !== '') { $parsed = parse_url($base); $basePath = rtrim((string) ($parsed['path'] ?? ''), '/'); $rebuilt = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? ''); if (! empty($parsed['port'])) { $rebuilt .= ':'.$parsed['port']; } $rebuilt .= $basePath.'/'.$suffix; if (! empty($parsed['query'])) { $rebuilt .= '?'.$parsed['query']; } if (! empty($parsed['fragment'])) { $rebuilt .= '#'.$parsed['fragment']; } $base = $rebuilt; } $requestQuery = $request->query(); if ($requestQuery === []) { return $base; } $parts = parse_url($base); $existing = []; if (! empty($parts['query'])) { parse_str((string) $parts['query'], $existing); } // Inbound short-link params win so campaigns can override destination defaults. $merged = array_merge($existing, $requestQuery); $query = http_build_query($merged); $target = ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? ''); if (! empty($parts['port'])) { $target .= ':'.$parts['port']; } $target .= $parts['path'] ?? ''; if ($query !== '') { $target .= '?'.$query; } if (! empty($parts['fragment'])) { $target .= '#'.$parts['fragment']; } return $target; } public function isExpired(): bool { return $this->expires_at !== null && $this->expires_at->isPast(); } /** Simple URL redirects created in Link; QR ecosystem slugs resolve via the platform proxy. */ public function isDirectRedirect(): bool { return $this->is_managed_here && $this->source_kind === 'redirect'; } 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), }; } }