2026-05-04 22:53:06 +02:00
|
|
|
/**
|
|
|
|
|
* 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';
|
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';
|
2026-05-04 22:53:06 +02:00
|
|
|
|
|
|
|
|
// ─── Settings access ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface UmamiPortConfig {
|
|
|
|
|
apiUrl: string;
|
|
|
|
|
apiToken: string | null;
|
|
|
|
|
username: string | null;
|
|
|
|
|
password: string | null;
|
|
|
|
|
websiteId: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SETTING_KEYS = [
|
|
|
|
|
'umami_api_url',
|
|
|
|
|
'umami_api_token',
|
|
|
|
|
'umami_username',
|
|
|
|
|
'umami_password',
|
|
|
|
|
'umami_website_id',
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Read the five Umami-related setting rows for one port and assemble them.
|
|
|
|
|
* Returns null if the minimum required config (URL + websiteId + an auth
|
|
|
|
|
* method) is missing - callers surface a "not configured" UI in that case.
|
|
|
|
|
*/
|
|
|
|
|
export async function loadUmamiConfig(portId: string): Promise<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(/\/$/, '');
|
|
|
|
|
const apiToken = ((map.get('umami_api_token') ?? '') as string).trim() || null;
|
|
|
|
|
const username = ((map.get('umami_username') ?? '') as string).trim() || null;
|
|
|
|
|
const password = ((map.get('umami_password') ?? '') as string).trim() || null;
|
|
|
|
|
const websiteId = ((map.get('umami_website_id') ?? '') as string).trim();
|
|
|
|
|
|
|
|
|
|
if (!apiUrl || !websiteId) return null;
|
|
|
|
|
if (!apiToken && !(username && password)) return null;
|
|
|
|
|
|
|
|
|
|
return { apiUrl, apiToken, username, password, websiteId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── JWT cache (username/password flow only) ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface CachedJwt {
|
|
|
|
|
token: string;
|
|
|
|
|
expiresAt: number;
|
|
|
|
|
}
|
|
|
|
|
// Keyed by `${apiUrl}::${username}` so different ports / different Umami
|
|
|
|
|
// instances don't share tokens. Tokens are presumed to last 1 hour; we
|
|
|
|
|
// refresh proactively a few minutes before expiry.
|
|
|
|
|
const jwtCache = new Map<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`, {
|
2026-05-04 22:53:06 +02:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
|
|
|
|
|
body: JSON.stringify({ username, password }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error(`Umami login failed: ${res.status} ${res.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
const body = (await res.json()) as { token?: string };
|
|
|
|
|
if (!body.token) throw new Error('Umami login response missing token');
|
|
|
|
|
jwtCache.set(`${apiUrl}::${username}`, {
|
|
|
|
|
token: body.token,
|
|
|
|
|
expiresAt: Date.now() + JWT_TTL_MS,
|
|
|
|
|
});
|
|
|
|
|
return body.token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolveBearer(config: UmamiPortConfig): Promise<string> {
|
|
|
|
|
if (config.apiToken) return config.apiToken;
|
|
|
|
|
if (!config.username || !config.password) {
|
|
|
|
|
throw new Error('Umami is misconfigured: no API token and no username/password.');
|
|
|
|
|
}
|
|
|
|
|
const cacheKey = `${config.apiUrl}::${config.username}`;
|
|
|
|
|
const cached = jwtCache.get(cacheKey);
|
|
|
|
|
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
|
|
|
return loginAndCache(config.apiUrl, config.username, config.password);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Generic request helper ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function umamiFetch<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(), {
|
2026-05-04 22:53:06 +02:00
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${bearer}`,
|
|
|
|
|
accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
// Don't share Next.js's request cache - analytics figures change every
|
|
|
|
|
// few seconds. The service-layer cache (if any) is the right place.
|
|
|
|
|
cache: 'no-store',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (res.status === 401 || res.status === 403) {
|
|
|
|
|
// Bearer rejected - drop cached JWT so next call re-logs in.
|
|
|
|
|
if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`);
|
|
|
|
|
throw new Error(`Umami unauthorized: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const text = await res.text().catch(() => '');
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (await res.json()) as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Range serialization ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function rangeToParams(range: DateRange): { startAt: number; endAt: number } {
|
|
|
|
|
// Umami expects unix milliseconds for both bounds.
|
|
|
|
|
const { from, to } = rangeToBounds(range);
|
|
|
|
|
return { startAt: from.getTime(), endAt: to.getTime() };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Pick a sensible bucket size for the pageviews timeseries given the
|
|
|
|
|
* range span. Avoids returning thousands of points for a 90d range. */
|
|
|
|
|
function pickUnit(range: DateRange): 'hour' | 'day' | 'month' {
|
|
|
|
|
const { from, to } = rangeToBounds(range);
|
|
|
|
|
const days = (to.getTime() - from.getTime()) / 86_400_000;
|
|
|
|
|
if (days <= 2) return 'hour';
|
|
|
|
|
if (days <= 120) return 'day';
|
|
|
|
|
return 'month';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface UmamiStats {
|
|
|
|
|
pageviews: { value: number; prev: number };
|
|
|
|
|
visitors: { value: number; prev: number };
|
|
|
|
|
visits: { value: number; prev: number };
|
|
|
|
|
bounces: { value: number; prev: number };
|
|
|
|
|
totaltime: { value: number; prev: number };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getStats(portId: string, range: DateRange): Promise<UmamiStats | null> {
|
|
|
|
|
const config = await loadUmamiConfig(portId);
|
|
|
|
|
if (!config) return null;
|
|
|
|
|
return umamiFetch<UmamiStats>(config, `/api/websites/${config.websiteId}/stats`, {
|
|
|
|
|
...rangeToParams(range),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UmamiPageviewsSeries {
|
|
|
|
|
pageviews: Array<{ x: string; y: number }>;
|
|
|
|
|
sessions: Array<{ x: string; y: number }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPageviewsSeries(
|
|
|
|
|
portId: string,
|
|
|
|
|
range: DateRange,
|
|
|
|
|
): Promise<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',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type UmamiMetricType =
|
|
|
|
|
| 'url'
|
|
|
|
|
| 'referrer'
|
|
|
|
|
| 'browser'
|
|
|
|
|
| 'os'
|
|
|
|
|
| 'device'
|
|
|
|
|
| 'country'
|
|
|
|
|
| 'event';
|
|
|
|
|
|
|
|
|
|
export interface UmamiMetricRow {
|
|
|
|
|
x: string;
|
|
|
|
|
y: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getMetric(
|
|
|
|
|
portId: string,
|
|
|
|
|
range: DateRange,
|
|
|
|
|
type: UmamiMetricType,
|
|
|
|
|
limit = 10,
|
|
|
|
|
): Promise<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`, {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
|
|
|
|
|
* authenticated endpoint that proves both auth + websiteId are good.
|
|
|
|
|
* Throws on any failure with a descriptive message; resolves on success.
|
|
|
|
|
*/
|
|
|
|
|
export async function testConnection(portId: string): Promise<{ ok: true; visitors: number }> {
|
|
|
|
|
const config = await loadUmamiConfig(portId);
|
|
|
|
|
if (!config) {
|
|
|
|
|
throw new Error('Umami is not configured for this port.');
|
|
|
|
|
}
|
|
|
|
|
const result = await umamiFetch<UmamiActiveVisitors>(
|
|
|
|
|
config,
|
|
|
|
|
`/api/websites/${config.websiteId}/active`,
|
|
|
|
|
{},
|
|
|
|
|
);
|
|
|
|
|
return { ok: true, visitors: result.visitors };
|
|
|
|
|
}
|