diff --git a/src/api/stats.ts b/src/api/stats.ts index cef117d..b36016d 100644 --- a/src/api/stats.ts +++ b/src/api/stats.ts @@ -1,13 +1,28 @@ import { request } from "@/api/client"; -import type { StatsQuery, StatsResponse } from "@/api/types"; -import { API_BASE_URL, API_V1, CLIENT_HEADER, CLIENT_HEADER_VALUE } from "@/lib/constants"; +import type { PublicStatsQuery, StatsQuery, StatsResponse } from "@/api/types"; +import { getUrlByAddress } from "@/api/urls"; +import { API_BASE_URL, API_V1 } from "@/lib/constants"; import { ApiError } from "@/lib/errors"; -import { statsResponseSchema } from "@/schemas/api"; +import { authModeStorage } from "@/lib/storage"; +import { publicStatsResponseSchema, statsResponseSchema } from "@/schemas/api"; /** - * Get stats via v1 API (works for v2/new URLs only). + * Thrown when per-link stats exist behind a gate we can't pass: + * the code is unknown, the owner made stats private (404), or the + * link is password protected (401 password_required). The UI treats + * all of these as a single "stats unavailable" state. */ -export function getStatsV1(query: StatsQuery = {}): Promise { +export class StatsUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "StatsUnavailableError"; + } +} + +/** + * Account-wide analytics. GET /api/v1/stats — auth required. + */ +export function getAccountStats(query: StatsQuery = {}): Promise { return request( `${API_V1}/stats`, { params: query as Record }, @@ -16,131 +31,80 @@ export function getStatsV1(query: StatsQuery = {}): Promise { } /** - * V0 stats response shape (legacy embedded analytics). - */ -export interface V0StatsResponse { - _id: string; - short_code: string; - url: string; - "total-clicks": number; - total_unique_clicks: number; - "creation-date": string; - "last-click": string | null; - "last-click-browser": string | null; - "last-click-os": string | null; - average_daily_clicks: number; - average_weekly_clicks: number; - average_monthly_clicks: number; - average_redirection_time: number; - counter: Record; - unique_counter: Record; - browser: Record; - unique_browser: Record; - os_name: Record; - unique_os_name: Record; - country: Record; - unique_country: Record; - referrer: Record; - unique_referrer: Record; -} - -/** - * Get stats via v0 API (works for legacy/v0 URLs). - * POST /stats/{shortCode} with optional password. + * Stats for one link the signed-in user owns. + * GET /api/v1/stats/links/{urlId} — auth required, 404 for foreign/unknown ids. */ -export async function getStatsV0(shortCode: string, password?: string): Promise { - const url = `${API_BASE_URL}/stats/${shortCode}`; - const headers: Record = { [CLIENT_HEADER]: CLIENT_HEADER_VALUE }; - - const body = password ? new URLSearchParams({ password }) : undefined; - if (body) { - headers["Content-Type"] = "application/x-www-form-urlencoded"; - } - - const res = await fetch(url, { - method: "POST", - headers, - body: body?.toString(), - }); - - if (!res.ok) { - throw new Error(`Stats request failed: ${res.statusText}`); - } - - return res.json(); +export function getLinkStats(urlId: string, query: StatsQuery = {}): Promise { + return request( + `${API_V1}/stats/links/${urlId}`, + { params: query as Record }, + statsResponseSchema, + ); } /** - * Convert v0 stats to the same shape the UI expects. + * Public per-link stats. GET /api/v1/public/stats/{shortCode} — no auth. + * The envelope is {generation, link, stats}; the inner stats object is the + * same wire shape as the authed endpoints, so we unwrap it here. */ -function v0ToStatsResponse(v0: V0StatsResponse): StatsResponse { - // Convert Record breakdowns to array format - const toArray = (obj: Record, nameKey: string) => - Object.entries(obj) - .sort(([, a], [, b]) => b - a) - .map(([name, clicks]) => ({ [nameKey]: name, clicks })); - - return { - scope: "anon", - filters: {}, - group_by: [], - timezone: "UTC", - time_range: { start_date: v0["creation-date"], end_date: null }, - summary: { - total_clicks: v0["total-clicks"], - unique_clicks: v0.total_unique_clicks, - first_click: v0["creation-date"], - last_click: v0["last-click"], - avg_redirection_time: v0.average_redirection_time, - }, - metrics: { - browser: toArray(v0.browser, "browser"), - os: toArray(v0.os_name, "os"), - country: toArray(v0.country, "country"), - referrer: toArray(v0.referrer, "referrer"), - clicks_over_time: Object.entries(v0.counter) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, clicks]) => ({ date, clicks })), +export async function getPublicStats( + shortCode: string, + query: PublicStatsQuery = {}, +): Promise { + const { stats } = await request( + `${API_V1}/public/stats/${encodeURIComponent(shortCode)}`, + { + params: query as Record, + noAuth: true, }, - computed_metrics: { - // Match v1 format: rates are already percentages (e.g. 6.25 = 6.25%) - unique_click_rate: - v0["total-clicks"] > 0 - ? Math.round((v0.total_unique_clicks / v0["total-clicks"]) * 10000) / 100 - : 0, - repeat_click_rate: - v0["total-clicks"] > 0 - ? Math.round( - ((v0["total-clicks"] - v0.total_unique_clicks) / v0["total-clicks"]) * 10000, - ) / 100 - : 0, - average_clicks_per_visitor: - v0.total_unique_clicks > 0 - ? Math.round((v0["total-clicks"] / v0.total_unique_clicks) * 100) / 100 - : 0, - }, - }; + publicStatsResponseSchema, + ); + return stats; } /** - * Get stats with automatic v1 -> v0 fallback. - * Tries v1 API first (for v2 URLs), falls back to v0 (for legacy URLs). + * Get stats for a short code, picking the right surface at runtime: + * + * 1. Signed in → resolve the code to an owned url id via + * GET /urls/{domain}/{alias} and use the per-link authed endpoint. + * 2. Resolution 404s (not our link) or we're anonymous → fall back to + * the public stats endpoint. + * + * Public 404 (unknown code or private stats) and 401 (password + * protected) both surface as StatsUnavailableError. */ -export async function getStats(query: StatsQuery = {}): Promise { - // If querying a specific short code, use scope=anon, try v1 first, fall back to v0 - if (query.short_code) { +export async function getUrlStats( + shortCode: string, + query: StatsQuery = {}, +): Promise { + const mode = await authModeStorage.getValue(); + + if (mode === "jwt" || mode === "apikey") { try { - return await getStatsV1({ ...query, scope: query.scope ?? "anon" }); + const url = await getUrlByAddress(new URL(API_BASE_URL).hostname, shortCode); + return await getLinkStats(url.id, query); } catch (e) { - // v1 404 means it's a legacy URL — fall back to v0 endpoint - if (e instanceof ApiError && e.isNotFound) { - const v0 = await getStatsV0(query.short_code); - return v0ToStatsResponse(v0); - } - throw e; + // 404 means the link isn't in this account (foreign or unknown) — + // the public surface is the only remaining read path. + if (!(e instanceof ApiError && e.isNotFound)) throw e; } } - // No specific short code — use v1 API only - return getStatsV1(query); + try { + return await getPublicStats(shortCode, { + start_date: query.start_date, + end_date: query.end_date, + timezone: query.timezone, + }); + } catch (e) { + if (e instanceof ApiError && e.isUnauthorized) { + throw new StatsUnavailableError("This link's stats are password protected."); + } + if (e instanceof ApiError && e.isNotFound) { + throw new StatsUnavailableError( + "Stats aren't available for this link. It may not exist, or its owner made stats private.", + ); + } + throw e; + } } diff --git a/src/api/types.ts b/src/api/types.ts index 4ce24db..195acc6 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -184,9 +184,11 @@ export interface ListUrlsQuery { // ── Stats ──────────────────────────────────────────────────── +/** + * Query for the authed stats endpoints: GET /api/v1/stats (account) + * and GET /api/v1/stats/links/{url_id} (one owned link). + */ export interface StatsQuery { - scope?: "all" | "anon"; - short_code?: string; start_date?: string; end_date?: string; group_by?: string; @@ -204,12 +206,23 @@ export interface ExportQuery extends StatsQuery { format: "csv" | "xlsx" | "json" | "xml"; } +/** + * Query for GET /api/v1/public/stats/{short_code} — the public endpoint + * takes a time window only (no grouping/metric selection). + */ +export interface PublicStatsQuery { + start_date?: string; + end_date?: string; + timezone?: string; +} + export interface StatsSummary { total_clicks: number; unique_clicks: number; first_click: string | null; last_click: string | null; - avg_redirection_time: number; + // null = no measurement (e.g. zero clicks in range), never 0 + avg_redirection_time: number | null; } export interface StatsTimeRange { @@ -246,6 +259,17 @@ export interface StatsResponse { computed_metrics?: ComputedMetrics | null; } +/** + * Envelope for GET /api/v1/public/stats/{short_code}. `link` is the + * frozen public-facts wire — kept loose since we only consume `stats`, + * which matches the authed stats wire. + */ +export interface PublicStatsResponse { + generation: string; + link: Record; + stats: StatsResponse; +} + // ── API Keys ───────────────────────────────────────────────── export type ApiKeyScope = diff --git a/src/api/urls.ts b/src/api/urls.ts index f369775..7e89c43 100644 --- a/src/api/urls.ts +++ b/src/api/urls.ts @@ -5,12 +5,14 @@ import type { UpdateUrlRequest, UpdateUrlResponse, UpdateUrlStatusRequest, + UrlListItem, UrlListResponse, } from "@/api/types"; import { API_V1 } from "@/lib/constants"; import { deleteUrlResponseSchema, updateUrlResponseSchema, + urlListItemSchema, urlListResponseSchema, } from "@/schemas/api"; @@ -22,6 +24,18 @@ export function listUrls(query: ListUrlsQuery = {}): Promise { ); } +/** + * Resolve an owned URL by its natural key (domain + alias). + * 404 covers both unknown aliases and links owned by someone else. + */ +export function getUrlByAddress(domain: string, alias: string): Promise { + return request( + `${API_V1}/urls/${encodeURIComponent(domain)}/${encodeURIComponent(alias)}`, + {}, + urlListItemSchema, + ); +} + export function updateUrl(urlId: string, data: UpdateUrlRequest): Promise { return request( `${API_V1}/urls/${urlId}`, diff --git a/src/components/sidepanel/AnalyticsTab.tsx b/src/components/sidepanel/AnalyticsTab.tsx index 32b4fc2..af378c7 100644 --- a/src/components/sidepanel/AnalyticsTab.tsx +++ b/src/components/sidepanel/AnalyticsTab.tsx @@ -13,6 +13,7 @@ import { XAxis, YAxis, } from "recharts"; +import { StatsUnavailableError } from "@/api/stats"; import type { StatsResponse } from "@/api/types"; import { Button } from "@/components/ui/button"; import { @@ -23,7 +24,7 @@ import { } from "@/components/ui/chart"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useStats } from "@/hooks/use-stats"; +import { useAccountStats, useUrlStats } from "@/hooks/use-stats"; import { extractShortCode } from "@/lib/url-utils"; import { useAuthStore } from "@/stores/auth"; @@ -61,8 +62,7 @@ export function AnalyticsTab() { // ── Account-Level Analytics ────────────────────────────────── function AccountAnalytics() { - const { data, isLoading, error } = useStats({ - scope: "all", + const { data, isLoading, error } = useAccountStats({ group_by: "time,browser,os,country,referrer,short_code", metrics: "clicks,unique_clicks", }); @@ -80,14 +80,11 @@ function UrlAnalytics() { const [shortCode, setShortCode] = useState(""); const [activeCode, setActiveCode] = useState(); - const { data, isLoading, error } = useStats( - activeCode - ? { - short_code: activeCode, - group_by: "time,browser,os,country,referrer", - metrics: "clicks,unique_clicks", - } - : {}, + // Grouping/metric selection only applies on the authed per-link path; + // the public endpoint returns its fixed dimension set regardless. + const { data, isLoading, error } = useUrlStats( + activeCode ?? "", + { group_by: "time,browser,os,country,referrer", metrics: "clicks,unique_clicks" }, !!activeCode, ); @@ -119,7 +116,12 @@ function UrlAnalytics() { {isLoading && } - {error &&

{error.message}

} + {error && + (error instanceof StatsUnavailableError ? ( +

{error.message}

+ ) : ( +

{error.message}

+ ))} {data && } {!activeCode && !isLoading && (
diff --git a/src/hooks/use-stats.ts b/src/hooks/use-stats.ts index 23a65c1..f54bfec 100644 --- a/src/hooks/use-stats.ts +++ b/src/hooks/use-stats.ts @@ -1,15 +1,23 @@ import { useQuery } from "@tanstack/react-query"; -import { getStats } from "@/api/stats"; +import { getAccountStats, getUrlStats, StatsUnavailableError } from "@/api/stats"; import type { StatsQuery } from "@/api/types"; -export function useStats(query: StatsQuery = {}, enabled = true) { +export function useAccountStats(query: StatsQuery = {}, enabled = true) { return useQuery({ - queryKey: ["stats", query], - queryFn: () => getStats(query), + queryKey: ["stats", "account", query], + queryFn: () => getAccountStats(query), enabled, }); } -export function useUrlStats(shortCode: string, enabled = true) { - return useStats({ short_code: shortCode }, enabled); +export function useUrlStats(shortCode: string, query: StatsQuery = {}, enabled = true) { + return useQuery({ + queryKey: ["stats", "url", shortCode, query], + queryFn: () => getUrlStats(shortCode, query), + enabled, + // "Stats unavailable" is a settled answer (private/password/missing) — + // retrying can't change it. Everything else keeps the default policy. + retry: (failureCount, error) => + error instanceof StatsUnavailableError ? false : failureCount < 2, + }); } diff --git a/src/schemas/api.ts b/src/schemas/api.ts index 1de7deb..a2dff69 100644 --- a/src/schemas/api.ts +++ b/src/schemas/api.ts @@ -130,7 +130,8 @@ export const statsSummarySchema = z.object({ unique_clicks: z.number(), first_click: z.string().nullable(), last_click: z.string().nullable(), - avg_redirection_time: z.number(), + // null = no measurement (e.g. zero clicks in range), never 0 + avg_redirection_time: z.number().nullable(), }); export const statsTimeRangeSchema = z.object({ @@ -167,6 +168,15 @@ export const statsResponseSchema = z.object({ computed_metrics: computedMetricsSchema.nullable().optional(), }); +// GET /api/v1/public/stats/{short_code} — {generation, link, stats}. +// `link` stays loose (frozen wire, unconsumed); `stats` is the same +// shape as the authed stats endpoints. +export const publicStatsResponseSchema = z.object({ + generation: z.string(), + link: z.record(z.string(), z.unknown()), + stats: statsResponseSchema, +}); + // ── API Keys ───────────────────────────────────────────────── export const apiKeyResponseSchema = z.object({