+ Add your Umami URL, API token (or username/password), and Website ID for this port to
+ unlock pageview trends, top pages, referrers, and audience geography.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/analytics/range.ts b/src/lib/analytics/range.ts
new file mode 100644
index 0000000..87a6aa1
--- /dev/null
+++ b/src/lib/analytics/range.ts
@@ -0,0 +1,76 @@
+/**
+ * Pure date-range types and helpers shared by client components and the
+ * server-side analytics service.
+ *
+ * Lives outside `src/lib/services/analytics.service.ts` because that file
+ * imports the DB driver (`postgres`) which can't be bundled into client
+ * components - see Next.js "Module not found: net" build error.
+ *
+ * No DB / no IO / no React.
+ */
+
+/**
+ * Preset date ranges used by the dashboard's quick-pick tabs.
+ */
+export type PresetDateRange = '7d' | '30d' | '90d' | 'today';
+
+/**
+ * A custom date range expressed as a pair of ISO date strings (YYYY-MM-DD).
+ * The lower bound is inclusive at 00:00; the upper bound is inclusive at
+ * 23:59:59.999 (resolved inside `rangeToBounds`).
+ */
+export interface CustomDateRange {
+ kind: 'custom';
+ from: string; // ISO YYYY-MM-DD
+ to: string; // ISO YYYY-MM-DD
+}
+
+export type DateRange = PresetDateRange | CustomDateRange;
+
+export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d'] as const;
+
+export function isCustomRange(range: DateRange): range is CustomDateRange {
+ return typeof range === 'object' && range.kind === 'custom';
+}
+
+/**
+ * Resolve any DateRange (preset or custom) to a concrete {from, to} pair.
+ * - Preset ranges anchor `to` at "now" and `from` at `now - N days`.
+ * - Custom ranges use the operator-supplied dates verbatim, with `to`
+ * normalized to end-of-day so a same-day range still includes that day.
+ */
+export function rangeToBounds(range: DateRange): { from: Date; to: Date } {
+ if (isCustomRange(range)) {
+ return {
+ from: new Date(`${range.from}T00:00:00.000Z`),
+ to: new Date(`${range.to}T23:59:59.999Z`),
+ };
+ }
+ const now = Date.now();
+ const days = rangeToDays(range);
+ return { from: new Date(now - days * 86_400_000), to: new Date(now) };
+}
+
+export function rangeToDays(range: PresetDateRange): number {
+ switch (range) {
+ case 'today':
+ return 1;
+ case '7d':
+ return 7;
+ case '30d':
+ return 30;
+ case '90d':
+ return 90;
+ }
+}
+
+/**
+ * Number of days a range spans (rounded up). Useful for sizing chart axes.
+ */
+export function rangeSpanDays(range: DateRange): number {
+ if (isCustomRange(range)) {
+ const { from, to } = rangeToBounds(range);
+ return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 86_400_000));
+ }
+ return rangeToDays(range);
+}
diff --git a/src/lib/services/umami.service.ts b/src/lib/services/umami.service.ts
new file mode 100644
index 0000000..a7c310f
--- /dev/null
+++ b/src/lib/services/umami.service.ts
@@ -0,0 +1,258 @@
+/**
+ * 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 };
+}