mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 05:43:18 +00:00
464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
|
|
import { eq } from "drizzle-orm";
|
||
|
|
import { Hono } from "hono";
|
||
|
|
import type { Db, DbHandle } from "../db/client.js";
|
||
|
|
import type { DocumentsStore } from "../documents.js";
|
||
|
|
import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js";
|
||
|
|
import type { AppBindings } from "../auth/middleware.js";
|
||
|
|
import { errorJson } from "../http.js";
|
||
|
|
import { applyMatchingPolicy, closePendingSteps } from "../approvals.js";
|
||
|
|
import {
|
||
|
|
asMoney,
|
||
|
|
asString,
|
||
|
|
caller,
|
||
|
|
firstById,
|
||
|
|
isDateOnly,
|
||
|
|
isUniqueViolation,
|
||
|
|
iso,
|
||
|
|
isUuid,
|
||
|
|
moneyCents,
|
||
|
|
newId,
|
||
|
|
optionalString,
|
||
|
|
parseJsonBody,
|
||
|
|
requireCan,
|
||
|
|
rowsOf,
|
||
|
|
} from "./helpers.js";
|
||
|
|
|
||
|
|
const PAYMENT_METHODS = ["check", "ach"] as const;
|
||
|
|
|
||
|
|
type InvoiceRow = typeof invoices.$inferSelect;
|
||
|
|
type LineRow = typeof invoiceLines.$inferSelect;
|
||
|
|
type DocumentRow = typeof documents.$inferSelect;
|
||
|
|
type VendorRow = typeof vendors.$inferSelect;
|
||
|
|
type LineInput = {
|
||
|
|
description: string;
|
||
|
|
amount: string;
|
||
|
|
glAccountId: string | null;
|
||
|
|
departmentId: string | null;
|
||
|
|
};
|
||
|
|
|
||
|
|
function isPaymentMethod(value: string): value is (typeof PAYMENT_METHODS)[number] {
|
||
|
|
return (PAYMENT_METHODS as readonly string[]).includes(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function toInvoice(row: InvoiceRow, lines: LineRow[]) {
|
||
|
|
return {
|
||
|
|
id: row.id,
|
||
|
|
vendorId: row.vendorId,
|
||
|
|
invoiceNumber: row.invoiceNumber,
|
||
|
|
amount: row.amount,
|
||
|
|
amountDue: row.amountDue,
|
||
|
|
dueDate: row.dueDate,
|
||
|
|
payDate: row.payDate,
|
||
|
|
sendPaymentOn: row.sendPaymentOn,
|
||
|
|
status: row.status,
|
||
|
|
paymentMethod: row.paymentMethod,
|
||
|
|
memo: row.memo,
|
||
|
|
createdAt: iso(row.createdAt),
|
||
|
|
updatedAt: iso(row.updatedAt),
|
||
|
|
lines: lines.map(toLine),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function toLine(row: LineRow) {
|
||
|
|
return {
|
||
|
|
id: row.id,
|
||
|
|
invoiceId: row.invoiceId,
|
||
|
|
description: row.description,
|
||
|
|
amount: row.amount,
|
||
|
|
glAccountId: row.glAccountId,
|
||
|
|
departmentId: row.departmentId,
|
||
|
|
createdAt: iso(row.createdAt),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function toDocument(
|
||
|
|
row: DocumentRow,
|
||
|
|
urls: { uploadUrl?: string; uploadHeaders?: Record<string, string>; downloadUrl?: string } = {},
|
||
|
|
) {
|
||
|
|
return {
|
||
|
|
id: row.id,
|
||
|
|
invoiceId: row.invoiceId,
|
||
|
|
objectKey: row.objectKey,
|
||
|
|
contentType: row.contentType,
|
||
|
|
fileName: row.fileName,
|
||
|
|
uploadedByUserId: row.uploadedByUserId,
|
||
|
|
createdAt: iso(row.createdAt),
|
||
|
|
...urls,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseLine(body: Record<string, unknown>): LineInput | string {
|
||
|
|
const description = asString(body.description).trim();
|
||
|
|
const amount = asMoney(body.amount);
|
||
|
|
if (!description || !amount) return "Line description and amount are required.";
|
||
|
|
const glAccountId = optionalString(body.glAccountId) ?? null;
|
||
|
|
const departmentId = optionalString(body.departmentId) ?? null;
|
||
|
|
if (glAccountId && !isUuid(glAccountId)) return "Invalid glAccountId.";
|
||
|
|
if (departmentId && !isUuid(departmentId)) return "Invalid departmentId.";
|
||
|
|
return { description, amount, glAccountId, departmentId };
|
||
|
|
}
|
||
|
|
|
||
|
|
function linesSumToAmount(lines: Array<{ amount: string }>, amount: string): boolean {
|
||
|
|
const sum = lines.reduce((total, line) => total + moneyCents(line.amount), 0);
|
||
|
|
return sum === moneyCents(amount);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function linesFor(handle: DbHandle, invoiceId: string): Promise<LineRow[]> {
|
||
|
|
const rows = await rowsOf<LineRow>(handle, invoiceLines);
|
||
|
|
return rows.filter((row) => row.invoiceId === invoiceId);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function activeDuplicate(
|
||
|
|
handle: DbHandle,
|
||
|
|
vendorId: string,
|
||
|
|
invoiceNumber: string,
|
||
|
|
exceptId?: string,
|
||
|
|
): Promise<boolean> {
|
||
|
|
const rows = await rowsOf<InvoiceRow>(handle, invoices);
|
||
|
|
return rows.some(
|
||
|
|
(row) =>
|
||
|
|
row.vendorId === vendorId &&
|
||
|
|
row.invoiceNumber === invoiceNumber &&
|
||
|
|
row.status !== "void" &&
|
||
|
|
row.id !== exceptId,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function safeFileName(value: string): string {
|
||
|
|
return (
|
||
|
|
value
|
||
|
|
.replace(/[/\\]+/g, "_")
|
||
|
|
.replace(/^\.+/, "_")
|
||
|
|
.slice(0, 180) || "document"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function createInvoiceRoutes(handle: Db, store: DocumentsStore) {
|
||
|
|
const routes = new Hono<AppBindings>();
|
||
|
|
|
||
|
|
routes.get("/invoices", async (c) => {
|
||
|
|
const denied = requireCan(c, "read:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const rows = await rowsOf<InvoiceRow>(handle, invoices);
|
||
|
|
const allLines = await rowsOf<LineRow>(handle, invoiceLines);
|
||
|
|
return c.json({
|
||
|
|
items: rows.map((row) =>
|
||
|
|
toInvoice(
|
||
|
|
row,
|
||
|
|
allLines.filter((line) => line.invoiceId === row.id),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
routes.get("/invoices/:id", async (c) => {
|
||
|
|
const denied = requireCan(c, "read:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const id = c.req.param("id");
|
||
|
|
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
|
||
|
|
const row = await firstById<InvoiceRow>(handle, invoices, id);
|
||
|
|
if (!row) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||
|
|
return c.json(toInvoice(row, await linesFor(handle, id)));
|
||
|
|
});
|
||
|
|
|
||
|
|
routes.post("/invoices", async (c) => {
|
||
|
|
const denied = requireCan(c, "write:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const body = await parseJsonBody(c);
|
||
|
|
const vendorId = asString(body.vendorId);
|
||
|
|
const invoiceNumber = asString(body.invoiceNumber).trim();
|
||
|
|
const amount = asMoney(body.amount);
|
||
|
|
const dueDate = asString(body.dueDate);
|
||
|
|
if (!isUuid(vendorId) || !invoiceNumber || !amount || !isDateOnly(dueDate)) {
|
||
|
|
return errorJson(
|
||
|
|
c,
|
||
|
|
400,
|
||
|
|
"VALIDATION_ERROR",
|
||
|
|
"vendorId, invoiceNumber, amount, and dueDate are required.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const vendor = await firstById<VendorRow>(handle, vendors, vendorId);
|
||
|
|
if (!vendor) return errorJson(c, 400, "VALIDATION_ERROR", "Vendor not found.");
|
||
|
|
const method = asString(body.paymentMethod, vendor.defaultPaymentMethod);
|
||
|
|
if (!isPaymentMethod(method)) {
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod.");
|
||
|
|
}
|
||
|
|
const parsedLines: LineInput[] = [];
|
||
|
|
if (Array.isArray(body.lines)) {
|
||
|
|
for (const raw of body.lines) {
|
||
|
|
if (!raw || typeof raw !== "object") {
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object.");
|
||
|
|
}
|
||
|
|
const parsed = parseLine(raw as Record<string, unknown>);
|
||
|
|
if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed);
|
||
|
|
parsedLines.push(parsed);
|
||
|
|
}
|
||
|
|
if (!linesSumToAmount(parsedLines, amount)) {
|
||
|
|
return errorJson(
|
||
|
|
c,
|
||
|
|
400,
|
||
|
|
"VALIDATION_ERROR",
|
||
|
|
"Line amounts must sum to the invoice amount.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (await activeDuplicate(handle, vendorId, invoiceNumber)) {
|
||
|
|
return errorJson(
|
||
|
|
c,
|
||
|
|
409,
|
||
|
|
"CONFLICT",
|
||
|
|
"An active invoice already uses this vendor and invoice number.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
let latest: InvoiceRow | undefined;
|
||
|
|
let currentLines: LineRow[] = [];
|
||
|
|
try {
|
||
|
|
await handle.db.transaction(async (tx) => {
|
||
|
|
const scoped: DbHandle = { db: tx };
|
||
|
|
const [row] = await tx
|
||
|
|
.insert(invoices)
|
||
|
|
.values({
|
||
|
|
vendorId,
|
||
|
|
invoiceNumber,
|
||
|
|
amount,
|
||
|
|
amountDue: amount,
|
||
|
|
dueDate,
|
||
|
|
paymentMethod: method,
|
||
|
|
memo: asString(body.memo),
|
||
|
|
status: "pending_approval",
|
||
|
|
})
|
||
|
|
.returning();
|
||
|
|
for (const line of parsedLines) {
|
||
|
|
await tx
|
||
|
|
.insert(invoiceLines)
|
||
|
|
.values({ invoiceId: row.id, ...line })
|
||
|
|
.returning();
|
||
|
|
}
|
||
|
|
latest = await applyMatchingPolicy(scoped, row, caller(c).id);
|
||
|
|
currentLines = await linesFor(scoped, row.id);
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
if (isUniqueViolation(error)) {
|
||
|
|
return errorJson(
|
||
|
|
c,
|
||
|
|
409,
|
||
|
|
"CONFLICT",
|
||
|
|
"An active invoice already uses this vendor and invoice number.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
if (!latest) {
|
||
|
|
return errorJson(c, 500, "INTERNAL_ERROR", "Invoice create did not complete.");
|
||
|
|
}
|
||
|
|
return c.json(toInvoice(latest, currentLines), 201);
|
||
|
|
});
|
||
|
|
|
||
|
|
routes.patch("/invoices/:id", async (c) => {
|
||
|
|
const denied = requireCan(c, "write:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const id = c.req.param("id");
|
||
|
|
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
|
||
|
|
const existing = await firstById<InvoiceRow>(handle, invoices, id);
|
||
|
|
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||
|
|
const body = await parseJsonBody(c);
|
||
|
|
const patch: Partial<typeof invoices.$inferInsert> = { id, updatedAt: new Date() };
|
||
|
|
if (body.vendorId !== undefined) {
|
||
|
|
const vendorId = asString(body.vendorId);
|
||
|
|
if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId.");
|
||
|
|
patch.vendorId = vendorId;
|
||
|
|
}
|
||
|
|
if (body.invoiceNumber !== undefined) {
|
||
|
|
const invoiceNumber = asString(body.invoiceNumber).trim();
|
||
|
|
if (!invoiceNumber)
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "invoiceNumber is required.");
|
||
|
|
patch.invoiceNumber = invoiceNumber;
|
||
|
|
}
|
||
|
|
if (body.amount !== undefined) {
|
||
|
|
const amount = asMoney(body.amount);
|
||
|
|
if (!amount) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid amount.");
|
||
|
|
patch.amount = amount;
|
||
|
|
patch.amountDue = amount;
|
||
|
|
}
|
||
|
|
if (body.dueDate !== undefined) {
|
||
|
|
const dueDate = asString(body.dueDate);
|
||
|
|
if (!isDateOnly(dueDate)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid dueDate.");
|
||
|
|
patch.dueDate = dueDate;
|
||
|
|
}
|
||
|
|
if (body.payDate !== undefined) {
|
||
|
|
const payDate = optionalString(body.payDate) ?? null;
|
||
|
|
if (payDate && !isDateOnly(payDate))
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid payDate.");
|
||
|
|
patch.payDate = payDate;
|
||
|
|
}
|
||
|
|
if (body.sendPaymentOn !== undefined) {
|
||
|
|
const sendPaymentOn = optionalString(body.sendPaymentOn) ?? null;
|
||
|
|
if (sendPaymentOn && !isDateOnly(sendPaymentOn)) {
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid sendPaymentOn.");
|
||
|
|
}
|
||
|
|
patch.sendPaymentOn = sendPaymentOn;
|
||
|
|
}
|
||
|
|
if (body.status !== undefined) {
|
||
|
|
const status = asString(body.status);
|
||
|
|
if (status !== "void") {
|
||
|
|
return errorJson(
|
||
|
|
c,
|
||
|
|
400,
|
||
|
|
"VALIDATION_ERROR",
|
||
|
|
"PATCH may only set status to void. Approval and payment statuses go through decision routes.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
patch.status = "void";
|
||
|
|
}
|
||
|
|
if (body.paymentMethod !== undefined) {
|
||
|
|
const method = asString(body.paymentMethod);
|
||
|
|
if (!isPaymentMethod(method))
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod.");
|
||
|
|
patch.paymentMethod = method;
|
||
|
|
}
|
||
|
|
if (body.memo !== undefined) patch.memo = asString(body.memo);
|
||
|
|
const nextVendor = patch.vendorId ?? existing.vendorId;
|
||
|
|
const nextNumber = patch.invoiceNumber ?? existing.invoiceNumber;
|
||
|
|
const nextStatus = patch.status ?? existing.status;
|
||
|
|
if (nextStatus !== "void" && (await activeDuplicate(handle, nextVendor, nextNumber, id))) {
|
||
|
|
return errorJson(
|
||
|
|
c,
|
||
|
|
409,
|
||
|
|
"CONFLICT",
|
||
|
|
"An active invoice already uses this vendor and invoice number.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const nextAmount = patch.amount ?? existing.amount;
|
||
|
|
const currentLines = await linesFor(handle, id);
|
||
|
|
if (currentLines.length > 0 && !linesSumToAmount(currentLines, nextAmount)) {
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount.");
|
||
|
|
}
|
||
|
|
let row: InvoiceRow;
|
||
|
|
try {
|
||
|
|
if (patch.status === "void") {
|
||
|
|
await closePendingSteps(handle, id, caller(c).id);
|
||
|
|
}
|
||
|
|
[row] = await handle.db.update(invoices).set(patch).where(eq(invoices.id, id)).returning();
|
||
|
|
} catch (error) {
|
||
|
|
if (isUniqueViolation(error)) {
|
||
|
|
return errorJson(
|
||
|
|
c,
|
||
|
|
409,
|
||
|
|
"CONFLICT",
|
||
|
|
"An active invoice already uses this vendor and invoice number.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
return c.json(toInvoice(row, currentLines));
|
||
|
|
});
|
||
|
|
|
||
|
|
routes.post("/invoices/:id/lines", async (c) => {
|
||
|
|
const denied = requireCan(c, "write:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const id = c.req.param("id");
|
||
|
|
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
|
||
|
|
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
|
||
|
|
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||
|
|
const parsed = parseLine(await parseJsonBody(c));
|
||
|
|
if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed);
|
||
|
|
const [row] = await handle.db
|
||
|
|
.insert(invoiceLines)
|
||
|
|
.values({ invoiceId: id, ...parsed })
|
||
|
|
.returning();
|
||
|
|
return c.json(toLine(row), 201);
|
||
|
|
});
|
||
|
|
|
||
|
|
routes.put("/invoices/:id/lines", async (c) => {
|
||
|
|
const denied = requireCan(c, "write:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const id = c.req.param("id");
|
||
|
|
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
|
||
|
|
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
|
||
|
|
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||
|
|
const body = await parseJsonBody(c);
|
||
|
|
if (!Array.isArray(body.items)) {
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "items must be an array of lines.");
|
||
|
|
}
|
||
|
|
const parsedLines: LineInput[] = [];
|
||
|
|
for (const raw of body.items) {
|
||
|
|
if (!raw || typeof raw !== "object") {
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object.");
|
||
|
|
}
|
||
|
|
const parsed = parseLine(raw as Record<string, unknown>);
|
||
|
|
if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed);
|
||
|
|
parsedLines.push(parsed);
|
||
|
|
}
|
||
|
|
if (!linesSumToAmount(parsedLines, invoice.amount)) {
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount.");
|
||
|
|
}
|
||
|
|
const created: LineRow[] = [];
|
||
|
|
await handle.db.transaction(async (tx) => {
|
||
|
|
await tx.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id));
|
||
|
|
for (const line of parsedLines) {
|
||
|
|
const [row] = await tx
|
||
|
|
.insert(invoiceLines)
|
||
|
|
.values({ invoiceId: id, ...line })
|
||
|
|
.returning();
|
||
|
|
created.push(row);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return c.json({ items: created.map(toLine) });
|
||
|
|
});
|
||
|
|
|
||
|
|
routes.post("/invoices/:id/documents", async (c) => {
|
||
|
|
const denied = requireCan(c, "write:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const id = c.req.param("id");
|
||
|
|
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
|
||
|
|
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
|
||
|
|
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
|
||
|
|
const body = await parseJsonBody(c);
|
||
|
|
const fileName = safeFileName(asString(body.fileName).trim());
|
||
|
|
const contentType = asString(body.contentType, "application/pdf").trim() || "application/pdf";
|
||
|
|
if (!asString(body.fileName).trim()) {
|
||
|
|
return errorJson(c, 400, "VALIDATION_ERROR", "fileName is required.");
|
||
|
|
}
|
||
|
|
const documentId = newId();
|
||
|
|
const objectKey = `invoices/${id}/${documentId}/${fileName}`;
|
||
|
|
const [row] = await handle.db
|
||
|
|
.insert(documents)
|
||
|
|
.values({
|
||
|
|
id: documentId,
|
||
|
|
invoiceId: id,
|
||
|
|
objectKey,
|
||
|
|
contentType,
|
||
|
|
fileName,
|
||
|
|
uploadedByUserId: caller(c).id,
|
||
|
|
})
|
||
|
|
.returning();
|
||
|
|
const put = await store.presignPut({ objectKey, contentType });
|
||
|
|
return c.json(toDocument(row, { uploadUrl: put.url, uploadHeaders: put.headers }), 201);
|
||
|
|
});
|
||
|
|
|
||
|
|
routes.post("/documents/:id/confirmations", async (c) => {
|
||
|
|
const denied = requireCan(c, "write:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const id = c.req.param("id");
|
||
|
|
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id.");
|
||
|
|
const row = await firstById<DocumentRow>(handle, documents, id);
|
||
|
|
if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found.");
|
||
|
|
if (!(await store.objectExists(row.objectKey))) {
|
||
|
|
return errorJson(c, 409, "CONFLICT", "Object has not been uploaded.");
|
||
|
|
}
|
||
|
|
return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) }));
|
||
|
|
});
|
||
|
|
|
||
|
|
routes.get("/documents/:id", async (c) => {
|
||
|
|
const denied = requireCan(c, "read:invoices");
|
||
|
|
if (denied) return denied;
|
||
|
|
const id = c.req.param("id");
|
||
|
|
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id.");
|
||
|
|
const row = await firstById<DocumentRow>(handle, documents, id);
|
||
|
|
if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found.");
|
||
|
|
return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) }));
|
||
|
|
});
|
||
|
|
|
||
|
|
return routes;
|
||
|
|
}
|