feat: add capture time overrides and apply in queries
This commit is contained in:
97
apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts
Normal file
97
apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts
Normal 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 };
|
||||||
|
}
|
||||||
31
apps/web/app/api/assets/[id]/override-capture-ts/route.ts
Normal file
31
apps/web/app/api/assets/[id]/override-capture-ts/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -68,35 +68,37 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
}[]
|
}[]
|
||||||
>`
|
>`
|
||||||
select
|
select
|
||||||
id,
|
a.id,
|
||||||
bucket,
|
a.bucket,
|
||||||
media_type,
|
a.media_type,
|
||||||
mime_type,
|
a.mime_type,
|
||||||
active_key,
|
a.active_key,
|
||||||
capture_ts_utc,
|
coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as capture_ts_utc,
|
||||||
date_confidence,
|
a.date_confidence,
|
||||||
width,
|
a.width,
|
||||||
height,
|
a.height,
|
||||||
rotation,
|
a.rotation,
|
||||||
duration_seconds,
|
a.duration_seconds,
|
||||||
thumb_small_key,
|
a.thumb_small_key,
|
||||||
thumb_med_key,
|
a.thumb_med_key,
|
||||||
poster_key,
|
a.poster_key,
|
||||||
status,
|
a.status,
|
||||||
error_message
|
a.error_message
|
||||||
from assets
|
from assets a
|
||||||
|
left join asset_overrides o
|
||||||
|
on o.asset_id = a.id
|
||||||
where true
|
where true
|
||||||
and capture_ts_utc is not null
|
and coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null
|
||||||
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
|
and (${start}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) >= ${start}::timestamptz)
|
||||||
and (${end}::timestamptz is null or capture_ts_utc < ${end}::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 media_type = ${query.mediaType ?? null}::media_type)
|
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 status = ${query.status ?? null}::asset_status)
|
and (${query.status ?? null}::asset_status is null or a.status = ${query.status ?? null}::asset_status)
|
||||||
and (
|
and (
|
||||||
${cursorId}::uuid is null
|
${cursorId}::uuid is null
|
||||||
or ${cursorTs}::timestamptz 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}
|
limit ${query.limit}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const querySchema = z
|
|||||||
type Granularity = z.infer<typeof querySchema>["granularity"];
|
type Granularity = z.infer<typeof querySchema>["granularity"];
|
||||||
|
|
||||||
function sqlGroupExpr(granularity: Granularity, alias: string) {
|
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 === "year") return `date_trunc('year', ${col})`;
|
||||||
if (granularity === "month") return `date_trunc('month', ${col})`;
|
if (granularity === "month") return `date_trunc('month', ${col})`;
|
||||||
return `date_trunc('day', ${col})`;
|
return `date_trunc('day', ${col})`;
|
||||||
@@ -71,23 +71,31 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
>`
|
>`
|
||||||
with filtered as (
|
with filtered as (
|
||||||
select
|
select
|
||||||
id,
|
a.id,
|
||||||
bucket,
|
a.bucket,
|
||||||
media_type,
|
a.media_type,
|
||||||
status,
|
a.status,
|
||||||
capture_ts_utc,
|
coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as effective_capture_ts_utc,
|
||||||
active_key,
|
a.active_key,
|
||||||
thumb_small_key,
|
a.thumb_small_key,
|
||||||
thumb_med_key,
|
a.thumb_med_key,
|
||||||
poster_key
|
a.poster_key
|
||||||
from assets
|
from assets a
|
||||||
where capture_ts_utc is not null
|
left join asset_overrides o
|
||||||
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
|
on o.asset_id = a.id
|
||||||
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
|
where coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null
|
||||||
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
|
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 (
|
and (
|
||||||
${query.includeFailed}::boolean = true
|
${query.includeFailed}::boolean = true
|
||||||
or status <> 'failed'
|
or a.status <> 'failed'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
grouped as (
|
grouped as (
|
||||||
@@ -120,7 +128,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
where f.bucket = g.bucket
|
where f.bucket = g.bucket
|
||||||
and ${db.unsafe(groupExprF)} = g.group_ts
|
and ${db.unsafe(groupExprF)} = g.group_ts
|
||||||
and f.status = 'ready'
|
and f.status = 'ready'
|
||||||
order by f.capture_ts_utc asc
|
order by f.effective_capture_ts_utc asc
|
||||||
limit 1
|
limit 1
|
||||||
) s on true
|
) s on true
|
||||||
order by g.group_ts desc
|
order by g.group_ts desc
|
||||||
|
|||||||
28
apps/web/src/__tests__/asset-overrides-admin-auth.test.ts
Normal file
28
apps/web/src/__tests__/asset-overrides-admin-auth.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
9
packages/db/migrations/0005_asset_overrides.sql
Normal file
9
packages/db/migrations/0005_asset_overrides.sql
Normal file
@@ -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);
|
||||||
Reference in New Issue
Block a user