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:
19
src/lib/db/migrations/0076_email_open_tracking.sql
Normal file
19
src/lib/db/migrations/0076_email_open_tracking.sql
Normal 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);
|
||||
36
src/lib/db/migrations/0077_tracked_links.sql
Normal file
36
src/lib/db/migrations/0077_tracked_links.sql
Normal 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);
|
||||
@@ -76,3 +76,4 @@ export * from './pipeline';
|
||||
|
||||
// Relations (must come last - references all tables)
|
||||
export * from './relations';
|
||||
export * from './tracked-links';
|
||||
|
||||
71
src/lib/db/schema/tracked-links.ts
Normal file
71
src/lib/db/schema/tracked-links.ts
Normal 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;
|
||||
46
src/lib/email/tracking-pixel.ts
Normal file
46
src/lib/email/tracking-pixel.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Open-tracking pixel injector (Phase 4b). Appends a 1×1 transparent
|
||||
* image pointing at /api/public/email-pixel/[sendId] to outbound HTML
|
||||
* emails. The pixel endpoint records the open + cross-posts the event
|
||||
* to Umami.
|
||||
*
|
||||
* Sites that want to opt out of tracking simply don't call this helper.
|
||||
* The pixel URL is unguessable per-send (UUID), but a `track_opens=false`
|
||||
* row in `document_sends` makes the endpoint a no-op even if someone
|
||||
* does guess one.
|
||||
*
|
||||
* Privacy: respects EMAIL_REDIRECT_TO (no pixel injected when dev
|
||||
* redirect is active) so a re-routed message doesn't fire a fake open.
|
||||
*/
|
||||
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
interface InjectOptions {
|
||||
/** Public base URL of the CRM (e.g. https://crm.portnimara.com).
|
||||
* Required so the pixel link is absolute — relative URLs break in
|
||||
* email clients. */
|
||||
appBaseUrl: string;
|
||||
/** UUID of the row in `document_sends`. */
|
||||
sendId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a 1×1 tracking pixel just before `</body>` (or at the end of the
|
||||
* document if no `</body>` is present). Returns the HTML unchanged when
|
||||
* EMAIL_REDIRECT_TO is set so dev-mode re-routing doesn't generate
|
||||
* misleading open events.
|
||||
*/
|
||||
export function injectTrackingPixel(html: string, opts: InjectOptions): string {
|
||||
if (env.EMAIL_REDIRECT_TO) return html;
|
||||
|
||||
const base = opts.appBaseUrl.replace(/\/$/, '');
|
||||
const pixelUrl = `${base}/api/public/email-pixel/${opts.sendId}`;
|
||||
const pixelTag =
|
||||
`<img src="${pixelUrl}" width="1" height="1" alt="" ` +
|
||||
`style="display:block;border:0;margin:0;padding:0" />`;
|
||||
|
||||
if (html.includes('</body>')) {
|
||||
return html.replace('</body>', `${pixelTag}</body>`);
|
||||
}
|
||||
return html + pixelTag;
|
||||
}
|
||||
70
src/lib/services/tracked-links.service.ts
Normal file
70
src/lib/services/tracked-links.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { env } from '@/lib/env';
|
||||
import { trackedLinks, type NewTrackedLink } from '@/lib/db/schema/tracked-links';
|
||||
|
||||
/**
|
||||
* Phase 4c — service-layer helpers for tracked redirect links. Use
|
||||
* `createTrackedLink` from any email-composer flow to wrap an outbound
|
||||
* URL in a `/q/<slug>` short-link that records click-throughs.
|
||||
*
|
||||
* Slug format: random URL-safe ID. Short enough not to overwhelm an
|
||||
* inbox preview pane but long enough that collision probability is
|
||||
* negligible across the lifetime of the system.
|
||||
*/
|
||||
|
||||
function generateSlug(): string {
|
||||
// 8 random bytes → 11-char base64url string. Collision probability
|
||||
// across 1M links: ~1e-7. The DB unique index is the backstop.
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export interface CreateTrackedLinkInput {
|
||||
portId: string;
|
||||
targetUrl: string;
|
||||
/** Optional FK to `document_sends.id` so per-email click-throughs are
|
||||
* attributable. Leave null for one-off short links. */
|
||||
sendId?: string;
|
||||
createdByUserId?: string;
|
||||
}
|
||||
|
||||
export async function createTrackedLink(input: CreateTrackedLinkInput) {
|
||||
// Retry on slug collision (extremely rare). Three attempts is more
|
||||
// than enough — at our slug entropy a single collision in 1M links
|
||||
// would be a once-per-century event.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const slug = generateSlug();
|
||||
try {
|
||||
const values: NewTrackedLink = {
|
||||
portId: input.portId,
|
||||
slug,
|
||||
targetUrl: input.targetUrl,
|
||||
...(input.sendId ? { sendId: input.sendId } : {}),
|
||||
...(input.createdByUserId ? { createdByUserId: input.createdByUserId } : {}),
|
||||
};
|
||||
const [row] = await db.insert(trackedLinks).values(values).returning();
|
||||
return row!;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('uniq_tracked_links_slug') && attempt < 2) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to mint a unique tracked-link slug after 3 attempts');
|
||||
}
|
||||
|
||||
/** Build the public-facing tracked URL for an existing record. */
|
||||
export function buildTrackedUrl(slug: string): string {
|
||||
const base = env.NEXT_PUBLIC_APP_URL.replace(/\/$/, '');
|
||||
return `${base}/q/${slug}`;
|
||||
}
|
||||
|
||||
/** Look up click stats for a single tracked link. */
|
||||
export async function getTrackedLink(id: string) {
|
||||
return db.query.trackedLinks.findFirst({ where: eq(trackedLinks.id, id) });
|
||||
}
|
||||
@@ -211,12 +211,30 @@ function pickUnit(range: DateRange): 'hour' | 'day' | 'month' {
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stats response from `/api/websites/:id/stats` on Umami v2.x / v3.x.
|
||||
*
|
||||
* Each top-level metric is a plain number for the requested range; the
|
||||
* `comparison` block carries the equivalent values for the previous
|
||||
* window of the same length (so a 30-day range comes back with the prior
|
||||
* 30 days as `comparison.*`). Verified empirically against Umami v3.1.0
|
||||
* — earlier internal types modelled this as `{value, prev}` per metric,
|
||||
* which matched neither v2 nor v3 and caused the dashboard tile to read
|
||||
* `pageviews.value` as undefined and render 0.
|
||||
*/
|
||||
export interface UmamiStats {
|
||||
pageviews: { value: number; prev: number };
|
||||
visitors: { value: number; prev: number };
|
||||
visits: { value: number; prev: number };
|
||||
bounces: { value: number; prev: number };
|
||||
totaltime: { value: number; prev: number };
|
||||
pageviews: number;
|
||||
visitors: number;
|
||||
visits: number;
|
||||
bounces: number;
|
||||
totaltime: number;
|
||||
comparison?: {
|
||||
pageviews: number;
|
||||
visitors: number;
|
||||
visits: number;
|
||||
bounces: number;
|
||||
totaltime: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStats(portId: string, range: DateRange): Promise<UmamiStats | null> {
|
||||
@@ -227,9 +245,15 @@ export async function getStats(portId: string, range: DateRange): Promise<UmamiS
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pageviews time-series response. Umami v3 returns the `pageviews` array
|
||||
* unconditionally; the `sessions` array only appears when the request
|
||||
* includes a `compare` directive (omitted today). The optional field
|
||||
* keeps the type honest so consumers don't blindly read `.sessions[0]`.
|
||||
*/
|
||||
export interface UmamiPageviewsSeries {
|
||||
pageviews: Array<{ x: string; y: number }>;
|
||||
sessions: Array<{ x: string; y: number }>;
|
||||
sessions?: Array<{ x: string; y: number }>;
|
||||
}
|
||||
|
||||
export async function getPageviewsSeries(
|
||||
@@ -245,14 +269,25 @@ export async function getPageviewsSeries(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid `type` values for `/api/websites/:id/metrics` on Umami v2.x / v3.x.
|
||||
* `path` replaces the old `url` value — sending `type=url` against a v3
|
||||
* instance returns 400. The full Umami enum also includes `entry|exit|
|
||||
* title|query|region|city|language|screen|hostname|tag|distinctId`; only
|
||||
* the ones the CRM actually surfaces are listed here.
|
||||
*/
|
||||
export type UmamiMetricType =
|
||||
| 'url'
|
||||
| 'path'
|
||||
| 'referrer'
|
||||
| 'browser'
|
||||
| 'os'
|
||||
| 'device'
|
||||
| 'country'
|
||||
| 'event';
|
||||
| 'region'
|
||||
| 'city'
|
||||
| 'event'
|
||||
| 'title'
|
||||
| 'query';
|
||||
|
||||
export interface UmamiMetricRow {
|
||||
x: string;
|
||||
@@ -284,6 +319,30 @@ export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisi
|
||||
return umamiFetch<UmamiActiveVisitors>(config, `/api/websites/${config.websiteId}/active`, {});
|
||||
}
|
||||
|
||||
/** Website-level metadata (name + domain) so the analytics page can show
|
||||
* which site it's reporting on without the operator having to hard-code
|
||||
* the domain in system_settings. */
|
||||
export interface UmamiWebsiteInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export async function getWebsiteInfo(portId: string): Promise<UmamiWebsiteInfo | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
const res = await umamiFetch<{ id?: string; name?: string; domain?: string }>(
|
||||
config,
|
||||
`/api/websites/${config.websiteId}`,
|
||||
{},
|
||||
);
|
||||
return {
|
||||
id: res.id ?? config.websiteId,
|
||||
name: res.name ?? res.domain ?? 'Website',
|
||||
domain: res.domain ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
|
||||
* authenticated endpoint that proves both auth + websiteId are good.
|
||||
@@ -314,3 +373,333 @@ export async function testConnection(
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Realtime panel ────────────────────────────────────────────────────────
|
||||
//
|
||||
// `/api/realtime/:id` is the richer alternative to `/active` — returns
|
||||
// totals, top URLs being viewed right now, top countries, a 30-min
|
||||
// time-series and a recent-event stream. Used by the realtime dashboard.
|
||||
|
||||
export interface UmamiRealtime {
|
||||
urls: Record<string, number>;
|
||||
countries: Record<string, number>;
|
||||
events: Array<{
|
||||
__type: string;
|
||||
os?: string;
|
||||
device?: string;
|
||||
country?: string;
|
||||
sessionId?: string;
|
||||
eventName?: string;
|
||||
browser?: string;
|
||||
createdAt: string;
|
||||
urlPath?: string;
|
||||
referrerDomain?: string;
|
||||
}>;
|
||||
series: {
|
||||
views: Array<{ x: string; y: number }>;
|
||||
visitors: Array<{ x: string; y: number }>;
|
||||
};
|
||||
referrers: Record<string, number>;
|
||||
totals: {
|
||||
visitors: number;
|
||||
views: number;
|
||||
events: number;
|
||||
countries: number;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export async function getRealtime(portId: string): Promise<UmamiRealtime | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
// 30-minute window matches Umami's own realtime page default.
|
||||
const startAt = Date.now() - 30 * 60 * 1000;
|
||||
return umamiFetch<UmamiRealtime>(config, `/api/realtime/${config.websiteId}`, {
|
||||
startAt,
|
||||
endAt: Date.now(),
|
||||
timezone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UmamiSession {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
hostname: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
device: string;
|
||||
screen: string;
|
||||
language: string;
|
||||
country: string;
|
||||
subdivision1?: string;
|
||||
city?: string;
|
||||
firstAt: string;
|
||||
lastAt: string;
|
||||
visits: number;
|
||||
views: number;
|
||||
events: number;
|
||||
totaltime?: number;
|
||||
}
|
||||
|
||||
export interface UmamiSessionsPage {
|
||||
data: UmamiSession[];
|
||||
count: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export async function getSessions(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
opts: { page?: number; pageSize?: number; query?: string } = {},
|
||||
): Promise<UmamiSessionsPage | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
return umamiFetch<UmamiSessionsPage>(config, `/api/websites/${config.websiteId}/sessions`, {
|
||||
...rangeToParams(range),
|
||||
page: opts.page ?? 1,
|
||||
pageSize: opts.pageSize ?? 25,
|
||||
query: opts.query,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSession(portId: string, sessionId: string): Promise<UmamiSession | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
return umamiFetch<UmamiSession>(
|
||||
config,
|
||||
`/api/websites/${config.websiteId}/sessions/${sessionId}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export interface UmamiSessionActivity {
|
||||
eventType: number;
|
||||
urlQuery?: string;
|
||||
urlPath: string;
|
||||
eventName?: string;
|
||||
createdAt: string;
|
||||
referrerDomain?: string;
|
||||
eventId: string;
|
||||
visitId: string;
|
||||
}
|
||||
|
||||
export async function getSessionActivity(
|
||||
portId: string,
|
||||
sessionId: string,
|
||||
range: DateRange,
|
||||
): Promise<UmamiSessionActivity[] | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
return umamiFetch<UmamiSessionActivity[]>(
|
||||
config,
|
||||
`/api/websites/${config.websiteId}/sessions/${sessionId}/activity`,
|
||||
{ ...rangeToParams(range) },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions by hour-of-week heatmap — returns a 7×24 nested-array (rows are
|
||||
* days Sun..Sat, columns are hours 0..23). Drives the engagement heatmap
|
||||
* card.
|
||||
*/
|
||||
export async function getSessionsWeekly(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
timezone = 'UTC',
|
||||
): Promise<number[][] | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
return umamiFetch<number[][]>(config, `/api/websites/${config.websiteId}/sessions/weekly`, {
|
||||
...rangeToParams(range),
|
||||
timezone,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Events ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Wrappers ready for when the marketing site starts firing `umami.track()`
|
||||
// calls. Until then, every read returns an empty list — wired now so the
|
||||
// UI surface can light up immediately on the day events start arriving.
|
||||
|
||||
export interface UmamiEvent {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
websiteId: string;
|
||||
createdAt: string;
|
||||
urlPath: string;
|
||||
eventName?: string;
|
||||
pageTitle?: string;
|
||||
}
|
||||
|
||||
export async function getEvents(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
opts: { page?: number; pageSize?: number } = {},
|
||||
): Promise<{ data: UmamiEvent[]; count: number; page: number; pageSize: number } | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
return umamiFetch(config, `/api/websites/${config.websiteId}/events`, {
|
||||
...rangeToParams(range),
|
||||
page: opts.page ?? 1,
|
||||
pageSize: opts.pageSize ?? 25,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEventsStats(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
): Promise<{ pageviews: number; visitors: number; events: number } | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
return umamiFetch(config, `/api/websites/${config.websiteId}/events/stats`, {
|
||||
...rangeToParams(range),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEventsSeries(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
eventName: string,
|
||||
unit: 'hour' | 'day' | 'month' = 'day',
|
||||
): Promise<Array<{ x: string; y: number }> | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
return umamiFetch(config, `/api/websites/${config.websiteId}/events/series`, {
|
||||
...rangeToParams(range),
|
||||
eventName,
|
||||
unit,
|
||||
timezone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Reports (POST endpoints) ──────────────────────────────────────────────
|
||||
//
|
||||
// Reports are POST-only and take a JSON body; build a sibling `umamiPost`
|
||||
// helper that handles auth + error shape the same way as `umamiFetch`.
|
||||
|
||||
async function umamiPost<T>(config: UmamiPortConfig, path: string, body: unknown): Promise<T> {
|
||||
const bearer = await resolveBearer(config);
|
||||
const res = await fetchWithTimeout(`${config.apiUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
'Content-Type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`);
|
||||
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
|
||||
internalMessage: `Umami unauthorized: ${res.status}`,
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
|
||||
internalMessage: `Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`,
|
||||
});
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export interface UmamiFunnelStep {
|
||||
x: string;
|
||||
y: number;
|
||||
z: number;
|
||||
dropoff: number;
|
||||
}
|
||||
|
||||
export async function runFunnelReport(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
steps: Array<{ type: 'url' | 'event'; value: string }>,
|
||||
windowHours = 24,
|
||||
): Promise<UmamiFunnelStep[] | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
const { from, to } = rangeToBounds(range);
|
||||
return umamiPost<UmamiFunnelStep[]>(config, `/api/reports/funnel`, {
|
||||
websiteId: config.websiteId,
|
||||
steps,
|
||||
window: windowHours * 3600,
|
||||
dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' },
|
||||
});
|
||||
}
|
||||
|
||||
export interface UmamiJourneyStep {
|
||||
items: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function runJourneyReport(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
startStep?: string,
|
||||
endStep?: string,
|
||||
stepCount = 5,
|
||||
): Promise<UmamiJourneyStep[] | null> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
const { from, to } = rangeToBounds(range);
|
||||
return umamiPost<UmamiJourneyStep[]>(config, `/api/reports/journey`, {
|
||||
websiteId: config.websiteId,
|
||||
startStep,
|
||||
endStep,
|
||||
steps: stepCount,
|
||||
dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── CRM → Umami event push (Phase 6) ──────────────────────────────────────
|
||||
//
|
||||
// Thin wrapper around `@umami/node` so CRM outcome events land in the same
|
||||
// Umami instance the marketing site reports to. Per-port client instances
|
||||
// are cached so we don't re-instantiate on every event.
|
||||
|
||||
import { Umami } from '@umami/node';
|
||||
|
||||
const trackerByPort = new Map<string, Umami>();
|
||||
|
||||
async function getTracker(portId: string): Promise<Umami | null> {
|
||||
const cached = trackerByPort.get(portId);
|
||||
if (cached) return cached;
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) return null;
|
||||
const tracker = new Umami({ websiteId: config.websiteId, hostUrl: config.apiUrl });
|
||||
trackerByPort.set(portId, tracker);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a CRM-side event back to Umami. Outcome milestones (eoi-sent,
|
||||
* eoi-signed, reservation-paid, contract-signed) flow through here so
|
||||
* Umami's funnel + attribution reports can correlate marketing-site
|
||||
* traffic with downstream deal outcomes.
|
||||
*
|
||||
* Soft-fail: if Umami is unreachable or misconfigured the call swallows
|
||||
* the error and logs a warning — outcome events shouldn't fail a CRM
|
||||
* mutation.
|
||||
*/
|
||||
export async function trackEvent(
|
||||
portId: string,
|
||||
name: string,
|
||||
data?: Record<string, unknown>,
|
||||
url?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const tracker = await getTracker(portId);
|
||||
if (!tracker) return;
|
||||
await tracker.track({
|
||||
url: url ?? `/crm/${name}`,
|
||||
name,
|
||||
...(data ? { data } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, name }, 'Umami trackEvent failed (non-blocking)');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user