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

79
src/app/q/[slug]/route.ts Normal file
View 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);
}