Initial commit

This commit is contained in:
OpenCode Test
2025-12-24 10:50:10 -08:00
commit e1a64aa092
70 changed files with 5827 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { getMinioBucket } from "@tline/minio";
import { enqueueScanMinioPrefix } from "@tline/queue";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
const bodySchema = z
.object({
bucket: z.string().min(1).optional(),
prefix: z.string().min(1).default("originals/"),
})
.strict();
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 params = paramsParsed.data;
const bodyJson = await request.json().catch(() => ({}));
const body = bodySchema.parse(bodyJson);
const bucket = body.bucket ?? getMinioBucket();
const db = getDb();
const rows = await db<
{
id: string;
}[]
>`
select id
from imports
where id = ${params.id}
limit 1
`;
const imp = rows[0];
if (!imp) {
return Response.json({ error: "not_found" }, { status: 404 });
}
await enqueueScanMinioPrefix({
importId: imp.id,
bucket,
prefix: body.prefix,
});
await db`
update imports
set status = 'queued'
where id = ${imp.id}
`;
return Response.json({ ok: true, importId: imp.id, bucket, prefix: body.prefix });
}

View File

@@ -0,0 +1,65 @@
import { z } from "zod";
import { getDb } from "@tline/db";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
export async function GET(
_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 params = paramsParsed.data;
const db = getDb();
const [imp] = await db<
{
id: string;
type: string;
status: string;
created_at: string;
total_count: number | null;
processed_count: number | null;
failed_count: number | null;
}[]
>`
select id, type, status, created_at, total_count, processed_count, failed_count
from imports
where id = ${params.id}
limit 1
`;
if (!imp) {
return Response.json({ error: "not_found" }, { status: 404 });
}
const counts = await db<
{
total: number;
ready: number;
failed: number;
processing: number;
new_count: number;
}[]
>`
select
count(*)::int as total,
count(*) filter (where status = 'ready')::int as ready,
count(*) filter (where status = 'failed')::int as failed,
count(*) filter (where status = 'processing')::int as processing,
count(*) filter (where status = 'new')::int as new_count
from assets
where source_key like ${`staging/${imp.id}/%`}
`;
return Response.json({ ...imp, asset_counts: counts[0] ?? null });
}

View File

@@ -0,0 +1,108 @@
import { randomUUID } from "crypto";
import { Readable } from "stream";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { z } from "zod";
import { getDb } from "@tline/db";
import { getMinioBucket, getMinioInternalClient } from "@tline/minio";
import { enqueueProcessAsset } from "@tline/queue";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
const contentTypeMediaMap: Array<{
match: (ct: string) => boolean;
mediaType: "image" | "video";
}> = [
{ match: (ct) => ct.startsWith("image/"), mediaType: "image" },
{ match: (ct) => ct.startsWith("video/"), mediaType: "video" },
];
function inferMediaTypeFromContentType(ct: string): "image" | "video" | null {
const found = contentTypeMediaMap.find((m) => m.match(ct));
return found?.mediaType ?? null;
}
function inferExtFromContentType(ct: string): string {
const parts = ct.split("/");
const ext = parts[1] ?? "bin";
return ext.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "bin";
}
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 params = paramsParsed.data;
const contentType = request.headers.get("content-type") ?? "application/octet-stream";
const mediaType = inferMediaTypeFromContentType(contentType);
if (!mediaType) {
return Response.json({ error: "unsupported_content_type", contentType }, { status: 400 });
}
const bucket = getMinioBucket();
const ext = inferExtFromContentType(contentType);
const objectId = randomUUID();
const key = `staging/${params.id}/${objectId}.${ext}`;
const db = getDb();
const [imp] = await db<{ id: string }[]>`
select id
from imports
where id = ${params.id}
limit 1
`;
if (!imp) {
return Response.json({ error: "import_not_found" }, { status: 404 });
}
if (!request.body) {
return Response.json({ error: "missing_body" }, { status: 400 });
}
const s3 = getMinioInternalClient();
const bodyStream = Readable.fromWeb(request.body as unknown as NodeReadableStream);
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: bodyStream,
ContentType: contentType,
}),
);
const rows = await db<
{
id: string;
status: "new" | "processing" | "ready" | "failed";
}[]
>`
insert into assets (bucket, media_type, mime_type, source_key, active_key)
values (${bucket}, ${mediaType}, ${contentType}, ${key}, ${key})
on conflict (bucket, source_key)
do update set active_key = excluded.active_key
returning id, status
`;
const asset = rows[0];
if (!asset) {
return Response.json({ error: "asset_insert_failed" }, { status: 500 });
}
await enqueueProcessAsset({ assetId: asset.id });
return Response.json({ ok: true, importId: imp.id, assetId: asset.id, bucket, key });
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { getDb } from "@tline/db";
export const runtime = "nodejs";
const bodySchema = z
.object({
type: z.enum(["upload", "minio_scan"]).default("upload"),
})
.strict();
export async function POST(request: Request): Promise<Response> {
const bodyJson = await request.json().catch(() => ({}));
const body = bodySchema.parse(bodyJson);
const db = getDb();
const rows = await db<
{
id: string;
type: "upload" | "minio_scan";
status: string;
created_at: string;
}[]
>`
insert into imports (type, status)
values (${body.type}, 'new')
returning id, type, status, created_at
`;
const created = rows[0];
if (!created) {
return Response.json({ error: "insert_failed" }, { status: 500 });
}
return Response.json(created);
}