feat(analytics): Umami integration with per-port admin settings

Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-04 22:53:06 +02:00
parent 49d34e00c8
commit f5772ce318
13 changed files with 1198 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { testConnection } from '@/lib/services/umami.service';
/**
* POST /api/v1/admin/umami/test - admin-only Umami connection check.
*
* Returns `{ data: { ok: true, visitors } }` on success or
* `{ data: { ok: false, error } }` on failure. Mirrors the shape used by
* the Documenso health endpoint so the existing test-button UI pattern
* just works.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const result = await testConnection(ctx.portId);
return NextResponse.json({ data: result });
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ data: { ok: false, error } });
}
}),
);

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
import {
getActiveVisitors,
getMetric,
getPageviewsSeries,
getStats,
type UmamiMetricType,
} from '@/lib/services/umami.service';
/**
* GET /api/v1/website-analytics?metric=...&range=...
*
* Single endpoint serving every Umami widget on the /website-analytics
* page. Mirrors the shape of /api/v1/analytics so the client side can
* reuse the same hook pattern.
*
* Supported metrics:
* - stats → KPI tiles (pageviews, visitors, visits, etc.)
* - pageviews → time-series for the trend chart
* - active → live "right now" count (range ignored)
* - top-{type} → top pages/referrers/countries/etc.
* where type ∈ url|referrer|country|browser|
* os|device|event
*
* Range param accepts the same presets as /api/v1/analytics, plus
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD`.
*/
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
function parseRange(req: NextRequest): DateRange | { error: string } {
const url = new URL(req.url);
const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' };
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' };
}
if (fromParam > toParam) {
return { error: '`from` must be on or before `to`' };
}
// Round-trip date check (catches "2026-02-31" type rollovers).
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return { error: `\`${label}\` is not a valid calendar date` };
}
}
return { kind: 'custom', from: fromParam, to: toParam };
}
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return { error: 'Invalid range' };
}
return rawRange as PresetDateRange;
}
export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric');
if (!metric) {
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
}
const rangeOrError = parseRange(req);
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
}
const range = rangeOrError as DateRange;
try {
let data: unknown;
if (metric === 'stats') {
data = await getStats(ctx.portId, range);
} else if (metric === 'pageviews') {
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
}
// `data === null` from the service means Umami isn't configured for
// this port - surface that explicitly so the UI can render a
// "configure your credentials" empty state instead of a chart.
if (data === null) {
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
}
return NextResponse.json({ metric, range, data });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message, metric, range }, { status: 502 });
}
}),
);