From 6525a553ae453f555134fa3fa6a6c9658457ac24 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 2 Feb 2026 21:21:11 -0800 Subject: [PATCH] feat: add capture time overrides and apply in queries --- .../[id]/override-capture-ts/handlers.ts | 97 +++++++++++++++++++ .../assets/[id]/override-capture-ts/route.ts | 31 ++++++ apps/web/app/api/assets/route.ts | 50 +++++----- apps/web/app/api/tree/route.ts | 42 ++++---- .../asset-overrides-admin-auth.test.ts | 28 ++++++ .../db/migrations/0005_asset_overrides.sql | 9 ++ 6 files changed, 216 insertions(+), 41 deletions(-) create mode 100644 apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts create mode 100644 apps/web/app/api/assets/[id]/override-capture-ts/route.ts create mode 100644 apps/web/src/__tests__/asset-overrides-admin-auth.test.ts create mode 100644 packages/db/migrations/0005_asset_overrides.sql diff --git a/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts b/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts new file mode 100644 index 0000000..9bbc3b5 --- /dev/null +++ b/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts @@ -0,0 +1,97 @@ +import { getAdminToken, isAdminRequest } from "@tline/config"; +import { getDb } from "@tline/db"; +import { z } from "zod"; + +const ADMIN_HEADER = "X-Porthole-Admin-Token"; + +const paramsSchema = z.object({ + id: z.string().uuid(), +}); + +const bodySchema = z + .object({ + captureTsUtcOverride: z.string().datetime().nullable().optional(), + captureOffsetMinutesOverride: z.coerce.number().int().nullable().optional(), + }) + .strict(); + +type DbLike = ReturnType; + +export function getAdminOk(headers: Headers) { + const headerToken = headers.get(ADMIN_HEADER); + return isAdminRequest({ adminToken: getAdminToken() }, { headerToken }); +} + +export async function handleSetCaptureOverride(input: { + adminOk: boolean; + params: { id: string }; + body: unknown; + db?: DbLike; +}): Promise<{ status: number; body: unknown }> { + if (!input.adminOk) { + return { status: 401, body: { error: "admin_required" } }; + } + + const paramsParsed = paramsSchema.safeParse(input.params); + if (!paramsParsed.success) { + return { + status: 400, + body: { error: "invalid_params", issues: paramsParsed.error.issues }, + }; + } + + const bodyParsed = bodySchema.safeParse(input.body ?? {}); + if (!bodyParsed.success) { + return { + status: 400, + body: { error: "invalid_body", issues: bodyParsed.error.issues }, + }; + } + + const db = (input.db ?? getDb()) as DbLike; + const data = bodyParsed.data; + const captureTs = data.captureTsUtcOverride + ? new Date(data.captureTsUtcOverride) + : null; + const captureOffset = + data.captureOffsetMinutesOverride !== undefined + ? data.captureOffsetMinutesOverride + : null; + + const rows = await db< + { + asset_id: string; + capture_ts_utc_override: string | null; + capture_offset_minutes_override: number | null; + created_at: string; + }[] + >` + insert into asset_overrides ( + asset_id, + capture_ts_utc_override, + capture_offset_minutes_override + ) + values (${paramsParsed.data.id}, ${captureTs}, ${captureOffset}) + on conflict (asset_id) + do update set + capture_ts_utc_override = excluded.capture_ts_utc_override, + capture_offset_minutes_override = excluded.capture_offset_minutes_override + returning asset_id, capture_ts_utc_override, capture_offset_minutes_override, created_at + `; + + const created = rows[0]; + if (!created) { + return { status: 500, body: { error: "insert_failed" } }; + } + + const payload = JSON.stringify({ + capture_ts_utc_override: created.capture_ts_utc_override, + capture_offset_minutes_override: created.capture_offset_minutes_override, + }); + await db` + insert into audit_log (actor, action, entity_type, entity_id, payload) + values ('admin', 'override_capture_ts', 'asset', ${created.asset_id}, ${payload}::jsonb) + `; + + return { status: 200, body: created }; +} diff --git a/apps/web/app/api/assets/[id]/override-capture-ts/route.ts b/apps/web/app/api/assets/[id]/override-capture-ts/route.ts new file mode 100644 index 0000000..4dffd05 --- /dev/null +++ b/apps/web/app/api/assets/[id]/override-capture-ts/route.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import { getAdminOk, handleSetCaptureOverride } from "./handlers"; + +export const runtime = "nodejs"; + +const paramsSchema = z.object({ + id: z.string().uuid(), +}); + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +): Promise { + const rawParams = await context.params; + const paramsParsed = paramsSchema.safeParse(rawParams); + if (!paramsParsed.success) { + return Response.json( + { error: "invalid_params", issues: paramsParsed.error.issues }, + { status: 400 }, + ); + } + + const bodyJson = await request.json().catch(() => ({})); + const res = await handleSetCaptureOverride({ + adminOk: getAdminOk(request.headers), + params: paramsParsed.data, + body: bodyJson, + }); + return Response.json(res.body, { status: res.status }); +} diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts index 2bc75b6..5fd3538 100644 --- a/apps/web/app/api/assets/route.ts +++ b/apps/web/app/api/assets/route.ts @@ -68,35 +68,37 @@ export async function GET(request: Request): Promise { }[] >` select - id, - bucket, - media_type, - mime_type, - active_key, - capture_ts_utc, - date_confidence, - width, - height, - rotation, - duration_seconds, - thumb_small_key, - thumb_med_key, - poster_key, - status, - error_message - from assets + a.id, + a.bucket, + a.media_type, + a.mime_type, + a.active_key, + coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as capture_ts_utc, + a.date_confidence, + a.width, + a.height, + a.rotation, + a.duration_seconds, + a.thumb_small_key, + a.thumb_med_key, + a.poster_key, + a.status, + a.error_message + from assets a + left join asset_overrides o + on o.asset_id = a.id where true - and capture_ts_utc is not null - and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz) - and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz) - and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type) - and (${query.status ?? null}::asset_status is null or status = ${query.status ?? null}::asset_status) + and coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null + and (${start}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) >= ${start}::timestamptz) + and (${end}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) < ${end}::timestamptz) + and (${query.mediaType ?? null}::media_type is null or a.media_type = ${query.mediaType ?? null}::media_type) + and (${query.status ?? null}::asset_status is null or a.status = ${query.status ?? null}::asset_status) and ( ${cursorId}::uuid is null or ${cursorTs}::timestamptz is null - or (capture_ts_utc, id) > (${cursorTs}::timestamptz, ${cursorId}::uuid) + or (coalesce(o.capture_ts_utc_override, a.capture_ts_utc), a.id) > (${cursorTs}::timestamptz, ${cursorId}::uuid) ) - order by capture_ts_utc asc nulls last, id asc + order by coalesce(o.capture_ts_utc_override, a.capture_ts_utc) asc nulls last, a.id asc limit ${query.limit} `; diff --git a/apps/web/app/api/tree/route.ts b/apps/web/app/api/tree/route.ts index 660f624..f8fd6e5 100644 --- a/apps/web/app/api/tree/route.ts +++ b/apps/web/app/api/tree/route.ts @@ -18,7 +18,7 @@ const querySchema = z type Granularity = z.infer["granularity"]; function sqlGroupExpr(granularity: Granularity, alias: string) { - const col = `${alias}.capture_ts_utc`; + const col = `${alias}.effective_capture_ts_utc`; if (granularity === "year") return `date_trunc('year', ${col})`; if (granularity === "month") return `date_trunc('month', ${col})`; return `date_trunc('day', ${col})`; @@ -71,23 +71,31 @@ export async function GET(request: Request): Promise { >` with filtered as ( select - id, - bucket, - media_type, - status, - capture_ts_utc, - active_key, - thumb_small_key, - thumb_med_key, - poster_key - from assets - where capture_ts_utc is not null - and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz) - and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz) - and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type) + a.id, + a.bucket, + a.media_type, + a.status, + coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as effective_capture_ts_utc, + a.active_key, + a.thumb_small_key, + a.thumb_med_key, + a.poster_key + from assets a + left join asset_overrides o + on o.asset_id = a.id + where coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null + and ( + ${start}::timestamptz is null + or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) >= ${start}::timestamptz + ) + and ( + ${end}::timestamptz is null + or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) < ${end}::timestamptz + ) + and (${query.mediaType ?? null}::media_type is null or a.media_type = ${query.mediaType ?? null}::media_type) and ( ${query.includeFailed}::boolean = true - or status <> 'failed' + or a.status <> 'failed' ) ), grouped as ( @@ -120,7 +128,7 @@ export async function GET(request: Request): Promise { where f.bucket = g.bucket and ${db.unsafe(groupExprF)} = g.group_ts and f.status = 'ready' - order by f.capture_ts_utc asc + order by f.effective_capture_ts_utc asc limit 1 ) s on true order by g.group_ts desc diff --git a/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts b/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts new file mode 100644 index 0000000..1762206 --- /dev/null +++ b/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts @@ -0,0 +1,28 @@ +import { test, expect } from "bun:test"; + +test("asset overrides POST rejects when missing admin token", async () => { + const { handleSetCaptureOverride } = await import( + "../../app/api/assets/[id]/override-capture-ts/handlers" + ); + const res = await handleSetCaptureOverride({ + adminOk: false, + params: { id: "00000000-0000-4000-8000-000000000000" }, + body: { captureTsUtcOverride: "2026-02-01T00:00:00.000Z" }, + }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "admin_required" }); +}); + +test("asset overrides POST rejects invalid body", async () => { + const { handleSetCaptureOverride } = await import( + "../../app/api/assets/[id]/override-capture-ts/handlers" + ); + const res = await handleSetCaptureOverride({ + adminOk: true, + params: { id: "00000000-0000-4000-8000-000000000000" }, + body: { captureTsUtcOverride: "not-a-date" }, + }); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ error: "invalid_body" }); + expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true); +}); diff --git a/packages/db/migrations/0005_asset_overrides.sql b/packages/db/migrations/0005_asset_overrides.sql new file mode 100644 index 0000000..d7fd0e6 --- /dev/null +++ b/packages/db/migrations/0005_asset_overrides.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS asset_overrides ( + asset_id uuid PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE, + capture_ts_utc_override timestamptz, + capture_offset_minutes_override int, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS asset_overrides_capture_ts_idx + ON asset_overrides(capture_ts_utc_override);