/** * 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'; import { CodedError } from '@/lib/errors'; import { fetchWithTimeout } from '@/lib/fetch-with-timeout'; import { decrypt } from '@/lib/utils/encryption'; import { logger } from '@/lib/logger'; // ─── Settings access ──────────────────────────────────────────────────────── interface UmamiPortConfig { apiUrl: string; apiToken: string | null; username: string | null; password: string | null; websiteId: string; } // `umami_api_token` and `umami_password` may be stored EITHER as encrypted // `iv:cipher:tag` strings (matching the SMTP / S3 secret pattern) OR as // legacy plaintext. The reader below tries decryption first and falls // back to the raw value when the format isn't AES-GCM-shaped, so an // operator can rotate to encrypted-at-rest by re-saving the setting // without a flag-day migration. const SETTING_KEYS = [ 'umami_api_url', 'umami_api_token', 'umami_username', 'umami_password', 'umami_website_id', ] as const; function readSecret(raw: string | null | undefined): string | null { const v = (raw ?? '').toString().trim(); if (!v) return null; // `encrypt()` returns `::` (3 colon-separated // hex chunks). If we see that shape, decrypt; otherwise treat as legacy // plaintext. The fallback path is a transition affordance — operators // should re-save the setting via the admin UI, which writes the // encrypted form going forward. const parts = v.split(':'); if (parts.length === 3 && parts.every((p) => /^[0-9a-f]+$/i.test(p))) { try { return decrypt(v); } catch (err) { logger.warn( { err }, 'Umami secret looked encrypted but decrypt failed; treating as plaintext', ); } } return v; } /** * 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(/\/$/, ''); // Sensitive values pass through readSecret() to support encrypted-at-rest // storage (with plaintext fallback for legacy rows). const apiToken = readSecret(map.get('umami_api_token') as string | null | undefined); const username = ((map.get('umami_username') ?? '') as string).trim() || null; const password = readSecret(map.get('umami_password') as string | null | undefined); 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 fetchWithTimeout(`${apiUrl}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ username, password }), }); if (!res.ok) { throw new CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage: `Umami login failed: ${res.status} ${res.statusText}`, }); } const body = (await res.json()) as { token?: string }; if (!body.token) throw new CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage: '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 CodedError('UMAMI_NOT_CONFIGURED', { internalMessage: '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 fetchWithTimeout(url.toString(), { 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 CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage: `Umami unauthorized: ${res.status}`, }); } if (!res.ok) { const text = await res.text().catch(() => ''); throw new CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage: `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 ───────────────────────────────────────────────────────────── /** * Stats response from `/api/websites/:id/stats` on Umami v2.x / v3.x. * * Each top-level metric is a plain number for the requested range; the * `comparison` block carries the equivalent values for the previous * window of the same length (so a 30-day range comes back with the prior * 30 days as `comparison.*`). Verified empirically against Umami v3.1.0 * — earlier internal types modelled this as `{value, prev}` per metric, * which matched neither v2 nor v3 and caused the dashboard tile to read * `pageviews.value` as undefined and render 0. */ export interface UmamiStats { pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number; comparison?: { pageviews: number; visitors: number; visits: number; bounces: number; totaltime: 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), }); } /** * Pageviews time-series response. Umami v3 returns the `pageviews` array * unconditionally; the `sessions` array only appears when the request * includes a `compare` directive (omitted today). The optional field * keeps the type honest so consumers don't blindly read `.sessions[0]`. */ 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', }); } /** * Valid `type` values for `/api/websites/:id/metrics` on Umami v2.x / v3.x. * `path` replaces the old `url` value — sending `type=url` against a v3 * instance returns 400. The full Umami enum also includes `entry|exit| * title|query|region|city|language|screen|hostname|tag|distinctId`; only * the ones the CRM actually surfaces are listed here. */ export type UmamiMetricType = | 'path' | 'referrer' | 'browser' | 'os' | 'device' | 'country' | 'region' | 'city' | 'event' | 'title' | 'query'; 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`, {}); } /** Website-level metadata (name + domain) so the analytics page can show * which site it's reporting on without the operator having to hard-code * the domain in system_settings. */ export interface UmamiWebsiteInfo { id: string; name: string; domain: string; } export async function getWebsiteInfo(portId: string): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; const res = await umamiFetch<{ id?: string; name?: string; domain?: string }>( config, `/api/websites/${config.websiteId}`, {}, ); return { id: res.id ?? config.websiteId, name: res.name ?? res.domain ?? 'Website', domain: res.domain ?? '', }; } /** * Verify the connection by hitting `/api/websites/:id/active` - the cheapest * authenticated endpoint that proves both auth + websiteId are good. * * M-IN05: returns a tagged union `{ ok: true | false }` instead of throwing, * matching the shape of `checkDocumensoHealth` / sales-email health probes. * Routes that just want to surface a green/red "Test connection" pill no * longer have to wrap the call in try/catch with hand-crafted error * extraction. */ export async function testConnection( portId: string, ): Promise<{ ok: true; visitors: number } | { ok: false; error: string }> { const config = await loadUmamiConfig(portId); if (!config) { return { ok: false, error: 'Umami is not configured for this port.' }; } try { const result = await umamiFetch( config, `/api/websites/${config.websiteId}/active`, {}, ); return { ok: true, visitors: result.visitors }; } catch (err) { const message = err instanceof Error ? err.message : typeof err === 'string' ? err : 'Umami request failed'; return { ok: false, error: message }; } } // ─── Realtime panel ──────────────────────────────────────────────────────── // // `/api/realtime/:id` is the richer alternative to `/active` — returns // totals, top URLs being viewed right now, top countries, a 30-min // time-series and a recent-event stream. Used by the realtime dashboard. export interface UmamiRealtime { urls: Record; countries: Record; events: Array<{ __type: string; os?: string; device?: string; country?: string; sessionId?: string; eventName?: string; browser?: string; createdAt: string; urlPath?: string; referrerDomain?: string; }>; series: { views: Array<{ x: string; y: number }>; visitors: Array<{ x: string; y: number }>; }; referrers: Record; totals: { visitors: number; views: number; events: number; countries: number; }; timestamp: number; } export async function getRealtime(portId: string): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; // 30-minute window matches Umami's own realtime page default. const startAt = Date.now() - 30 * 60 * 1000; return umamiFetch(config, `/api/realtime/${config.websiteId}`, { startAt, endAt: Date.now(), timezone: 'UTC', }); } // ─── Sessions ────────────────────────────────────────────────────────────── export interface UmamiSession { id: string; websiteId: string; hostname: string; browser: string; os: string; device: string; screen: string; language: string; country: string; subdivision1?: string; city?: string; firstAt: string; lastAt: string; visits: number; views: number; events: number; totaltime?: number; } export interface UmamiSessionsPage { data: UmamiSession[]; count: number; page: number; pageSize: number; } export async function getSessions( portId: string, range: DateRange, opts: { page?: number; pageSize?: number; query?: string } = {}, ): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/sessions`, { ...rangeToParams(range), page: opts.page ?? 1, pageSize: opts.pageSize ?? 25, query: opts.query, }); } export async function getSession(portId: string, sessionId: string): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch( config, `/api/websites/${config.websiteId}/sessions/${sessionId}`, {}, ); } export interface UmamiSessionActivity { eventType: number; urlQuery?: string; urlPath: string; eventName?: string; createdAt: string; referrerDomain?: string; eventId: string; visitId: string; } export async function getSessionActivity( portId: string, sessionId: string, range: DateRange, ): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch( config, `/api/websites/${config.websiteId}/sessions/${sessionId}/activity`, { ...rangeToParams(range) }, ); } /** * Sessions by hour-of-week heatmap — returns a 7×24 nested-array (rows are * days Sun..Sat, columns are hours 0..23). Drives the engagement heatmap * card. */ export async function getSessionsWeekly( portId: string, range: DateRange, timezone = 'UTC', ): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/sessions/weekly`, { ...rangeToParams(range), timezone, }); } // ─── Events ──────────────────────────────────────────────────────────────── // // Wrappers ready for when the marketing site starts firing `umami.track()` // calls. Until then, every read returns an empty list — wired now so the // UI surface can light up immediately on the day events start arriving. export interface UmamiEvent { id: string; sessionId: string; websiteId: string; createdAt: string; urlPath: string; eventName?: string; pageTitle?: string; } export async function getEvents( portId: string, range: DateRange, opts: { page?: number; pageSize?: number } = {}, ): Promise<{ data: UmamiEvent[]; count: number; page: number; pageSize: number } | null> { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/events`, { ...rangeToParams(range), page: opts.page ?? 1, pageSize: opts.pageSize ?? 25, }); } export async function getEventsStats( portId: string, range: DateRange, ): Promise<{ pageviews: number; visitors: number; events: number } | null> { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/events/stats`, { ...rangeToParams(range), }); } export async function getEventsSeries( portId: string, range: DateRange, eventName: string, unit: 'hour' | 'day' | 'month' = 'day', ): Promise | null> { const config = await loadUmamiConfig(portId); if (!config) return null; return umamiFetch(config, `/api/websites/${config.websiteId}/events/series`, { ...rangeToParams(range), eventName, unit, timezone: 'UTC', }); } // ─── Reports (POST endpoints) ────────────────────────────────────────────── // // Reports are POST-only and take a JSON body; build a sibling `umamiPost` // helper that handles auth + error shape the same way as `umamiFetch`. async function umamiPost(config: UmamiPortConfig, path: string, body: unknown): Promise { const bearer = await resolveBearer(config); const res = await fetchWithTimeout(`${config.apiUrl}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json', accept: 'application/json', }, body: JSON.stringify(body), cache: 'no-store', }); if (res.status === 401 || res.status === 403) { if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`); throw new CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage: `Umami unauthorized: ${res.status}`, }); } if (!res.ok) { const text = await res.text().catch(() => ''); throw new CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage: `Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`, }); } return (await res.json()) as T; } export interface UmamiFunnelStep { x: string; y: number; z: number; dropoff: number; } export async function runFunnelReport( portId: string, range: DateRange, steps: Array<{ type: 'url' | 'event'; value: string }>, windowHours = 24, ): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; const { from, to } = rangeToBounds(range); return umamiPost(config, `/api/reports/funnel`, { websiteId: config.websiteId, steps, window: windowHours * 3600, dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' }, }); } export interface UmamiJourneyStep { items: string[]; count: number; } export async function runJourneyReport( portId: string, range: DateRange, startStep?: string, endStep?: string, stepCount = 5, ): Promise { const config = await loadUmamiConfig(portId); if (!config) return null; const { from, to } = rangeToBounds(range); return umamiPost(config, `/api/reports/journey`, { websiteId: config.websiteId, startStep, endStep, steps: stepCount, dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' }, }); } // ─── CRM → Umami event push (Phase 6) ────────────────────────────────────── // // Thin wrapper around `@umami/node` so CRM outcome events land in the same // Umami instance the marketing site reports to. Per-port client instances // are cached so we don't re-instantiate on every event. import { Umami } from '@umami/node'; const trackerByPort = new Map(); async function getTracker(portId: string): Promise { const cached = trackerByPort.get(portId); if (cached) return cached; const config = await loadUmamiConfig(portId); if (!config) return null; const tracker = new Umami({ websiteId: config.websiteId, hostUrl: config.apiUrl }); trackerByPort.set(portId, tracker); return tracker; } /** * Push a CRM-side event back to Umami. Outcome milestones (eoi-sent, * eoi-signed, reservation-paid, contract-signed) flow through here so * Umami's funnel + attribution reports can correlate marketing-site * traffic with downstream deal outcomes. * * Soft-fail: if Umami is unreachable or misconfigured the call swallows * the error and logs a warning — outcome events shouldn't fail a CRM * mutation. */ export async function trackEvent( portId: string, name: string, data?: Record, url?: string, ): Promise { try { const tracker = await getTracker(portId); if (!tracker) return; await tracker.track({ url: url ?? `/crm/${name}`, name, ...(data ? { data } : {}), }); } catch (err) { logger.warn({ err, name }, 'Umami trackEvent failed (non-blocking)'); } }