feat: add capture time overrides and apply in queries

This commit is contained in:
William Valentin
2026-02-02 21:21:11 -08:00
parent 1f8c28e1db
commit 6525a553ae
6 changed files with 216 additions and 41 deletions

View File

@@ -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<typeof getDb>;
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 };
}

View File

@@ -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<Response> {
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 });
}

View File

@@ -68,35 +68,37 @@ export async function GET(request: Request): Promise<Response> {
}[]
>`
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}
`;

View File

@@ -18,7 +18,7 @@ const querySchema = z
type Granularity = z.infer<typeof querySchema>["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<Response> {
>`
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<Response> {
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