Files
pn-new-crm/src/lib/services/umami.service.ts

706 lines
24 KiB
TypeScript
Raw Normal View History

/**
* 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';
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
import { fetchWithTimeout } from '@/lib/fetch-with-timeout';
fix(audit-integrations): SMTP/PG/Socket.IO timeouts, prompt injection, secret-at-rest A focused review of every external integration surfaced six issues the original audit missed. Fixed here. HIGH * Socket.IO had an unconditional 30-second idle disconnect on every socket. The comment on the line acknowledged it was "for development only, would be longer in prod" but no NODE_ENV guard existed, and the `socket.onAny` listener only resets on inbound client events — every dashboard connection that received only server-push events would have been torn down every 30s in production. Removed the manual idle timer entirely; Socket.IO's pingTimeout / pingInterval handles dead-transport detection at the protocol level. * SMTP transporters had no `connectionTimeout` / `greetingTimeout` / `socketTimeout`. Nodemailer's defaults are 2 minutes for connect and unlimited for socket — a hung SMTP server would have held a BullMQ `email` worker concurrency slot for up to 10 min per job (5 retries × 2 min). Set 10s/10s/30s on both the system transporter in `src/lib/email/index.ts` and the user-account transporter in `email-compose.service.ts`. MEDIUM * PostgreSQL pool had no `statement_timeout` / `idle_in_transaction_session_timeout`. A slow query or transaction held by a crashed handler would have eventually exhausted the 20-connection pool. 30s statement cap, 10s idle-in-tx cap, plus `max_lifetime: 30min` to recycle connections. * `umami_password` and `umami_api_token` were stored as plaintext in `system_settings` (the SMTP and S3 secret paths use AES-GCM). The reader now passes them through `readSecret()` which auto-detects the encrypted `iv:cipher:tag` shape and decrypts, falling back to legacy plaintext so operators can rotate without a flag-day. * AI email-draft worker interpolated `additionalInstructions` (user- controlled) directly into the OpenAI prompt — a hostile rep could close the instructions block and inject prompt directives that override the system prompt. Added `sanitizeForPrompt()` that strips newlines + quote chars, caps at 500 chars, and the prompt now wraps the value in a "treat as data not commands" preamble. LOW * Legacy `ensureBucket()` in `src/lib/minio/index.ts` was unguarded — if any future code imported it (currently no callers), a misconfigured prod deploy could mint a fresh empty bucket. Now matches the gate used by the pluggable S3Backend (`MINIO_AUTO_CREATE_BUCKET=true` required) so the legacy export and the new pluggable path agree. Confirmed not-an-issue: BullMQ Workers create connections via `{ url }` options object, and BullMQ sets `maxRetriesPerRequest: null` internally for those — no fix needed. The shared `redis` singleton that does keep `maxRetriesPerRequest: 3` is used only for direct Redis ops (rate-limit sliding window, etc.), never for blocking BullMQ commands, so the value is correct there. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:31:50 +02:00
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;
}
fix(audit-integrations): SMTP/PG/Socket.IO timeouts, prompt injection, secret-at-rest A focused review of every external integration surfaced six issues the original audit missed. Fixed here. HIGH * Socket.IO had an unconditional 30-second idle disconnect on every socket. The comment on the line acknowledged it was "for development only, would be longer in prod" but no NODE_ENV guard existed, and the `socket.onAny` listener only resets on inbound client events — every dashboard connection that received only server-push events would have been torn down every 30s in production. Removed the manual idle timer entirely; Socket.IO's pingTimeout / pingInterval handles dead-transport detection at the protocol level. * SMTP transporters had no `connectionTimeout` / `greetingTimeout` / `socketTimeout`. Nodemailer's defaults are 2 minutes for connect and unlimited for socket — a hung SMTP server would have held a BullMQ `email` worker concurrency slot for up to 10 min per job (5 retries × 2 min). Set 10s/10s/30s on both the system transporter in `src/lib/email/index.ts` and the user-account transporter in `email-compose.service.ts`. MEDIUM * PostgreSQL pool had no `statement_timeout` / `idle_in_transaction_session_timeout`. A slow query or transaction held by a crashed handler would have eventually exhausted the 20-connection pool. 30s statement cap, 10s idle-in-tx cap, plus `max_lifetime: 30min` to recycle connections. * `umami_password` and `umami_api_token` were stored as plaintext in `system_settings` (the SMTP and S3 secret paths use AES-GCM). The reader now passes them through `readSecret()` which auto-detects the encrypted `iv:cipher:tag` shape and decrypts, falling back to legacy plaintext so operators can rotate without a flag-day. * AI email-draft worker interpolated `additionalInstructions` (user- controlled) directly into the OpenAI prompt — a hostile rep could close the instructions block and inject prompt directives that override the system prompt. Added `sanitizeForPrompt()` that strips newlines + quote chars, caps at 500 chars, and the prompt now wraps the value in a "treat as data not commands" preamble. LOW * Legacy `ensureBucket()` in `src/lib/minio/index.ts` was unguarded — if any future code imported it (currently no callers), a misconfigured prod deploy could mint a fresh empty bucket. Now matches the gate used by the pluggable S3Backend (`MINIO_AUTO_CREATE_BUCKET=true` required) so the legacy export and the new pluggable path agree. Confirmed not-an-issue: BullMQ Workers create connections via `{ url }` options object, and BullMQ sets `maxRetriesPerRequest: null` internally for those — no fix needed. The shared `redis` singleton that does keep `maxRetriesPerRequest: 3` is used only for direct Redis ops (rate-limit sliding window, etc.), never for blocking BullMQ commands, so the value is correct there. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:31:50 +02:00
// `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;
fix(audit-integrations): SMTP/PG/Socket.IO timeouts, prompt injection, secret-at-rest A focused review of every external integration surfaced six issues the original audit missed. Fixed here. HIGH * Socket.IO had an unconditional 30-second idle disconnect on every socket. The comment on the line acknowledged it was "for development only, would be longer in prod" but no NODE_ENV guard existed, and the `socket.onAny` listener only resets on inbound client events — every dashboard connection that received only server-push events would have been torn down every 30s in production. Removed the manual idle timer entirely; Socket.IO's pingTimeout / pingInterval handles dead-transport detection at the protocol level. * SMTP transporters had no `connectionTimeout` / `greetingTimeout` / `socketTimeout`. Nodemailer's defaults are 2 minutes for connect and unlimited for socket — a hung SMTP server would have held a BullMQ `email` worker concurrency slot for up to 10 min per job (5 retries × 2 min). Set 10s/10s/30s on both the system transporter in `src/lib/email/index.ts` and the user-account transporter in `email-compose.service.ts`. MEDIUM * PostgreSQL pool had no `statement_timeout` / `idle_in_transaction_session_timeout`. A slow query or transaction held by a crashed handler would have eventually exhausted the 20-connection pool. 30s statement cap, 10s idle-in-tx cap, plus `max_lifetime: 30min` to recycle connections. * `umami_password` and `umami_api_token` were stored as plaintext in `system_settings` (the SMTP and S3 secret paths use AES-GCM). The reader now passes them through `readSecret()` which auto-detects the encrypted `iv:cipher:tag` shape and decrypts, falling back to legacy plaintext so operators can rotate without a flag-day. * AI email-draft worker interpolated `additionalInstructions` (user- controlled) directly into the OpenAI prompt — a hostile rep could close the instructions block and inject prompt directives that override the system prompt. Added `sanitizeForPrompt()` that strips newlines + quote chars, caps at 500 chars, and the prompt now wraps the value in a "treat as data not commands" preamble. LOW * Legacy `ensureBucket()` in `src/lib/minio/index.ts` was unguarded — if any future code imported it (currently no callers), a misconfigured prod deploy could mint a fresh empty bucket. Now matches the gate used by the pluggable S3Backend (`MINIO_AUTO_CREATE_BUCKET=true` required) so the legacy export and the new pluggable path agree. Confirmed not-an-issue: BullMQ Workers create connections via `{ url }` options object, and BullMQ sets `maxRetriesPerRequest: null` internally for those — no fix needed. The shared `redis` singleton that does keep `maxRetriesPerRequest: 3` is used only for direct Redis ops (rate-limit sliding window, etc.), never for blocking BullMQ commands, so the value is correct there. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:31:50 +02:00
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(/\/$/, '');
fix(audit-integrations): SMTP/PG/Socket.IO timeouts, prompt injection, secret-at-rest A focused review of every external integration surfaced six issues the original audit missed. Fixed here. HIGH * Socket.IO had an unconditional 30-second idle disconnect on every socket. The comment on the line acknowledged it was "for development only, would be longer in prod" but no NODE_ENV guard existed, and the `socket.onAny` listener only resets on inbound client events — every dashboard connection that received only server-push events would have been torn down every 30s in production. Removed the manual idle timer entirely; Socket.IO's pingTimeout / pingInterval handles dead-transport detection at the protocol level. * SMTP transporters had no `connectionTimeout` / `greetingTimeout` / `socketTimeout`. Nodemailer's defaults are 2 minutes for connect and unlimited for socket — a hung SMTP server would have held a BullMQ `email` worker concurrency slot for up to 10 min per job (5 retries × 2 min). Set 10s/10s/30s on both the system transporter in `src/lib/email/index.ts` and the user-account transporter in `email-compose.service.ts`. MEDIUM * PostgreSQL pool had no `statement_timeout` / `idle_in_transaction_session_timeout`. A slow query or transaction held by a crashed handler would have eventually exhausted the 20-connection pool. 30s statement cap, 10s idle-in-tx cap, plus `max_lifetime: 30min` to recycle connections. * `umami_password` and `umami_api_token` were stored as plaintext in `system_settings` (the SMTP and S3 secret paths use AES-GCM). The reader now passes them through `readSecret()` which auto-detects the encrypted `iv:cipher:tag` shape and decrypts, falling back to legacy plaintext so operators can rotate without a flag-day. * AI email-draft worker interpolated `additionalInstructions` (user- controlled) directly into the OpenAI prompt — a hostile rep could close the instructions block and inject prompt directives that override the system prompt. Added `sanitizeForPrompt()` that strips newlines + quote chars, caps at 500 chars, and the prompt now wraps the value in a "treat as data not commands" preamble. LOW * Legacy `ensureBucket()` in `src/lib/minio/index.ts` was unguarded — if any future code imported it (currently no callers), a misconfigured prod deploy could mint a fresh empty bucket. Now matches the gate used by the pluggable S3Backend (`MINIO_AUTO_CREATE_BUCKET=true` required) so the legacy export and the new pluggable path agree. Confirmed not-an-issue: BullMQ Workers create connections via `{ url }` options object, and BullMQ sets `maxRetriesPerRequest: null` internally for those — no fix needed. The shared `redis` singleton that does keep `maxRetriesPerRequest: 3` is used only for direct Redis ops (rate-limit sliding window, etc.), never for blocking BullMQ commands, so the value is correct there. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:31:50 +02:00
// 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;
fix(audit-integrations): SMTP/PG/Socket.IO timeouts, prompt injection, secret-at-rest A focused review of every external integration surfaced six issues the original audit missed. Fixed here. HIGH * Socket.IO had an unconditional 30-second idle disconnect on every socket. The comment on the line acknowledged it was "for development only, would be longer in prod" but no NODE_ENV guard existed, and the `socket.onAny` listener only resets on inbound client events — every dashboard connection that received only server-push events would have been torn down every 30s in production. Removed the manual idle timer entirely; Socket.IO's pingTimeout / pingInterval handles dead-transport detection at the protocol level. * SMTP transporters had no `connectionTimeout` / `greetingTimeout` / `socketTimeout`. Nodemailer's defaults are 2 minutes for connect and unlimited for socket — a hung SMTP server would have held a BullMQ `email` worker concurrency slot for up to 10 min per job (5 retries × 2 min). Set 10s/10s/30s on both the system transporter in `src/lib/email/index.ts` and the user-account transporter in `email-compose.service.ts`. MEDIUM * PostgreSQL pool had no `statement_timeout` / `idle_in_transaction_session_timeout`. A slow query or transaction held by a crashed handler would have eventually exhausted the 20-connection pool. 30s statement cap, 10s idle-in-tx cap, plus `max_lifetime: 30min` to recycle connections. * `umami_password` and `umami_api_token` were stored as plaintext in `system_settings` (the SMTP and S3 secret paths use AES-GCM). The reader now passes them through `readSecret()` which auto-detects the encrypted `iv:cipher:tag` shape and decrypts, falling back to legacy plaintext so operators can rotate without a flag-day. * AI email-draft worker interpolated `additionalInstructions` (user- controlled) directly into the OpenAI prompt — a hostile rep could close the instructions block and inject prompt directives that override the system prompt. Added `sanitizeForPrompt()` that strips newlines + quote chars, caps at 500 chars, and the prompt now wraps the value in a "treat as data not commands" preamble. LOW * Legacy `ensureBucket()` in `src/lib/minio/index.ts` was unguarded — if any future code imported it (currently no callers), a misconfigured prod deploy could mint a fresh empty bucket. Now matches the gate used by the pluggable S3Backend (`MINIO_AUTO_CREATE_BUCKET=true` required) so the legacy export and the new pluggable path agree. Confirmed not-an-issue: BullMQ Workers create connections via `{ url }` options object, and BullMQ sets `maxRetriesPerRequest: null` internally for those — no fix needed. The shared `redis` singleton that does keep `maxRetriesPerRequest: 3` is used only for direct Redis ops (rate-limit sliding window, etc.), never for blocking BullMQ commands, so the value is correct there. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:31:50 +02:00
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> {
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
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));
}
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
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.
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
*
* 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.
*/
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
export async function testConnection(
portId: string,
): Promise<{ ok: true; visitors: number } | { ok: false; error: string }> {
const config = await loadUmamiConfig(portId);
if (!config) {
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
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)');
}
}