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>
706 lines
24 KiB
TypeScript
706 lines
24 KiB
TypeScript
/**
|
||
* 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)');
|
||
}
|
||
}
|