From 9e1cae0fe9e707e152a9fece0d5992a6fe1720c9 Mon Sep 17 00:00:00 2001 From: Wasim Amiri <7220175+wasimxyz@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:13:42 -0700 Subject: [PATCH 1/2] Use calendar day and week windows for run stats (#142) * Use calendar day and week windows for run stats and filters. Replace rolling 24h/7d lookbacks with timezone-aware today/this-week boundaries via a browser timezone cookie, and expand date-filter presets accordingly. Co-authored-by: Cursor * Use date-fns for relative time labels. Co-authored-by: Cursor * Polish calendar run windows for local testing and UI. Seed calendar-relative runs for date presets, rename rolling filters to Last 7 days / Last 4 weeks, and tighten a few dashboard filter UI details. Co-authored-by: Cursor * Tighten calendar timezone sync and date-filter UX after review. Cache viewer timezone, avoid fetch waterfalls and unnecessary refreshes, and keep colliding day/week/month presets highlighted correctly. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- developer-docs/local-development.md | 6 +- web/app/layout.tsx | 6 + web/app/page.tsx | 24 ++- web/components/dashboard/dashboard-stats.tsx | 43 +++-- web/components/dashboard/runs-table.tsx | 2 +- web/components/runs/run-filters-combobox.tsx | 8 +- web/components/runs/runs-date-filter.tsx | 171 +++++++++++++++---- web/components/timezone-cookie-sync.tsx | 49 ++++++ web/lib/api/dashboard.ts | 102 ++++++----- web/lib/api/instruments.ts | 13 +- web/lib/date.ts | 115 ++++++++++++- web/lib/db/seed.ts | 98 +++++++++-- web/lib/utils.ts | 25 +-- web/lib/viewer-timezone.ts | 25 +++ web/scripts/seed-database.ts | 2 +- web/tests/unit/date-boundaries.test.ts | 111 ++++++++++++ 16 files changed, 631 insertions(+), 169 deletions(-) create mode 100644 web/components/timezone-cookie-sync.tsx create mode 100644 web/lib/viewer-timezone.ts create mode 100644 web/tests/unit/date-boundaries.test.ts diff --git a/developer-docs/local-development.md b/developer-docs/local-development.md index 42fb6b90..33b574c1 100644 --- a/developer-docs/local-development.md +++ b/developer-docs/local-development.md @@ -110,14 +110,14 @@ You can also run `npm run db:seed` on its own — it calls the schema-driven `cl | `watchers` | 7 (for active instruments) | Rotates through `watching` / `registered` / `stopped` | | `watcher_heartbeats` | ~10 per watching watcher | Spread over the last hour | | `watcher_events` | 3 per watching watcher | `watcher_started`, `config_synced`, `file_uploaded` | -| `instrument_runs` | 5 per active instrument | Spread across the last ~2 weeks (3, 6, 9, 12, 15 days back), alternating `lambda` / `watcher` source | +| `instrument_runs` | 8 per active instrument | Calendar-relative `acquired_at` (today, yesterday, this week, ~7d / ~10d / ~22d / earlier this month) so date-filter presets and today/this-week stats have distinct non-empty sets; alternating `lambda` / `watcher` source | | `files` | 3 per run, or 1 for fixture-bearing runs | Mix of `uploaded` / `completed` / `failed` (and `raw` / `processed` for the 3-file shape). qPCR / gel doc / plate reader runs render exactly one row — the real fixture, bytes copied into `LOCAL_S3_MIRROR` (see [Working with file bytes locally](#working-with-file-bytes-locally)) | -| `run_comments` | 1 per run | Authored by the dev user | +| `run_comments` | 1 per run | Authored by the dev user; most stamped this week, every 4th last week | | `run_attributions` | 1 per run | Dev user attributed | | `archive_jobs` | 3 | One each of `ready` / `building` / `failed` | | `watcher_release_config` | 1 (singleton) | `9.9.9 / 0.1.0 / stable / false` | -Externally-visible identifiers used in URLs and API paths are deterministic across reseeds, so screenshots, bug reports, and `curl` examples stay stable. Instrument types backed by a real lambda `process_file` (qPCR, gel doc, plate reader) use the canonical kebab-case ids the lambda expects (`azure-cielo-qpcr`, `azure-600-gel-doc`, `spectramax-id3-plate-reader`) with realistic-looking run ids (`Experiment_20260129`, `26.02.02_10.45.05`, `012926_AR_OD600`, …). Other instrument types use cosmetic `seed-` ids and `seed-run-1`…`seed-run-5` since they don't round-trip through any pipeline. +Externally-visible identifiers used in URLs and API paths are deterministic across reseeds, so screenshots, bug reports, and `curl` examples stay stable. Instrument types backed by a real lambda `process_file` (qPCR, gel doc, plate reader) use the canonical kebab-case ids the lambda expects (`azure-cielo-qpcr`, `azure-600-gel-doc`, `spectramax-id3-plate-reader`) with realistic-looking run ids (`Experiment_20260129`, `26.02.02_10.45.05`, `012926_AR_OD600`, …). Other instrument types use cosmetic `seed-` ids and `seed-run-1`…`seed-run-8` since they don't round-trip through any pipeline. Surrogate UUIDs (watcher IDs, archive job IDs, the per-row primary keys on `instrument_runs` and `files`) and the PAT plaintext are regenerated on every reseed — the seed does not use Faker but it does call `crypto.randomUUID()` and `crypto.randomBytes()` where the schema needs server-side IDs. diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f1a183fa..74b92300 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -12,6 +12,7 @@ import { PreviewDeploymentBanner } from "@/components/preview-deployment-banner" import { ArchiveDownloadProvider } from "@/components/runs/archive-download-provider"; import { SearchTrigger } from "@/components/search/search-trigger"; import { ThemeProvider } from "@/components/theme-provider"; +import { TimezoneCookieSync } from "@/components/timezone-cookie-sync"; import { SIDEBAR_COOKIE_NAME, SidebarInset, @@ -24,6 +25,7 @@ import { countUnread } from "@/lib/api/notifications"; import { getSidebarInstruments } from "@/lib/api/sidebar"; import { auth, signOut } from "@/lib/auth"; import { cn } from "@/lib/utils"; +import { getViewerTimeZone } from "@/lib/viewer-timezone"; const fontSans = Geist({ subsets: ["latin"], variable: "--font-sans" }); @@ -107,6 +109,9 @@ export default async function RootLayout({ // first-visit experience expanded. const sidebarCookie = (await cookies()).get(SIDEBAR_COOKIE_NAME)?.value; const sidebarDefaultOpen = sidebarCookie !== "false"; + // Deduped with page/stats callers via React.cache(); passed to the client + // sync so we can skip refresh when the server zone already matches. + const serverTimeZone = await getViewerTimeZone(); // `--banner-height` is the single knob that offsets the body, the // viewport-fixed sidebar, and the full-height auth screen for the preview @@ -128,6 +133,7 @@ export default async function RootLayout({ + diff --git a/web/app/page.tsx b/web/app/page.tsx index c7c16f1b..eb7b7a85 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -34,7 +34,9 @@ import { } from "@/lib/api/instrument-runs"; import { getRecentActiveInstrumentsForDashboard } from "@/lib/api/instruments"; import { auth } from "@/lib/auth"; +import { startOfTodayISO } from "@/lib/date"; import { dashboardParamsCache, hasActiveFilters } from "@/lib/search-params"; +import { getViewerTimeZone } from "@/lib/viewer-timezone"; type DashboardParams = Awaited>; @@ -49,10 +51,6 @@ export const metadata: Metadata = { const RECENT_INSTRUMENTS_LIMIT = 3; -function last24hISOString(): string { - return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); -} - export default async function DashboardPage({ searchParams, }: { @@ -154,17 +152,15 @@ async function DashboardRunsSection({ const instrumentIds = params.instrument_id.length > 0 ? params.instrument_id : undefined; - // When no explicit date filter is set, default to a 24-hour lookback. This - // matches the "Last 24 hours" label surfaced by the dashboard's - // RunsDateFilter and keeps the initial payload bounded. - const defaultDateFrom = last24hISOString(); + // Start independent toolbar fetches immediately; only the run list needs the + // viewer timezone for the default "today" lookback. + const instrumentsPromise = getInstruments(true); + const ranByUsersPromise = getRanByFilterOptions(); + const defaultDateFrom = startOfTodayISO(await getViewerTimeZone()); - // The toolbar instrument list, the fleet-wide attributor options, and the - // filtered run page are all independent. Only active instruments are useful - // filter targets on the dashboard. const [instruments, ranByUsers, runResult] = await Promise.all([ - getInstruments(true), - getRanByFilterOptions(), + instrumentsPromise, + ranByUsersPromise, buildRunListQuery({ instrumentId: instrumentIds, search: params.search || undefined, @@ -209,7 +205,7 @@ async function DashboardRunsSection({
- + } - value={formatNumber(runsLast24Hours.total)} + value={formatNumber(runsToday.total)} />
); @@ -191,8 +191,7 @@ export function MyRunsStatsCards({ stats: MyRunsStats; commentsLabel?: string; }) { - const { runsLast24Hours, runsLast7Days, commentsLast7Days, pendingUploads } = - stats; + const { runsToday, runsThisWeek, commentsThisWeek, pendingUploads } = stats; const pendingHasBacklog = pendingUploads.count > 0; @@ -202,30 +201,26 @@ export function MyRunsStatsCards({ label={MY_RUNS_STAT_LABELS[0]} subline={ } - value={formatNumber(runsLast24Hours.total)} + value={formatNumber(runsToday.total)} /> } - value={formatNumber(runsLast7Days.total)} + value={formatNumber(runsThisWeek.total)} /> 0 - ? "in the last 7 days" - : "None in the last 7 days" - } - value={formatNumber(commentsLast7Days.count)} + subline={commentsThisWeek.count > 0 ? "this week" : "None this week"} + value={formatNumber(commentsThisWeek.count)} /> toggleStatus(status.value)} value={status.label} > - + {status.label} ); diff --git a/web/components/runs/runs-date-filter.tsx b/web/components/runs/runs-date-filter.tsx index 818e7d06..6f3531f9 100644 --- a/web/components/runs/runs-date-filter.tsx +++ b/web/components/runs/runs-date-filter.tsx @@ -2,7 +2,7 @@ import { Calendar as CalendarIcon, Check, ChevronDown } from "lucide-react"; import dynamic from "next/dynamic"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Popover, @@ -10,7 +10,14 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; -import { formatDateRange } from "@/lib/date"; +import { + formatDateRange, + getBrowserTimeZone, + startOfMonthISO, + startOfTodayISO, + startOfWeekISO, + startOfYesterdayISO, +} from "@/lib/date"; import { cn } from "@/lib/utils"; export interface DateRange { @@ -18,48 +25,142 @@ export interface DateRange { to: string | null; } -export type PresetId = "24h" | "3d" | "1w" | "2w" | "1m"; +export type PresetId = + | "today" + | "yesterday" + | "week" + | "7d" + | "2w" + | "month" + | "4w"; interface Preset { - days: number; id: PresetId; label: string; } // Module-scoped so we don't reallocate on every render. const PRESETS: readonly Preset[] = [ - { id: "24h", label: "Last 24 hours", days: 1 }, - { id: "3d", label: "Last 3 days", days: 3 }, - { id: "1w", label: "Last week", days: 7 }, - { id: "2w", label: "Last 2 weeks", days: 14 }, - { id: "1m", label: "Last month", days: 30 }, + { id: "today", label: "Today" }, + { id: "yesterday", label: "Yesterday" }, + { id: "week", label: "This week" }, + { id: "7d", label: "Last 7 days" }, + { id: "2w", label: "Last 2 weeks" }, + { id: "month", label: "This month" }, + { id: "4w", label: "Last 4 weeks" }, ] as const; const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Calendar presets are exact midnight cutoffs; allow a small clock skew. */ +const CALENDAR_TOLERANCE_MS = 60_000; + +const ROLLING_PRESETS: readonly { days: number; id: PresetId }[] = [ + { id: "7d", days: 7 }, + { id: "2w", days: 14 }, + { id: "4w", days: 28 }, +] as const; function isoDaysAgo(days: number): string { return new Date(Date.now() - days * MS_PER_DAY).toISOString(); } +function withinTolerance(aMs: number, bMs: number): boolean { + return Math.abs(aMs - bMs) < CALENDAR_TOLERANCE_MS; +} + +function rangesEqual(a: DateRange, b: DateRange): boolean { + return a.from === b.from && a.to === b.to; +} + +/** + * Resolves a preset to URL `date_from` / `date_to` values. + * + * Open-ended presets (today / this week / this month / rolling) leave `to` + * null. Bounded presets (yesterday) set `to` to the start of the inclusive + * end day so the list API's "advance date_to by one day" rule yields the + * correct exclusive upper bound (start of today). + */ +function rangeForPreset(id: PresetId): DateRange { + const tz = getBrowserTimeZone(); + switch (id) { + case "today": + return { from: startOfTodayISO(tz), to: null }; + case "yesterday": { + const start = startOfYesterdayISO(tz); + return { from: start, to: start }; + } + case "week": + return { from: startOfWeekISO(tz), to: null }; + case "7d": + return { from: isoDaysAgo(7), to: null }; + case "2w": + return { from: isoDaysAgo(14), to: null }; + case "month": + return { from: startOfMonthISO(tz), to: null }; + case "4w": + return { from: isoDaysAgo(28), to: null }; + default: { + const _exhaustive: never = id; + throw new Error(`Unhandled preset: ${_exhaustive}`); + } + } +} + +/** + * Infer which preset matches URL bounds. On Mondays (and the 1st), today / + * this-week / this-month share a midnight `from`, so we prefer the shortest + * window (today → week → month). Interactive picks that collide are kept via + * `pinnedPreset` in `RunsDateFilter`. + */ function resolveActivePreset(value: DateRange): PresetId | null { - if (!value.from || value.to) { + if (!value.from) { return null; } const fromMs = Date.parse(value.from); if (Number.isNaN(fromMs)) { return null; } - const now = Date.now(); - // Pick the closest preset within a half-day tolerance so a value written a + const toMs = value.to ? Date.parse(value.to) : null; + if (value.to && (toMs === null || Number.isNaN(toMs))) { + return null; + } + + const tz = getBrowserTimeZone(); + + // Bounded calendar presets (require both ends). + if (toMs !== null) { + const yesterdayStart = Date.parse(startOfYesterdayISO(tz)); + if ( + withinTolerance(fromMs, yesterdayStart) && + withinTolerance(toMs, yesterdayStart) + ) { + return "yesterday"; + } + return null; + } + + // Open-ended calendar presets (shortest match first — see docstring). + if (withinTolerance(fromMs, Date.parse(startOfTodayISO(tz)))) { + return "today"; + } + if (withinTolerance(fromMs, Date.parse(startOfWeekISO(tz)))) { + return "week"; + } + if (withinTolerance(fromMs, Date.parse(startOfMonthISO(tz)))) { + return "month"; + } + + // Rolling presets: closest within a half-day tolerance so a value written a // few minutes ago still lights up its preset on subsequent renders. + const now = Date.now(); let best: PresetId | null = null; let bestDiff = Number.POSITIVE_INFINITY; - for (const preset of PRESETS) { - const target = now - preset.days * MS_PER_DAY; + for (const { id, days } of ROLLING_PRESETS) { + const target = now - days * MS_PER_DAY; const diff = Math.abs(target - fromMs); if (diff < MS_PER_DAY / 2 && diff < bestDiff) { bestDiff = diff; - best = preset.id; + best = id; } } return best; @@ -69,10 +170,13 @@ function presetLabel(id: PresetId): string { return PRESETS.find((p) => p.id === id)?.label ?? "Date range"; } -function resolveLabel(value: DateRange, defaultPreset?: PresetId): string { - const preset = resolveActivePreset(value); - if (preset) { - return presetLabel(preset); +function resolveLabel( + value: DateRange, + activePreset: PresetId | null, + defaultPreset?: PresetId +): string { + if (activePreset) { + return presetLabel(activePreset); } if (value.from && value.to) { return formatDateRange(new Date(value.from), new Date(value.to)); @@ -118,22 +222,18 @@ export function RunsDateFilter({ }) { const [open, setOpen] = useState(false); const [view, setView] = useState<"presets" | "custom">("presets"); + // When today / this-week / this-month share a midnight cutoff, keep the + // preset the user last clicked so the popover highlight matches intent. + const [pinnedPreset, setPinnedPreset] = useState(null); const isEmpty = value.from === null && value.to === null; - const activePreset = useMemo(() => { - const resolved = resolveActivePreset(value); - if (resolved) { - return resolved; - } - if (isEmpty && defaultPreset) { - return defaultPreset; - } - return null; - }, [value, isEmpty, defaultPreset]); - const label = useMemo( - () => resolveLabel(value, defaultPreset), - [value, defaultPreset] - ); + const pinMatches = + pinnedPreset !== null && rangesEqual(value, rangeForPreset(pinnedPreset)); + const resolved = resolveActivePreset(value); + const activePreset = pinMatches + ? pinnedPreset + : (resolved ?? (isEmpty && defaultPreset ? defaultPreset : null)); + const label = resolveLabel(value, activePreset, defaultPreset); const isCustom = activePreset === null && (value.from !== null || value.to !== null); @@ -146,16 +246,19 @@ export function RunsDateFilter({ } function applyPreset(preset: Preset) { - onChange({ from: isoDaysAgo(preset.days), to: null }); + setPinnedPreset(preset.id); + onChange(rangeForPreset(preset.id)); setOpen(false); } function clearRange() { + setPinnedPreset(null); onChange({ from: null, to: null }); setOpen(false); } function applyCustom(range: { from: Date; to: Date }) { + setPinnedPreset(null); // Snap start to 00:00 and end to 23:59:59.999 of the selected local day. const start = new Date(range.from); start.setHours(0, 0, 0, 0); diff --git a/web/components/timezone-cookie-sync.tsx b/web/components/timezone-cookie-sync.tsx new file mode 100644 index 00000000..f2c93183 --- /dev/null +++ b/web/components/timezone-cookie-sync.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { + getBrowserTimeZone, + TIMEZONE_COOKIE_MAX_AGE, + TIMEZONE_COOKIE_NAME, +} from "@/lib/date"; + +const TIMEZONE_COOKIE_RE = new RegExp( + `(?:^|; )${TIMEZONE_COOKIE_NAME}=([^;]*)` +); + +function readTimezoneCookie(): string | undefined { + const match = document.cookie.match(TIMEZONE_COOKIE_RE); + return match ? decodeURIComponent(match[1] ?? "") : undefined; +} + +/** + * Persists the browser IANA timezone in a cookie so RSC stats can compute + * calendar day/week boundaries. Refreshes only when the server rendered with + * a different zone (missing cookie + UTC, or IP guess ≠ browser) so the next + * paint matches local midnight — skipped when the cookie or IP fallback already + * agreed with the client. + */ +export function TimezoneCookieSync({ + serverTimeZone, +}: { + serverTimeZone: string; +}) { + const router = useRouter(); + + useEffect(() => { + const browserTz = getBrowserTimeZone(); + const current = readTimezoneCookie(); + if (current !== browserTz) { + // Same document.cookie pattern as the sidebar open-state cookie: must be + // JS-writable so RSC can read it on the next request via `cookies()`. + // biome-ignore lint/suspicious/noDocumentCookie: intentional client cookie write for SSR timezone + document.cookie = `${TIMEZONE_COOKIE_NAME}=${encodeURIComponent(browserTz)}; path=/; max-age=${TIMEZONE_COOKIE_MAX_AGE}; samesite=lax`; + } + if (serverTimeZone !== browserTz) { + router.refresh(); + } + }, [router, serverTimeZone]); + + return null; +} diff --git a/web/lib/api/dashboard.ts b/web/lib/api/dashboard.ts index 0ec36029..1fb2fcfa 100644 --- a/web/lib/api/dashboard.ts +++ b/web/lib/api/dashboard.ts @@ -1,6 +1,7 @@ import { and, desc, eq, isNull, type SQL, sql } from "drizzle-orm"; import { cache } from "react"; import type { UserAvatarUser } from "@/components/user-avatar"; +import { startOfTodayISO, startOfWeekISO } from "@/lib/date"; import { db } from "@/lib/db"; import { files, @@ -12,6 +13,7 @@ import { watchers, } from "@/lib/db/schema"; import { toInitials } from "@/lib/utils"; +import { getViewerTimeZone } from "@/lib/viewer-timezone"; export interface InstrumentSummary { displayName: string; @@ -121,11 +123,11 @@ export interface DashboardStats { count: number; totalBytes: number; }; - runsLast24Hours: { + runsThisWeek: { total: number; bytesGenerated: number; }; - runsThisWeek: { + runsToday: { total: number; bytesGenerated: number; }; @@ -138,13 +140,18 @@ export interface DashboardStats { * row-multiplication problems we'd hit joining instruments × runs × files × * attributions in a single statement. * - * Fleet-wide and viewer-independent, so it's `cache()`-deduped without a key — - * duplicate calls within a request (e.g. layout + page) share one result. + * Fleet-wide counts, keyed only by the viewer's timezone (cookie → Vercel IP + * zone → UTC). Zero-arg `cache()` is safe within a request: one timezone, one + * result shared by duplicate callers (e.g. layout + page). */ export const getDashboardStats = cache( async function getDashboardStats(): Promise { + const timeZone = await getViewerTimeZone(); + const todayStart = startOfTodayISO(timeZone); + const weekStart = startOfWeekISO(timeZone); + const [ - [runsLast24HoursRow], + [runsTodayRow], [bytesGeneratedRow], [pendingRow], [runsThisWeekRow], @@ -159,21 +166,20 @@ export const getDashboardStats = cache( and( eq(instruments.status, "active"), isNull(instrumentRuns.deletedAt), - sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '24 hours'` + sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${todayStart}::timestamptz` ) ), - // Span the last 24 hours + the past 7 days in a single pass; the 24-hour - // window is a subset of the weekly window, so FILTER clauses give us both - // with one index scan. Bytes are attributed to the file's owning run so - // the time window matches the corresponding "Runs in the last X" card — - // this avoids the null-`processedAt` blind spot for not-yet-processed + // Span today + this calendar week in a single pass; today is a subset + // of the weekly window, so FILTER clauses give us both with one index + // scan. Bytes are attributed to the file's owning run so the time + // window matches the corresponding "Runs today"/"Runs this week" card + // — this avoids the null-`processedAt` blind spot for not-yet-processed // files (which still represent data the instrument generated). Windows // are anchored to the run's actual acquisition time when known so - // backfilled historical data doesn't pollute the "last 24h"/"this week" - // counts. + // backfilled historical data doesn't pollute the today/this-week counts. db .select({ - bytesLast24Hours: sql`coalesce(sum(${files.sizeBytes}) filter (where coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '24 hours'), 0)`, + bytesToday: sql`coalesce(sum(${files.sizeBytes}) filter (where coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${todayStart}::timestamptz), 0)`, bytesWeek: sql`coalesce(sum(${files.sizeBytes}), 0)`, }) .from(files) @@ -184,7 +190,7 @@ export const getDashboardStats = cache( eq(instruments.status, "active"), isNull(files.deletedAt), isNull(instrumentRuns.deletedAt), - sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '7 days'` + sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${weekStart}::timestamptz` ) ), db @@ -212,16 +218,16 @@ export const getDashboardStats = cache( and( eq(instruments.status, "active"), isNull(instrumentRuns.deletedAt), - sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '7 days'` + sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${weekStart}::timestamptz` ) ), ]); return { - runsLast24Hours: { - total: runsLast24HoursRow?.total ?? 0, + runsToday: { + total: runsTodayRow?.total ?? 0, // bigint sums come back as strings from pg; coerce explicitly. - bytesGenerated: Number(bytesGeneratedRow?.bytesLast24Hours ?? 0), + bytesGenerated: Number(bytesGeneratedRow?.bytesToday ?? 0), }, pendingUploads: { count: pendingRow?.count ?? 0, @@ -297,18 +303,18 @@ export const getUserProfile = cache(async function getUserProfile( }); export interface MyRunsStats { - commentsLast7Days: { + commentsThisWeek: { count: number; }; pendingUploads: { count: number; totalBytes: number; }; - runsLast7Days: { + runsThisWeek: { total: number; bytesGenerated: number; }; - runsLast24Hours: { + runsToday: { total: number; bytesGenerated: number; }; @@ -330,27 +336,30 @@ function attributedToUser(userId: string): SQL { * instruments — a user's own runs stay relevant even after an instrument is * retired, and this matches the unrestricted `ranBy` run list on the page. * `cache()` keys on `userId` so parallel requests from different users don't - * collide. + * collide. Day/week windows use `getViewerTimeZone()` (cookie → IP → UTC). */ export const getMyRunsStats = cache(async function getMyRunsStats( userId: string ): Promise { const attributed = attributedToUser(userId); + const timeZone = await getViewerTimeZone(); + const todayStart = startOfTodayISO(timeZone); + const weekStart = startOfWeekISO(timeZone); const [[runsRow], [bytesRow], [commentsRow], [pendingRow]] = await Promise.all([ - // Both run-count windows in one pass — the 24-hour window is a subset of - // the weekly window, so a FILTER clause gives us both from one scan. + // Both run-count windows in one pass — today is a subset of this week, + // so a FILTER clause gives us both from one scan. db .select({ - last24Hours: sql`cast(count(*) filter (where coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '24 hours') as int)`, - last7Days: sql`cast(count(*) as int)`, + today: sql`cast(count(*) filter (where coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${todayStart}::timestamptz) as int)`, + thisWeek: sql`cast(count(*) as int)`, }) .from(instrumentRuns) .where( and( isNull(instrumentRuns.deletedAt), - sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '7 days'`, + sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${weekStart}::timestamptz`, attributed ) ), @@ -358,8 +367,8 @@ export const getMyRunsStats = cache(async function getMyRunsStats( // line up with the run-count cards above. db .select({ - bytesLast24Hours: sql`coalesce(sum(${files.sizeBytes}) filter (where coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '24 hours'), 0)`, - bytesLast7Days: sql`coalesce(sum(${files.sizeBytes}), 0)`, + bytesToday: sql`coalesce(sum(${files.sizeBytes}) filter (where coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${todayStart}::timestamptz), 0)`, + bytesThisWeek: sql`coalesce(sum(${files.sizeBytes}), 0)`, }) .from(files) .innerJoin(instrumentRuns, eq(files.instrumentRunId, instrumentRuns.id)) @@ -367,11 +376,11 @@ export const getMyRunsStats = cache(async function getMyRunsStats( and( isNull(files.deletedAt), isNull(instrumentRuns.deletedAt), - sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '7 days'`, + sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${weekStart}::timestamptz`, attributed ) ), - // Comments left in the last 7 days on runs the viewer is attributed to + // Comments left this calendar week on runs the viewer is attributed to // (including the viewer's own comments). db .select({ @@ -383,7 +392,7 @@ export const getMyRunsStats = cache(async function getMyRunsStats( and( isNull(runComments.deletedAt), isNull(instrumentRuns.deletedAt), - sql`${runComments.createdAt} > now() - interval '7 days'`, + sql`${runComments.createdAt} >= ${weekStart}::timestamptz`, attributed ) ), @@ -407,15 +416,15 @@ export const getMyRunsStats = cache(async function getMyRunsStats( ]); return { - runsLast24Hours: { - total: runsRow?.last24Hours ?? 0, - bytesGenerated: Number(bytesRow?.bytesLast24Hours ?? 0), + runsToday: { + total: runsRow?.today ?? 0, + bytesGenerated: Number(bytesRow?.bytesToday ?? 0), }, - runsLast7Days: { - total: runsRow?.last7Days ?? 0, - bytesGenerated: Number(bytesRow?.bytesLast7Days ?? 0), + runsThisWeek: { + total: runsRow?.thisWeek ?? 0, + bytesGenerated: Number(bytesRow?.bytesThisWeek ?? 0), }, - commentsLast7Days: { + commentsThisWeek: { count: commentsRow?.count ?? 0, }, pendingUploads: { @@ -432,13 +441,16 @@ export interface TopAttributor { } /** - * The user attributed to the most active-instrument runs in the last 7 days, - * with the volume of data across those runs. Ties are broken by data - * generated. Returns null when no runs were attributed this week. Powers the - * "Most runs this week" leaderboard card on the dashboard. + * The user attributed to the most active-instrument runs this calendar week + * (Monday midnight in the viewer's timezone), with the volume of data across + * those runs. Ties are broken by data generated. Returns null when no runs + * were attributed this week. Powers the "Most runs this week" leaderboard + * card on the dashboard. */ export const getTopAttributorThisWeek = cache( async function getTopAttributorThisWeek(): Promise { + const timeZone = await getViewerTimeZone(); + const weekStart = startOfWeekISO(timeZone); const runCountExpr = sql`count(distinct ${instrumentRuns.id})`; const bytesExpr = sql`coalesce(sum(${files.sizeBytes}), 0)`; @@ -469,7 +481,7 @@ export const getTopAttributorThisWeek = cache( and( eq(instruments.status, "active"), isNull(instrumentRuns.deletedAt), - sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '7 days'` + sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${weekStart}::timestamptz` ) ) .groupBy(users.id, users.name, users.email, users.image) diff --git a/web/lib/api/instruments.ts b/web/lib/api/instruments.ts index f9ac6ad8..3a65bee0 100644 --- a/web/lib/api/instruments.ts +++ b/web/lib/api/instruments.ts @@ -2,6 +2,7 @@ import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { cache } from "react"; import YAML from "yaml"; import { type ActorUser, resolveActorUser } from "@/lib/api/actor"; +import { startOfWeekISO } from "@/lib/date"; import { type DbExecutor, db } from "@/lib/db"; import { type InstrumentType, @@ -10,6 +11,7 @@ import { users, watchers, } from "@/lib/db/schema"; +import { getViewerTimeZone } from "@/lib/viewer-timezone"; export interface InstrumentListItem { createdAt: Date; @@ -105,18 +107,19 @@ function mergeFilePatterns(configs: (string | null)[]): string[] { // focused dashboard query. Inlined helpers (rather than module-level // constants) so each caller gets a fresh drizzle alias and the queries can // be composed independently. -function buildRunCountSubquery() { +function buildRunCountSubquery(weekStartISO: string) { // `runsThisWeek` and `lastRunAt` are anchored to the run's true // acquisition time when known so backfilled runs (where created_at is // "today" but the data is older) don't pollute the recent-runs window // or get reported as the most recent activity. Falls back to created_at // for Lambda-only and pre-backfill runs where acquired_at is NULL. + // `weekStartISO` is Monday 00:00 from `getViewerTimeZone()` (cookie → IP → UTC). return db .select({ instrumentId: instrumentRuns.instrumentId, count: sql`cast(count(*) as int)`.as("run_count"), countThisWeek: - sql`cast(count(*) filter (where coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '7 days') as int)`.as( + sql`cast(count(*) filter (where coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) >= ${weekStartISO}::timestamptz) as int)`.as( "run_count_this_week" ), lastRunAt: @@ -212,7 +215,8 @@ function indexConfigsByInstrument( // (e.g. layout + page) share a single result. export const getInstrumentListWithCounts = cache( async function getInstrumentListWithCounts(): Promise { - const runCountSq = buildRunCountSubquery(); + const weekStart = startOfWeekISO(await getViewerTimeZone()); + const runCountSq = buildRunCountSubquery(weekStart); const watcherCountSq = buildWatcherCountSubquery(); const [rows, watcherConfigs] = await Promise.all([ @@ -270,7 +274,8 @@ export const getRecentActiveInstrumentsForDashboard = cache( async function getRecentActiveInstrumentsForDashboard( limit: number ): Promise { - const runCountSq = buildRunCountSubquery(); + const weekStart = startOfWeekISO(await getViewerTimeZone()); + const runCountSq = buildRunCountSubquery(weekStart); const watcherCountSq = buildWatcherCountSubquery(); // Row fetch and the active-count run in parallel; they share no data. diff --git a/web/lib/date.ts b/web/lib/date.ts index 6ac3e2e5..26c6b05c 100644 --- a/web/lib/date.ts +++ b/web/lib/date.ts @@ -1,10 +1,123 @@ -import { formatInTimeZone } from "date-fns-tz"; +import { + startOfDay, + startOfMonth, + startOfWeek, + subDays, + subWeeks, +} from "date-fns"; +import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz"; + +/** Cookie name for the viewer's IANA timezone (writable from the browser). */ +export const TIMEZONE_COOKIE_NAME = "timezone"; + +/** One year — timezone rarely changes, and we re-sync on mismatch. */ +export const TIMEZONE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; /** Returns the IANA timezone of the current runtime (e.g. `"America/New_York"`). */ function getTimeZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone; } +/** Browser IANA timezone; same as the runtime zone in client components. */ +export function getBrowserTimeZone(): string { + return getTimeZone(); +} + +/** + * True when `tz` is a real IANA zone `Intl` accepts. Rejects empty strings and + * garbage cookie values so we never pass an invalid zone into date-fns-tz. + */ +export function isValidTimeZone(tz: string): boolean { + if (!tz || tz.length > 64) { + return false; + } + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** + * UTC ISO string for 00:00:00.000 of the calendar day containing `now` in + * `timeZone`. Inject `now` in tests to pin the clock. + */ +export function startOfTodayISO( + timeZone: string, + now: Date = new Date() +): string { + const dateStr = formatInTimeZone(now, timeZone, "yyyy-MM-dd"); + return fromZonedTime(`${dateStr}T00:00:00.000`, timeZone).toISOString(); +} + +/** + * UTC ISO string for 00:00:00.000 of the calendar day before `now` in + * `timeZone`. + */ +export function startOfYesterdayISO( + timeZone: string, + now: Date = new Date() +): string { + const zoned = toZonedTime(now, timeZone); + return fromZonedTime(startOfDay(subDays(zoned, 1)), timeZone).toISOString(); +} + +/** + * UTC ISO string for Monday 00:00:00.000 of the calendar week containing `now` + * in `timeZone` (ISO week, Monday start). Inject `now` in tests to pin the clock. + */ +export function startOfWeekISO( + timeZone: string, + now: Date = new Date() +): string { + const zoned = toZonedTime(now, timeZone); + const weekStart = startOfWeek(zoned, { weekStartsOn: 1 }); + return fromZonedTime(weekStart, timeZone).toISOString(); +} + +/** + * UTC ISO string for Monday 00:00:00.000 of the previous calendar week in + * `timeZone`. + */ +export function startOfLastWeekISO( + timeZone: string, + now: Date = new Date() +): string { + const zoned = toZonedTime(now, timeZone); + const thisMonday = startOfWeek(zoned, { weekStartsOn: 1 }); + return fromZonedTime(subWeeks(thisMonday, 1), timeZone).toISOString(); +} + +/** + * UTC ISO string for Sunday 00:00:00.000 of the previous calendar week in + * `timeZone`. Pair with `startOfLastWeekISO` as `date_to` when the API + * advances the end by one day (yielding this Monday exclusive). + */ +export function startOfLastWeekEndDayISO( + timeZone: string, + now: Date = new Date() +): string { + const zoned = toZonedTime(now, timeZone); + const thisMonday = startOfWeek(zoned, { weekStartsOn: 1 }); + return fromZonedTime( + startOfDay(subDays(thisMonday, 1)), + timeZone + ).toISOString(); +} + +/** + * UTC ISO string for 00:00:00.000 on the 1st of the calendar month containing + * `now` in `timeZone`. + */ +export function startOfMonthISO( + timeZone: string, + now: Date = new Date() +): string { + const zoned = toZonedTime(now, timeZone); + return fromZonedTime(startOfMonth(zoned), timeZone).toISOString(); +} + /** Formats a date as a 12-hour time string, e.g. `"2:30 PM"`. */ export function formatTime(date: Date): string { return formatInTimeZone(date, getTimeZone(), "h:mm a"); diff --git a/web/lib/db/seed.ts b/web/lib/db/seed.ts index efef3c5b..fc07e0b1 100644 --- a/web/lib/db/seed.ts +++ b/web/lib/db/seed.ts @@ -14,6 +14,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { getTableName, isTable, sql } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { + startOfLastWeekEndDayISO, + startOfLastWeekISO, + startOfMonthISO, + startOfTodayISO, + startOfWeekISO, +} from "@/lib/date"; import { generateToken, getTokenPrefix, hashToken } from "@/lib/tokens"; // biome-ignore lint/performance/noNamespaceImport: seed needs the full schema module for Db typing and table iteration import * as schema from "./schema"; @@ -342,11 +349,11 @@ export interface InstrumentFixture { // run ids that look like real ones from that instrument (the qPCR // `process_file` documents `Experiment_YYYYMMDD`; the gel-doc and // plate-reader modules treat the filename stem as the run id, so -// these mirror those formats). The run-id list length sets how many -// runs `seedRuns` produces for fixture-bearing instruments; other -// instruments keep the count argument and use synthetic -// `seed-run-N` ids. Adding a new entry here is enough to make every -// seeded run for that instrument type render real bytes (provided +// these mirror those formats). Keep the run-id list at least as long as +// the `seedRuns` default count so every fixture-bearing run gets a +// realistic id; other instruments keep the count argument and use +// synthetic `seed-run-N` ids. Adding a new entry here is enough to make +// every seeded run for that instrument type render real bytes (provided // `LOCAL_S3_MIRROR` is set). // // Exported so `web/scripts/process-fixtures.ts` can re-derive the @@ -364,6 +371,9 @@ export const INSTRUMENT_FIXTURES: Partial< "Experiment_20260115", "Experiment_20260108", "Experiment_20260101", + "Experiment_20251225", + "Experiment_20251218", + "Experiment_20251211", ], }, gel_doc: { @@ -375,6 +385,9 @@ export const INSTRUMENT_FIXTURES: Partial< "26.01.19_11.05.42", "26.01.12_14.22.18", "26.01.05_09.30.00", + "25.12.29_16.40.00", + "25.12.22_13.15.00", + "25.12.15_10.00.00", ], }, plate_reader: { @@ -386,6 +399,9 @@ export const INSTRUMENT_FIXTURES: Partial< "011526_AR_GFP_endpoint", "010826_DK_OD600", "010126_AR_OD750", + "122525_DK_OD600", + "121825_AR_OD750", + "121125_DK_GFP_endpoint", ], }, }; @@ -405,10 +421,62 @@ export const FIXTURES_DIR = path.resolve( "fixtures" ); +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +/** + * Calendar-relative `acquired_at` timestamps so local reseeds exercise the + * dashboard date presets (Today / Yesterday / This week / Last 7 days / …) + * and the today / this-week stat cards. Uses the host IANA timezone — the + * same zone the browser cookie syncs for local `make db-reseed` workflows. + */ +function seedAcquiredAtSchedule(now: Date = new Date()): Date[] { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const todayStart = new Date(startOfTodayISO(timeZone, now)).getTime(); + const weekStart = new Date(startOfWeekISO(timeZone, now)).getTime(); + const lastWeekStart = new Date(startOfLastWeekISO(timeZone, now)).getTime(); + const lastWeekSunday = new Date( + startOfLastWeekEndDayISO(timeZone, now) + ).getTime(); + const monthStart = new Date(startOfMonthISO(timeZone, now)).getTime(); + + // Prefer Tuesday noon of this week; on Mon/Tue fall back to shortly after + // week start so the stamp stays in "this week" and still before `now`. + let earlierThisWeek = weekStart + 1.5 * DAY_MS; + if (earlierThisWeek >= todayStart) { + earlierThisWeek = weekStart + HOUR_MS; + } + if (earlierThisWeek >= now.getTime()) { + earlierThisWeek = now.getTime() - 30 * 60_000; + } + + // Earlier this month, preferably outside the rolling 14-day window so + // "This month" and "Last 2 weeks" diverge. Clamp into the month when + // reseeding early in the calendar month. + let earlierThisMonth = now.getTime() - 16 * DAY_MS; + if (earlierThisMonth < monthStart) { + earlierThisMonth = monthStart + HOUR_MS; + } + if (earlierThisMonth >= now.getTime()) { + earlierThisMonth = now.getTime() - HOUR_MS; + } + + return [ + new Date(now.getTime() - 2 * HOUR_MS), // Today + new Date(todayStart - 12 * HOUR_MS), // Yesterday + new Date(earlierThisWeek), // This week (not today) + new Date(lastWeekStart + 2.5 * DAY_MS), // Last 7 days / prior calendar week + new Date(lastWeekSunday + 12 * HOUR_MS), // Last 7–14 days + new Date(now.getTime() - 10 * DAY_MS), // Last 2 weeks (rolling) + new Date(now.getTime() - 22 * DAY_MS), // Last 4 weeks (rolling) + new Date(earlierThisMonth), // This month / older custom ranges + ]; +} + export async function seedRuns( db: Db, instrumentId: string, - count = 5, + count = 8, instrumentType?: schema.InstrumentType ): Promise { if (count <= 0) { @@ -427,13 +495,13 @@ export async function seedRuns( // fine. const fixtureRunIds = fixture?.runIds.slice(0, count); - // Spread runs across the last ~2 weeks (3, 6, 9, 12, 15 days back for - // count = 5) so UI date filters like "last 7 days" / "last 14 days" - // return non-empty, differing result sets. - const now = new Date(); - const dayMs = 24 * 60 * 60_000; + // Place runs on calendar-aware stamps so Today / Yesterday / This week / + // Last 7 days / Last 2 weeks / This month / Last 4 weeks presets each return + // a non-empty, differing set after a local reseed. + const schedule = seedAcquiredAtSchedule(); const runValues = Array.from({ length: count }, (_, i) => { - const acquiredAt = new Date(now.getTime() - (i + 1) * 3 * dayMs); + const acquiredAt = + schedule[i] ?? new Date(Date.now() - (i + 1) * 3 * DAY_MS); return { instrumentId, runId: fixtureRunIds?.[i] ?? `seed-run-${i + 1}`, @@ -544,10 +612,16 @@ export async function seedRunComments( if (runs.length === 0) { return; } + // Stamp most comments as "this week" and a couple as last week so the + // user-runs "Comments this week" card is a proper subset of total comments. + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const weekStartMs = new Date(startOfWeekISO(timeZone)).getTime(); + const lastWeekCommentAt = new Date(weekStartMs - 2 * DAY_MS); const rows = runs.map((run, i) => ({ runId: run.id, userId, body: `Seeded comment ${i + 1} on **${run.runId}** — looks good!`, + createdAt: i % 4 === 3 ? lastWeekCommentAt : new Date(), })); await db.insert(schema.runComments).values(rows); } diff --git a/web/lib/utils.ts b/web/lib/utils.ts index 61f910b3..cf060a03 100644 --- a/web/lib/utils.ts +++ b/web/lib/utils.ts @@ -1,23 +1,11 @@ import { type ClassValue, clsx } from "clsx"; +import { formatDistanceToNow } from "date-fns"; import { twMerge } from "tailwind-merge"; -import { formatDate } from "@/lib/date"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); - -const DIVISIONS: { amount: number; name: Intl.RelativeTimeFormatUnit }[] = [ - { amount: 60, name: "seconds" }, - { amount: 60, name: "minutes" }, - { amount: 24, name: "hours" }, - { amount: 7, name: "days" }, - { amount: 4.345_24, name: "weeks" }, - { amount: 12, name: "months" }, - { amount: Number.POSITIVE_INFINITY, name: "years" }, -]; - export function formatBytes(bytes: number | null): string { if (bytes === null || bytes === undefined) { return "—"; @@ -53,14 +41,5 @@ export function toInitials(displayName: string): string { export function formatRelativeTime(date: Date | string): string { const d = typeof date === "string" ? new Date(date) : date; - let duration = (d.getTime() - Date.now()) / 1000; - - for (const division of DIVISIONS) { - if (Math.abs(duration) < division.amount) { - return rtf.format(Math.round(duration), division.name); - } - duration /= division.amount; - } - - return formatDate(d); + return formatDistanceToNow(d, { addSuffix: true }); } diff --git a/web/lib/viewer-timezone.ts b/web/lib/viewer-timezone.ts new file mode 100644 index 00000000..812bf6da --- /dev/null +++ b/web/lib/viewer-timezone.ts @@ -0,0 +1,25 @@ +import { cookies, headers } from "next/headers"; +import { cache } from "react"; +import { isValidTimeZone, TIMEZONE_COOKIE_NAME } from "@/lib/date"; + +/** + * IANA timezone for the current request. + * + * Prefer the `timezone` cookie the browser syncs. When it is missing (first + * visit, MCP, cron), fall back to Vercel's `x-vercel-ip-timezone` so calendar + * windows are usually correct without a UTC first paint + `router.refresh()`. + * Last resort is UTC. + */ +export const getViewerTimeZone = cache( + async function getViewerTimeZone(): Promise { + const value = (await cookies()).get(TIMEZONE_COOKIE_NAME)?.value; + if (value && isValidTimeZone(value)) { + return value; + } + const ipTz = (await headers()).get("x-vercel-ip-timezone"); + if (ipTz && isValidTimeZone(ipTz)) { + return ipTz; + } + return "UTC"; + } +); diff --git a/web/scripts/seed-database.ts b/web/scripts/seed-database.ts index c2da0f6c..14622b2c 100644 --- a/web/scripts/seed-database.ts +++ b/web/scripts/seed-database.ts @@ -68,7 +68,7 @@ const activeInstruments = instruments.filter((i) => i.status === "active"); const runs: SeededRun[] = []; for (const instrument of activeInstruments) { runs.push( - ...(await seedRuns(db, instrument.id, 5, instrument.instrumentType)) + ...(await seedRuns(db, instrument.id, 8, instrument.instrumentType)) ); } diff --git a/web/tests/unit/date-boundaries.test.ts b/web/tests/unit/date-boundaries.test.ts new file mode 100644 index 00000000..f8a1cc76 --- /dev/null +++ b/web/tests/unit/date-boundaries.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + isValidTimeZone, + startOfLastWeekEndDayISO, + startOfLastWeekISO, + startOfMonthISO, + startOfTodayISO, + startOfWeekISO, + startOfYesterdayISO, +} from "@/lib/date"; + +describe("isValidTimeZone", () => { + it("accepts common IANA zones", () => { + expect(isValidTimeZone("America/Los_Angeles")).toBe(true); + expect(isValidTimeZone("Asia/Tokyo")).toBe(true); + expect(isValidTimeZone("UTC")).toBe(true); + }); + + it("rejects empty and garbage values", () => { + expect(isValidTimeZone("")).toBe(false); + expect(isValidTimeZone("Not/A_Zone")).toBe(false); + expect(isValidTimeZone("America/Los_Angeles; DROP TABLE")).toBe(false); + }); +}); + +describe("startOfTodayISO", () => { + it("returns local midnight as UTC for America/Los_Angeles", () => { + // Wednesday afternoon Pacific (UTC-7 in July). + const now = new Date("2026-07-08T21:30:00.000Z"); // 2:30 PM PDT + expect(startOfTodayISO("America/Los_Angeles", now)).toBe( + "2026-07-08T07:00:00.000Z" + ); + }); + + it("returns local midnight as UTC for Asia/Tokyo", () => { + // Thursday morning UTC is already Thursday afternoon in Tokyo (UTC+9). + const now = new Date("2026-07-08T20:00:00.000Z"); // Jul 9 05:00 JST + expect(startOfTodayISO("Asia/Tokyo", now)).toBe("2026-07-08T15:00:00.000Z"); + }); + + it("crosses the UTC date line for a late Pacific evening", () => { + // 10 PM PDT on Jul 8 is Jul 9 05:00 UTC — local day is still Jul 8. + const now = new Date("2026-07-09T05:00:00.000Z"); + expect(startOfTodayISO("America/Los_Angeles", now)).toBe( + "2026-07-08T07:00:00.000Z" + ); + }); +}); + +describe("startOfWeekISO", () => { + it("returns Monday midnight for a mid-week Pacific afternoon", () => { + // Wednesday Jul 8 2026 → week starts Monday Jul 6 00:00 PDT. + const now = new Date("2026-07-08T21:30:00.000Z"); + expect(startOfWeekISO("America/Los_Angeles", now)).toBe( + "2026-07-06T07:00:00.000Z" + ); + }); + + it("keeps Sunday in the week that started the prior Monday", () => { + // Sunday Jul 12 2026 15:00 PDT → still the week of Monday Jul 6. + const now = new Date("2026-07-12T22:00:00.000Z"); + expect(startOfWeekISO("America/Los_Angeles", now)).toBe( + "2026-07-06T07:00:00.000Z" + ); + }); + + it("rolls to the new week at Monday midnight Tokyo", () => { + // Monday Jul 13 2026 00:30 JST = Sunday Jul 12 15:30 UTC. + const justAfterMonday = new Date("2026-07-12T15:30:00.000Z"); + expect(startOfWeekISO("Asia/Tokyo", justAfterMonday)).toBe( + "2026-07-12T15:00:00.000Z" + ); + + // Sunday Jul 12 2026 23:30 JST = still the prior week (Mon Jul 6). + const stillSunday = new Date("2026-07-12T14:30:00.000Z"); + expect(startOfWeekISO("Asia/Tokyo", stillSunday)).toBe( + "2026-07-05T15:00:00.000Z" + ); + }); +}); + +describe("startOfYesterdayISO", () => { + it("returns the prior local midnight in America/Los_Angeles", () => { + const now = new Date("2026-07-08T21:30:00.000Z"); // Wed Jul 8 afternoon PDT + expect(startOfYesterdayISO("America/Los_Angeles", now)).toBe( + "2026-07-07T07:00:00.000Z" + ); + }); +}); + +describe("startOfLastWeekISO", () => { + it("returns the prior Monday and Sunday for a mid-week Pacific day", () => { + // Wed Jul 8 → this week Mon Jul 6; last week Mon Jun 29 – Sun Jul 5. + const now = new Date("2026-07-08T21:30:00.000Z"); + expect(startOfLastWeekISO("America/Los_Angeles", now)).toBe( + "2026-06-29T07:00:00.000Z" + ); + expect(startOfLastWeekEndDayISO("America/Los_Angeles", now)).toBe( + "2026-07-05T07:00:00.000Z" + ); + }); +}); + +describe("startOfMonthISO", () => { + it("returns the 1st at local midnight", () => { + const now = new Date("2026-07-08T21:30:00.000Z"); + expect(startOfMonthISO("America/Los_Angeles", now)).toBe( + "2026-07-01T07:00:00.000Z" + ); + }); +}); From 45c816a23712fdefbec3451ec3dac11fa17dc8a3 Mon Sep 17 00:00:00 2001 From: Wasim Amiri <7220175+wasimxyz@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:36:44 -0700 Subject: [PATCH 2/2] Generate OpenAPI from Zod for the integrator API (#143) * Generate OpenAPI from Zod and expose /api/v1/openapi.json. Introduce zod-to-openapi schemas for the integrator REST surface, validate write-route bodies with the same schemas, and publish a committed plus live OpenAPI 3.1 document for the docs site to consume. Co-authored-by: Cursor * Stop committing openapi.json; serve a build-time static schema. Drop the checked-in schema artifact, gitignore local generate output, and mark GET /api/v1/openapi.json as force-static so deploys bake the document from Zod. Co-authored-by: Cursor * Fix OpenAPI scope and request-schema review findings. Align restore/archive scopes with the routes, require non-empty file_ids, trim run_id and display_name, and emit required integer path params instead of optional coerced numbers. Co-authored-by: Cursor * Replace z.unknown() API responses with typed schemas. Document accurate response schemas for every v1 operation that previously used z.unknown(), correcting existing schemas (runDetail, runListItem, fileDetail, watcherDetail) and the commentId param that drifted from the real handler output. Add schema.parse() drift assertions to the happy-path integration tests so responses are checked against their documented shapes. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- Makefile | 4 + developer-docs/run-archives.md | 2 +- web/.gitignore | 4 + web/app/api/v1/archive-jobs/[id]/route.ts | 45 +-- web/app/api/v1/files/[fileId]/route.ts | 28 +- .../v1/instruments/[instrumentId]/route.ts | 63 +--- .../[runId]/comments/[commentId]/route.ts | 13 +- .../runs/[runId]/comments/route.ts | 13 +- .../runs/[runId]/files/route.ts | 42 +-- .../runs/[runId]/request-upload-url/route.ts | 30 +- .../runs/[runId]/request-upload/route.ts | 17 +- .../[instrumentId]/runs/[runId]/route.ts | 24 +- .../instruments/[instrumentId]/runs/route.ts | 30 +- web/app/api/v1/instruments/route.ts | 51 +--- web/app/api/v1/openapi.json/route.ts | 13 + .../v1/watchers/[watcherId]/config/route.ts | 20 +- .../v1/watchers/[watcherId]/events/route.ts | 44 +-- .../watchers/[watcherId]/heartbeat/route.ts | 50 +--- web/app/api/v1/watchers/register/route.ts | 19 +- web/lib/api/openapi/document.ts | 38 +++ web/lib/api/openapi/index.ts | 59 ++++ web/lib/api/openapi/parse.ts | 25 ++ web/lib/api/openapi/paths/archive.ts | 27 ++ web/lib/api/openapi/paths/files.ts | 107 +++++++ web/lib/api/openapi/paths/instruments.ts | 77 +++++ web/lib/api/openapi/paths/meta.ts | 12 + web/lib/api/openapi/paths/runs.ts | 260 +++++++++++++++++ web/lib/api/openapi/paths/watchers.ts | 179 ++++++++++++ web/lib/api/openapi/registry.ts | 76 +++++ web/lib/api/openapi/schemas/archive.ts | 24 ++ web/lib/api/openapi/schemas/common.ts | 32 +++ web/lib/api/openapi/schemas/files.ts | 58 ++++ web/lib/api/openapi/schemas/instruments.ts | 39 +++ web/lib/api/openapi/schemas/runs.ts | 269 ++++++++++++++++++ web/lib/api/openapi/schemas/search.ts | 12 + web/lib/api/openapi/schemas/watchers.ts | 139 +++++++++ web/package-lock.json | 22 ++ web/package.json | 2 + web/scripts/generate-openapi.ts | 9 + web/tests/integration/archive-jobs.test.ts | 4 + web/tests/integration/files.test.ts | 6 + web/tests/integration/instrument-runs.test.ts | 16 ++ web/tests/integration/instruments.test.ts | 7 + .../integration/request-upload-url.test.ts | 5 + .../request-upload-validation.test.ts | 4 + .../integration/run-attributions.test.ts | 6 + web/tests/integration/run-comments.test.ts | 11 + web/tests/integration/run-lifecycle.test.ts | 5 + .../upload-request-cancellation.test.ts | 7 +- web/tests/integration/watchers.test.ts | 25 ++ web/tests/unit/openapi-document.test.ts | 79 +++++ 51 files changed, 1781 insertions(+), 372 deletions(-) create mode 100644 web/app/api/v1/openapi.json/route.ts create mode 100644 web/lib/api/openapi/document.ts create mode 100644 web/lib/api/openapi/index.ts create mode 100644 web/lib/api/openapi/parse.ts create mode 100644 web/lib/api/openapi/paths/archive.ts create mode 100644 web/lib/api/openapi/paths/files.ts create mode 100644 web/lib/api/openapi/paths/instruments.ts create mode 100644 web/lib/api/openapi/paths/meta.ts create mode 100644 web/lib/api/openapi/paths/runs.ts create mode 100644 web/lib/api/openapi/paths/watchers.ts create mode 100644 web/lib/api/openapi/registry.ts create mode 100644 web/lib/api/openapi/schemas/archive.ts create mode 100644 web/lib/api/openapi/schemas/common.ts create mode 100644 web/lib/api/openapi/schemas/files.ts create mode 100644 web/lib/api/openapi/schemas/instruments.ts create mode 100644 web/lib/api/openapi/schemas/runs.ts create mode 100644 web/lib/api/openapi/schemas/search.ts create mode 100644 web/lib/api/openapi/schemas/watchers.ts create mode 100644 web/scripts/generate-openapi.ts create mode 100644 web/tests/unit/openapi-document.test.ts diff --git a/Makefile b/Makefile index c51fee26..bcf0b6f4 100644 --- a/Makefile +++ b/Makefile @@ -94,6 +94,10 @@ db-process-fixtures: fe-build: cd web && npm run build +.PHONY: openapi-generate +openapi-generate: + cd web && npm run openapi:generate + # Formatting, linting, and type checking. .PHONY: py-check py-check: diff --git a/developer-docs/run-archives.md b/developer-docs/run-archives.md index 3e8b0007..f75c650f 100644 --- a/developer-docs/run-archives.md +++ b/developer-docs/run-archives.md @@ -4,7 +4,7 @@ The "Download all" actions on the run detail page and the runs table deliver eve Each archive can mix files from the raw bucket and the processed bucket in a single zip. This matters for instruments that produce processed artifacts via Lambda preprocessing (SpectraMax raw `.xls` → processed CSV; Hina `.nd2` → processed JPG; Azure 600 Gel Doc `.tif` → processed PNG): the run's file rows reference both buckets, and "Download all" zips them together. -This page covers the end-to-end flow, the cache + dedup model, and the on-call runbook. For the Lambda invocation contract, see [Lambda → Function URL (archive build)](lambda.md#function-url-archive-build). For the HTTP endpoints, see [REST API → Archive jobs](https://datahub.arcadiascience.com/docs/api-reference#archive-jobs). +This page covers the end-to-end flow, the cache + dedup model, and the on-call runbook. For the Lambda invocation contract, see [Lambda → Function URL (archive build)](lambda.md#function-url-archive-build). For the HTTP endpoints, see the [generated API docs](https://datahub.arcadiascience.com/docs/api) (Archive tag). ## Flow diff --git a/web/.gitignore b/web/.gitignore index c5f01335..83c3198c 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -36,3 +36,7 @@ pnpm-debug.log* *.tsbuildinfo next-env.d.ts .vercel + +# Local OpenAPI dump from `npm run openapi:generate` (not committed; +# production serves the schema from GET /api/v1/openapi.json). +/openapi.json diff --git a/web/app/api/v1/archive-jobs/[id]/route.ts b/web/app/api/v1/archive-jobs/[id]/route.ts index 19d52539..c02d619d 100644 --- a/web/app/api/v1/archive-jobs/[id]/route.ts +++ b/web/app/api/v1/archive-jobs/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { patchArchiveJobBody, readJsonBody } from "@/lib/api/openapi"; import { isValidUUID } from "@/lib/api/validators"; import { db } from "@/lib/db"; import { archiveJobs } from "@/lib/db/schema"; @@ -31,14 +32,6 @@ interface RouteContext { const TERMINAL_STATUSES = new Set(["ready", "failed"]); -interface PatchBody { - archive_bucket?: unknown; - archive_key?: unknown; - error_message?: unknown; - size_bytes?: unknown; - status?: unknown; -} - export async function PATCH(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "archive-jobs:write"); if (authResult instanceof Response) { @@ -50,31 +43,13 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { return apiError(400, VALIDATION_ERROR, "Invalid job ID format"); } - let body: PatchBody; - try { - body = (await request.json()) as PatchBody; - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, patchArchiveJobBody); + if (body instanceof Response) { + return body; } + const status = body.status; - if ( - typeof body.status !== "string" || - !["pending", "building", "ready", "failed"].includes(body.status) - ) { - return apiError( - 400, - VALIDATION_ERROR, - "status must be one of pending|building|ready|failed" - ); - } - - const status = body.status as "pending" | "building" | "ready" | "failed"; - - if ( - status === "ready" && - (typeof body.archive_bucket !== "string" || - typeof body.archive_key !== "string") - ) { + if (status === "ready" && !(body.archive_bucket && body.archive_key)) { return apiError( 400, VALIDATION_ERROR, @@ -83,16 +58,16 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { } const update: Partial = { status }; - if (typeof body.archive_bucket === "string") { + if (body.archive_bucket !== undefined) { update.archiveBucket = body.archive_bucket; } - if (typeof body.archive_key === "string") { + if (body.archive_key !== undefined) { update.archiveKey = body.archive_key; } - if (typeof body.size_bytes === "number") { + if (body.size_bytes !== undefined) { update.sizeBytes = body.size_bytes; } - if (typeof body.error_message === "string") { + if (body.error_message !== undefined) { update.errorMessage = body.error_message; } if (TERMINAL_STATUSES.has(status)) { diff --git a/web/app/api/v1/files/[fileId]/route.ts b/web/app/api/v1/files/[fileId]/route.ts index 0fcfe697..76063f60 100644 --- a/web/app/api/v1/files/[fileId]/route.ts +++ b/web/app/api/v1/files/[fileId]/route.ts @@ -9,6 +9,7 @@ import { VALIDATION_ERROR, } from "@/lib/api/errors"; import { dismissFile } from "@/lib/api/files"; +import { patchFileBody, readJsonBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; @@ -84,18 +85,16 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { ); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, patchFileBody); + if (body instanceof Response) { + return body; } const updates: Record = {}; const now = new Date(); // Status transition validation. - if ("status" in body && typeof body.status === "string") { + if (body.status !== undefined) { const allowed = VALID_TRANSITIONS[file.status]; if (!allowed?.includes(body.status)) { return apiError( @@ -130,31 +129,26 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { } // S3 info — set when transitioning to "uploaded" (watcher path). - if (typeof body.s3_bucket === "string") { + if (body.s3_bucket !== undefined) { updates.s3Bucket = body.s3_bucket; } - if (typeof body.s3_key === "string") { + if (body.s3_key !== undefined) { updates.s3Key = body.s3_key; } - if (typeof body.content_type === "string") { + if (body.content_type !== undefined) { updates.contentType = body.content_type; } - if (typeof body.size_bytes === "number") { + if (body.size_bytes !== undefined) { updates.sizeBytes = body.size_bytes; } // Metadata — flat JSON object set by the Lambda after processing. - if ( - "metadata" in body && - typeof body.metadata === "object" && - body.metadata !== null && - !Array.isArray(body.metadata) - ) { + if (body.metadata !== undefined) { updates.metadata = body.metadata; } // Error message — set when status transitions to "failed". - if (typeof body.error_message === "string") { + if (body.error_message !== undefined) { updates.errorMessage = body.error_message; } diff --git a/web/app/api/v1/instruments/[instrumentId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/route.ts index a9b0ccd0..4b377467 100644 --- a/web/app/api/v1/instruments/[instrumentId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/route.ts @@ -2,14 +2,10 @@ import { and, count, eq, isNull } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize, requireAdminForSession } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { patchInstrumentBody, readJsonBody } from "@/lib/api/openapi"; import { deregisterInstrumentWatchers } from "@/lib/api/watchers"; import { db } from "@/lib/db"; -import { - instrumentRuns, - instruments, - VALID_INSTRUMENT_TYPES, - watchers, -} from "@/lib/db/schema"; +import { instrumentRuns, instruments, watchers } from "@/lib/db/schema"; export async function GET( request: NextRequest, @@ -63,12 +59,6 @@ export async function GET( }); } -const ALLOWED_PATCH_FIELDS = new Set([ - "status", - "display_name", - "instrument_type", -]); - export async function PATCH( request: NextRequest, { params }: { params: Promise<{ instrumentId: string }> } @@ -89,21 +79,9 @@ export async function PATCH( const { instrumentId } = await params; - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - const unknownKeys = Object.keys(body).filter( - (k) => !ALLOWED_PATCH_FIELDS.has(k) - ); - if (unknownKeys.length > 0) { - return apiError(400, VALIDATION_ERROR, "Unknown fields", { - unknown_fields: unknownKeys, - allowed_fields: [...ALLOWED_PATCH_FIELDS], - }); + const body = await readJsonBody(request, patchInstrumentBody); + if (body instanceof Response) { + return body; } const [existing] = await db @@ -116,33 +94,8 @@ export async function PATCH( return apiError(404, NOT_FOUND, `Instrument '${instrumentId}' not found`); } - const VALID_INSTRUMENT_STATUSES = ["pending", "active", "inactive"]; - if ( - "status" in body && - !VALID_INSTRUMENT_STATUSES.includes(body.status as string) - ) { - return apiError( - 400, - VALIDATION_ERROR, - `Invalid status — must be one of: ${VALID_INSTRUMENT_STATUSES.join(", ")}` - ); - } - - if ( - "instrument_type" in body && - !(VALID_INSTRUMENT_TYPES as readonly string[]).includes( - body.instrument_type as string - ) - ) { - return apiError( - 400, - VALIDATION_ERROR, - `Invalid instrument_type — must be one of: ${VALID_INSTRUMENT_TYPES.join(", ")}` - ); - } - const updates: Record = {}; - if ("status" in body) { + if (body.status !== undefined) { updates.status = body.status; // Keep the retirement audit fields in lockstep with the status: only an // `inactive` instrument has a retirer. @@ -154,10 +107,10 @@ export async function PATCH( updates.retiredBy = null; } } - if ("display_name" in body) { + if (body.display_name !== undefined) { updates.displayName = body.display_name; } - if ("instrument_type" in body) { + if (body.instrument_type !== undefined) { updates.instrumentType = body.instrument_type; } diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts index 5c62e2e4..33aedbb7 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts @@ -8,6 +8,7 @@ import { VALIDATION_ERROR, } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; +import { commentBody, readJsonBody } from "@/lib/api/openapi"; import { getCommentForAuthorCheck, softDeleteComment, @@ -103,15 +104,9 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { return pre.response; } - let payload: Record; - try { - payload = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - if (typeof payload.body !== "string") { - return apiError(400, VALIDATION_ERROR, "body must be a string"); + const payload = await readJsonBody(request, commentBody); + if (payload instanceof Response) { + return payload; } const validated = validateCommentBody(payload.body); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts index d2ca91da..0939af08 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts @@ -7,6 +7,7 @@ import { VALIDATION_ERROR, } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; +import { commentBody, readJsonBody } from "@/lib/api/openapi"; import { createCommentAndNotify, listCommentsForRun, @@ -72,15 +73,9 @@ export async function POST(request: NextRequest, { params }: RouteContext) { return apiError(409, CONFLICT, "Cannot comment on a soft-deleted run"); } - let payload: Record; - try { - payload = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - if (typeof payload.body !== "string") { - return apiError(400, VALIDATION_ERROR, "body must be a string"); + const payload = await readJsonBody(request, commentBody); + if (payload instanceof Response) { + return payload; } const validated = validateCommentBody(payload.body); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts index 79b7872e..12054c45 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts @@ -1,13 +1,9 @@ import { and, eq, isNull } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; -import { - apiError, - CONFLICT, - NOT_FOUND, - VALIDATION_ERROR, -} from "@/lib/api/errors"; +import { apiError, CONFLICT, NOT_FOUND } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; +import { createFileBody, readJsonBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; import { files } from "@/lib/db/schema"; @@ -52,33 +48,17 @@ export async function POST(request: NextRequest, { params }: RouteContext) { return apiError(409, CONFLICT, "Cannot add files to a soft-deleted run"); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, createFileBody); + if (body instanceof Response) { + return body; } - const s3Bucket = - typeof body.s3_bucket === "string" ? body.s3_bucket.trim() : ""; - const s3Key = typeof body.s3_key === "string" ? body.s3_key.trim() : ""; - const filename = - typeof body.filename === "string" ? body.filename.trim() : ""; - - if (!(s3Bucket && s3Key && filename)) { - return apiError( - 400, - VALIDATION_ERROR, - "s3_bucket, s3_key, and filename are required" - ); - } - - const contentType = - typeof body.content_type === "string" ? body.content_type : null; - const sizeBytes = - typeof body.size_bytes === "number" ? body.size_bytes : null; - const category = - body.category === "processed" ? ("processed" as const) : ("raw" as const); + const s3Bucket = body.s3_bucket; + const s3Key = body.s3_key; + const filename = body.filename; + const contentType = body.content_type ?? null; + const sizeBytes = body.size_bytes ?? null; + const category = body.category ?? "raw"; const now = new Date(); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts index 1a8a1cfa..86c8a769 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts @@ -6,9 +6,9 @@ import { CONFLICT, INTERNAL_ERROR, NOT_FOUND, - VALIDATION_ERROR, } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; +import { readJsonBody, requestUploadUrlBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; import { files } from "@/lib/db/schema"; import { getPresignedUploadUrl, getS3RawDataBucket } from "@/lib/s3"; @@ -56,27 +56,17 @@ export async function POST(request: NextRequest, { params }: RouteContext) { return apiError(409, CONFLICT, "Cannot upload files to a soft-deleted run"); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - const filename = - typeof body.filename === "string" ? body.filename.trim() : ""; - if (!filename) { - return apiError(400, VALIDATION_ERROR, "filename is required"); + const body = await readJsonBody(request, requestUploadUrlBody); + if (body instanceof Response) { + return body; } - const contentType = - typeof body.content_type === "string" ? body.content_type : undefined; - const sizeBytes = - typeof body.size_bytes === "number" ? body.size_bytes : undefined; - const fileCreatedAt = - typeof body.file_created_at === "string" - ? new Date(body.file_created_at) - : null; + const filename = body.filename; + const contentType = body.content_type; + const sizeBytes = body.size_bytes; + const fileCreatedAt = body.file_created_at + ? new Date(body.file_created_at) + : null; // Look up existing file record by run + filename. The file may have been // created by report_run with a full relative_path (e.g. "EXP-001/data.csv"), diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts index c7315204..8c2b65bf 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts @@ -1,10 +1,7 @@ import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; -import { - apiError, - apiErrorFromResult, - VALIDATION_ERROR, -} from "@/lib/api/errors"; +import { apiErrorFromResult } from "@/lib/api/errors"; +import { readJsonBody, requestUploadBody } from "@/lib/api/openapi"; import { requestRunUploads } from "@/lib/api/run-uploads"; interface RouteContext { @@ -26,16 +23,14 @@ export async function POST(request: NextRequest, { params }: RouteContext) { const { instrumentId, runId } = await params; - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, requestUploadBody); + if (body instanceof Response) { + return body; } // Pass raw ids through; `requestRunUploads` fails closed on non-integers. // Filtering here would silently drop bad entries and queue the rest. - const fileIds: unknown[] = Array.isArray(body.file_ids) ? body.file_ids : []; + const fileIds = body.file_ids; const result = await requestRunUploads({ instrumentId, runId, fileIds }); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts index bacbe4e7..e31b17c6 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts @@ -6,12 +6,12 @@ import { apiErrorFromResult, CONFLICT, NOT_FOUND, - VALIDATION_ERROR, } from "@/lib/api/errors"; import { lookupRunByNaturalKey, parseAcquiredAt, } from "@/lib/api/instrument-runs"; +import { patchRunBody, readJsonBody } from "@/lib/api/openapi"; import { softDeleteRun } from "@/lib/api/run-lifecycle"; import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; @@ -126,25 +126,15 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { return apiError(409, CONFLICT, "Cannot update a soft-deleted run"); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, patchRunBody); + if (body instanceof Response) { + return body; } // Metadata is a full replacement (not a deep merge). The Lambda writes the // complete metadata object after processing. Patch-by-key would require a // read-then-merge cycle that adds complexity with no benefit at current scale. - if ("metadata" in body) { - if ( - typeof body.metadata !== "object" || - body.metadata === null || - Array.isArray(body.metadata) - ) { - return apiError(400, VALIDATION_ERROR, "metadata must be a JSON object"); - } - + if (body.metadata !== undefined) { await db .update(instrumentRuns) .set({ metadata: body.metadata }) @@ -171,9 +161,7 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { } // Handle detected_files upsert (watcher reporting new files for a run). - const detectedFiles = Array.isArray(body.detected_files) - ? body.detected_files - : []; + const detectedFiles = body.detected_files ?? []; if (detectedFiles.length > 0) { const now = new Date(); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/route.ts index 594f54ef..2fd93ce7 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/route.ts @@ -4,6 +4,7 @@ import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { buildRunListQuery, parseAcquiredAt } from "@/lib/api/instrument-runs"; import { notifyRunCreated } from "@/lib/api/notifications"; +import { createRunBody, readJsonBody } from "@/lib/api/openapi"; import { parseRunMetadataFilters } from "@/lib/api/run-metadata-filters"; import { parseIntParam, parseRunStatusParam } from "@/lib/api/validators"; import { db } from "@/lib/db"; @@ -43,29 +44,14 @@ export async function POST(request: NextRequest, { params }: RouteContext) { return apiError(404, NOT_FOUND, `Instrument '${instrumentId}' not found`); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, createRunBody); + if (body instanceof Response) { + return body; } - - const runId = typeof body.run_id === "string" ? body.run_id.trim() : ""; - if (!runId) { - return apiError(400, VALIDATION_ERROR, "run_id is required"); - } - + const runId = body.run_id; const source = body.source; - if (source !== "lambda" && source !== "watcher") { - return apiError( - 400, - VALIDATION_ERROR, - 'source must be "lambda" or "watcher"' - ); - } - const watcherId = - typeof body.watcher_id === "string" ? body.watcher_id : null; + const watcherId = body.watcher_id ?? null; // Parse the watcher-supplied acquired_at, falling back to the floor of // any detected_files[].file_created_at when omitted. This is defense-in- @@ -151,9 +137,7 @@ export async function POST(request: NextRequest, { params }: RouteContext) { // Watcher payloads may include detected files to bulk-insert alongside // the run. Duplicates (same run + relative_path) are silently skipped. - const detectedFiles = Array.isArray(body.detected_files) - ? body.detected_files - : []; + const detectedFiles = body.detected_files ?? []; if (detectedFiles.length > 0) { const now = new Date(); diff --git a/web/app/api/v1/instruments/route.ts b/web/app/api/v1/instruments/route.ts index fa21ea7b..4850182f 100644 --- a/web/app/api/v1/instruments/route.ts +++ b/web/app/api/v1/instruments/route.ts @@ -1,14 +1,10 @@ import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; -import { apiError, CONFLICT, VALIDATION_ERROR } from "@/lib/api/errors"; -import { isValidKebabCase } from "@/lib/api/validators"; +import { apiError, CONFLICT } from "@/lib/api/errors"; +import { createInstrumentBody, readJsonBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; -import { - type InstrumentType, - instruments, - VALID_INSTRUMENT_TYPES, -} from "@/lib/db/schema"; +import { instruments } from "@/lib/db/schema"; export async function GET(request: NextRequest) { const authResult = await authorize(request, "instruments:read"); @@ -34,24 +30,11 @@ export async function POST(request: NextRequest) { return authResult; } - let body: { id?: string; display_name?: string; instrument_type?: string }; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - const id = typeof body.id === "string" ? body.id.trim() : ""; - if (!id) { - return apiError(400, VALIDATION_ERROR, "id is required"); - } - if (!isValidKebabCase(id)) { - return apiError( - 400, - VALIDATION_ERROR, - "id must be lowercase kebab-case (e.g., my-instrument)" - ); + const body = await readJsonBody(request, createInstrumentBody); + if (body instanceof Response) { + return body; } + const id = body.id; const existing = await db .select({ id: instruments.id }) @@ -65,19 +48,15 @@ export async function POST(request: NextRequest) { // Default display name is derived from the kebab-case ID: // "spectramax-id3-plate-reader" → "Spectramax Id3 Plate Reader" - const displayName = - typeof body.display_name === "string" && body.display_name.trim() - ? body.display_name.trim() - : id - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" "); + // `display_name` is trimmed by the schema; empty string means "use default". + const displayName = body.display_name + ? body.display_name + : id + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); - const instrumentType: InstrumentType = - typeof body.instrument_type === "string" && - (VALID_INSTRUMENT_TYPES as readonly string[]).includes(body.instrument_type) - ? (body.instrument_type as InstrumentType) - : "generic"; + const instrumentType = body.instrument_type ?? "generic"; const [created] = await db .insert(instruments) diff --git a/web/app/api/v1/openapi.json/route.ts b/web/app/api/v1/openapi.json/route.ts new file mode 100644 index 00000000..00b78a5b --- /dev/null +++ b/web/app/api/v1/openapi.json/route.ts @@ -0,0 +1,13 @@ +import { buildOpenApiDocument } from "@/lib/api/openapi"; + +// Schema is pure (no DB/env); bake it into the build so every deploy +// serves a fixed document without recomputing on each request. +export const dynamic = "force-static"; + +export function GET() { + return Response.json(buildOpenApiDocument(), { + headers: { + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/web/app/api/v1/watchers/[watcherId]/config/route.ts b/web/app/api/v1/watchers/[watcherId]/config/route.ts index 70219fde..eb94a931 100644 --- a/web/app/api/v1/watchers/[watcherId]/config/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/config/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { readJsonBody, watcherConfigBody } from "@/lib/api/openapi"; import { isValidUUID } from "@/lib/api/validators"; import { extractWatchDirectory, @@ -30,22 +31,9 @@ export async function PUT( return apiError(404, NOT_FOUND, `Watcher '${watcherId}' not found`); } - let body: { config_checksum?: string; config_yaml?: string }; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - if ( - typeof body.config_checksum !== "string" || - typeof body.config_yaml !== "string" - ) { - return apiError( - 400, - VALIDATION_ERROR, - "config_checksum and config_yaml are required" - ); + const body = await readJsonBody(request, watcherConfigBody); + if (body instanceof Response) { + return body; } const previousWatchDir = extractWatchDirectory(watcher.configYaml); diff --git a/web/app/api/v1/watchers/[watcherId]/events/route.ts b/web/app/api/v1/watchers/[watcherId]/events/route.ts index 55e111dd..7a47e800 100644 --- a/web/app/api/v1/watchers/[watcherId]/events/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/events/route.ts @@ -2,6 +2,7 @@ import { and, desc, eq, gte, inArray } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { readJsonBody, watcherEventBody } from "@/lib/api/openapi"; import { isValidUUID, parseDateParam, @@ -36,49 +37,14 @@ export async function POST( return apiError(404, NOT_FOUND, `Watcher '${watcherId}' not found`); } - let body: { events?: unknown[] }; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - if (!Array.isArray(body.events) || body.events.length === 0) { - return apiError( - 400, - VALIDATION_ERROR, - "events array is required and must not be empty" - ); - } - - if (body.events.length > 100) { - return apiError(400, VALIDATION_ERROR, "Maximum 100 events per request"); - } - - interface EventInput { - details?: Record; - event_type: string; - message: string; - timestamp: string; + const body = await readJsonBody(request, watcherEventBody); + if (body instanceof Response) { + return body; } const values: (typeof watcherEvents.$inferInsert)[] = []; for (let i = 0; i < body.events.length; i++) { - const evt = body.events[i] as EventInput; - if (!(evt.event_type && evt.timestamp && evt.message)) { - return apiError( - 400, - VALIDATION_ERROR, - `Event at index ${i} requires event_type, timestamp, and message` - ); - } - if (!VALID_EVENT_TYPES.has(evt.event_type)) { - return apiError( - 400, - VALIDATION_ERROR, - `Invalid event_type '${evt.event_type}' at index ${i}` - ); - } + const evt = body.events[i]; const ts = new Date(evt.timestamp); if (Number.isNaN(ts.getTime())) { return apiError(400, VALIDATION_ERROR, `Invalid timestamp at index ${i}`); diff --git a/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts b/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts index 5ea7cbb3..027bfa3c 100644 --- a/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts @@ -7,6 +7,7 @@ import { UPGRADE_REQUIRED, VALIDATION_ERROR, } from "@/lib/api/errors"; +import { heartbeatBody, readJsonBody } from "@/lib/api/openapi"; import { isValidUUID } from "@/lib/api/validators"; import { isBelowFloor } from "@/lib/api/watcher-versions"; import { @@ -39,33 +40,13 @@ export async function POST( return apiError(404, NOT_FOUND, `Watcher '${watcherId}' not found`); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, heartbeatBody); + if (body instanceof Response) { + return body; } + const status = body.status; - const VALID_WATCHER_STATUSES = ["registered", "watching", "stopped"] as const; - const status = body.status as string | undefined; - if (!status) { - return apiError(400, VALIDATION_ERROR, "status is required"); - } - if ( - !VALID_WATCHER_STATUSES.includes( - status as (typeof VALID_WATCHER_STATUSES)[number] - ) - ) { - return apiError( - 400, - VALIDATION_ERROR, - `Invalid status '${status}' — must be one of: ${VALID_WATCHER_STATUSES.join(", ")}` - ); - } - - const timestamp = body.timestamp - ? new Date(body.timestamp as string) - : new Date(); + const timestamp = body.timestamp ? new Date(body.timestamp) : new Date(); if (Number.isNaN(timestamp.getTime())) { return apiError(400, VALIDATION_ERROR, "Invalid timestamp"); } @@ -78,10 +59,7 @@ export async function POST( // heartbeat after a manual upgrade is judged on the new version, not // the stale stored one. The singleton constraint on // `watcher_release_config` keeps this a constant-cost select. - const reportedVersion = - typeof body.watcher_version === "string" && body.watcher_version - ? body.watcher_version - : null; + const reportedVersion = body.watcher_version || null; const [releaseRow] = await db .select({ minSupportedVersion: watcherReleaseConfig.minSupportedVersion, @@ -119,19 +97,17 @@ export async function POST( watcherId, timestamp, status, - uploadMode: (body.upload_mode as "auto" | "manual") ?? null, - filesUploadedSinceLast: - (body.files_uploaded_since_last_heartbeat as number) ?? 0, - runsReportedSinceLast: - (body.runs_reported_since_last_heartbeat as number) ?? 0, - errorsSinceLast: (body.errors_since_last_heartbeat as number) ?? 0, - uptimeSeconds: (body.uptime_seconds as number) ?? null, + uploadMode: body.upload_mode ?? null, + filesUploadedSinceLast: body.files_uploaded_since_last_heartbeat ?? 0, + runsReportedSinceLast: body.runs_reported_since_last_heartbeat ?? 0, + errorsSinceLast: body.errors_since_last_heartbeat ?? 0, + uptimeSeconds: body.uptime_seconds ?? null, }), db .update(watchers) .set({ lastHeartbeatAt: new Date(), - status: status as "registered" | "watching" | "stopped", + status, // Older watchers (pre-version-reporting) won't include this field — // fall back to the existing value rather than nulling it out so the // dashboard keeps the last-known version visible. diff --git a/web/app/api/v1/watchers/register/route.ts b/web/app/api/v1/watchers/register/route.ts index cb8bfbc8..d0e8d906 100644 --- a/web/app/api/v1/watchers/register/route.ts +++ b/web/app/api/v1/watchers/register/route.ts @@ -7,6 +7,7 @@ import { NOT_FOUND, VALIDATION_ERROR, } from "@/lib/api/errors"; +import { readJsonBody, registerWatcherBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; import { instruments, watchers } from "@/lib/db/schema"; @@ -16,22 +17,12 @@ export async function POST(request: NextRequest) { return authResult; } - let body: { - instrument_id?: string; - hostname?: string; - os_info?: string; - }; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, registerWatcherBody); + if (body instanceof Response) { + return body; } - const instrumentId = - typeof body.instrument_id === "string" ? body.instrument_id.trim() : ""; - if (!instrumentId) { - return apiError(400, VALIDATION_ERROR, "instrument_id is required"); - } + const instrumentId = body.instrument_id; const [instrument] = await db .select({ id: instruments.id, status: instruments.status }) diff --git a/web/lib/api/openapi/document.ts b/web/lib/api/openapi/document.ts new file mode 100644 index 00000000..573908bc --- /dev/null +++ b/web/lib/api/openapi/document.ts @@ -0,0 +1,38 @@ +import { OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi"; +import { registry } from "./registry"; +import "./paths/meta"; +import "./paths/instruments"; +import "./paths/runs"; +import "./paths/files"; +import "./paths/watchers"; +import "./paths/archive"; + +const DOCUMENT_TAGS = [ + { name: "Meta", description: "Schema discovery" }, + { name: "Instruments", description: "Instrument catalog" }, + { + name: "Runs", + description: "Instrument runs, comments, attributions, uploads, and search", + }, + { name: "Files", description: "Run files, downloads, and reprocessing" }, + { name: "Watchers", description: "Watcher registration and telemetry" }, + { name: "Archive", description: "Run archive builds" }, +] as const; + +export function buildOpenApiDocument() { + const generator = new OpenApiGeneratorV31(registry.definitions); + return generator.generateDocument({ + openapi: "3.1.0", + info: { + title: "Data Hub API", + version: "1.0.0", + description: + "Integrator REST API for Data Hub. Authenticate with a personal access token (`Authorization: Bearer dhub_…`). Session cookies also work for browser callers.", + }, + servers: [ + { url: "/api/v1", description: "Relative to your Data Hub host" }, + ], + security: [{ bearerAuth: [] }], + tags: [...DOCUMENT_TAGS], + }); +} diff --git a/web/lib/api/openapi/index.ts b/web/lib/api/openapi/index.ts new file mode 100644 index 00000000..a6353f98 --- /dev/null +++ b/web/lib/api/openapi/index.ts @@ -0,0 +1,59 @@ +// Routes use this single public boundary for schemas and document generation. +// Response schemas are exported here too so tests can assert real responses +// against them (drift detection) while going through this module, which loads +// the registry — and thus the Zod OpenAPI extension — before any schema. +// biome-ignore lint/performance/noBarrelFile: This is the package entry point. +export { buildOpenApiDocument } from "./document"; +export { readJsonBody } from "./parse"; +export { archiveJobDetail, patchArchiveJobBody } from "./schemas/archive"; +export { + createFileBody, + fileDetail, + fileDismissed, + fileReprocessed, + patchFileBody, +} from "./schemas/files"; +export { + createInstrumentBody, + instrumentDetail, + instrumentListItem, + patchInstrumentBody, +} from "./schemas/instruments"; +export { + attributionsResponse, + commentBody, + commentDeleted, + commentsListResponse, + createRunBody, + patchRunBody, + requestUploadBody, + requestUploadUrlBody, + runComment, + runCreated, + runDeleted, + runDetail, + runListResponse, + runReprocessed, + runRestored, + runUpdated, + uploadAllQueued, + uploadQueued, + uploadUrlResponse, +} from "./schemas/runs"; +export { + heartbeatBody, + registerWatcherBody, + watcherChecksumResponse, + watcherConfigBody, + watcherDeleted, + watcherDetail, + watcherEventBody, + watcherEventCreated, + watcherEventsListResponse, + watcherHeartbeatAck, + watcherHeartbeatsListResponse, + watcherListResponse, + watcherRegistered, + watcherUpdateCheckResponse, + watcherUploadQueueResponse, +} from "./schemas/watchers"; diff --git a/web/lib/api/openapi/parse.ts b/web/lib/api/openapi/parse.ts new file mode 100644 index 00000000..d73ec201 --- /dev/null +++ b/web/lib/api/openapi/parse.ts @@ -0,0 +1,25 @@ +import type { z } from "zod"; +import { apiError, VALIDATION_ERROR } from "@/lib/api/errors"; + +export async function readJsonBody( + request: Request, + schema: z.ZodType +): Promise { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + } + const parsed = schema.safeParse(raw); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const message = issue + ? `${issue.path.length ? `${issue.path.join(".")}: ` : ""}${issue.message}` + : "Invalid request body"; + return apiError(400, VALIDATION_ERROR, message, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} diff --git a/web/lib/api/openapi/paths/archive.ts b/web/lib/api/openapi/paths/archive.ts new file mode 100644 index 00000000..52e68df4 --- /dev/null +++ b/web/lib/api/openapi/paths/archive.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { + archiveJobIdParam, + bearerSecurity, + errorResponses, + jsonResponse, + registry, +} from "../registry"; +import { archiveJobDetail, patchArchiveJobBody } from "../schemas/archive"; + +registry.registerPath({ + method: "patch", + path: "/archive-jobs/{id}", + operationId: "updateArchiveJob", + summary: "Update an archive job", + description: "Requires scope `archive-jobs:write`.", + tags: ["Archive"], + security: bearerSecurity, + request: { + params: z.object({ id: archiveJobIdParam }), + body: { content: { "application/json": { schema: patchArchiveJobBody } } }, + }, + responses: { + 200: jsonResponse("Updated archive job.", archiveJobDetail), + ...errorResponses(), + }, +}); diff --git a/web/lib/api/openapi/paths/files.ts b/web/lib/api/openapi/paths/files.ts new file mode 100644 index 00000000..5c5b03ef --- /dev/null +++ b/web/lib/api/openapi/paths/files.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import { + bearerSecurity, + errorResponses, + fileIdParam, + instrumentIdParam, + jsonResponse, + registry, + runIdParam, +} from "../registry"; +import { + createFileBody, + fileDetail, + fileDismissed, + fileReprocessed, + patchFileBody, +} from "../schemas/files"; + +const fileParams = z.object({ fileId: fileIdParam }); +const runParams = z.object({ + instrumentId: instrumentIdParam, + runId: runIdParam, +}); +const body = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, +}); +const responses = (description: string, schema: z.ZodType = z.unknown()) => ({ + 200: jsonResponse(description, schema), + ...errorResponses(), +}); + +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/files", + operationId: "createRunFile", + summary: "Create a run file record", + description: "Requires scope `files:create`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: runParams, body: body(createFileBody) }, + responses: { + 200: jsonResponse("Existing file.", fileDetail), + 201: jsonResponse("Created file.", fileDetail), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "patch", + path: "/files/{fileId}", + operationId: "updateFile", + summary: "Update a file", + description: "Requires scope `files:update`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: fileParams, body: body(patchFileBody) }, + responses: responses("Updated file.", fileDetail), +}); +registry.registerPath({ + method: "delete", + path: "/files/{fileId}", + operationId: "dismissFile", + summary: "Dismiss a file", + description: "Requires scope `files:delete`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: fileParams }, + responses: responses("Dismissal result.", fileDismissed), +}); +registry.registerPath({ + method: "get", + path: "/files/{fileId}/download", + operationId: "downloadFile", + summary: "Redirect to file download", + description: "Requires scope `files:read`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: fileParams }, + responses: { + 302: { description: "Redirect to a presigned download URL." }, + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "post", + path: "/files/{fileId}/reprocess", + operationId: "reprocessFile", + summary: "Reprocess a file", + description: "Requires scope `files:reprocess`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: fileParams }, + responses: responses("Reprocessing result.", fileReprocessed), +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}/runs/{runId}/download-archive", + operationId: "downloadRunArchive", + summary: "Download a run archive", + description: "Requires scope `files:read`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: runParams }, + responses: { + 302: { description: "Redirect to a presigned archive URL." }, + ...errorResponses(), + }, +}); diff --git a/web/lib/api/openapi/paths/instruments.ts b/web/lib/api/openapi/paths/instruments.ts new file mode 100644 index 00000000..3422f0db --- /dev/null +++ b/web/lib/api/openapi/paths/instruments.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; +import { + bearerSecurity, + errorResponses, + instrumentIdParam, + jsonResponse, + registry, +} from "../registry"; +import { + createInstrumentBody, + instrumentDetail, + instrumentListItem, + patchInstrumentBody, +} from "../schemas/instruments"; + +const instrumentParams = z.object({ instrumentId: instrumentIdParam }); + +registry.registerPath({ + method: "get", + path: "/instruments", + operationId: "listInstruments", + summary: "List instruments", + description: "Requires scope `instruments:read`.", + tags: ["Instruments"], + security: bearerSecurity, + responses: { + 200: jsonResponse("Instruments.", z.array(instrumentListItem)), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "post", + path: "/instruments", + operationId: "createInstrument", + summary: "Create an instrument", + description: "Requires scope `instruments:write`.", + tags: ["Instruments"], + security: bearerSecurity, + request: { + body: { content: { "application/json": { schema: createInstrumentBody } } }, + }, + responses: { + 201: jsonResponse("Created instrument.", instrumentDetail), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}", + operationId: "getInstrument", + summary: "Get an instrument", + description: "Requires scope `instruments:read`.", + tags: ["Instruments"], + security: bearerSecurity, + request: { params: instrumentParams }, + responses: { + 200: jsonResponse("Instrument.", instrumentDetail), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "patch", + path: "/instruments/{instrumentId}", + operationId: "updateInstrument", + summary: "Update an instrument", + description: "Requires scope `instruments:write`.", + tags: ["Instruments"], + security: bearerSecurity, + request: { + params: instrumentParams, + body: { content: { "application/json": { schema: patchInstrumentBody } } }, + }, + responses: { + 200: jsonResponse("Updated instrument.", instrumentDetail), + ...errorResponses(), + }, +}); diff --git a/web/lib/api/openapi/paths/meta.ts b/web/lib/api/openapi/paths/meta.ts new file mode 100644 index 00000000..00c1a8eb --- /dev/null +++ b/web/lib/api/openapi/paths/meta.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { jsonResponse, registry } from "../registry"; + +registry.registerPath({ + method: "get", + path: "/openapi.json", + operationId: "getOpenApiDocument", + summary: "Get the OpenAPI document", + tags: ["Meta"], + security: [], + responses: { 200: jsonResponse("OpenAPI 3.1 document.", z.unknown()) }, +}); diff --git a/web/lib/api/openapi/paths/runs.ts b/web/lib/api/openapi/paths/runs.ts new file mode 100644 index 00000000..dd146143 --- /dev/null +++ b/web/lib/api/openapi/paths/runs.ts @@ -0,0 +1,260 @@ +import { z } from "zod"; +import { + bearerSecurity, + commentIdParam, + errorResponses, + instrumentIdParam, + jsonResponse, + registry, + runIdParam, +} from "../registry"; +import { + attributionsResponse, + commentBody, + commentDeleted, + commentsListResponse, + createRunBody, + patchRunBody, + requestUploadBody, + requestUploadUrlBody, + runComment, + runCreated, + runDeleted, + runDetail, + runListQuery, + runListResponse, + runReprocessed, + runRestored, + runUpdated, + uploadAllQueued, + uploadQueued, + uploadUrlResponse, +} from "../schemas/runs"; +import { searchQuery, searchResponse } from "../schemas/search"; + +const runParams = z.object({ + instrumentId: instrumentIdParam, + runId: runIdParam, +}); +const commentParams = runParams.extend({ commentId: commentIdParam }); +const tag = ["Runs"]; +const scoped = (scope: string) => `Requires scope \`${scope}\`.`; +const body = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, +}); +const ok = (description: string, schema: z.ZodType) => ({ + 200: jsonResponse(description, schema), + ...errorResponses(), +}); + +registry.registerPath({ + method: "get", + path: "/instrument-runs", + operationId: "listInstrumentRuns", + summary: "List runs across instruments", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { query: runListQuery }, + responses: ok("Paginated runs.", runListResponse), +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}/runs", + operationId: "listInstrumentRunsForInstrument", + summary: "List an instrument's runs", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { + params: z.object({ instrumentId: instrumentIdParam }), + query: runListQuery, + }, + responses: ok("Paginated runs.", runListResponse), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs", + operationId: "createInstrumentRun", + summary: "Create an instrument run", + description: scoped("runs:create"), + tags: tag, + security: bearerSecurity, + request: { + params: z.object({ instrumentId: instrumentIdParam }), + body: body(createRunBody), + }, + responses: { + 200: jsonResponse("Existing run.", runCreated), + 201: jsonResponse("Created run.", runCreated), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}/runs/{runId}", + operationId: "getInstrumentRun", + summary: "Get a run", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Run detail.", runDetail), +}); +registry.registerPath({ + method: "patch", + path: "/instruments/{instrumentId}/runs/{runId}", + operationId: "updateInstrumentRun", + summary: "Update a run", + description: scoped("runs:update"), + tags: tag, + security: bearerSecurity, + request: { params: runParams, body: body(patchRunBody) }, + responses: ok("Updated run.", runUpdated), +}); +registry.registerPath({ + method: "delete", + path: "/instruments/{instrumentId}/runs/{runId}", + operationId: "deleteInstrumentRun", + summary: "Soft-delete a run", + description: scoped("runs:delete"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Deletion result.", runDeleted), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/restore", + operationId: "restoreInstrumentRun", + summary: "Restore a run", + description: scoped("runs:delete"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Restored run.", runRestored), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/reprocess", + operationId: "reprocessInstrumentRun", + summary: "Reprocess a run", + description: scoped("runs:reprocess"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Reprocessing result.", runReprocessed), +}); +registry.registerPath({ + method: "get", + path: "/search", + operationId: "search", + summary: "Search Data Hub", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { query: searchQuery }, + responses: ok("Search results.", searchResponse), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/request-upload", + operationId: "requestRunUpload", + summary: "Queue selected files for upload", + description: scoped("runs:upload"), + tags: tag, + security: bearerSecurity, + request: { params: runParams, body: body(requestUploadBody) }, + responses: ok("Queued files.", uploadQueued), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/request-upload-all", + operationId: "requestRunUploadAll", + summary: "Queue all files for upload", + description: scoped("runs:upload"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Queued files.", uploadAllQueued), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/request-upload-url", + operationId: "requestRunUploadUrl", + summary: "Request a presigned upload URL", + description: scoped("runs:upload"), + tags: tag, + security: bearerSecurity, + request: { params: runParams, body: body(requestUploadUrlBody) }, + responses: ok("Upload URL.", uploadUrlResponse), +}); +registry.registerPath({ + method: "put", + path: "/instruments/{instrumentId}/runs/{runId}/attributions/me", + operationId: "claimRun", + summary: "Claim a run", + description: scoped("runs:attribute"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Attributions.", attributionsResponse), +}); +registry.registerPath({ + method: "delete", + path: "/instruments/{instrumentId}/runs/{runId}/attributions/me", + operationId: "unclaimRun", + summary: "Remove your run claim", + description: scoped("runs:attribute"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Attributions.", attributionsResponse), +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}/runs/{runId}/comments", + operationId: "listRunComments", + summary: "List run comments", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Comments.", commentsListResponse), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/comments", + operationId: "createRunComment", + summary: "Create a run comment", + description: scoped("runs:comment"), + tags: tag, + security: bearerSecurity, + request: { params: runParams, body: body(commentBody) }, + responses: { + 201: jsonResponse("Created comment.", runComment), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "patch", + path: "/instruments/{instrumentId}/runs/{runId}/comments/{commentId}", + operationId: "updateRunComment", + summary: "Update a run comment", + description: scoped("runs:comment"), + tags: tag, + security: bearerSecurity, + request: { params: commentParams, body: body(commentBody) }, + responses: ok("Updated comment.", runComment), +}); +registry.registerPath({ + method: "delete", + path: "/instruments/{instrumentId}/runs/{runId}/comments/{commentId}", + operationId: "deleteRunComment", + summary: "Delete a run comment", + description: scoped("runs:comment"), + tags: tag, + security: bearerSecurity, + request: { params: commentParams }, + responses: ok("Deleted comment.", commentDeleted), +}); diff --git a/web/lib/api/openapi/paths/watchers.ts b/web/lib/api/openapi/paths/watchers.ts new file mode 100644 index 00000000..39fd5abf --- /dev/null +++ b/web/lib/api/openapi/paths/watchers.ts @@ -0,0 +1,179 @@ +import { z } from "zod"; +import { + bearerSecurity, + errorResponses, + jsonResponse, + registry, + watcherIdParam, +} from "../registry"; +import { + heartbeatBody, + registerWatcherBody, + watcherChecksumResponse, + watcherConfigBody, + watcherDeleted, + watcherDetail, + watcherEventBody, + watcherEventCreated, + watcherEventsListResponse, + watcherHeartbeatAck, + watcherHeartbeatsListResponse, + watcherListResponse, + watcherRegistered, + watcherUpdateCheckResponse, + watcherUploadQueueResponse, +} from "../schemas/watchers"; + +const watcherParams = z.object({ watcherId: watcherIdParam }); +const body = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, +}); +const responses = (description: string, schema: z.ZodType = z.unknown()) => ({ + 200: jsonResponse(description, schema), + ...errorResponses(), +}); +const operation = ( + method: "get" | "post" | "put" | "delete", + path: string, + operationId: string, + summary: string, + scope: string, + responseSchema: z.ZodType, + request?: object +) => + registry.registerPath({ + method, + path, + operationId, + summary, + description: `Requires scope \`${scope}\`.`, + tags: ["Watchers"], + security: bearerSecurity, + ...(request ? { request } : {}), + responses: responses(`${summary}.`, responseSchema), + }); + +operation( + "get", + "/watchers", + "listWatchers", + "List watchers", + "watchers:read", + watcherListResponse, + { + query: z.object({ + instrument_id: z.string().optional(), + status: z.enum(["registered", "watching", "stopped", "stale"]).optional(), + include_deleted: z.coerce.boolean().optional(), + }), + } +); +registry.registerPath({ + method: "post", + path: "/watchers/register", + operationId: "registerWatcher", + summary: "Register a watcher", + description: "Requires scope `watchers:report`.", + tags: ["Watchers"], + security: bearerSecurity, + request: { body: body(registerWatcherBody) }, + responses: { + 201: jsonResponse("Registered watcher.", watcherRegistered), + ...errorResponses(), + }, +}); +operation( + "get", + "/watchers/{watcherId}", + "getWatcher", + "Get a watcher", + "watchers:read", + watcherDetail, + { params: watcherParams } +); +operation( + "delete", + "/watchers/{watcherId}", + "deleteWatcher", + "Deregister a watcher", + "watchers:admin", + watcherDeleted, + { params: watcherParams } +); +operation( + "put", + "/watchers/{watcherId}/config", + "updateWatcherConfig", + "Update watcher configuration", + "watchers:report", + watcherChecksumResponse, + { params: watcherParams, body: body(watcherConfigBody) } +); +operation( + "get", + "/watchers/{watcherId}/config-checksum", + "getWatcherConfigChecksum", + "Get watcher configuration checksum", + "watchers:read", + watcherChecksumResponse, + { params: watcherParams } +); +registry.registerPath({ + method: "post", + path: "/watchers/{watcherId}/events", + operationId: "createWatcherEvent", + summary: "Report a watcher event", + description: "Requires scope `watchers:report`.", + tags: ["Watchers"], + security: bearerSecurity, + request: { params: watcherParams, body: body(watcherEventBody) }, + responses: { + 201: jsonResponse("Created event.", watcherEventCreated), + ...errorResponses(), + }, +}); +operation( + "get", + "/watchers/{watcherId}/events", + "listWatcherEvents", + "List watcher events", + "watchers:read", + watcherEventsListResponse, + { params: watcherParams } +); +operation( + "post", + "/watchers/{watcherId}/heartbeat", + "recordWatcherHeartbeat", + "Record a watcher heartbeat", + "watchers:report", + watcherHeartbeatAck, + { params: watcherParams, body: body(heartbeatBody) } +); +operation( + "get", + "/watchers/{watcherId}/heartbeats", + "listWatcherHeartbeats", + "List watcher heartbeats", + "watchers:read", + watcherHeartbeatsListResponse, + { params: watcherParams } +); +operation( + "get", + "/watchers/{watcherId}/upload-queue", + "getWatcherUploadQueue", + "Get watcher upload queue", + "watchers:read", + watcherUploadQueueResponse, + { params: watcherParams } +); +operation( + "get", + "/watchers/{watcherId}/update-check", + "checkWatcherUpdate", + "Check watcher update availability", + "watchers:read", + watcherUpdateCheckResponse, + { params: watcherParams } +); diff --git a/web/lib/api/openapi/registry.ts b/web/lib/api/openapi/registry.ts new file mode 100644 index 00000000..c5bfa504 --- /dev/null +++ b/web/lib/api/openapi/registry.ts @@ -0,0 +1,76 @@ +import { + extendZodWithOpenApi, + OpenAPIRegistry, + type ResponseConfig, +} from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; + +extendZodWithOpenApi(z); + +export const registry = new OpenAPIRegistry(); + +registry.registerComponent("securitySchemes", "bearerAuth", { + type: "http", + scheme: "bearer", + description: + "Personal access tokens begin with `dhub_…` and are restricted by their assigned scopes.", +}); + +export const ErrorBody = z + .object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + }), + }) + .openapi("ErrorResponse"); + +registry.register("ErrorResponse", ErrorBody); + +export const instrumentIdParam = z + .string() + .openapi({ example: "plate-reader" }); +export const runIdParam = z.string().openapi({ example: "run-2026-001" }); +export const watcherIdParam = z.string().uuid(); +// Path params are always present; avoid z.coerce.number() which Zod 4 treats +// as nullable and which zod-to-openapi then emits as required: false. +export const fileIdParam = z + .number() + .int() + .openapi({ + param: { required: true }, + example: 1, + }); +export const commentIdParam = z + .string() + .uuid() + .openapi({ example: "3f1a2b4c-5d6e-7f80-9a1b-2c3d4e5f6071" }); +export const archiveJobIdParam = z.string().uuid(); + +export function jsonResponse( + description: string, + schema: z.ZodType, + status?: number +): ResponseConfig { + return { + description, + ...(status ? { status } : {}), + content: { "application/json": { schema } }, + }; +} + +export function errorResponses(): Record< + 401 | 403 | 404 | 400 | 409, + ResponseConfig +> { + return { + 400: jsonResponse("Invalid request.", ErrorBody), + 401: jsonResponse("Authentication is required.", ErrorBody), + 403: jsonResponse("The token does not have the required scope.", ErrorBody), + 404: jsonResponse("The requested resource was not found.", ErrorBody), + 409: jsonResponse("The request conflicts with resource state.", ErrorBody), + }; +} + +export const bearerSecurity = [{ bearerAuth: [] as string[] }]; diff --git a/web/lib/api/openapi/schemas/archive.ts b/web/lib/api/openapi/schemas/archive.ts new file mode 100644 index 00000000..d08a0c83 --- /dev/null +++ b/web/lib/api/openapi/schemas/archive.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { archiveJobStatusSchema, isoDateTime } from "./common"; + +// Lambda callback body. `archive_bucket` / `archive_key` are required when +// status is `ready` (enforced in the route after parse). +export const patchArchiveJobBody = z.object({ + status: archiveJobStatusSchema, + archive_bucket: z.string().min(1).optional(), + archive_key: z.string().min(1).optional(), + size_bytes: z.number().int().nonnegative().optional(), + error_message: z.string().nullable().optional(), +}); + +export const archiveJobDetail = z + .object({ + id: z.string().uuid(), + status: archiveJobStatusSchema, + archive_bucket: z.string().nullable(), + archive_key: z.string().nullable(), + size_bytes: z.number().int().nullable(), + error_message: z.string().nullable(), + completed_at: isoDateTime.nullable(), + }) + .openapi("ArchiveJobDetail"); diff --git a/web/lib/api/openapi/schemas/common.ts b/web/lib/api/openapi/schemas/common.ts new file mode 100644 index 00000000..c19f90dd --- /dev/null +++ b/web/lib/api/openapi/schemas/common.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { + archiveJobStatusEnum, + fileCategoryEnum, + fileStatusEnum, + VALID_INSTRUMENT_TYPES, + watcherEventTypeEnum, +} from "@/lib/db/schema"; +import { RUN_STATUS_VALUES } from "@/lib/runs/run-status"; + +export const isoDateTime = z.string().datetime({ offset: true }); +export const instrumentTypeSchema = z.enum(VALID_INSTRUMENT_TYPES); +export const instrumentStatusSchema = z.enum(["pending", "active", "inactive"]); +export const runStatusSchema = z.enum(RUN_STATUS_VALUES); +export const runSourceSchema = z.enum(["lambda", "watcher"]); +export const fileCategorySchema = z.enum(fileCategoryEnum.enumValues); +export const fileStatusSchema = z.enum(fileStatusEnum.enumValues); +export const watcherStatusSchema = z.enum([ + "registered", + "watching", + "stopped", + "stale", +]); +export const watcherEventTypeSchema = z.enum(watcherEventTypeEnum.enumValues); +export const uploadModeSchema = z.enum(["auto", "manual"]); +export const archiveJobStatusSchema = z.enum(archiveJobStatusEnum.enumValues); +export const paginationSchema = z.object({ + page: z.number().int(), + per_page: z.number().int(), + total: z.number().int(), + total_pages: z.number().int(), +}); diff --git a/web/lib/api/openapi/schemas/files.ts b/web/lib/api/openapi/schemas/files.ts new file mode 100644 index 00000000..22cce890 --- /dev/null +++ b/web/lib/api/openapi/schemas/files.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { fileCategorySchema, fileStatusSchema, isoDateTime } from "./common"; + +export const createFileBody = z.object({ + s3_bucket: z.string().min(1), + s3_key: z.string().min(1), + filename: z.string().min(1), + content_type: z.string().optional(), + size_bytes: z.number().optional(), + category: fileCategorySchema.optional(), +}); + +export const patchFileBody = z.object({ + status: fileStatusSchema.optional(), + s3_bucket: z.string().optional(), + s3_key: z.string().optional(), + content_type: z.string().optional(), + size_bytes: z.number().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + error_message: z.string().optional(), +}); + +// Shared by the create (`formatFileResponse`) and update responses. Create +// omits `detected_at` / `upload_requested_at`, so both are optional here. +export const fileDetail = z + .object({ + id: z.number().int(), + instrument_run_id: z.string().uuid(), + filename: z.string(), + relative_path: z.string(), + s3_bucket: z.string().nullable(), + s3_key: z.string().nullable(), + content_type: z.string().nullable(), + size_bytes: z.number().nullable(), + category: fileCategorySchema, + status: fileStatusSchema, + metadata: z.record(z.string(), z.unknown()).nullable(), + error_message: z.string().nullable(), + detected_at: isoDateTime.nullable().optional(), + upload_requested_at: isoDateTime.nullable().optional(), + uploaded_at: isoDateTime.nullable(), + processed_at: isoDateTime.nullable(), + created_at: isoDateTime, + file_created_at: isoDateTime.nullable(), + }) + .openapi("FileDetail"); + +export const fileDismissed = z.object({ + id: z.number().int(), + filename: z.string(), + deleted_at: isoDateTime.nullable(), + already_applied: z.boolean(), +}); + +export const fileReprocessed = z.object({ + status: z.literal("processing"), + file_id: z.number().int(), +}); diff --git a/web/lib/api/openapi/schemas/instruments.ts b/web/lib/api/openapi/schemas/instruments.ts new file mode 100644 index 00000000..41a1dea4 --- /dev/null +++ b/web/lib/api/openapi/schemas/instruments.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { + instrumentStatusSchema, + instrumentTypeSchema, + isoDateTime, +} from "./common"; + +export const createInstrumentBody = z.object({ + id: z + .string() + .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "Must be lowercase kebab-case"), + // Trim so whitespace-only values fall through to the id-derived default. + display_name: z.string().trim().optional(), + instrument_type: instrumentTypeSchema.optional(), +}); + +export const patchInstrumentBody = z.object({ + status: instrumentStatusSchema.optional(), + display_name: z.string().trim().min(1).optional(), + instrument_type: instrumentTypeSchema.optional(), +}); + +export const instrumentListItem = z + .object({ + id: z.string(), + display_name: z.string(), + status: instrumentStatusSchema, + instrument_type: instrumentTypeSchema, + }) + .openapi("InstrumentListItem"); + +export const instrumentDetail = instrumentListItem + .extend({ + created_at: isoDateTime, + updated_at: isoDateTime.optional(), + run_count: z.number().int().optional(), + watcher_count: z.number().int().optional(), + }) + .openapi("InstrumentDetail"); diff --git a/web/lib/api/openapi/schemas/runs.ts b/web/lib/api/openapi/schemas/runs.ts new file mode 100644 index 00000000..79fdbae7 --- /dev/null +++ b/web/lib/api/openapi/schemas/runs.ts @@ -0,0 +1,269 @@ +import { z } from "zod"; +import { + fileCategorySchema, + fileStatusSchema, + isoDateTime, + paginationSchema, + runSourceSchema, + runStatusSchema, +} from "./common"; + +export const detectedFileSchema = z.object({ + relative_path: z.string().min(1), + filename: z.string().min(1), + size_bytes: z.number().int().nonnegative().optional(), + file_created_at: isoDateTime.optional(), +}); + +export const createRunBody = z.object({ + run_id: z.string().trim().min(1), + source: runSourceSchema, + watcher_id: z.string().uuid().optional(), + acquired_at: isoDateTime.optional(), + detected_files: z.array(detectedFileSchema).optional(), +}); + +export const patchRunBody = z.object({ + metadata: z.record(z.string(), z.unknown()).optional(), + acquired_at: isoDateTime.optional(), + detected_files: z.array(detectedFileSchema).optional(), +}); + +export const runListQuery = z.object({ + instrument_id: z.string().optional(), + source: runSourceSchema.optional(), + search: z.string().optional(), + sort: z.string().optional(), + order: z.enum(["asc", "desc"]).optional(), + date_from: isoDateTime.optional(), + date_to: isoDateTime.optional(), + page: z.coerce.number().int().min(1).optional(), + per_page: z.coerce.number().int().min(1).max(100).optional(), + include_deleted: z.coerce.boolean().optional(), + ran_by: z.string().optional(), + status: z.union([runStatusSchema, z.array(runStatusSchema)]).optional(), + metadata_key: z + .string() + .optional() + .openapi({ description: "Metadata key filter." }), + metadata_value: z + .string() + .optional() + .openapi({ description: "Metadata value filter." }), +}); + +const metadataObject = z.record(z.string(), z.unknown()); + +// A user who has claimed a run. Wire shape mixes `userId` with camelCase +// display fields — mirror it exactly rather than "fixing" the casing here. +export const attribution = z + .object({ + userId: z.string(), + displayName: z.string(), + initials: z.string(), + avatarUrl: z.string().nullable(), + }) + .openapi("RunAttribution"); + +export const runCreated = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + run_id: z.string(), + source: runSourceSchema, + }) + .openapi("RunCreated"); + +export const runUpdated = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + instrument_display_name: z.string().nullable(), + run_id: z.string(), + source: runSourceSchema, + watcher_id: z.string().uuid().nullable(), + metadata: metadataObject.nullable(), + created_at: isoDateTime, + acquired_at: isoDateTime.nullable(), + updated_at: isoDateTime, + deleted_at: isoDateTime.nullable(), + }) + .openapi("RunUpdated"); + +// File sub-object embedded in run detail. Distinct from the top-level file +// resource: it carries a presigned `download_url` and omits `s3_bucket`. +export const runDetailFile = z + .object({ + id: z.number().int(), + filename: z.string(), + relative_path: z.string(), + s3_key: z.string().nullable(), + content_type: z.string().nullable(), + size_bytes: z.number().nullable(), + category: fileCategorySchema, + status: fileStatusSchema, + metadata: metadataObject.nullable(), + error_message: z.string().nullable(), + detected_at: isoDateTime.nullable(), + upload_requested_at: isoDateTime.nullable(), + uploaded_at: isoDateTime.nullable(), + processed_at: isoDateTime.nullable(), + download_url: z.string().url().nullable(), + created_at: isoDateTime, + file_created_at: isoDateTime.nullable(), + }) + .openapi("RunDetailFile"); + +export const runDetail = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + instrument_display_name: z.string().nullable(), + run_id: z.string(), + source: runSourceSchema, + watcher_id: z.string().uuid().nullable(), + created_at: isoDateTime, + acquired_at: isoDateTime.nullable(), + updated_at: isoDateTime, + deleted_at: isoDateTime.nullable(), + deleted_by: z.string().nullable(), + metadata: metadataObject.nullable(), + attributions: z.array(attribution), + files: z.array(runDetailFile), + }) + .openapi("RunDetail"); + +export const runListItem = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + instrument_display_name: z.string().nullable(), + run_id: z.string(), + source: runSourceSchema, + metadata: metadataObject.nullable(), + created_at: isoDateTime, + acquired_at: isoDateTime.nullable(), + updated_at: isoDateTime, + deleted_at: isoDateTime.nullable(), + file_count: z.number().int(), + files_completed: z.number().int(), + files_failed: z.number().int(), + files_pending_upload: z.number().int(), + files_uploaded: z.number().int(), + files_processing: z.number().int(), + // Aggregated with a `bigint` cast, which the driver may surface as a + // string on large totals. + total_size_bytes: z.union([z.number(), z.string()]), + error_messages: z.array(z.string()), + attributions: z.array(attribution), + }) + .openapi("RunListItem"); + +export const runListResponse = z.object({ + data: z.array(runListItem), + pagination: paginationSchema, +}); + +export const runDeleted = z.object({ + instrument_id: z.string(), + run_id: z.string(), + deleted_at: isoDateTime.nullable(), + deleted_by: z.string().nullable(), + already_applied: z.boolean(), +}); + +export const runRestored = z.object({ + id: z.string().uuid(), + instrument_id: z.string(), + run_id: z.string(), + deleted_at: isoDateTime.nullable(), + already_applied: z.boolean(), +}); + +export const runReprocessed = z.object({ + instrument_id: z.string(), + run_id: z.string(), + files_queued: z.number().int(), + files_failed: z.number().int(), +}); + +export const requestUploadBody = z.object({ + file_ids: z.array(z.union([z.string(), z.number()])).min(1), +}); +export const requestUploadUrlBody = z.object({ + filename: z.string().min(1), + content_type: z.string().optional(), + size_bytes: z.number().optional(), + file_created_at: isoDateTime.optional(), +}); +export const commentBody = z.object({ body: z.string().min(1).max(10_000) }); + +export const uploadQueued = z.object({ + instrument_id: z.string(), + run_id: z.string(), + files_queued: z.number().int(), + files: z + .array( + z.object({ + id: z.number().int(), + filename: z.string(), + upload_requested_at: isoDateTime.nullable(), + }) + ) + .optional(), +}); + +export const uploadAllQueued = z.object({ + instrument_id: z.string(), + run_id: z.string(), + files_queued: z.number().int(), +}); + +export const uploadUrlResponse = z + .union([ + z.object({ + already_uploaded: z.literal(true), + file_id: z.number().int(), + s3_bucket: z.string().nullable(), + s3_key: z.string().nullable(), + }), + z.object({ + already_uploaded: z.literal(false), + upload_url: z.string().url(), + s3_bucket: z.string(), + s3_key: z.string(), + file_id: z.number().int(), + expires_in: z.number().int(), + }), + ]) + .openapi("UploadUrlResponse"); + +export const attributionsResponse = z.object({ + attributions: z.array(attribution), +}); + +const commentUser = z.object({ + id: z.string(), + displayName: z.string(), + initials: z.string(), + avatarUrl: z.string().nullable(), +}); + +export const runComment = z + .object({ + id: z.string().uuid(), + body: z.string(), + user: commentUser, + created_at: isoDateTime, + edited_at: isoDateTime.nullable(), + }) + .openapi("RunComment"); + +export const commentsListResponse = z.object({ + comments: z.array(runComment), +}); + +export const commentDeleted = z.object({ + id: z.string(), + deleted: z.literal(true), +}); diff --git a/web/lib/api/openapi/schemas/search.ts b/web/lib/api/openapi/schemas/search.ts new file mode 100644 index 00000000..f66ca8f9 --- /dev/null +++ b/web/lib/api/openapi/schemas/search.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const searchQuery = z.object({ + q: z.string().optional(), + scope: z.enum(["all", "runs", "files", "instruments"]).optional(), +}); + +export const searchResponse = z.object({ + runs: z.array(z.record(z.string(), z.unknown())), + files: z.array(z.record(z.string(), z.unknown())), + instruments: z.array(z.record(z.string(), z.unknown())), +}); diff --git a/web/lib/api/openapi/schemas/watchers.ts b/web/lib/api/openapi/schemas/watchers.ts new file mode 100644 index 00000000..c54a91c3 --- /dev/null +++ b/web/lib/api/openapi/schemas/watchers.ts @@ -0,0 +1,139 @@ +import { z } from "zod"; +import { + isoDateTime, + uploadModeSchema, + watcherEventTypeSchema, + watcherStatusSchema, +} from "./common"; + +export const registerWatcherBody = z.object({ + instrument_id: z.string().min(1), + hostname: z.string().optional(), + os_info: z.string().optional(), +}); + +export const watcherConfigBody = z.object({ + config_checksum: z.string().min(1), + config_yaml: z.string().min(1), +}); + +const watcherEventItem = z.object({ + event_type: watcherEventTypeSchema, + timestamp: z.string().min(1), + message: z.string().min(1), + details: z.record(z.string(), z.unknown()).optional(), +}); + +export const watcherEventBody = z.object({ + events: z.array(watcherEventItem).min(1).max(100), +}); + +export const heartbeatBody = z.object({ + status: z.enum(["registered", "watching", "stopped"]), + timestamp: z.string().optional(), + watcher_version: z.string().optional(), + upload_mode: uploadModeSchema.optional(), + files_uploaded_since_last_heartbeat: z + .number() + .int() + .nonnegative() + .optional(), + runs_reported_since_last_heartbeat: z.number().int().nonnegative().optional(), + errors_since_last_heartbeat: z.number().int().nonnegative().optional(), + uptime_seconds: z.number().int().nonnegative().optional(), +}); + +// Shared by the detail (`GET /watchers/{id}`) and list responses. Detail +// includes `config_*` and omits `deleted_at`; the list is the reverse — so +// the fields that differ are optional. +export const watcherDetail = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + instrument_display_name: z.string().nullable().optional(), + hostname: z.string().nullable(), + os_info: z.string().nullable(), + status: watcherStatusSchema, + config_yaml: z.string().nullable().optional(), + config_checksum: z.string().nullable().optional(), + last_heartbeat_at: isoDateTime.nullable(), + created_at: isoDateTime, + updated_at: isoDateTime, + deleted_at: isoDateTime.nullable().optional(), + }) + .openapi("WatcherDetail"); + +export const watcherListResponse = z.object({ + data: z.array(watcherDetail), +}); + +export const watcherRegistered = z.object({ + watcher_id: z.string().uuid(), +}); + +export const watcherDeleted = z.object({ + id: z.string().uuid(), + deleted_at: isoDateTime, +}); + +// Both `PUT /config` and `GET /config-checksum` return only the checksum. +export const watcherChecksumResponse = z.object({ + config_checksum: z.string(), +}); + +export const watcherEventCreated = z.object({ + received: z.number().int(), +}); + +const watcherEvent = z.object({ + id: z.number().int(), + event_type: watcherEventTypeSchema, + message: z.string(), + details: z.record(z.string(), z.unknown()).nullable(), + timestamp: isoDateTime, + created_at: isoDateTime, +}); + +export const watcherEventsListResponse = z.object({ + data: z.array(watcherEvent), +}); + +export const watcherHeartbeatAck = z.object({ + ok: z.literal(true), +}); + +const watcherHeartbeat = z.object({ + id: z.number().int(), + timestamp: isoDateTime, + status: z.string(), + upload_mode: uploadModeSchema.nullable(), + files_uploaded_since_last: z.number().int().nullable(), + runs_reported_since_last: z.number().int().nullable(), + errors_since_last: z.number().int().nullable(), + uptime_seconds: z.number().int().nullable(), + created_at: isoDateTime, +}); + +export const watcherHeartbeatsListResponse = z.object({ + data: z.array(watcherHeartbeat), +}); + +export const watcherUploadQueueResponse = z.object({ + files: z.array( + z.object({ + id: z.number().int(), + instrument_id: z.string(), + run_id: z.string(), + relative_path: z.string(), + filename: z.string(), + size_bytes: z.number().nullable(), + }) + ), +}); + +export const watcherUpdateCheckResponse = z.object({ + channel: z.string(), + latest_version: z.string().nullable(), + mandatory: z.boolean(), + min_supported_version: z.string().nullable(), +}); diff --git a/web/package-lock.json b/web/package-lock.json index d5404e5b..80387a11 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", "@auth/drizzle-adapter": "^1.11.1", "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", @@ -80,6 +81,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@auth/core": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", @@ -12326,6 +12339,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.0.tgz", + "integrity": "sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==", + "license": "MIT", + "dependencies": { + "yaml": "^2.9.0" + } + }, "node_modules/ora": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", diff --git a/web/package.json b/web/package.json index f3c67a43..fc74dcf9 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "db:reseed": "npm run db:reset && npm run db:push && npm run db:seed", "db:process-fixtures": "tsx scripts/process-seeded-fixtures.ts", "db:clear-runs": "tsx scripts/clear-run-file-records.ts", + "openapi:generate": "tsx scripts/generate-openapi.ts", "test:unit": "vitest run --config vitest.unit.config.ts", "test:unit:watch": "vitest --config vitest.unit.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts", @@ -29,6 +30,7 @@ "check-all": "npm run check && npm run test-all && npm run build" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", "@auth/drizzle-adapter": "^1.11.1", "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", diff --git a/web/scripts/generate-openapi.ts b/web/scripts/generate-openapi.ts new file mode 100644 index 00000000..a64dd580 --- /dev/null +++ b/web/scripts/generate-openapi.ts @@ -0,0 +1,9 @@ +// Optional local dump for inspection. Output is gitignored; production +// serves the schema from GET /api/v1/openapi.json (built statically). +import { writeFile } from "node:fs/promises"; +import { buildOpenApiDocument } from "@/lib/api/openapi"; + +await writeFile( + "openapi.json", + `${JSON.stringify(buildOpenApiDocument(), null, 2)}\n` +); diff --git a/web/tests/integration/archive-jobs.test.ts b/web/tests/integration/archive-jobs.test.ts index d7cf0e24..34b2065e 100644 --- a/web/tests/integration/archive-jobs.test.ts +++ b/web/tests/integration/archive-jobs.test.ts @@ -5,6 +5,7 @@ import { STUCK_BUILD_ERROR_MESSAGE, STUCK_BUILD_TIMEOUT_MS, } from "@/lib/api/archive-jobs"; +import { archiveJobDetail } from "@/lib/api/openapi"; import { archiveJobs, instrumentRuns, instruments } from "@/lib/db/schema"; import { closeTestDb, @@ -161,6 +162,9 @@ describe("Archive Jobs API", () => { const body = await res.json(); expect(body.status).toBe("ready"); expect(body.completed_at).not.toBeNull(); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + archiveJobDetail.parse(body); // Verify the row was actually updated rather than just the response shaped. const [stored] = await db diff --git a/web/tests/integration/files.test.ts b/web/tests/integration/files.test.ts index 01d07d1a..ddaaea2f 100644 --- a/web/tests/integration/files.test.ts +++ b/web/tests/integration/files.test.ts @@ -1,5 +1,6 @@ import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { fileDetail, fileDismissed } from "@/lib/api/openapi"; import { instruments, files as schemaFiles } from "@/lib/db/schema"; import { api, @@ -117,6 +118,9 @@ describe("Files API", () => { expect(data.category).toBe("processed"); expect(data.relative_path).toBe("processed_output.csv"); lambdaFileId = data.id; + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + fileDetail.parse(data); }); // Reconcile case 1: the watcher reported a detected row first, then the @@ -304,6 +308,7 @@ describe("Files API", () => { expect(data.status).toBe("uploaded"); expect(data.s3_bucket).toBe("test-bucket"); expect(data.uploaded_at).toBeTruthy(); + fileDetail.parse(data); }); it("PATCH transitions uploaded → processing", async () => { @@ -412,6 +417,7 @@ describe("Files API", () => { const data = await res.json(); expect(data.deleted_at).toBeTruthy(); expect(data.already_applied).toBe(false); + fileDismissed.parse(data); }); it("DELETE is idempotent — re-dismissing an already-deleted file succeeds", async () => { diff --git a/web/tests/integration/instrument-runs.test.ts b/web/tests/integration/instrument-runs.test.ts index 160f9286..048fa3b9 100644 --- a/web/tests/integration/instrument-runs.test.ts +++ b/web/tests/integration/instrument-runs.test.ts @@ -1,4 +1,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + runCreated, + runDeleted, + runDetail, + runListResponse, + runUpdated, +} from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -47,6 +54,9 @@ describe("Instrument Runs API", () => { expect(data.run_id).toBe("run-001"); expect(data.source).toBe("lambda"); expect(data.id).toBeTruthy(); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + runCreated.parse(data); }); // Run creation is idempotent on (instrument_id, run_id). This handles the @@ -61,6 +71,7 @@ describe("Instrument Runs API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.run_id).toBe("run-001"); + runCreated.parse(data); }); it("POST rejects missing run_id", async () => { @@ -137,6 +148,7 @@ describe("Instrument Runs API", () => { expect(body.data.length).toBeGreaterThanOrEqual(2); expect(body.pagination).toBeTruthy(); expect(body.pagination.total).toBeGreaterThanOrEqual(2); + runListResponse.parse(body); }); it("GET supports source filter", async () => { @@ -161,6 +173,7 @@ describe("Instrument Runs API", () => { const body = await res.json(); expect(body.data.length).toBeGreaterThanOrEqual(2); expect(body.pagination).toBeTruthy(); + runListResponse.parse(body); }); it("GET /api/v1/instrument-runs supports instrument_id filter", async () => { @@ -189,6 +202,7 @@ describe("Instrument Runs API", () => { expect(data.instrument_id).toBe(instrumentId); expect(data).toHaveProperty("files"); expect(data).toHaveProperty("metadata"); + runDetail.parse(data); }); it("GET returns 404 for nonexistent run", async () => { @@ -214,6 +228,7 @@ describe("Instrument Runs API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.metadata).toEqual({ assay: "Bradford", plate: "96-well" }); + runUpdated.parse(data); }); it("PATCH rejects non-object metadata", async () => { @@ -251,6 +266,7 @@ describe("Instrument Runs API", () => { // The acting user (the PAT's owner) is recorded as the deleter. expect(data.deleted_by).toBe(userId); expect(data.already_applied).toBe(false); + runDeleted.parse(data); }); it("DELETE is idempotent — re-deleting an already-deleted run succeeds", async () => { diff --git a/web/tests/integration/instruments.test.ts b/web/tests/integration/instruments.test.ts index e899b087..af6da477 100644 --- a/web/tests/integration/instruments.test.ts +++ b/web/tests/integration/instruments.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { instrumentDetail, instrumentListItem } from "@/lib/api/openapi"; import { api, closeTestDb, @@ -35,6 +36,9 @@ describe("Instruments API", () => { expect(data.id).toBe("test-instrument"); expect(data.display_name).toBe("Test Instrument"); expect(data.status).toBe("pending"); + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + instrumentDetail.parse(data); }); // When no display_name is provided, the API derives one from the kebab-case @@ -95,6 +99,7 @@ describe("Instruments API", () => { expect( data.find((i: { id: string }) => i.id === "test-instrument") ).toBeTruthy(); + instrumentListItem.array().parse(data); }); // ------------------------------------------------------------------------- @@ -109,6 +114,7 @@ describe("Instruments API", () => { expect(data.display_name).toBe("Test Instrument"); expect(data).toHaveProperty("run_count"); expect(data).toHaveProperty("watcher_count"); + instrumentDetail.parse(data); }); it("GET /api/v1/instruments/:id returns 404 for nonexistent id", async () => { @@ -131,6 +137,7 @@ describe("Instruments API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe("active"); + instrumentDetail.parse(data); }); it("PATCH /api/v1/instruments/:id updates display_name", async () => { diff --git a/web/tests/integration/request-upload-url.test.ts b/web/tests/integration/request-upload-url.test.ts index 6d60065d..cde3667e 100644 --- a/web/tests/integration/request-upload-url.test.ts +++ b/web/tests/integration/request-upload-url.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { uploadUrlResponse } from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -66,6 +67,9 @@ describe("Request Upload URL API", () => { expect(data.file_id).toBeGreaterThan(0); expect(data.expires_in).toBe(3600); expect(data.already_uploaded).toBe(false); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + uploadUrlResponse.parse(data); }); it("creates a file record if none exists", async () => { @@ -135,6 +139,7 @@ describe("Request Upload URL API", () => { const data = await res.json(); expect(data.already_uploaded).toBe(true); expect(data.file_id).toBe(fileId); + uploadUrlResponse.parse(data); }); // ------------------------------------------------------------------------- diff --git a/web/tests/integration/request-upload-validation.test.ts b/web/tests/integration/request-upload-validation.test.ts index 494ed650..4856b062 100644 --- a/web/tests/integration/request-upload-validation.test.ts +++ b/web/tests/integration/request-upload-validation.test.ts @@ -1,5 +1,6 @@ import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { uploadQueued } from "@/lib/api/openapi"; import { files, instrumentRuns, instruments, watchers } from "@/lib/db/schema"; import { api, @@ -129,6 +130,9 @@ describe("Request Upload — file_ids validation", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.files_queued).toBe(fileIds.length); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + uploadQueued.parse(data); expect(await fileStatuses()).toEqual([ "upload_requested", "upload_requested", diff --git a/web/tests/integration/run-attributions.test.ts b/web/tests/integration/run-attributions.test.ts index 58afb21b..744c83d0 100644 --- a/web/tests/integration/run-attributions.test.ts +++ b/web/tests/integration/run-attributions.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { attributionsResponse, runDetail } from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -93,6 +94,9 @@ describe("Run Attributions API", () => { expect(body.attributions[0].userId).toBe(userIdA); expect(body.attributions[0].displayName).toBeTruthy(); expect(body.attributions[0].initials).toBeTruthy(); + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + attributionsResponse.parse(body); }); it("PUT is idempotent — claiming twice still yields one entry", async () => { @@ -141,6 +145,7 @@ describe("Run Attributions API", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.attributions).toEqual([]); + attributionsResponse.parse(body); }); it("DELETE is idempotent — deleting when no attribution exists is a no-op", async () => { @@ -251,5 +256,6 @@ describe("Run Attributions API", () => { expect(Array.isArray(body.attributions)).toBe(true); expect(body.attributions).toHaveLength(1); expect(body.attributions[0].userId).toBe(userIdA); + runDetail.parse(body); }); }); diff --git a/web/tests/integration/run-comments.test.ts b/web/tests/integration/run-comments.test.ts index 32ddc5ed..976c8a23 100644 --- a/web/tests/integration/run-comments.test.ts +++ b/web/tests/integration/run-comments.test.ts @@ -1,4 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + commentDeleted, + commentsListResponse, + runComment, +} from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -100,6 +105,9 @@ describe("Run Comments API", () => { expect(created.id).toBeTruthy(); expect(created.body).toBe("Hello **world**"); expect(created.edited_at).toBeNull(); + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + runComment.parse(created); const res = await api(commentsPath(runId), { token: tokenA }); expect(res.status).toBe(200); @@ -109,6 +117,7 @@ describe("Run Comments API", () => { expect(body.comments[0].user.id).toBe(userIdA); expect(body.comments[0].user.displayName).toBeTruthy(); expect(body.comments[0].user.initials).toBeTruthy(); + commentsListResponse.parse(body); }); it("POST returns 400 for missing body field", async () => { @@ -167,6 +176,7 @@ describe("Run Comments API", () => { const updated = await res.json(); expect(updated.body).toBe("second draft"); expect(updated.edited_at).toBeTruthy(); + runComment.parse(updated); }); it("PATCH by another user returns 403 FORBIDDEN", async () => { @@ -205,6 +215,7 @@ describe("Run Comments API", () => { token: tokenA, }); expect(del.status).toBe(200); + commentDeleted.parse(await del.json()); const list = await api(commentsPath(runId), { token: tokenA }); const body = await list.json(); diff --git a/web/tests/integration/run-lifecycle.test.ts b/web/tests/integration/run-lifecycle.test.ts index 9dd7cdf2..428d0668 100644 --- a/web/tests/integration/run-lifecycle.test.ts +++ b/web/tests/integration/run-lifecycle.test.ts @@ -1,5 +1,6 @@ import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runDeleted, runRestored } from "@/lib/api/openapi"; import { instrumentRuns, instruments } from "@/lib/db/schema"; import { api, @@ -70,6 +71,9 @@ describe("Run lifecycle API (delete / restore)", () => { expect(body.run_id).toBe(runId); expect(body.deleted_at).toBeNull(); expect(body.already_applied).toBe(false); + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + runRestored.parse(body); }); it("restore on a run that isn't deleted is an idempotent no-op", async () => { @@ -92,6 +96,7 @@ describe("Run lifecycle API (delete / restore)", () => { const firstBody = await first.json(); expect(firstBody.already_applied).toBe(false); expect(firstBody.deleted_at).toBeTruthy(); + runDeleted.parse(firstBody); const second = await api( `/api/v1/instruments/${instrumentId}/runs/${runId}`, diff --git a/web/tests/integration/upload-request-cancellation.test.ts b/web/tests/integration/upload-request-cancellation.test.ts index 1e7407e4..ac846081 100644 --- a/web/tests/integration/upload-request-cancellation.test.ts +++ b/web/tests/integration/upload-request-cancellation.test.ts @@ -1,5 +1,6 @@ import { inArray } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { watcherUploadQueueResponse } from "@/lib/api/openapi"; import { files, instruments } from "@/lib/db/schema"; import { api, @@ -107,7 +108,11 @@ describe("Upload request cancellation on watch-directory change", () => { const before = await api(`/api/v1/watchers/${watcherId}/upload-queue`, { token, }); - expect((await before.json()).files).toHaveLength(2); + const beforeBody = await before.json(); + expect(beforeBody.files).toHaveLength(2); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + watcherUploadQueueResponse.parse(beforeBody); const res = await api(`/api/v1/watchers/${watcherId}/config`, { method: "PUT", diff --git a/web/tests/integration/watchers.test.ts b/web/tests/integration/watchers.test.ts index bbe0feed..715de8bb 100644 --- a/web/tests/integration/watchers.test.ts +++ b/web/tests/integration/watchers.test.ts @@ -1,4 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + watcherChecksumResponse, + watcherDeleted, + watcherDetail, + watcherEventCreated, + watcherEventsListResponse, + watcherHeartbeatAck, + watcherHeartbeatsListResponse, + watcherListResponse, + watcherRegistered, + watcherUpdateCheckResponse, +} from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -51,6 +63,9 @@ describe("Watchers API", () => { const data = await res.json(); expect(data.watcher_id).toBeTruthy(); watcherId = data.watcher_id; + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + watcherRegistered.parse(data); }); // Enforces the 1:1 active-watcher-per-instrument invariant. The DB-level @@ -126,6 +141,7 @@ describe("Watchers API", () => { expect(watcher).toBeTruthy(); expect(watcher.instrument_id).toBe(instrumentId); expect(watcher.hostname).toBe("lab-pc-01"); + watcherListResponse.parse(body); }); it("GET /api/v1/watchers filters by instrument_id", async () => { @@ -160,6 +176,7 @@ describe("Watchers API", () => { expect(data.instrument_id).toBe(instrumentId); expect(data.hostname).toBe("lab-pc-01"); expect(data.os_info).toBe("Windows 11"); + watcherDetail.parse(data); }); it("GET /api/v1/watchers/:id returns 404 for nonexistent watcher", async () => { @@ -199,6 +216,7 @@ describe("Watchers API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.ok).toBe(true); + watcherHeartbeatAck.parse(data); }); it("POST /api/v1/watchers/:id/heartbeat rejects missing status", async () => { @@ -222,6 +240,7 @@ describe("Watchers API", () => { const body = await res.json(); expect(body.data.length).toBeGreaterThanOrEqual(1); expect(body.data[0].status).toBe("watching"); + watcherHeartbeatsListResponse.parse(body); }); // ------------------------------------------------------------------------- @@ -253,6 +272,7 @@ describe("Watchers API", () => { expect(res.status).toBe(201); const data = await res.json(); expect(data.received).toBe(2); + watcherEventCreated.parse(data); }); it("POST /api/v1/watchers/:id/events rejects empty events array", async () => { @@ -326,6 +346,7 @@ describe("Watchers API", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.data.length).toBeGreaterThanOrEqual(2); + watcherEventsListResponse.parse(body); }); it("GET /api/v1/watchers/:id/events filters by event_type", async () => { @@ -359,6 +380,7 @@ describe("Watchers API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.config_checksum).toBe("abc123"); + watcherChecksumResponse.parse(data); }); it("PUT /api/v1/watchers/:id/config rejects missing fields", async () => { @@ -381,6 +403,7 @@ describe("Watchers API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.config_checksum).toBe("abc123"); + watcherChecksumResponse.parse(data); }); // ------------------------------------------------------------------------- @@ -403,6 +426,7 @@ describe("Watchers API", () => { channel: "stable", mandatory: false, }); + watcherUpdateCheckResponse.parse(data); }); it("GET /api/v1/watchers/:id/update-check requires authentication", async () => { @@ -440,6 +464,7 @@ describe("Watchers API", () => { const data = await res.json(); expect(data.id).toBe(watcherId); expect(data.deleted_at).toBeTruthy(); + watcherDeleted.parse(data); }); it("DELETE /api/v1/watchers/:id returns 409 for already-deleted watcher", async () => { diff --git a/web/tests/unit/openapi-document.test.ts b/web/tests/unit/openapi-document.test.ts new file mode 100644 index 00000000..04cc73ac --- /dev/null +++ b/web/tests/unit/openapi-document.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + buildOpenApiDocument, + createInstrumentBody, + createRunBody, + requestUploadBody, +} from "@/lib/api/openapi"; + +describe("OpenAPI document", () => { + it("generates the documented API", () => { + const document = buildOpenApiDocument(); + + expect(document.openapi).toBe("3.1.0"); + expect(document.paths).toHaveProperty("/instruments"); + expect(document.paths).toHaveProperty("/openapi.json"); + expect(document.components?.securitySchemes).toHaveProperty("bearerAuth"); + expect(document.tags?.map((tag) => tag.name)).toEqual( + expect.arrayContaining([ + "Meta", + "Instruments", + "Runs", + "Files", + "Watchers", + "Archive", + ]) + ); + }); + + it("documents restore and archive download with the route scopes", () => { + const document = buildOpenApiDocument(); + const restore = + document.paths?.["/instruments/{instrumentId}/runs/{runId}/restore"] + ?.post; + const archive = + document.paths?.[ + "/instruments/{instrumentId}/runs/{runId}/download-archive" + ]?.get; + + expect(restore?.description).toContain("`runs:delete`"); + expect(archive?.description).toContain("`files:read`"); + }); + + it("marks numeric path params as required integers", () => { + const document = buildOpenApiDocument(); + const params = document.paths?.["/files/{fileId}"]?.patch?.parameters ?? []; + const fileId = params.find( + (param) => "name" in param && param.name === "fileId" + ); + + expect(fileId).toMatchObject({ + in: "path", + required: true, + schema: { type: "integer" }, + }); + }); +}); + +describe("request body schemas", () => { + it("trims run_id and rejects blank values", () => { + expect( + createRunBody.parse({ run_id: " abc ", source: "lambda" }).run_id + ).toBe("abc"); + expect( + createRunBody.safeParse({ run_id: " ", source: "lambda" }).success + ).toBe(false); + }); + + it("trims display_name so whitespace falls back in the route", () => { + expect( + createInstrumentBody.parse({ id: "my-instrument", display_name: " " }) + .display_name + ).toBe(""); + }); + + it("requires at least one file_id for upload requests", () => { + expect(requestUploadBody.safeParse({ file_ids: [] }).success).toBe(false); + expect(requestUploadBody.parse({ file_ids: [1] }).file_ids).toEqual([1]); + }); +});