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:
@@ -6,33 +6,27 @@ import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
/**
|
||||
* Per-port Umami credentials. We deliberately keep all three values
|
||||
* port-scoped (per the operator decision) so different ports can point at
|
||||
* different Umami instances if needed. The /website-analytics dashboard
|
||||
* page reads these settings via the umami.service layer at request time.
|
||||
* Per-port Umami credentials. Self-hosted Umami uses username + password →
|
||||
* JWT bearer token (https://docs.umami.is/docs/api/authentication); the
|
||||
* service POSTs to /api/auth/login and caches the JWT in-memory. Umami
|
||||
* Cloud installations use a long-lived API key instead; the optional field
|
||||
* below covers that case. All credentials are port-scoped so different
|
||||
* ports can point at different Umami instances.
|
||||
*/
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'umami_api_url',
|
||||
label: 'Umami API URL',
|
||||
label: 'Umami URL',
|
||||
description:
|
||||
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
|
||||
type: 'string',
|
||||
placeholder: 'https://analytics.portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_api_token',
|
||||
label: 'API token',
|
||||
description:
|
||||
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_username',
|
||||
label: 'Username',
|
||||
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
|
||||
description: 'Umami login username (self-hosted).',
|
||||
type: 'string',
|
||||
placeholder: 'admin',
|
||||
defaultValue: '',
|
||||
@@ -40,7 +34,8 @@ const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'umami_password',
|
||||
label: 'Password',
|
||||
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
|
||||
description:
|
||||
'Umami login password (self-hosted). Exchanged for a JWT via /api/auth/login on each port; the JWT is cached for 55 minutes. Stored AES-256-GCM at rest.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
@@ -53,6 +48,28 @@ const FIELDS: SettingFieldDef[] = [
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_api_token',
|
||||
label: 'API key (Umami Cloud only — optional)',
|
||||
description:
|
||||
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Tracking-pixel kill switch — opt-in per port. When enabled, outbound
|
||||
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
|
||||
// records opens to `document_send_opens` and cross-posts to Umami.
|
||||
const TRACKING_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'email_open_tracking_enabled',
|
||||
label: 'Track email opens',
|
||||
description:
|
||||
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function WebsiteAnalyticsSettingsPage() {
|
||||
@@ -65,10 +82,16 @@ export default function WebsiteAnalyticsSettingsPage() {
|
||||
|
||||
<SettingsFormCard
|
||||
title="Umami connection"
|
||||
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
|
||||
description="Self-hosted Umami: enter URL + username + password + website ID. Umami Cloud: enter URL + API key (Cloud field at the bottom) + website ID. Each port can point at its own Umami instance, or share one instance with different website IDs."
|
||||
fields={FIELDS}
|
||||
extra={<UmamiTestButton />}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Email open tracking"
|
||||
description="Opt-in tracking for outbound sales emails. Disabled by default."
|
||||
fields={TRACKING_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { MetricDetailShell } from '@/components/website-analytics/metric-detail-shell';
|
||||
|
||||
/**
|
||||
* Full ranked-list view for one analytics metric (pages / referrers /
|
||||
* countries / browsers / os / devices). Reached via the "View all" link
|
||||
* on each top-N card. Honours the `range` (and optional `from`/`to`)
|
||||
* query params so the detail page mirrors the time window the operator
|
||||
* had selected on the parent page.
|
||||
*/
|
||||
|
||||
const VALID_METRICS = ['pages', 'referrers', 'countries', 'browsers', 'os', 'devices'] as const;
|
||||
type ValidMetric = (typeof VALID_METRICS)[number];
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ portSlug: string; metric: string }>;
|
||||
searchParams: Promise<{ range?: string; from?: string; to?: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
const { metric } = await params;
|
||||
const { range, from, to } = await searchParams;
|
||||
if (!VALID_METRICS.includes(metric as ValidMetric)) notFound();
|
||||
return (
|
||||
<MetricDetailShell
|
||||
metric={metric as ValidMetric}
|
||||
initialRange={range ?? '30d'}
|
||||
initialFrom={from}
|
||||
initialTo={to}
|
||||
/>
|
||||
);
|
||||
}
|
||||
106
src/app/api/public/email-pixel/[sendId]/route.ts
Normal file
106
src/app/api/public/email-pixel/[sendId]/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
79
src/app/q/[slug]/route.ts
Normal file
79
src/app/q/[slug]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { trackedLinkClicks, trackedLinks } from '@/lib/db/schema/tracked-links';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { trackEvent } from '@/lib/services/umami.service';
|
||||
|
||||
/**
|
||||
* GET /q/[slug]
|
||||
*
|
||||
* Phase 4c — tracked redirect link. Looks up the slug, records the
|
||||
* click (fire-and-forget so the redirect stays fast), and 302s the
|
||||
* recipient to the target URL. Unknown slugs 404 — we deliberately do
|
||||
* NOT redirect anonymous traffic to a default home page since that
|
||||
* would be an open-redirect risk (although `targetUrl` is admin-stored
|
||||
* not user-supplied, this keeps the endpoint surface small).
|
||||
*
|
||||
* Cross-posts to Umami as a `link-clicked` event so marketing can see
|
||||
* email click-throughs alongside their normal pageview funnel.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ slug: string }> },
|
||||
): Promise<NextResponse> {
|
||||
const { slug } = await ctx.params;
|
||||
|
||||
// Slug format gate — reject obvious noise without hitting the DB.
|
||||
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(slug)) {
|
||||
return new NextResponse('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const link = await db.query.trackedLinks.findFirst({
|
||||
where: eq(trackedLinks.slug, slug),
|
||||
columns: { id: true, portId: true, targetUrl: true, sendId: true },
|
||||
});
|
||||
|
||||
if (!link) return new NextResponse('Not found', { status: 404 });
|
||||
|
||||
const userAgent = req.headers.get('user-agent');
|
||||
const referer = req.headers.get('referer');
|
||||
|
||||
// Fire-and-forget click recording; the redirect doesn't wait on DB.
|
||||
db.insert(trackedLinkClicks)
|
||||
.values({
|
||||
trackedLinkId: link.id,
|
||||
portId: link.portId,
|
||||
userAgent: userAgent ?? null,
|
||||
referer: referer ?? null,
|
||||
})
|
||||
.then(() =>
|
||||
db
|
||||
.update(trackedLinks)
|
||||
.set({
|
||||
clickCount: sql`${trackedLinks.clickCount} + 1`,
|
||||
firstClickedAt: sql`COALESCE(${trackedLinks.firstClickedAt}, NOW())`,
|
||||
lastClickedAt: sql`NOW()`,
|
||||
})
|
||||
.where(eq(trackedLinks.id, link.id)),
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.warn({ err, slug }, '/q: failed to record click');
|
||||
});
|
||||
|
||||
// Umami cross-post for funnel analysis. Soft-fails.
|
||||
trackEvent(
|
||||
link.portId,
|
||||
'link-clicked',
|
||||
{
|
||||
slug,
|
||||
sendId: link.sendId ?? null,
|
||||
},
|
||||
`/q/${slug}`,
|
||||
).catch((err) => {
|
||||
logger.debug({ err, slug }, '/q: umami cross-post failed');
|
||||
});
|
||||
|
||||
return NextResponse.redirect(link.targetUrl, 302);
|
||||
}
|
||||
Reference in New Issue
Block a user