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

@@ -8,6 +8,7 @@ import {
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
import { trackDownload } from "~/server/track-download";
const assets: Record<string, string> = {
lineage: "Life and Lineage.apk",
@@ -73,7 +74,14 @@ const latestAssets: Record<
export const downloadsRouter = createTRPCRouter({
getDownloadUrl: publicProcedure
.input(z.object({ asset_name: z.string() }))
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
// Logged before signing: a click on a door that then fails to sign is
// exactly the event the funnel needs to show. Unknown asset names are
// skipped rather than recorded as a download that never happened.
if (assets[input.asset_name] || latestAssets[input.asset_name]) {
await trackDownload(ctx.event, input.asset_name);
}
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
const credentials = {