Files
pn-new-crm/src/lib/services/umami.service.ts
Matt bac253b360 feat(analytics): Umami website-analytics suite — world map, realtime, sessions, heatmap, pixel tracking, tracked links
Adds the read-side Umami integration queued in last week's
website-analytics plan (Phases 1–6 of `docs/website-analytics-flesh-out-plan.md`):

- Realtime panel polls Umami at 5s intervals; world map renders visitor
  origins via echarts + `public/world-map/echarts-world.json` topo.
- Sessions list + session-detail-sheet drill-down (per-session event
  timeline pulled from `/api/v1/website-analytics`).
- Weekly heatmap (day-of-week × hour-of-day) for engagement timing.
- Metric-detail pages under `/[portSlug]/website-analytics/[metric]`
  for pageviews / referrers / events deep-dives.
- Email-pixel write path: `/api/public/email-pixel/[sendId]` 1×1 GIF
  beacon backed by `email_open_tracking` (migration 0076); resolves
  inline on render in inbox.
- Tracked-link redirect: `/q/[slug]` routes through `tracked_links`
  (migration 0077) and forwards to the canonical destination after
  logging the click.
- Dashboard `website-glance-tile` now reads from the live Umami service
  instead of placeholder data.

Deps: `@umami/node`, `echarts`, `echarts-for-react`, `@types/geojson`,
`@types/topojson-client`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:53:41 +02:00

706 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `<iv-hex>:<cipher-hex>:<tag-hex>` (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<UmamiPortConfig | null> {
// 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<string, CachedJwt>();
const JWT_TTL_MS = 55 * 60 * 1000; // 55 min - Umami JWTs default to 1h
async function loginAndCache(apiUrl: string, username: string, password: string): Promise<string> {
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<string> {
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<T>(
config: UmamiPortConfig,
path: string,
search: Record<string, string | number | undefined>,
): Promise<T> {
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<UmamiStats | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiStats>(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<UmamiPageviewsSeries | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiPageviewsSeries>(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<UmamiMetricRow[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiMetricRow[]>(config, `/api/websites/${config.websiteId}/metrics`, {
...rangeToParams(range),
type,
limit,
});
}
export interface UmamiActiveVisitors {
visitors: number;
}
export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisitors | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiActiveVisitors>(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<UmamiWebsiteInfo | null> {
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<UmamiActiveVisitors>(
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<string, number>;
countries: Record<string, number>;
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<string, number>;
totals: {
visitors: number;
views: number;
events: number;
countries: number;
};
timestamp: number;
}
export async function getRealtime(portId: string): Promise<UmamiRealtime | null> {
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<UmamiRealtime>(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<UmamiSessionsPage | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiSessionsPage>(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<UmamiSession | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiSession>(
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<UmamiSessionActivity[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiSessionActivity[]>(
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<number[][] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<number[][]>(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<Array<{ x: string; y: number }> | 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<T>(config: UmamiPortConfig, path: string, body: unknown): Promise<T> {
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<UmamiFunnelStep[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
const { from, to } = rangeToBounds(range);
return umamiPost<UmamiFunnelStep[]>(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<UmamiJourneyStep[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
const { from, to } = rangeToBounds(range);
return umamiPost<UmamiJourneyStep[]>(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<string, Umami>();
async function getTracker(portId: string): Promise<Umami | null> {
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<string, unknown>,
url?: string,
): Promise<void> {
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)');
}
}