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:
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