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/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/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/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/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/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/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/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/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/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" + ); + }); +}); 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]); + }); +});