/** * The Activity search parameters, validated as pure functions so the route's * `validateSearch` stays a one-liner and every rejection is testable without a * router. * * A search value arrives from a URL, from history state, or from a hand-typed * link, so nothing about its type is given. Anything that is not exactly the * value the API can filter on becomes `undefined`: an unbounded page is honest, * a page filtered on a coerced guess is not. */ import type { QueriesFilter } from "@/lib/types"; export const ACTIVITY_MODES = ["history", "live"] as const; export type ActivityMode = (typeof ACTIVITY_MODES)[number]; export interface ActivitySearch { mode: ActivityMode; since: number | undefined; until: number | undefined; domain: string | undefined; client: string | undefined; blocked: boolean | undefined; } /** History is the surface a bare `/activity` should open on: it answers questions. */ export function validateMode(value: unknown): ActivityMode { return value === "live" ? "live" : "history"; } /** * A unix-second bound. `Number.isSafeInteger` is the whole test: it rejects a * fraction, an infinity, a NaN and a magnitude past 2^53 in one step, and a * string never passes, so `?since=now` cannot reach the API as garbage. */ export function validateTimestamp(value: unknown): number | undefined { return Number.isSafeInteger(value) ? (value as number) : undefined; } /** * The blocked filter. `false` is a real filter — "allowed only" — so it must * survive; only a genuine boolean does, because `"false"` out of a URL parser * that did not decode JSON would otherwise read as true. */ export function validateBlocked(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } /** * A text filter, trimmed. An empty result becomes `undefined` rather than `""`: * the server treats an empty filter as no filter, and a URL that showed * `domain=` as applied state would claim a filter that is not filtering. */ export function validateText(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed === "" ? undefined : trimmed; } /** * The API filter for a validated search, built field by field. * * Only the fields that are actually set are written, so an unfiltered request * carries no keys at all: `GET /api/queries` rejects a parameter it does not * know, and the infinite query's cache key is the filter object, so a key * present-but-undefined and a key absent must not be two different windows onto * the same rows. `mode` never appears — it selects the surface, not the rows. */ export function queriesFilterOf(search: Omit): QueriesFilter { const filter: QueriesFilter = {}; if (search.domain !== undefined) filter.domain = search.domain; if (search.client !== undefined) filter.client = search.client; if (search.blocked !== undefined) filter.blocked = search.blocked; if (search.since !== undefined) filter.since = search.since; if (search.until !== undefined) filter.until = search.until; return filter; } export function validateActivitySearch(search: Record): ActivitySearch { return { mode: validateMode(search["mode"]), since: validateTimestamp(search["since"]), until: validateTimestamp(search["until"]), domain: validateText(search["domain"]), client: validateText(search["client"]), blocked: validateBlocked(search["blocked"]), }; }