import { z } from "zod"; import { getDb } from "@tline/db"; export const runtime = "nodejs"; const querySchema = z .object({ start: z.string().datetime().optional(), end: z.string().datetime().optional(), granularity: z.enum(["year", "month", "day"]).default("day"), mediaType: z.enum(["image", "video"]).optional(), includeFailed: z.enum(["0", "1"]).default("0").transform((v) => v === "1"), limit: z.coerce.number().int().positive().max(500).default(200), }) .strict(); type Granularity = z.infer["granularity"]; function sqlGroupExpr(granularity: Granularity, alias: string) { const col = `${alias}.capture_ts_utc`; if (granularity === "year") return `date_trunc('year', ${col})`; if (granularity === "month") return `date_trunc('month', ${col})`; return `date_trunc('day', ${col})`; } export async function GET(request: Request): Promise { const url = new URL(request.url); const parsed = querySchema.safeParse({ start: url.searchParams.get("start") ?? undefined, end: url.searchParams.get("end") ?? undefined, granularity: url.searchParams.get("granularity") ?? undefined, mediaType: url.searchParams.get("mediaType") ?? undefined, includeFailed: url.searchParams.get("includeFailed") ?? undefined, limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { return Response.json( { error: "invalid_query", issues: parsed.error.issues }, { status: 400 }, ); } const query = parsed.data; const start = query.start ? new Date(query.start) : null; const end = query.end ? new Date(query.end) : null; const db = getDb(); // Note: capture_ts_utc can be null (unprocessed). Those rows are excluded. const groupExprFiltered = sqlGroupExpr(query.granularity, "filtered"); const groupExprF = sqlGroupExpr(query.granularity, "f"); const rows = await db< { bucket: string; group_ts: string; count_total: number; count_ready: number; sample_asset_id: string | null; sample_thumb_small_key: string | null; sample_thumb_med_key: string | null; sample_poster_key: string | null; sample_active_key: string | null; sample_status: string | null; sample_media_type: "image" | "video" | null; }[] >` 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) and ( ${query.includeFailed}::boolean = true or status <> 'failed' ) ), grouped as ( select bucket, ${db.unsafe(groupExprFiltered)} as group_ts, count(*)::int as count_total, count(*) filter (where status = 'ready')::int as count_ready from filtered group by bucket, ${db.unsafe(groupExprFiltered)} order by group_ts desc limit ${query.limit} ) select g.bucket, to_char(g.group_ts AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as group_ts, g.count_total, g.count_ready, s.id as sample_asset_id, s.thumb_small_key as sample_thumb_small_key, s.thumb_med_key as sample_thumb_med_key, s.poster_key as sample_poster_key, s.active_key as sample_active_key, s.status as sample_status, s.media_type as sample_media_type from grouped g left join lateral ( select * from filtered f where f.bucket = g.bucket and ${db.unsafe(groupExprF)} = g.group_ts and f.status = 'ready' order by f.capture_ts_utc asc limit 1 ) s on true order by g.group_ts desc `; return Response.json({ granularity: query.granularity, start: start ? start.toISOString() : null, end: end ? end.toISOString() : null, mediaType: query.mediaType ?? null, includeFailed: query.includeFailed, nodes: rows, }); }