Files
ladill-link/app/Services/Link/LinkAnalyticsService.php
T
isaaccladandCursor 0e5e0f6ca5
Deploy Ladill Link / deploy (push) Successful in 32s
Add device, platform, and country analytics to link show page.
Record parsed user-agent and geo data on clicks, then surface breakdowns and richer recent-click context on per-link and account analytics views.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 19:32:40 +00:00

215 lines
7.6 KiB
PHP

<?php
namespace App\Services\Link;
use App\Models\LinkClick;
use App\Models\ShortLink;
use App\Models\User;
use App\Support\Link\LinkGeoResolver;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class LinkAnalyticsService
{
/** @var list<string> */
private const BREAKDOWN_COLUMNS = ['device_type', 'browser', 'os', 'country'];
/** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,links_total:int} */
public function summaryForAccount(User $account): array
{
$links = ShortLink::query()->where('user_id', $account->id);
$clicks = LinkClick::query()->whereIn('short_link_id', (clone $links)->select('id'));
return [
'total_clicks' => (int) (clone $links)->sum('clicks_total'),
'unique_clicks' => (int) (clone $links)->sum('unique_clicks_total'),
'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(),
'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(),
'links_total' => (int) (clone $links)->count(),
];
}
/** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,last_clicked_at:\Illuminate\Support\Carbon|null} */
public function summaryForLink(ShortLink $link): array
{
$clicks = LinkClick::query()->where('short_link_id', $link->id);
return [
'total_clicks' => (int) $link->clicks_total,
'unique_clicks' => (int) $link->unique_clicks_total,
'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(),
'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(),
'last_clicked_at' => $link->last_clicked_at,
];
}
/** @return Collection<int, object{date:string,total:int}> */
public function dailyClicksForAccount(User $account, int $days = 30): Collection
{
return $this->dailyClicks(
LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')),
$days,
);
}
/** @return Collection<int, object{date:string,total:int}> */
public function dailyClicksForLink(ShortLink $link, int $days = 30): Collection
{
return $this->dailyClicks(
LinkClick::query()->where('short_link_id', $link->id),
$days,
);
}
/** @return Collection<int, object{date:string,total:int}> */
private function dailyClicks($query, int $days): Collection
{
$rows = (clone $query)
->selectRaw('DATE(clicked_at) as date, COUNT(*) as total')
->where('clicked_at', '>=', now()->subDays($days - 1)->startOfDay())
->groupBy('date')
->orderBy('date')
->get()
->keyBy('date');
return collect(range(0, $days - 1))->map(function (int $offset) use ($rows, $days) {
$date = now()->subDays($days - 1 - $offset)->toDateString();
return (object) [
'date' => $date,
'total' => (int) ($rows[$date]->total ?? 0),
];
});
}
/** @return array<int, array{label:string,total:int}> */
public function breakdownForAccount(User $account, string $column, int $limit = 8): array
{
return $this->breakdown(
LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')),
$column,
$limit,
);
}
/** @return array<int, array{label:string,total:int}> */
public function breakdownForLink(ShortLink $link, string $column, int $limit = 8): array
{
return $this->breakdown(
LinkClick::query()->where('short_link_id', $link->id),
$column,
$limit,
);
}
/** @return array<int, array{label:string,total:int}> */
private function breakdown($query, string $column, int $limit): array
{
if (! in_array($column, self::BREAKDOWN_COLUMNS, true)) {
throw new \InvalidArgumentException("Unsupported breakdown column: {$column}");
}
return (clone $query)
->select($column, DB::raw('COUNT(*) as total'))
->whereNotNull($column)
->where($column, '!=', '')
->groupBy($column)
->orderByDesc('total')
->limit($limit)
->get()
->map(fn ($row) => [
'label' => $this->breakdownLabel($column, (string) $row->{$column}),
'total' => (int) $row->total,
])
->all();
}
/** @return Collection<int, array{label:string,total:int}> */
public function topLinksForAccount(User $account, int $limit = 8): Collection
{
return ShortLink::query()
->where('user_id', $account->id)
->orderByDesc('clicks_total')
->limit($limit)
->get()
->map(fn (ShortLink $link) => [
'label' => $link->label ?: $link->slug,
'slug' => $link->slug,
'total' => $link->clicks_total,
'url' => route('user.links.show', $link),
]);
}
/** @return Collection<int, object> */
public function recentClicksForAccount(User $account, int $limit = 15): Collection
{
return LinkClick::query()
->with('shortLink:id,slug,label')
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->latest('clicked_at')
->limit($limit)
->get();
}
/** @return Collection<int, LinkClick> */
public function recentClicksForLink(ShortLink $link, int $limit = 20): Collection
{
return LinkClick::query()
->where('short_link_id', $link->id)
->latest('clicked_at')
->limit($limit)
->get();
}
/** @return Collection<int, array{label:string,total:int}> */
public function referrersForAccount(User $account, int $limit = 8): Collection
{
return $this->referrers(
LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')),
$limit,
);
}
/** @return Collection<int, array{label:string,total:int}> */
public function referrersForLink(ShortLink $link, int $limit = 8): Collection
{
return $this->referrers(
LinkClick::query()->where('short_link_id', $link->id),
$limit,
);
}
/** @return Collection<int, array{label:string,total:int}> */
private function referrers($query, int $limit): Collection
{
return (clone $query)
->select('referer', DB::raw('COUNT(*) as total'))
->whereNotNull('referer')
->where('referer', '!=', '')
->groupBy('referer')
->orderByDesc('total')
->limit($limit)
->get()
->map(fn ($row) => [
'label' => $this->refererLabel((string) $row->referer),
'total' => (int) $row->total,
]);
}
private function breakdownLabel(string $column, string $value): string
{
return match ($column) {
'device_type' => ucfirst($value),
'country' => LinkGeoResolver::countryLabel($value),
default => $value,
};
}
private function refererLabel(string $referer): string
{
$host = parse_url($referer, PHP_URL_HOST);
return is_string($host) && $host !== '' ? $host : $referer;
}
}