diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index de07436..17a67f0 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -269,6 +269,35 @@ const tools = [ }, }, }, + { + name: "orders.add_attachment", + description: + "Upload a single attachment (proof, drawing, signed PO, supplier quote) to an order. The file is stored against the order's matched quote (WFDASH has no `order_attachments` collection; orders resolve to their matched quote for attachment purposes). Max 25 MB. Use `kind: 'proof'` to tag the filename with a [proof] prefix.", + inputSchema: { + type: "object", + required: ["order_id", "filename", "base64"], + properties: { + order_id: { type: "string", description: "15-char PocketBase order id" }, + filename: { type: "string" }, + content_type: { type: "string" }, + size: { type: "number" }, + base64: { type: "string" }, + kind: { type: "string", enum: ["proof", "outbound"], description: "Defaults to 'outbound'" }, + }, + }, + }, + { + name: "orders.list_attachments", + description: + "List the attachments associated with an order. Returns the order's matched quote's attachments (since orders share the quote_attachments table with their source quote). Includes the original RFQ email attachments, the rendered quote PDF, and any operator-uploaded proof/outbound files. Newest first.", + inputSchema: { + type: "object", + required: ["order_id"], + properties: { + order_id: { type: "string", description: "15-char PocketBase order id" }, + }, + }, + }, { name: "order_items.list", description: @@ -597,6 +626,34 @@ const tools = [ }, }, }, + { + name: "quotes.list_attachments", + description: + "List all attachments on a quote (the generated PDF, original RFQ email attachments, operator-uploaded proofs, outbound attachments). Newest first. Returns up to 50 per page.", + inputSchema: { + type: "object", + required: ["quote_id"], + properties: { + quote_id: { type: "string", description: "15-char PocketBase quote id" }, + page: { type: "number" }, + perPage: { type: "number" }, + }, + }, + }, + { + name: "quotes.get_pdf", + description: + "Retrieve the rendered quote PDF as a base64 string in the MCP response. The PDF is auto-rendered if one doesn't already exist on the quote. Pass `letterhead` to force CNL or ISHERWOOD; defaults to the quote's stored `company_letterhead`. Returns the base64 PDF plus `attachment_id`, `filename`, `size`, and `content_type` so the agent can save/forward the file.", + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "15-char PocketBase quote id" }, + letterhead: { type: "string", enum: ["CNL", "ISHERWOOD"] }, + regenerate: { type: "boolean", description: "Force a fresh render even if a PDF already exists" }, + }, + }, + }, { name: "purchase_orders.add_attachment", description: @@ -1561,6 +1618,62 @@ export async function POST(req: NextRequest) { }); } + if (toolName === "orders.add_attachment") { + if (!args.order_id || !args.filename || !args.base64) { + throw new Error( + "Missing order_id, filename, or base64 for orders.add_attachment", + ); + } + const bytes = Buffer.from(String(args.base64), "base64"); + const fd = new FormData(); + // The server route expects a `file` Blob. Reconstruct one from + // the base64 bytes (Node's File polyfill is in `node:buffer`). + const blob = new Blob([bytes], { + type: typeof args.content_type === "string" + ? args.content_type + : "application/octet-stream", + }); + // Attach a filename so the route can read file.name + Object.defineProperty(blob, "name", { value: String(args.filename) }); + fd.append("file", blob, String(args.filename)); + if (typeof args.kind === "string") fd.append("kind", args.kind); + + const res = await fetch( + `${req.nextUrl.origin}/api/v1/orders/${args.order_id}/attachments/upload`, + { + method: "POST", + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: fd, + }, + ); + if (!res.ok) { + throw new Error(`orders.add_attachment upstream ${res.status}: ${await res.text()}`); + } + const data = await res.json(); + return jsonRpcOk(body.id, data); + } + + if (toolName === "orders.list_attachments") { + if (!args.order_id) { + throw new Error("Missing order_id for orders.list_attachments."); + } + const res = await fetch( + `${req.nextUrl.origin}/api/v1/orders/${args.order_id}/matched-attachments`, + { + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + }, + ); + if (!res.ok) { + throw new Error(`orders.list_attachments upstream ${res.status}: ${await res.text()}`); + } + const data = await res.json(); + return jsonRpcOk(body.id, data); + } + if (toolName === "quotes.create_from_email") { const payload: EmailIntakePayload = { from_email: args.from_email, @@ -1634,6 +1747,48 @@ export async function POST(req: NextRequest) { }); } + if (toolName === "quotes.list_attachments") { + if (!args.quote_id) throw new Error("Missing quote_id for quotes.list_attachments."); + const quoteId = String(args.quote_id); + const page = Number(args.page || 1); + const perPage = Math.min(500, Number(args.perPage || 50)); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/quotes/${quoteId}/attachments?page=${page}&perPage=${perPage}`, + { + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + }, + ); + if (!res.ok) { + throw new Error(`quotes.list_attachments upstream ${res.status}: ${await res.text()}`); + } + const data = await res.json(); + return jsonRpcOk(body.id, data); + } + + if (toolName === "quotes.get_pdf") { + if (!args.id) throw new Error("Missing id for quotes.get_pdf."); + const quoteId = String(args.id); + const params = new URLSearchParams(); + if (typeof args.letterhead === "string") params.set("letterhead", args.letterhead); + if (args.regenerate === true) params.set("regenerate", "1"); + const qs = params.toString(); + const res = await fetch( + `${req.nextUrl.origin}/api/v1/quotes/${quoteId}/pdf/base64${qs ? `?${qs}` : ""}`, + { + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + }, + ); + if (!res.ok) { + throw new Error(`quotes.get_pdf upstream ${res.status}: ${await res.text()}`); + } + const data = await res.json(); + return jsonRpcOk(body.id, data); + } + if (toolName === "purchase_orders.add_attachment") { if (!args.po_id || !args.filename || !args.base64) { throw new Error("Missing po_id, filename, or base64 for purchase_orders.add_attachment"); diff --git a/src/app/api/v1/orders/[id]/attachments/upload/route.ts b/src/app/api/v1/orders/[id]/attachments/upload/route.ts new file mode 100644 index 0000000..b86e87b --- /dev/null +++ b/src/app/api/v1/orders/[id]/attachments/upload/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +/** + * POST /api/v1/orders/[id]/attachments + * + * Upload an attachment to an order. The file is stored against the + * order's MATCHED QUOTE (PB has no `order_attachments` collection; + * `quote_attachments` is the single attachments table, and orders + * resolve to their matched quote for attachment purposes). + * + * Why a server route: `quote_attachments` createRule is + * `@request.auth.id != ''` — the unauthenticated MCP client (and the + * v1 no-auth browser client) can't create records directly. Admin PB + * bypasses the rule. + * + * Body: multipart/form-data with one required field: + * - `file` — the file blob (required, max 25 MB by the quote_attachments + * PB schema) + * - `kind` — "proof" | "outbound" (default "outbound"). Same semantics + * as the quote upload route. + * + * Response: + * 201 — { id, filename, file, size_bytes, content_type, direction, matched_quote_id } + * 400 — { error: "No file" | "Invalid kind" } + * 404 — { error: "Order not found" | "Order has no matched quote" } + * 500 — { error: "PB admin unavailable" | } + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VALID_KINDS = new Set(["proof", "outbound"]); + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: orderId } = await params; + + let formData: FormData; + try { + formData = await req.formData(); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid form data" }, + { status: 400 }, + ); + } + + const file = formData.get("file"); + if (!file || !(file instanceof File)) { + return NextResponse.json( + { error: "No file in request (expected field 'file')" }, + { status: 400 }, + ); + } + + const kind = (formData.get("kind") as string) || "outbound"; + if (!VALID_KINDS.has(kind)) { + return NextResponse.json( + { error: `Invalid kind '${kind}' (expected one of ${[...VALID_KINDS].join(", ")})` }, + { status: 400 }, + ); + } + + let adminPb; + try { + adminPb = await getAdminPocketBase(); + } catch { + return NextResponse.json( + { error: "PB admin unavailable" }, + { status: 500 }, + ); + } + + try { + // Find the order's matched quote. Orders have a `quote` relation + // pointing at the source quote from `orders.create_from_quote`. + const order = await adminPb.collection("orders").getOne(orderId, { + fields: "id,quote", + }); + const matchedQuoteId = (order.quote as string) || ""; + if (!matchedQuoteId) { + return NextResponse.json( + { error: "Order has no matched quote — nothing to attach to." }, + { status: 404 }, + ); + } + + // Same proof-prefix behaviour as the quote upload route. + const originalName = file.name || "attachment"; + const taggedName = + kind === "proof" && !originalName.startsWith("[proof]") + ? `[proof] ${originalName}` + : originalName; + + const created = await adminPb.collection("quote_attachments").create({ + quote: matchedQuoteId, + filename: taggedName, + file: file, + content_type: file.type || "", + size_bytes: file.size, + direction: "outbound", + email_message_id: "", + }); + return NextResponse.json( + { + id: created.id, + filename: created.filename, + file: created.file, + size_bytes: created.size_bytes, + content_type: created.content_type, + direction: created.direction, + matched_quote_id: matchedQuoteId, + }, + { status: 201 }, + ); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Upload failed" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/v1/quotes/[id]/pdf/base64/route.ts b/src/app/api/v1/quotes/[id]/pdf/base64/route.ts new file mode 100644 index 0000000..a8f6d27 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/pdf/base64/route.ts @@ -0,0 +1,232 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { LETTERHEAD_CODES, type LetterheadCode } from "@/lib/letterhead"; + +/** + * GET /api/v1/quotes/[id]/pdf/base64 + * + * Returns the rendered quote PDF as a base64-encoded string (plus + * metadata) in a single JSON response. Companion to the streaming + * endpoint `GET /api/v1/quotes/[id]/pdf?attachmentId=…` (which serves + * raw `application/pdf` for iframes and downloads). + * + * Why this exists: + * MCP clients like the Giellie agent can't open HTTP streams directly + * — they need the full payload in the JSON-RPC `result` envelope. + * This route assembles the PDF as a base64 string so an MCP + * `quotes.get_pdf` tool can hand it back in one call. Useful for: + * - AI agents that want to "download" the rendered quote + * - Forwarding the PDF to another system (e.g. an email pipeline) + * - Archival / verification + * + * Behaviour: + * 1. POST /api/v1/quotes/[id]/pdf is invoked server-side with the + * requested `letterhead` (or the quote's stored letterhead) so the + * PDF is freshly rendered. The POST is idempotent and re-renders + * cheaply if `pdf_generated_at` is already set. + * 2. We then GET the resulting PDF binary (one round-trip to + * PocketBase's file storage) and base64-encode it. + * 3. Return `{ pdf, attachment_id, filename, size, content_type }`. + * + * Query params: + * - `letterhead`: optional, one of "CNL" | "ISHERWOOD". Defaults to + * the quote's stored `company_letterhead` (or "CNL" if blank). + * - `regenerate`: "1" to force a fresh render even if a PDF already + * exists. Default: only render if no PDF exists. + * + * Response: + * 200 — { pdf: string (base64), attachment_id, filename, size, content_type, letterhead, regenerated_at } + * 422 — { error: } on placeholder customer / unpriced items / no items + * 500 — { error: } + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type PdfRenderResult = { + ok: true; + attachmentId: string; + status: string; + pdfGeneratedAt: string; + filename: string; + skippedItemIds: string[]; + readyItemCount: number; + totalItemCount: number; + isPartial: boolean; +}; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: quoteId } = await ctx_safe(params); + const url = new URL(req.url); + const letterheadParam = url.searchParams.get("letterhead"); + const regenerate = url.searchParams.get("regenerate") === "1"; + + if (letterheadParam && !LETTERHEAD_CODES.includes(letterheadParam as LetterheadCode)) { + return NextResponse.json( + { error: `letterhead must be one of: ${LETTERHEAD_CODES.join(", ")}` }, + { status: 422 }, + ); + } + + let adminPb; + try { + adminPb = await getAdminPocketBase(); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "PB admin unavailable" }, + { status: 500 }, + ); + } + + try { + // Load the quote (we need its company_letterhead as default). + const quote = await adminPb.collection("quotes").getOne(quoteId, { + fields: "id,company_letterhead,pdf_generated_at", + }); + const chosenLetterhead = + letterheadParam ?? quote.company_letterhead ?? "CNL"; + + // Render only if needed: no PDF yet, regeneration forced, or + // letterhead differs from the existing one (best-effort: we just + // always render — POST is idempotent and cheap). + const hasPdf = !!(quote.pdf_generated_at || "").trim(); + if (!hasPdf || regenerate) { + // Re-use the same code path the user-facing button does. The POST + // handler in `[id]/pdf/route.ts` accepts a `letterhead` body. + const postRes = await fetch( + `${req.nextUrl.origin}/api/v1/quotes/${quoteId}/pdf`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ letterhead: chosenLetterhead }), + } + ); + if (!postRes.ok) { + const text = await postRes.text(); + return NextResponse.json( + { error: `PDF render failed: ${postRes.status} ${text}` }, + { status: postRes.status === 404 ? 404 : 500 }, + ); + } + const renderResult = (await postRes.json()) as PdfRenderResult; + if (!renderResult.ok) { + return NextResponse.json( + { error: "PDF render returned non-ok result" }, + { status: 500 }, + ); + } + return await streamBase64({ + adminPb, + req, + quoteId, + attachmentId: renderResult.attachmentId, + letterhead: chosenLetterhead, + }); + } + + // Existing PDF — find the most recent quote_attachments row with + // kind="pdf" for this quote. (The PDF endpoint always writes one + // per render; we want the freshest.) + const existing = await adminPb + .collection("quote_attachments") + .getList(1, 1, { + filter: `quote = "${quoteId}" && kind = "pdf"`, + sort: "-created", + }); + if (existing.items.length === 0) { + // pdf_generated_at says yes but the file is gone — force a regen. + const postRes = await fetch( + `${req.nextUrl.origin}/api/v1/quotes/${quoteId}/pdf`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify({ letterhead: chosenLetterhead }), + } + ); + if (!postRes.ok) { + return NextResponse.json( + { error: `PDF re-render failed: ${postRes.status}` }, + { status: 500 }, + ); + } + const r = (await postRes.json()) as PdfRenderResult; + return await streamBase64({ + adminPb, + req, + quoteId, + attachmentId: r.attachmentId, + letterhead: chosenLetterhead, + }); + } + return await streamBase64({ + adminPb, + req, + quoteId, + attachmentId: existing.items[0].id, + letterhead: chosenLetterhead, + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unknown error" }, + { status: 500 }, + ); + } +} + +async function streamBase64(args: { + adminPb: Awaited>; + req: NextRequest; + quoteId: string; + attachmentId: string; + letterhead: string; +}) { + const att = await args.adminPb.collection("quote_attachments").getOne(args.attachmentId); + if (att.quote !== args.quoteId) { + return NextResponse.json( + { error: "Attachment does not belong to this quote" }, + { status: 403 }, + ); + } + const storedName = Array.isArray(att.file) ? att.file[0] : att.file; + if (!storedName) { + return NextResponse.json( + { error: "Attachment has no stored file" }, + { status: 404 }, + ); + } + const base = + process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; + const fileRes = await fetch( + `${base}/api/files/quote_attachments/${att.id}/${storedName}` + ); + if (!fileRes.ok) { + return NextResponse.json( + { error: `File fetch failed: ${fileRes.status}` }, + { status: 502 }, + ); + } + const bytes = Buffer.from(await fileRes.arrayBuffer()); + return NextResponse.json({ + pdf: bytes.toString("base64"), + attachment_id: att.id, + filename: att.filename, + size: bytes.length, + content_type: att.content_type || "application/pdf", + letterhead: args.letterhead, + }); +} + +// Helper to satisfy `await ctx_safe(params)` while keeping the public +// route signature `function GET(req, { params })` simple. +async function ctx_safe(p: Promise<{ id: string }>): Promise<{ id: string }> { + return p; +}