feat(mcp): quote PDF retrieval + order/quote attachment tools

Closes the four gaps the agent flagged for the email→quote→PDF→send
workflow:

NEW REST ROUTES
- GET  /api/v1/quotes/[id]/pdf/base64  — auto-renders the quote PDF
  (idempotent against an existing pdf_generated_at) and returns it as
  { pdf: <base64>, attachment_id, filename, size, content_type,
  letterhead }. 422 on placeholder customer / unpriced items. Used by
  the new quotes.get_pdf MCP tool to give MCP clients a way to
  download the rendered PDF — the streaming /api/v1/quotes/[id]/pdf
  endpoint is still available for the WebUI's iframe.

- POST /api/v1/orders/[id]/attachments  — uploads a file to the
  order's MATCHED QUOTE. PB has no order_attachments collection;
  orders share the quote_attachments table via their matched quote.
  201 returns the new attachment + matched_quote_id so the agent can
  confirm where the file landed.

NEW MCP TOOLS (catalog now 76, was 72)
- quotes.list_attachments   — list all attachments on a quote
  (generated PDF + original RFQ email attachments + operator uploads)
- quotes.get_pdf             — auto-render + return the PDF binary as
  base64 so the agent can save/forward/email it
- orders.add_attachment      — upload to an order (proxy to the new
  order upload route; multipart form)
- orders.list_attachments    — list attachments on an order (proxies
  the existing /api/v1/orders/[id]/matched-attachments route, which
  returns the matched quote's attachments)

The 'upload to a PDF' workflow is now: attachments.upload_for_quote
(or orders.add_attachment) → quotes.list_attachments to see what's
there (including the generated PDF) → quotes.get_pdf to download
the PDF → quotes.send_email with selectedAttachmentIds to ship it all.

WHY the existing 'attachments.upload_for_quote' was not enough:
the agent previously had no way to retrieve the rendered PDF binary
or to attach files to orders. Now it has both. The order upload goes
via the matched quote because that's the single attachment table in PB.

All 1382 tests pass (was 1378; +4 for the new tools).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-05 11:51:23 +02:00
parent 0ac05b847b
commit b3344f9f47
3 changed files with 512 additions and 0 deletions

View file

@ -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");

View file

@ -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" | <message> }
*/
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 },
);
}
}

View file

@ -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: <message> } on placeholder customer / unpriced items / no items
* 500 — { error: <message> }
*/
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<ReturnType<typeof getAdminPocketBase>>;
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;
}