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,106 @@
import { NextResponse, type NextRequest } from 'next/server';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentSendOpens, documentSends } from '@/lib/db/schema/brochures';
import { logger } from '@/lib/logger';
import { trackEvent } from '@/lib/services/umami.service';
/**
* GET /api/public/email-pixel/[sendId]
*
* Returns a 1×1 transparent GIF and records an open event in
* `document_send_opens` + bumps the cached aggregates on `document_sends`.
*
* Lookups are gated by `track_opens=true` on the send row, so a leaked
* sendId for an untracked email is a no-op (the pixel still returns
* 200/GIF so email clients don't surface a broken-image icon).
*
* Privacy: we deliberately don't store IP addresses or any data beyond
* user-agent + referer. Apple Mail privacy proxy pre-fetches images, so
* opens from iOS users are over-counted; image-blocking clients
* (Outlook with images disabled) under-count. Standard email-tracking
* caveats apply.
*/
// 1×1 transparent GIF, base64-encoded. Generated once at module-load so
// every request returns the same buffer without re-allocating.
const TRANSPARENT_GIF = Buffer.from(
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
'base64',
);
function gifResponse(): NextResponse {
return new NextResponse(TRANSPARENT_GIF as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type': 'image/gif',
'Content-Length': String(TRANSPARENT_GIF.length),
// Tell every upstream cache to keep its hands off — we count opens
// on the FETCH itself, so any cached response is a missed open.
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
Pragma: 'no-cache',
Expires: '0',
},
});
}
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ sendId: string }> },
): Promise<NextResponse> {
const { sendId } = await ctx.params;
try {
// Look up the send row; ignore unknown / un-tracked sends silently.
const sendRow = await db.query.documentSends.findFirst({
where: and(eq(documentSends.id, sendId), eq(documentSends.trackOpens, true)),
columns: { id: true, portId: true, recipientEmail: true, documentKind: true },
});
if (!sendRow) return gifResponse();
const userAgent = req.headers.get('user-agent');
const referer = req.headers.get('referer');
// Best-effort write — never block the pixel response on a slow DB.
// The pixel must return promptly so email clients render normally.
db.insert(documentSendOpens)
.values({
portId: sendRow.portId,
sendId: sendRow.id,
userAgent: userAgent ?? null,
referer: referer ?? null,
})
.then(() =>
db
.update(documentSends)
.set({
openCount: sql`${documentSends.openCount} + 1`,
firstOpenedAt: sql`COALESCE(${documentSends.firstOpenedAt}, NOW())`,
})
.where(eq(documentSends.id, sendRow.id)),
)
.catch((err) => {
logger.warn({ err, sendId: sendRow.id }, 'email-pixel: failed to record open');
});
// Cross-post to Umami so the marketing funnel includes opens. Don't
// await — fire-and-forget so the pixel response stays fast.
trackEvent(
sendRow.portId,
'email-opened',
{
sendId: sendRow.id,
documentKind: sendRow.documentKind,
},
'email://pixel',
).catch((err) => {
logger.debug({ err, sendId: sendRow.id }, 'email-pixel: umami cross-post failed');
});
return gifResponse();
} catch (err) {
logger.warn({ err, sendId }, 'email-pixel: unexpected error');
return gifResponse();
}
}

View File

@@ -7,7 +7,13 @@ import {
getActiveVisitors,
getMetric,
getPageviewsSeries,
getRealtime,
getSession,
getSessionActivity,
getSessions,
getSessionsWeekly,
getStats,
getWebsiteInfo,
type UmamiMetricType,
} from '@/lib/services/umami.service';
@@ -31,7 +37,11 @@ import {
*/
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
// Umami v2/v3 metric `type` values surfaced by the CRM. `path` is the
// current name for what older versions called `url` — accept both as
// inbound metric names (old clients won't break) but `path` is what the
// service forwards to Umami.
const TOP_METRIC_RX = /^top-(path|url|referrer|country|browser|os|device|event)$/;
function parseRange(req: NextRequest): DateRange | { error: string } {
const url = new URL(req.url);
@@ -88,8 +98,30 @@ export const GET = withAuth(
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (metric === 'realtime') {
data = await getRealtime(ctx.portId);
} else if (metric === 'website') {
data = await getWebsiteInfo(ctx.portId);
} else if (metric === 'sessions') {
const page = Number(url.searchParams.get('page') ?? 1);
const pageSize = Number(url.searchParams.get('pageSize') ?? 25);
const query = url.searchParams.get('query') ?? undefined;
data = await getSessions(ctx.portId, range, { page, pageSize, query });
} else if (metric === 'session') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) throw new ValidationError('Missing sessionId');
data = await getSession(ctx.portId, sessionId);
} else if (metric === 'session-activity') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) throw new ValidationError('Missing sessionId');
data = await getSessionActivity(ctx.portId, sessionId, range);
} else if (metric === 'sessions-weekly') {
data = await getSessionsWeekly(ctx.portId, range);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const raw = metric.replace(/^top-/, '');
// Legacy alias — older callers still send `top-url`; map to the
// Umami v3 enum name to keep them working post-rewrite.
const type = (raw === 'url' ? 'path' : raw) as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {