/** * Umami v2 API client. Reads credentials from `system_settings` per port, * caches JWTs in-memory when using the username/password flow, and exposes * typed wrappers for the handful of endpoints the /website-analytics page * uses. * * Auth resolution order (per port): * 1. If `umami_api_token` is set → use it as a Bearer token (Umami Cloud * pattern, also supported by v2 self-hosted with API keys enabled). * 2. Otherwise → POST /api/auth/login with `umami_username` + * `umami_password` to get a JWT, cache it, use it as Bearer. * * No env vars - all config lives in port-scoped system_settings so the * operator can configure Umami at runtime via /admin/website-analytics. * * v2 docs: https://docs.umami.is/docs/api */ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; import { rangeToBounds, type DateRange } from '@/lib/analytics/range'; // ─── Settings access ──────────────────────────────────────────────────────── interface UmamiPortConfig { apiUrl: string; apiToken: string | null; username: string | null; password: string | null; websiteId: string; } const SETTING_KEYS = [ 'umami_api_url', 'umami_api_token', 'umami_username', 'umami_password', 'umami_website_id', ] as const; /** * Read the five Umami-related setting rows for one port and assemble them. * Returns null if the minimum required config (URL + websiteId + an auth * method) is missing - callers surface a "not configured" UI in that case. */ export async function loadUmamiConfig(portId: string): Promise { // Filter to ONLY the five Umami keys. Without this, every analytics page // request pulls every system_settings row for the port (Documenso keys, // SMTP, email templates, etc), which scales poorly as the port grows. const rows = await db .select({ key: systemSettings.key, value: systemSettings.value }) .from(systemSettings) .where(and(eq(systemSettings.portId, portId), inArray(systemSettings.key, [...SETTING_KEYS]))); const map = new Map(rows.map((r) => [r.key, r.value as string | null | undefined])); const apiUrl = (map.get('umami_api_url') ?? '').toString().trim().replace(/\/$/, ''); const apiToken = ((map.get('umami_api_token') ?? '') as string).trim() || null; const username = ((map.get('umami_username') ?? '') as string).trim() || null; const password = ((map.get('umami_password') ?? '') as string).trim() || null; const websiteId = ((map.get('umami_website_id') ?? '') as string).trim(); if (!apiUrl || !websiteId) return null; if (!apiToken && !(username && password)) return null; return { apiUrl, apiToken, username, password, websiteId }; } // ─── JWT cache (username/password flow only) ──────────────────────────────── interface CachedJwt { token: string; expiresAt: number; } // Keyed by `${apiUrl}::${username}` so different ports / different Umami // instances don't share tokens. Tokens are presumed to last 1 hour; we // refresh proactively a few minutes before expiry. const jwtCache = new Map(); const JWT_TTL_MS = 55 * 60 * 1000; // 55 min - Umami JWTs default to 1h async function loginAndCache(apiUrl: string, username: string, password: string): Promise { const res = await fetch(`${apiUrl}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ username, password }), }); if (!res.ok) { throw new Error(`Umami login failed: ${res.status} ${res.statusText}`); } const body = (await res.json()) as { token?: string }; if (!body.token) throw new Error('Umami login response missing token'); jwtCache.set(`${apiUrl}::${username}`, { token: body.token, expiresAt: Date.now() + JWT_TTL_MS, }); return body.token; } async function resolveBearer(config: UmamiPortConfig): Promise { if (config.apiToken) return config.apiToken; if (!config.username || !config.password) { throw new Error('Umami is misconfigured: no API token and no username/password.'); } const cacheKey = `${config.apiUrl}::${config.username}`; const cached = jwtCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) return cached.token; return loginAndCache(config.apiUrl, config.username, config.password); } // ─── Generic request helper ───────────────────────────────────────────────── async function umamiFetch( config: UmamiPortConfig, path: string, search: Record, ): Promise { const bearer = await resolveBearer(config); const url = new URL(`${config.apiUrl}${path}`); for (const [k, v] of Object.entries(search)) { if (v === undefined) continue; url.searchParams.set(k, String(v)); } const res = await fetch(url, { headers: { Authorization: `Bearer ${bearer}`, accept: 'application/json', }, // Don't share Next.js's request cache - analytics figures change every // few seconds. The service-layer cache (if any) is the right place. cache: 'no-store', }); if (res.status === 401 || res.status === 403) { // Bearer rejected - drop cached JWT so next call re-logs in. if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`); throw new Error(`Umami unauthorized: ${res.status}`); } if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`, ); } return (await res.json()) as T; } // ─── Range serialization ──────────────────────────────────────────────────── function rangeToParams(range: DateRange): { startAt: number; endAt: number } { // Umami expects unix milliseconds for both bounds. const { from, to } = rangeToBounds(range); return { startAt: from.getTime(), endAt: to.getTime() }; } /** Pick a sensible bucket size for the pageviews timeseries given the * range span. Avoids returning thousands of points for a 90d range. */ function pickUnit(range: DateRange): 'hour' | 'day' | 'month' { const { from, to } = rangeToBounds(range); const days = (to.getTime() - from.getTime()) / 86_400_000; if (days <= 2) return 'hour'; if (days <= 120) return 'day'; return 'month'; } // ─── Public API ───────────────────────────────────────────────────────────── export interface UmamiStats { pageviews: { value: number; prev: number }; visitors: { value: number; prev: number }; visits: { value: number; prev: number }; bounces: { value: number; prev: number }; totaltime: { value: number; prev: number }; } export async function getStats(portId: string, range: DateRange): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/stats`, { ...rangeToParams(range), }); } export interface UmamiPageviewsSeries { pageviews: Array<{ x: string; y: number }>; sessions: Array<{ x: string; y: number }>; } export async function getPageviewsSeries( portId: string, range: DateRange, ): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/pageviews`, { ...rangeToParams(range), unit: pickUnit(range), timezone: 'UTC', }); } export type UmamiMetricType = | 'url' | 'referrer' | 'browser' | 'os' | 'device' | 'country' | 'event'; export interface UmamiMetricRow { x: string; y: number; } export async function getMetric( portId: string, range: DateRange, type: UmamiMetricType, limit = 10, ): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/metrics`, { ...rangeToParams(range), type, limit, }); } export interface UmamiActiveVisitors { visitors: number; } export async function getActiveVisitors(portId: string): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/active`, {}); } /** * Verify the connection by hitting `/api/websites/:id/active` - the cheapest * authenticated endpoint that proves both auth + websiteId are good. * Throws on any failure with a descriptive message; resolves on success. */ export async function testConnection(portId: string): Promise<{ ok: true; visitors: number }> { const config = await loadUmamiConfig(portId); if (!config) { throw new Error('Umami is not configured for this port.'); } const result = await umamiFetch( config, `/api/websites/${config.websiteId}/active`, {}, ); return { ok: true, visitors: result.visitors }; }