WFDASH-WEBAPP/scripts/migrations/2026-09-09-add-quotes-attachment-fields.ts
Dave cd359b75a9 feat(quotes): split attachments into internal vs outbound with operator upload UI
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).

Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.

API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`

MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.

Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.

Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
  (`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
  `email_outbound_attachment_ids`, downloads each from
  `/api/files/quotes/<id>/<token>`, and emits
  `options.attachments: [{name, content, type}]` consumed by
  `Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
  `retryQuoteEmail` can find failed sends (it only accepts the
  latter). Previously failed sends were stranded.

Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
  pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
  (/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
  and current `Q202609246` formats; canonicalised digits-only for
  PB `~` substring match.

Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.

Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.

Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes

Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
2026-09-09 13:43:56 +02:00

127 lines
4.4 KiB
TypeScript

/* eslint-disable */
/**
* Migration: add `outbound_attachments` and `internal_attachments`
* file fields to the `quotes` collection.
*
* These split what was a single `quote_attachments.direction` enum
* into physically separate stores with explicit audience semantics:
*
* - `outbound_attachments` (file, maxSelect 10)
* Files that go to the customer with the quote email —
* datasheets, drawings, our generated PDF, anything explicitly
* marked for the customer. The send-email pipeline reads this
* field directly; there is no filter-by-direction that could
* accidentally include internal files.
*
* - `internal_attachments` (file, maxSelect 10)
* Operator-only files attached to a quote — supplier quotes,
* pricing evidence PDFs, drawings we received from suppliers.
* Never sent to the customer automatically.
*
* `quote_attachments` (the old direction-typed table) is left in
* place read-only for the historical inbound archive. Generated PDFs
* currently living there will be migrated by the follow-up
* `scripts/migrations/2026-09-09-migrate-quote-attachments-to-fields.ts`
* script.
*
* Run with:
* npx tsx scripts/migrations/2026-09-09-add-quotes-attachment-fields.ts
*
* Idempotent — re-running is a no-op once the fields exist.
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
// file not present — fine, rely on shell env
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[quotes-attachment-fields] Authenticated against ${url}`);
async function addFieldIfMissing(
fieldDef: Record<string, unknown> & { name: string },
logTag: string,
) {
const quotes = await pb.collections.getOne("quotes");
if ((quotes.fields as { name: string }[]).some((f) => f.name === fieldDef.name)) {
console.log(`[${logTag}] quotes.${fieldDef.name} already present — skipping.`);
return;
}
await pb.collections.update(quotes.id, {
fields: [...quotes.fields, fieldDef],
});
console.log(`[${logTag}] Added quotes.${fieldDef.name}.`);
}
await addFieldIfMissing(
{
name: "outbound_attachments",
type: "file",
required: false,
presentable: false,
protected: false,
// File-field options live FLAT on the field object in PB
// (not nested under `options: {}`). Nesting lands them all as
// maxSelect=0 (= "unlimited"). Mirrors the existing
// `quote_attachments.file` field at maxSelect=10, no size cap.
maxSelect: 10,
maxSize: 0,
mimeTypes: [],
thumbs: [],
help:
"Files that go to the customer with the quote email (datasheets, drawings, etc.). Every file here is sent to the customer. Operator-only and supplier files belong in internal_attachments or quote_item_attachments, NOT here.",
},
"quotes-attachment-fields",
);
await addFieldIfMissing(
{
name: "internal_attachments",
type: "file",
required: false,
presentable: false,
protected: false,
maxSelect: 10,
maxSize: 0,
mimeTypes: [],
thumbs: [],
help:
"Operator-only attachments (supplier quotes, internal pricing evidence). Never sent to the customer automatically.",
},
"quotes-attachment-fields",
);
console.log("[quotes-attachment-fields] Migration complete.");
}
main().catch((err) => {
console.error("[quotes-attachment-fields] Migration failed:", err);
process.exit(1);
});