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,19 @@
-- Phase 4b — email open tracking via a 1×1 pixel endpoint.
-- Adds a per-send open log + cached aggregates on document_sends.
ALTER TABLE "document_sends"
ADD COLUMN IF NOT EXISTS "track_opens" boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "first_opened_at" timestamptz,
ADD COLUMN IF NOT EXISTS "open_count" integer NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS "document_send_opens" (
"id" text PRIMARY KEY,
"port_id" text NOT NULL REFERENCES "ports"("id"),
"send_id" text NOT NULL REFERENCES "document_sends"("id") ON DELETE CASCADE,
"opened_at" timestamptz NOT NULL DEFAULT now(),
"user_agent" text,
"referer" text
);
CREATE INDEX IF NOT EXISTS "idx_dso_send" ON "document_send_opens" ("send_id", "opened_at" DESC);
CREATE INDEX IF NOT EXISTS "idx_dso_port" ON "document_send_opens" ("port_id", "opened_at" DESC);

View File

@@ -0,0 +1,36 @@
-- Phase 4c — tracked redirect links for email click-through tracking.
-- A short URL at /q/<slug> redirects to the target and records the
-- click against the originating send. Cross-posted to Umami as a
-- `link-clicked` event.
CREATE TABLE IF NOT EXISTS "tracked_links" (
"id" text PRIMARY KEY,
"port_id" text NOT NULL REFERENCES "ports"("id"),
"slug" text NOT NULL,
"target_url" text NOT NULL,
"send_id" text REFERENCES "document_sends"("id") ON DELETE SET NULL,
"click_count" integer NOT NULL DEFAULT 0,
"first_clicked_at" timestamptz,
"last_clicked_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"created_by_user_id" text REFERENCES "user"("id") ON DELETE SET NULL
);
-- Slugs are scoped to a port; an admin can rotate them per-port. Global
-- uniqueness isn't required because /q/<slug> is gated by tenancy in
-- the route handler.
CREATE UNIQUE INDEX IF NOT EXISTS "uniq_tracked_links_slug" ON "tracked_links" ("slug");
CREATE INDEX IF NOT EXISTS "idx_tracked_links_send" ON "tracked_links" ("send_id");
CREATE INDEX IF NOT EXISTS "idx_tracked_links_port" ON "tracked_links" ("port_id", "created_at" DESC);
CREATE TABLE IF NOT EXISTS "tracked_link_clicks" (
"id" text PRIMARY KEY,
"tracked_link_id" text NOT NULL REFERENCES "tracked_links"("id") ON DELETE CASCADE,
"port_id" text NOT NULL REFERENCES "ports"("id"),
"clicked_at" timestamptz NOT NULL DEFAULT now(),
"user_agent" text,
"referer" text
);
CREATE INDEX IF NOT EXISTS "idx_tlc_link" ON "tracked_link_clicks" ("tracked_link_id", "clicked_at" DESC);
CREATE INDEX IF NOT EXISTS "idx_tlc_port" ON "tracked_link_clicks" ("port_id", "clicked_at" DESC);

View File

@@ -76,3 +76,4 @@ export * from './pipeline';
// Relations (must come last - references all tables)
export * from './relations';
export * from './tracked-links';

View File

@@ -0,0 +1,71 @@
import { pgTable, text, integer, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { user } from './users';
import { documentSends } from './brochures';
/**
* Phase 4c — tracked redirect links. A short URL `/q/<slug>` records a
* click and 302s the recipient on to `targetUrl`. The matching click
* row is fire-and-forget so the redirect stays snappy; an aggregate
* `clickCount` on the parent row keeps "was clicked at all" queries
* cheap.
*
* `sendId` is the optional link back to the originating outbound email
* — set when the link is minted via the email-composer flow so reps can
* see per-email click-throughs. Manual one-off short links leave it null.
*/
export const trackedLinks = pgTable(
'tracked_links',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
slug: text('slug').notNull(),
targetUrl: text('target_url').notNull(),
sendId: text('send_id').references(() => documentSends.id, { onDelete: 'set null' }),
clickCount: integer('click_count').notNull().default(0),
firstClickedAt: timestamp('first_clicked_at', { withTimezone: true }),
lastClickedAt: timestamp('last_clicked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdByUserId: text('created_by_user_id').references(() => user.id, { onDelete: 'set null' }),
},
(t) => [
uniqueIndex('uniq_tracked_links_slug').on(t.slug),
index('idx_tracked_links_send').on(t.sendId),
index('idx_tracked_links_port').on(t.portId, t.createdAt),
],
);
/** Per-click log. Apple Mail privacy proxy will pre-fetch tracked link
* URLs the same way it does pixels — clicks from iOS users are
* over-counted. Standard email-tracking caveats apply. */
export const trackedLinkClicks = pgTable(
'tracked_link_clicks',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
trackedLinkId: text('tracked_link_id')
.notNull()
.references(() => trackedLinks.id, { onDelete: 'cascade' }),
portId: text('port_id')
.notNull()
.references(() => ports.id),
clickedAt: timestamp('clicked_at', { withTimezone: true }).notNull().defaultNow(),
userAgent: text('user_agent'),
referer: text('referer'),
},
(t) => [
index('idx_tlc_link').on(t.trackedLinkId, t.clickedAt),
index('idx_tlc_port').on(t.portId, t.clickedAt),
],
);
export type TrackedLink = typeof trackedLinks.$inferSelect;
export type NewTrackedLink = typeof trackedLinks.$inferInsert;
export type TrackedLinkClick = typeof trackedLinkClicks.$inferSelect;
export type NewTrackedLinkClick = typeof trackedLinkClicks.$inferInsert;