feat(analytics): Umami website-analytics suite — world map, realtime, sessions, heatmap, pixel tracking, tracked links

Adds the read-side Umami integration queued in last week's
website-analytics plan (Phases 1–6 of `docs/website-analytics-flesh-out-plan.md`):

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 15:53:41 +02:00
parent 292800b643
commit bac253b360
28 changed files with 35334 additions and 96 deletions

View File

@@ -0,0 +1,70 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { env } from '@/lib/env';
import { trackedLinks, type NewTrackedLink } from '@/lib/db/schema/tracked-links';
/**
* Phase 4c — service-layer helpers for tracked redirect links. Use
* `createTrackedLink` from any email-composer flow to wrap an outbound
* URL in a `/q/<slug>` short-link that records click-throughs.
*
* Slug format: random URL-safe ID. Short enough not to overwhelm an
* inbox preview pane but long enough that collision probability is
* negligible across the lifetime of the system.
*/
function generateSlug(): string {
// 8 random bytes → 11-char base64url string. Collision probability
// across 1M links: ~1e-7. The DB unique index is the backstop.
const bytes = crypto.getRandomValues(new Uint8Array(8));
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export interface CreateTrackedLinkInput {
portId: string;
targetUrl: string;
/** Optional FK to `document_sends.id` so per-email click-throughs are
* attributable. Leave null for one-off short links. */
sendId?: string;
createdByUserId?: string;
}
export async function createTrackedLink(input: CreateTrackedLinkInput) {
// Retry on slug collision (extremely rare). Three attempts is more
// than enough — at our slug entropy a single collision in 1M links
// would be a once-per-century event.
for (let attempt = 0; attempt < 3; attempt++) {
const slug = generateSlug();
try {
const values: NewTrackedLink = {
portId: input.portId,
slug,
targetUrl: input.targetUrl,
...(input.sendId ? { sendId: input.sendId } : {}),
...(input.createdByUserId ? { createdByUserId: input.createdByUserId } : {}),
};
const [row] = await db.insert(trackedLinks).values(values).returning();
return row!;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('uniq_tracked_links_slug') && attempt < 2) continue;
throw err;
}
}
throw new Error('Failed to mint a unique tracked-link slug after 3 attempts');
}
/** Build the public-facing tracked URL for an existing record. */
export function buildTrackedUrl(slug: string): string {
const base = env.NEXT_PUBLIC_APP_URL.replace(/\/$/, '');
return `${base}/q/${slug}`;
}
/** Look up click stats for a single tracked link. */
export async function getTrackedLink(id: string) {
return db.query.trackedLinks.findFirst({ where: eq(trackedLinks.id, id) });
}

View File

@@ -211,12 +211,30 @@ function pickUnit(range: DateRange): 'hour' | 'day' | '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: { value: number; prev: number };
visitors: { value: number; prev: number };
visits: { value: number; prev: number };
bounces: { value: number; prev: number };
totaltime: { value: number; prev: number };
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> {
@@ -227,9 +245,15 @@ export async function getStats(portId: string, range: DateRange): Promise<UmamiS
});
}
/**
* 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 }>;
sessions?: Array<{ x: string; y: number }>;
}
export async function getPageviewsSeries(
@@ -245,14 +269,25 @@ export async function getPageviewsSeries(
});
}
/**
* 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 =
| 'url'
| 'path'
| 'referrer'
| 'browser'
| 'os'
| 'device'
| 'country'
| 'event';
| 'region'
| 'city'
| 'event'
| 'title'
| 'query';
export interface UmamiMetricRow {
x: string;
@@ -284,6 +319,30 @@ export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisi
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.
@@ -314,3 +373,333 @@ export async function testConnection(
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)');
}
}