From f5772ce318b66277fb3778e264bb23ae505746b9 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Mon, 4 May 2026 22:53:06 +0200 Subject: [PATCH] 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) --- src/app/(dashboard)/[portSlug]/admin/page.tsx | 7 + .../admin/website-analytics/page.tsx | 74 +++++ .../[portSlug]/website-analytics/page.tsx | 11 + src/app/api/v1/admin/umami/test/route.ts | 24 ++ src/app/api/v1/website-analytics/route.ts | 113 ++++++++ .../website-analytics/umami-test-button.tsx | 67 +++++ .../dashboard/website-glance-tile.tsx | 79 ++++++ .../website-analytics/pageviews-chart.tsx | 111 ++++++++ src/components/website-analytics/top-list.tsx | 66 +++++ .../use-website-analytics.ts | 80 ++++++ .../website-analytics-shell.tsx | 232 ++++++++++++++++ src/lib/analytics/range.ts | 76 ++++++ src/lib/services/umami.service.ts | 258 ++++++++++++++++++ 13 files changed, 1198 insertions(+) create mode 100644 src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/website-analytics/page.tsx create mode 100644 src/app/api/v1/admin/umami/test/route.ts create mode 100644 src/app/api/v1/website-analytics/route.ts create mode 100644 src/components/admin/website-analytics/umami-test-button.tsx create mode 100644 src/components/dashboard/website-glance-tile.tsx create mode 100644 src/components/website-analytics/pageviews-chart.tsx create mode 100644 src/components/website-analytics/top-list.tsx create mode 100644 src/components/website-analytics/use-website-analytics.ts create mode 100644 src/components/website-analytics/website-analytics-shell.tsx create mode 100644 src/lib/analytics/range.ts create mode 100644 src/lib/services/umami.service.ts diff --git a/src/app/(dashboard)/[portSlug]/admin/page.tsx b/src/app/(dashboard)/[portSlug]/admin/page.tsx index 308560b..baa3591 100644 --- a/src/app/(dashboard)/[portSlug]/admin/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/page.tsx @@ -18,6 +18,7 @@ import { Users, UsersRound, Webhook, + Globe, } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -209,6 +210,12 @@ const GROUPS: AdminGroup[] = [ description: 'Configure the AI provider used by the mobile receipt scanner.', icon: ScrollText, }, + { + href: 'website-analytics', + label: 'Website analytics (Umami)', + description: 'Per-port Umami URL, API token, and Website ID.', + icon: Globe, + }, ], }, ]; diff --git a/src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx b/src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx new file mode 100644 index 0000000..221b0fa --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx @@ -0,0 +1,74 @@ +import { + SettingsFormCard, + type SettingFieldDef, +} from '@/components/admin/shared/settings-form-card'; +import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test-button'; +import { PageHeader } from '@/components/shared/page-header'; + +/** + * Per-port Umami credentials. We deliberately keep all three values + * port-scoped (per the operator decision) so different ports can point at + * different Umami instances if needed. The /website-analytics dashboard + * page reads these settings via the umami.service layer at request time. + */ +const FIELDS: SettingFieldDef[] = [ + { + key: 'umami_api_url', + label: 'Umami API URL', + description: + 'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).', + type: 'string', + placeholder: 'https://analytics.portnimara.com', + defaultValue: '', + }, + { + key: 'umami_api_token', + label: 'API token', + description: + 'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.', + type: 'password', + defaultValue: '', + }, + { + key: 'umami_username', + label: 'Username', + description: 'Self-hosted JWT fallback. Only used if API token is blank.', + type: 'string', + placeholder: 'admin', + defaultValue: '', + }, + { + key: 'umami_password', + label: 'Password', + description: 'Self-hosted JWT fallback. Only used if API token is blank.', + type: 'password', + defaultValue: '', + }, + { + key: 'umami_website_id', + label: 'Website ID', + description: + 'UUID of this port’s website inside Umami. Find it in Umami → Settings → Websites → Edit → Website ID.', + type: 'string', + placeholder: '00000000-0000-0000-0000-000000000000', + defaultValue: '', + }, +]; + +export default function WebsiteAnalyticsSettingsPage() { + return ( +
+ + + } + /> +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/website-analytics/page.tsx b/src/app/(dashboard)/[portSlug]/website-analytics/page.tsx new file mode 100644 index 0000000..c47bbc8 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/website-analytics/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; + +import { WebsiteAnalyticsShell } from '@/components/website-analytics/website-analytics-shell'; + +export const metadata: Metadata = { + title: 'Website analytics', +}; + +export default function WebsiteAnalyticsPage() { + return ; +} diff --git a/src/app/api/v1/admin/umami/test/route.ts b/src/app/api/v1/admin/umami/test/route.ts new file mode 100644 index 0000000..66c7c18 --- /dev/null +++ b/src/app/api/v1/admin/umami/test/route.ts @@ -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 } }); + } + }), +); diff --git a/src/app/api/v1/website-analytics/route.ts b/src/app/api/v1/website-analytics/route.ts new file mode 100644 index 0000000..debe066 --- /dev/null +++ b/src/app/api/v1/website-analytics/route.ts @@ -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 }); + } + }), +); diff --git a/src/components/admin/website-analytics/umami-test-button.tsx b/src/components/admin/website-analytics/umami-test-button.tsx new file mode 100644 index 0000000..f482f6f --- /dev/null +++ b/src/components/admin/website-analytics/umami-test-button.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/api/client'; + +interface TestResponse { + ok: boolean; + visitors?: number; + error?: string; +} + +/** + * Hits POST /api/v1/admin/umami/test which calls Umami's `/api/websites/:id/ + * active` to verify auth + websiteId in one request. On success, shows the + * live visitor count as proof we got real data back. + */ +export function UmamiTestButton() { + const [pending, setPending] = useState(false); + const [result, setResult] = useState(null); + + async function runTest() { + setPending(true); + setResult(null); + try { + const res = await apiFetch<{ data: TestResponse }>('/api/v1/admin/umami/test', { + method: 'POST', + }); + setResult(res.data); + if (res.data.ok) { + toast.success(`Umami reachable - ${res.data.visitors ?? 0} active visitor(s) right now`); + } else { + toast.error(res.data.error ?? 'Umami test failed'); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Test failed'; + setResult({ ok: false, error: message }); + toast.error(message); + } finally { + setPending(false); + } + } + + return ( +
+ {result && + (result.ok ? ( + + + Connected ({result.visitors ?? 0} active) + + ) : ( + + + {result.error ?? 'Failed'} + + ))} + +
+ ); +} diff --git a/src/components/dashboard/website-glance-tile.tsx b/src/components/dashboard/website-glance-tile.tsx new file mode 100644 index 0000000..8326b13 --- /dev/null +++ b/src/components/dashboard/website-glance-tile.tsx @@ -0,0 +1,79 @@ +'use client'; + +/** + * Compact "Website at a glance" tile for the main sales dashboard. Shows + * pageviews today + active visitors right now + a deep-link to the full + * /website-analytics page. Soft-fails (renders nothing) when Umami isn't + * configured for this port - so the dashboard doesn't get cluttered with + * a "configure Umami" prompt that the user already saw on the dedicated + * page. + */ + +import Link from 'next/link'; +import { Globe, ArrowRight } from 'lucide-react'; + +import { useUIStore } from '@/stores/ui-store'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + useUmamiActive, + useUmamiStats, +} from '@/components/website-analytics/use-website-analytics'; + +export function WebsiteGlanceTile() { + const portSlug = useUIStore((s) => s.currentPortSlug); + const stats = useUmamiStats('today'); + const active = useUmamiActive('today'); + + // Hide the tile entirely if Umami isn't configured - this dashboard is + // for sales, not for prompting the operator into integration setup. + if ( + stats.data?.error === 'umami_not_configured' || + active.data?.error === 'umami_not_configured' + ) { + return null; + } + + const today = stats.data?.data?.pageviews?.value ?? 0; + const activeNow = active.data?.data?.visitors ?? 0; + const loading = stats.isLoading || active.isLoading; + + return ( + + +
+
+
+
+ + Website today +
+ {loading ? ( + + ) : ( +
+ {today.toLocaleString()} + pageviews +
+ )} +
+ + + + + {activeNow} active right now +
+
+ +
+ + + ); +} diff --git a/src/components/website-analytics/pageviews-chart.tsx b/src/components/website-analytics/pageviews-chart.tsx new file mode 100644 index 0000000..cc9908b --- /dev/null +++ b/src/components/website-analytics/pageviews-chart.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import type { UmamiPageviewsSeries } from '@/lib/services/umami.service'; + +interface Props { + data: UmamiPageviewsSeries | null; +} + +/** + * Stacks pageviews on top of sessions in a simple area chart. Umami's + * timeseries comes pre-bucketed (the service picks bucket size based on + * range span - minute/hour/day/month). + * + * X-axis labels are kept short to avoid overflow on dense ranges. + */ +export function PageviewsChart({ data }: Props) { + if (!data || data.pageviews.length === 0) { + return ( +
+ No data in this range +
+ ); + } + + // Merge the two series (Umami returns them separately) into one row per + // bucket so we can drive a single chart. + const byX = new Map(); + for (const p of data.pageviews) { + byX.set(p.x, { x: p.x, pageviews: p.y, sessions: 0 }); + } + for (const s of data.sessions) { + const row = byX.get(s.x); + if (row) row.sessions = s.y; + else byX.set(s.x, { x: s.x, pageviews: 0, sessions: s.y }); + } + const merged = Array.from(byX.values()).sort((a, b) => a.x.localeCompare(b.x)); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** Compact tick labels: full datetime → just MM-DD or MM-DD HH:00. */ +function formatXTick(value: string): string { + // Umami can return either "YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD". + if (value.length >= 16) { + return value.slice(5, 16); // "MM-DD HH:mm" + } + return value.slice(5); // "MM-DD" +} diff --git a/src/components/website-analytics/top-list.tsx b/src/components/website-analytics/top-list.tsx new file mode 100644 index 0000000..e58729c --- /dev/null +++ b/src/components/website-analytics/top-list.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { UmamiMetricRow } from '@/lib/services/umami.service'; + +interface Props { + title: string; + rows: UmamiMetricRow[] | null; + loading: boolean; + /** Label substituted when `x` is empty (e.g. direct traffic referrers). */ + defaultLabel?: string; +} + +/** + * Compact "top N" list used for top pages / referrers / countries. + * Renders each row as label + numeric count, with a thin progress bar + * scaled to the largest count in the set so the visual density tells + * the same story at a glance as the numbers. + */ +export function TopList({ title, rows, loading, defaultLabel = '-' }: Props) { + return ( + + + {title} + + + {loading ? ( +
+ + + + +
+ ) : !rows || rows.length === 0 ? ( +
No data
+ ) : ( +
    + {rows.slice(0, 10).map((row, i) => { + const max = rows[0]?.y ?? 1; + const pct = (row.y / max) * 100; + const label = row.x?.trim() || defaultLabel; + return ( +
  • +
    + {label} + + {row.y.toLocaleString()} + +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/website-analytics/use-website-analytics.ts b/src/components/website-analytics/use-website-analytics.ts new file mode 100644 index 0000000..fbe013a --- /dev/null +++ b/src/components/website-analytics/use-website-analytics.ts @@ -0,0 +1,80 @@ +'use client'; + +/** + * React Query hooks for the Website Analytics page. Mirrors the structure + * of `src/components/dashboard/use-analytics.ts` but talks to the Umami + * proxy at /api/v1/website-analytics. + * + * All hooks are gated on `currentPortId` so they don't fire before the + * port is resolved (which would cause a 401 to land in the React Query + * cache and persist as `isError: true` until staleTime expires). + */ + +import { useQuery } from '@tanstack/react-query'; + +import { apiFetch } from '@/lib/api/client'; +import { useUIStore } from '@/stores/ui-store'; +import { isCustomRange, type DateRange } from '@/lib/analytics/range'; +import type { + UmamiActiveVisitors, + UmamiMetricRow, + UmamiPageviewsSeries, + UmamiStats, +} from '@/lib/services/umami.service'; + +interface MetricResponse { + metric: string; + range: DateRange; + data: T; + /** Surfaced when Umami isn't configured for the port - UI can render a + * "set up Umami" empty state instead of a generic loading spinner. */ + error?: string; +} + +function rangeToQuery(range: DateRange): string { + if (isCustomRange(range)) { + return `range=custom&from=${range.from}&to=${range.to}`; + } + return `range=${range}`; +} + +function useUmamiQuery( + metric: string, + range: DateRange, + extraParams = '', + /** Override the cache key segment that defaults to `range`. Use for + * metrics whose response is range-independent (e.g. active visitors) + * so the cache isn't fragmented across each date the user has picked. */ + cacheKeySegment?: unknown, +) { + const portId = useUIStore((s) => s.currentPortId); + return useQuery>({ + queryKey: ['website-analytics', metric, cacheKeySegment ?? range, portId], + queryFn: () => + apiFetch>( + `/api/v1/website-analytics?metric=${metric}&${rangeToQuery(range)}${extraParams}`, + ), + staleTime: 30_000, // umami data refreshes constantly; short stale time + retry: 1, + enabled: !!portId, + }); +} + +export const useUmamiStats = (range: DateRange) => useUmamiQuery('stats', range); +export const useUmamiPageviews = (range: DateRange) => + useUmamiQuery('pageviews', range); + +// Active visitors are server-side range-independent (Umami's /active endpoint +// returns the last-5-minute count regardless of what range we pass). Use a +// fixed cache-key segment so the dashboard tile (range='today') and the +// website-analytics shell (any selected range) share one cache entry instead +// of fragmenting it across every range the user picks. +export const useUmamiActive = (range: DateRange) => + useUmamiQuery('active', range, '', 'fixed'); + +export const useUmamiTopPages = (range: DateRange, limit = 10) => + useUmamiQuery('top-url', range, `&limit=${limit}`); +export const useUmamiTopReferrers = (range: DateRange, limit = 10) => + useUmamiQuery('top-referrer', range, `&limit=${limit}`); +export const useUmamiTopCountries = (range: DateRange, limit = 10) => + useUmamiQuery('top-country', range, `&limit=${limit}`); diff --git a/src/components/website-analytics/website-analytics-shell.tsx b/src/components/website-analytics/website-analytics-shell.tsx new file mode 100644 index 0000000..68c8a20 --- /dev/null +++ b/src/components/website-analytics/website-analytics-shell.tsx @@ -0,0 +1,232 @@ +'use client'; + +/** + * /website-analytics page shell. Mirrors the dashboard shell's layout: + * - PageHeader with date-range picker (presets + custom) + * - KPI tiles (pageviews / visitors / visits / bounces) + * - Two-column grid: pageviews trend + active visitors badge + * - Top-N tables: pages, referrers, countries + * + * Gracefully renders an empty state when Umami isn't configured for the + * port - points the operator at /admin/website-analytics to set creds. + */ + +import { useState } from 'react'; +import Link from 'next/link'; +import { Globe, Settings, ExternalLink } from 'lucide-react'; + +import { PageHeader } from '@/components/shared/page-header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { KPITile } from '@/components/ui/kpi-tile'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { DateRangePicker } from '@/components/dashboard/date-range-picker'; +import { type DateRange } from '@/lib/analytics/range'; +import { useUIStore } from '@/stores/ui-store'; +import { + useUmamiActive, + useUmamiPageviews, + useUmamiStats, + useUmamiTopCountries, + useUmamiTopPages, + useUmamiTopReferrers, +} from './use-website-analytics'; +import { PageviewsChart } from './pageviews-chart'; +import { TopList } from './top-list'; + +export function WebsiteAnalyticsShell() { + const [range, setRange] = useState('30d'); + const portSlug = useUIStore((s) => s.currentPortSlug); + + const stats = useUmamiStats(range); + const pageviews = useUmamiPageviews(range); + const active = useUmamiActive(range); + const topPages = useUmamiTopPages(range); + const topReferrers = useUmamiTopReferrers(range); + const topCountries = useUmamiTopCountries(range); + + // Any of the queries returning `error: 'umami_not_configured'` means we + // need to prompt the operator to set credentials. Single empty state + // covers all widgets so the page doesn't show six loading spinners that + // never resolve. + const notConfigured = stats.data?.error === 'umami_not_configured'; + + return ( +
+ } + /> + + {notConfigured ? ( + + ) : ( + <> + {/* Live indicator + KPI tiles */} +
+ + + + + +
+ + {/* Pageviews trend */} + + + Pageviews trend + + + {pageviews.isLoading ? ( + + ) : ( + + )} + + + + {/* Top-N tables */} +
+ + + +
+ + )} +
+ ); +} + +function ActiveVisitorsBadge({ value, loading }: { value?: number; loading: boolean }) { + return ( +
+
+
+ Active right now +
+
+ {loading ? ( + + ) : ( + <> + + + + + {value ?? 0} + + )} +
+
+ ); +} + +function KpiPair({ + label, + value, + prev, + accent, + loading, + invertDelta = false, +}: { + label: string; + value: number | undefined; + prev: number | undefined; + accent: 'brand' | 'success' | 'warning' | 'mint' | 'teal' | 'purple'; + loading: boolean; + /** For metrics where lower is better (bounces). Flip the sign so green + * still means "good" in the UI. */ + invertDelta?: boolean; +}) { + if (loading) { + return ( +
+ + +
+ ); + } + const v = value ?? 0; + const p = prev ?? 0; + let delta: number | undefined; + if (p > 0) { + delta = Math.round(((v - p) / p) * 100); + if (invertDelta) delta = -delta; + } + return ; +} + +function NotConfiguredEmptyState({ portSlug }: { portSlug: string | null }) { + return ( + + + +
+

Connect Umami to see your website analytics

+

+ Add your Umami URL, API token (or username/password), and Website ID for this port to + unlock pageview trends, top pages, referrers, and audience geography. +

+
+
+ + +
+
+
+ ); +} diff --git a/src/lib/analytics/range.ts b/src/lib/analytics/range.ts new file mode 100644 index 0000000..87a6aa1 --- /dev/null +++ b/src/lib/analytics/range.ts @@ -0,0 +1,76 @@ +/** + * Pure date-range types and helpers shared by client components and the + * server-side analytics service. + * + * Lives outside `src/lib/services/analytics.service.ts` because that file + * imports the DB driver (`postgres`) which can't be bundled into client + * components - see Next.js "Module not found: net" build error. + * + * No DB / no IO / no React. + */ + +/** + * Preset date ranges used by the dashboard's quick-pick tabs. + */ +export type PresetDateRange = '7d' | '30d' | '90d' | 'today'; + +/** + * A custom date range expressed as a pair of ISO date strings (YYYY-MM-DD). + * The lower bound is inclusive at 00:00; the upper bound is inclusive at + * 23:59:59.999 (resolved inside `rangeToBounds`). + */ +export interface CustomDateRange { + kind: 'custom'; + from: string; // ISO YYYY-MM-DD + to: string; // ISO YYYY-MM-DD +} + +export type DateRange = PresetDateRange | CustomDateRange; + +export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d'] as const; + +export function isCustomRange(range: DateRange): range is CustomDateRange { + return typeof range === 'object' && range.kind === 'custom'; +} + +/** + * Resolve any DateRange (preset or custom) to a concrete {from, to} pair. + * - Preset ranges anchor `to` at "now" and `from` at `now - N days`. + * - Custom ranges use the operator-supplied dates verbatim, with `to` + * normalized to end-of-day so a same-day range still includes that day. + */ +export function rangeToBounds(range: DateRange): { from: Date; to: Date } { + if (isCustomRange(range)) { + return { + from: new Date(`${range.from}T00:00:00.000Z`), + to: new Date(`${range.to}T23:59:59.999Z`), + }; + } + const now = Date.now(); + const days = rangeToDays(range); + return { from: new Date(now - days * 86_400_000), to: new Date(now) }; +} + +export function rangeToDays(range: PresetDateRange): number { + switch (range) { + case 'today': + return 1; + case '7d': + return 7; + case '30d': + return 30; + case '90d': + return 90; + } +} + +/** + * Number of days a range spans (rounded up). Useful for sizing chart axes. + */ +export function rangeSpanDays(range: DateRange): number { + if (isCustomRange(range)) { + const { from, to } = rangeToBounds(range); + return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 86_400_000)); + } + return rangeToDays(range); +} diff --git a/src/lib/services/umami.service.ts b/src/lib/services/umami.service.ts new file mode 100644 index 0000000..a7c310f --- /dev/null +++ b/src/lib/services/umami.service.ts @@ -0,0 +1,258 @@ +/** + * Umami v2 API client. Reads credentials from `system_settings` per port, + * caches JWTs in-memory when using the username/password flow, and exposes + * typed wrappers for the handful of endpoints the /website-analytics page + * uses. + * + * Auth resolution order (per port): + * 1. If `umami_api_token` is set → use it as a Bearer token (Umami Cloud + * pattern, also supported by v2 self-hosted with API keys enabled). + * 2. Otherwise → POST /api/auth/login with `umami_username` + + * `umami_password` to get a JWT, cache it, use it as Bearer. + * + * No env vars - all config lives in port-scoped system_settings so the + * operator can configure Umami at runtime via /admin/website-analytics. + * + * v2 docs: https://docs.umami.is/docs/api + */ + +import { and, eq, inArray } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { systemSettings } from '@/lib/db/schema/system'; +import { rangeToBounds, type DateRange } from '@/lib/analytics/range'; + +// ─── Settings access ──────────────────────────────────────────────────────── + +interface UmamiPortConfig { + apiUrl: string; + apiToken: string | null; + username: string | null; + password: string | null; + websiteId: string; +} + +const SETTING_KEYS = [ + 'umami_api_url', + 'umami_api_token', + 'umami_username', + 'umami_password', + 'umami_website_id', +] as const; + +/** + * Read the five Umami-related setting rows for one port and assemble them. + * Returns null if the minimum required config (URL + websiteId + an auth + * method) is missing - callers surface a "not configured" UI in that case. + */ +export async function loadUmamiConfig(portId: string): Promise { + // Filter to ONLY the five Umami keys. Without this, every analytics page + // request pulls every system_settings row for the port (Documenso keys, + // SMTP, email templates, etc), which scales poorly as the port grows. + const rows = await db + .select({ key: systemSettings.key, value: systemSettings.value }) + .from(systemSettings) + .where(and(eq(systemSettings.portId, portId), inArray(systemSettings.key, [...SETTING_KEYS]))); + + const map = new Map(rows.map((r) => [r.key, r.value as string | null | undefined])); + const apiUrl = (map.get('umami_api_url') ?? '').toString().trim().replace(/\/$/, ''); + const apiToken = ((map.get('umami_api_token') ?? '') as string).trim() || null; + const username = ((map.get('umami_username') ?? '') as string).trim() || null; + const password = ((map.get('umami_password') ?? '') as string).trim() || null; + const websiteId = ((map.get('umami_website_id') ?? '') as string).trim(); + + if (!apiUrl || !websiteId) return null; + if (!apiToken && !(username && password)) return null; + + return { apiUrl, apiToken, username, password, websiteId }; +} + +// ─── JWT cache (username/password flow only) ──────────────────────────────── + +interface CachedJwt { + token: string; + expiresAt: number; +} +// Keyed by `${apiUrl}::${username}` so different ports / different Umami +// instances don't share tokens. Tokens are presumed to last 1 hour; we +// refresh proactively a few minutes before expiry. +const jwtCache = new Map(); +const JWT_TTL_MS = 55 * 60 * 1000; // 55 min - Umami JWTs default to 1h + +async function loginAndCache(apiUrl: string, username: string, password: string): Promise { + const res = await fetch(`${apiUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + throw new Error(`Umami login failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()) as { token?: string }; + if (!body.token) throw new Error('Umami login response missing token'); + jwtCache.set(`${apiUrl}::${username}`, { + token: body.token, + expiresAt: Date.now() + JWT_TTL_MS, + }); + return body.token; +} + +async function resolveBearer(config: UmamiPortConfig): Promise { + if (config.apiToken) return config.apiToken; + if (!config.username || !config.password) { + throw new Error('Umami is misconfigured: no API token and no username/password.'); + } + const cacheKey = `${config.apiUrl}::${config.username}`; + const cached = jwtCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.token; + return loginAndCache(config.apiUrl, config.username, config.password); +} + +// ─── Generic request helper ───────────────────────────────────────────────── + +async function umamiFetch( + config: UmamiPortConfig, + path: string, + search: Record, +): Promise { + const bearer = await resolveBearer(config); + const url = new URL(`${config.apiUrl}${path}`); + for (const [k, v] of Object.entries(search)) { + if (v === undefined) continue; + url.searchParams.set(k, String(v)); + } + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${bearer}`, + accept: 'application/json', + }, + // Don't share Next.js's request cache - analytics figures change every + // few seconds. The service-layer cache (if any) is the right place. + cache: 'no-store', + }); + + if (res.status === 401 || res.status === 403) { + // Bearer rejected - drop cached JWT so next call re-logs in. + if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`); + throw new Error(`Umami unauthorized: ${res.status}`); + } + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`, + ); + } + return (await res.json()) as T; +} + +// ─── Range serialization ──────────────────────────────────────────────────── + +function rangeToParams(range: DateRange): { startAt: number; endAt: number } { + // Umami expects unix milliseconds for both bounds. + const { from, to } = rangeToBounds(range); + return { startAt: from.getTime(), endAt: to.getTime() }; +} + +/** Pick a sensible bucket size for the pageviews timeseries given the + * range span. Avoids returning thousands of points for a 90d range. */ +function pickUnit(range: DateRange): 'hour' | 'day' | 'month' { + const { from, to } = rangeToBounds(range); + const days = (to.getTime() - from.getTime()) / 86_400_000; + if (days <= 2) return 'hour'; + if (days <= 120) return 'day'; + return 'month'; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +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 }; +} + +export async function getStats(portId: string, range: DateRange): Promise { + const config = await loadUmamiConfig(portId); + if (!config) return null; + return umamiFetch(config, `/api/websites/${config.websiteId}/stats`, { + ...rangeToParams(range), + }); +} + +export interface UmamiPageviewsSeries { + pageviews: Array<{ x: string; y: number }>; + sessions: Array<{ x: string; y: number }>; +} + +export async function getPageviewsSeries( + portId: string, + range: DateRange, +): Promise { + const config = await loadUmamiConfig(portId); + if (!config) return null; + return umamiFetch(config, `/api/websites/${config.websiteId}/pageviews`, { + ...rangeToParams(range), + unit: pickUnit(range), + timezone: 'UTC', + }); +} + +export type UmamiMetricType = + | 'url' + | 'referrer' + | 'browser' + | 'os' + | 'device' + | 'country' + | 'event'; + +export interface UmamiMetricRow { + x: string; + y: number; +} + +export async function getMetric( + portId: string, + range: DateRange, + type: UmamiMetricType, + limit = 10, +): Promise { + const config = await loadUmamiConfig(portId); + if (!config) return null; + return umamiFetch(config, `/api/websites/${config.websiteId}/metrics`, { + ...rangeToParams(range), + type, + limit, + }); +} + +export interface UmamiActiveVisitors { + visitors: number; +} + +export async function getActiveVisitors(portId: string): Promise { + const config = await loadUmamiConfig(portId); + if (!config) return null; + return umamiFetch(config, `/api/websites/${config.websiteId}/active`, {}); +} + +/** + * Verify the connection by hitting `/api/websites/:id/active` - the cheapest + * authenticated endpoint that proves both auth + websiteId are good. + * Throws on any failure with a descriptive message; resolves on success. + */ +export async function testConnection(portId: string): Promise<{ ok: true; visitors: number }> { + const config = await loadUmamiConfig(portId); + if (!config) { + throw new Error('Umami is not configured for this port.'); + } + const result = await umamiFetch( + config, + `/api/websites/${config.websiteId}/active`, + {}, + ); + return { ok: true, visitors: result.visitors }; +}