11import { request } from "@/api/client" ;
2- import type { StatsQuery , StatsResponse } from "@/api/types" ;
3- import { API_BASE_URL , API_V1 , CLIENT_HEADER , CLIENT_HEADER_VALUE } from "@/lib/constants" ;
2+ import type { PublicStatsQuery , StatsQuery , StatsResponse } from "@/api/types" ;
3+ import { getUrlByAddress } from "@/api/urls" ;
4+ import { API_BASE_URL , API_V1 } from "@/lib/constants" ;
45import { ApiError } from "@/lib/errors" ;
5- import { statsResponseSchema } from "@/schemas/api" ;
6+ import { authModeStorage } from "@/lib/storage" ;
7+ import { publicStatsResponseSchema , statsResponseSchema } from "@/schemas/api" ;
68
79/**
8- * Get stats via v1 API (works for v2/new URLs only).
10+ * Thrown when per-link stats exist behind a gate we can't pass:
11+ * the code is unknown, the owner made stats private (404), or the
12+ * link is password protected (401 password_required). The UI treats
13+ * all of these as a single "stats unavailable" state.
914 */
10- export function getStatsV1 ( query : StatsQuery = { } ) : Promise < StatsResponse > {
15+ export class StatsUnavailableError extends Error {
16+ constructor ( message : string ) {
17+ super ( message ) ;
18+ this . name = "StatsUnavailableError" ;
19+ }
20+ }
21+
22+ /**
23+ * Account-wide analytics. GET /api/v1/stats — auth required.
24+ */
25+ export function getAccountStats ( query : StatsQuery = { } ) : Promise < StatsResponse > {
1126 return request (
1227 `${ API_V1 } /stats` ,
1328 { params : query as Record < string , string | number | boolean | undefined > } ,
@@ -16,131 +31,80 @@ export function getStatsV1(query: StatsQuery = {}): Promise<StatsResponse> {
1631}
1732
1833/**
19- * V0 stats response shape (legacy embedded analytics).
20- */
21- export interface V0StatsResponse {
22- _id : string ;
23- short_code : string ;
24- url : string ;
25- "total-clicks" : number ;
26- total_unique_clicks : number ;
27- "creation-date" : string ;
28- "last-click" : string | null ;
29- "last-click-browser" : string | null ;
30- "last-click-os" : string | null ;
31- average_daily_clicks : number ;
32- average_weekly_clicks : number ;
33- average_monthly_clicks : number ;
34- average_redirection_time : number ;
35- counter : Record < string , number > ;
36- unique_counter : Record < string , number > ;
37- browser : Record < string , number > ;
38- unique_browser : Record < string , number > ;
39- os_name : Record < string , number > ;
40- unique_os_name : Record < string , number > ;
41- country : Record < string , number > ;
42- unique_country : Record < string , number > ;
43- referrer : Record < string , number > ;
44- unique_referrer : Record < string , number > ;
45- }
46-
47- /**
48- * Get stats via v0 API (works for legacy/v0 URLs).
49- * POST /stats/{shortCode} with optional password.
34+ * Stats for one link the signed-in user owns.
35+ * GET /api/v1/stats/links/{urlId} — auth required, 404 for foreign/unknown ids.
5036 */
51- export async function getStatsV0 ( shortCode : string , password ?: string ) : Promise < V0StatsResponse > {
52- const url = `${ API_BASE_URL } /stats/${ shortCode } ` ;
53- const headers : Record < string , string > = { [ CLIENT_HEADER ] : CLIENT_HEADER_VALUE } ;
54-
55- const body = password ? new URLSearchParams ( { password } ) : undefined ;
56- if ( body ) {
57- headers [ "Content-Type" ] = "application/x-www-form-urlencoded" ;
58- }
59-
60- const res = await fetch ( url , {
61- method : "POST" ,
62- headers,
63- body : body ?. toString ( ) ,
64- } ) ;
65-
66- if ( ! res . ok ) {
67- throw new Error ( `Stats request failed: ${ res . statusText } ` ) ;
68- }
69-
70- return res . json ( ) ;
37+ export function getLinkStats ( urlId : string , query : StatsQuery = { } ) : Promise < StatsResponse > {
38+ return request (
39+ `${ API_V1 } /stats/links/${ urlId } ` ,
40+ { params : query as Record < string , string | number | boolean | undefined > } ,
41+ statsResponseSchema ,
42+ ) ;
7143}
7244
7345/**
74- * Convert v0 stats to the same shape the UI expects.
46+ * Public per-link stats. GET /api/v1/public/stats/{shortCode} — no auth.
47+ * The envelope is {generation, link, stats}; the inner stats object is the
48+ * same wire shape as the authed endpoints, so we unwrap it here.
7549 */
76- function v0ToStatsResponse ( v0 : V0StatsResponse ) : StatsResponse {
77- // Convert Record<string, number> breakdowns to array format
78- const toArray = ( obj : Record < string , number > , nameKey : string ) =>
79- Object . entries ( obj )
80- . sort ( ( [ , a ] , [ , b ] ) => b - a )
81- . map ( ( [ name , clicks ] ) => ( { [ nameKey ] : name , clicks } ) ) ;
82-
83- return {
84- scope : "anon" ,
85- filters : { } ,
86- group_by : [ ] ,
87- timezone : "UTC" ,
88- time_range : { start_date : v0 [ "creation-date" ] , end_date : null } ,
89- summary : {
90- total_clicks : v0 [ "total-clicks" ] ,
91- unique_clicks : v0 . total_unique_clicks ,
92- first_click : v0 [ "creation-date" ] ,
93- last_click : v0 [ "last-click" ] ,
94- avg_redirection_time : v0 . average_redirection_time ,
95- } ,
96- metrics : {
97- browser : toArray ( v0 . browser , "browser" ) ,
98- os : toArray ( v0 . os_name , "os" ) ,
99- country : toArray ( v0 . country , "country" ) ,
100- referrer : toArray ( v0 . referrer , "referrer" ) ,
101- clicks_over_time : Object . entries ( v0 . counter )
102- . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) )
103- . map ( ( [ date , clicks ] ) => ( { date, clicks } ) ) ,
50+ export async function getPublicStats (
51+ shortCode : string ,
52+ query : PublicStatsQuery = { } ,
53+ ) : Promise < StatsResponse > {
54+ const { stats } = await request (
55+ `${ API_V1 } /public/stats/${ encodeURIComponent ( shortCode ) } ` ,
56+ {
57+ params : query as Record < string , string | number | boolean | undefined > ,
58+ noAuth : true ,
10459 } ,
105- computed_metrics : {
106- // Match v1 format: rates are already percentages (e.g. 6.25 = 6.25%)
107- unique_click_rate :
108- v0 [ "total-clicks" ] > 0
109- ? Math . round ( ( v0 . total_unique_clicks / v0 [ "total-clicks" ] ) * 10000 ) / 100
110- : 0 ,
111- repeat_click_rate :
112- v0 [ "total-clicks" ] > 0
113- ? Math . round (
114- ( ( v0 [ "total-clicks" ] - v0 . total_unique_clicks ) / v0 [ "total-clicks" ] ) * 10000 ,
115- ) / 100
116- : 0 ,
117- average_clicks_per_visitor :
118- v0 . total_unique_clicks > 0
119- ? Math . round ( ( v0 [ "total-clicks" ] / v0 . total_unique_clicks ) * 100 ) / 100
120- : 0 ,
121- } ,
122- } ;
60+ publicStatsResponseSchema ,
61+ ) ;
62+ return stats ;
12363}
12464
12565/**
126- * Get stats with automatic v1 -> v0 fallback.
127- * Tries v1 API first (for v2 URLs), falls back to v0 (for legacy URLs).
66+ * Get stats for a short code, picking the right surface at runtime:
67+ *
68+ * 1. Signed in → resolve the code to an owned url id via
69+ * GET /urls/{domain}/{alias} and use the per-link authed endpoint.
70+ * 2. Resolution 404s (not our link) or we're anonymous → fall back to
71+ * the public stats endpoint.
72+ *
73+ * Public 404 (unknown code or private stats) and 401 (password
74+ * protected) both surface as StatsUnavailableError.
12875 */
129- export async function getStats ( query : StatsQuery = { } ) : Promise < StatsResponse > {
130- // If querying a specific short code, use scope=anon, try v1 first, fall back to v0
131- if ( query . short_code ) {
76+ export async function getUrlStats (
77+ shortCode : string ,
78+ query : StatsQuery = { } ,
79+ ) : Promise < StatsResponse > {
80+ const mode = await authModeStorage . getValue ( ) ;
81+
82+ if ( mode === "jwt" || mode === "apikey" ) {
13283 try {
133- return await getStatsV1 ( { ...query , scope : query . scope ?? "anon" } ) ;
84+ const url = await getUrlByAddress ( new URL ( API_BASE_URL ) . hostname , shortCode ) ;
85+ return await getLinkStats ( url . id , query ) ;
13486 } catch ( e ) {
135- // v1 404 means it's a legacy URL — fall back to v0 endpoint
136- if ( e instanceof ApiError && e . isNotFound ) {
137- const v0 = await getStatsV0 ( query . short_code ) ;
138- return v0ToStatsResponse ( v0 ) ;
139- }
140- throw e ;
87+ // 404 means the link isn't in this account (foreign or unknown) —
88+ // the public surface is the only remaining read path.
89+ if ( ! ( e instanceof ApiError && e . isNotFound ) ) throw e ;
14190 }
14291 }
14392
144- // No specific short code — use v1 API only
145- return getStatsV1 ( query ) ;
93+ try {
94+ return await getPublicStats ( shortCode , {
95+ start_date : query . start_date ,
96+ end_date : query . end_date ,
97+ timezone : query . timezone ,
98+ } ) ;
99+ } catch ( e ) {
100+ if ( e instanceof ApiError && e . isUnauthorized ) {
101+ throw new StatsUnavailableError ( "This link's stats are password protected." ) ;
102+ }
103+ if ( e instanceof ApiError && e . isNotFound ) {
104+ throw new StatsUnavailableError (
105+ "Stats aren't available for this link. It may not exist, or its owner made stats private." ,
106+ ) ;
107+ }
108+ throw e ;
109+ }
146110}
0 commit comments