downloads: track every door and surface them on /analytics

The analytics page could not answer "how many downloads, of which
product". Three of the four download doors recorded nothing: the tRPC
click proxy only left the generic request row, whose path is the raw URL
with the asset buried in `?batch=1&input={"asset_name":...}`, and the S3
file route that serves Sparkle updates and the Nook app logged nothing
at all. The one door that did log (the Nook redirect) kept a private copy
of the logVisit/enrich/flush sequence, under its own path.

All doors now go through trackDownload, which writes one row per event
under /api/downloads/<asset> and flushes it inside the request — a click
is low-volume and serverless is not guaranteed to run the 30s batch
timer after the response. The file route maps its prefix allowlist to
asset keys, so a version bump stays one asset rather than one per
release. getAnalyticsSummary groups those rows into a downloads block and
the freno.me panel renders it under the overview cards.

History is recovered to the degree it exists: the Nook door's rows and
the tRPC clicks still carry enough to derive from (asset name in the URL).
scripts/backfill-downloads.ts inserts those derived rows with a
`backfill-<source id>` primary key and INSERT OR IGNORE, leaving the
originals untouched, so re-running is a no-op. Run against the live table
it added 66 rows: gaze 42, thenook 19, inputhalo 4, lineage 1.

Tests: new backfill suite (derivation, visitor detail carried over,
idempotency, empty history) and summariseDownloads cases; the Nook route
test now pins the canonical path, and the NOOK-337 allowlist audit reads
the map keys instead of quoted strings. Red-checked by stubbing the
downloads block back to zeros — the summary case fails, then passes.
Full suite matches master.

Co-Authored-By: nw-flash <noreply@nw-flash.local>
MISC-48
This commit is contained in:
2026-10-22 12:59:28 -04:00
parent 0bf15e528d
commit a524f23be2
12 changed files with 495 additions and 58 deletions

View File

@@ -1,6 +1,7 @@
import { ConnectionFactory } from "./database";
import { v4 as uuid } from "uuid";
import type { VisitorAnalytics, AnalyticsQuery } from "~/db/types";
import { DOWNLOAD_PATH_PREFIX, summariseDownloads } from "~/lib/download-asset";
import { CACHE_CONFIG } from "~/config";
import {
VISITOR_ANALYTICS_ADDED_COLUMNS,
@@ -272,6 +273,10 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
topReferrers: Array<{ referrer: string; count: number }>;
deviceTypes: Array<{ type: string; count: number }>;
browsers: Array<{ browser: string; count: number }>;
downloads: {
total: number;
byAsset: Array<{ asset: string; count: number }>;
};
topCampaigns: Array<{
source: string | null;
medium: string | null;
@@ -409,6 +414,24 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
count: row.count as number
}));
// Downloads: every door records one row under `/api/downloads/<asset>` via
// `trackDownload`, so the asset is the path suffix and nothing has to be
// parsed out of a query string.
const downloadsResult = await conn.execute({
sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics
WHERE path LIKE '${DOWNLOAD_PATH_PREFIX}%'
AND created_at >= datetime('now', '-${days} days')
GROUP BY path
ORDER BY count DESC`,
args: []
});
const downloads = summariseDownloads(
downloadsResult.rows.map((row) => ({
path: String(row.path),
count: row.count as number
}))
);
return {
totalVisits,
totalPageVisits,
@@ -420,7 +443,8 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
topReferrers,
deviceTypes,
browsers,
topCampaigns
topCampaigns,
downloads
};
}