import { getAdminPocketBase } from "./pocketbase-admin"; import type { Quote, Customer, Company } from "@/lib/types"; /** * Server-side quote pipeline used by the MCP layer (n8n) and internal API routes. * * This module is the canonical home for all email-to-quote business logic. * It deliberately avoids the pitfalls of the old Django implementation: * * - No fuzzy customer/company matching — only exact + normalized. * - No fabricated cost or selling prices on AI-extracted items. * - No racy `last + 1` quote number generation — uses a PocketBase-side * atomic counter. * - Replies are written to the `quote_replies` collection, never to a * text blob on `quotes.notes`. * - Idempotency: the same email (by Message-ID) cannot create two quotes. */ const QUOTES_COLLECTION = "quotes"; const QUOTE_ITEMS_COLLECTION = "quote_items"; const QUOTE_ATTACHMENTS_COLLECTION = "quote_attachments"; const QUOTE_REPLIES_COLLECTION = "quote_replies"; const QUOTE_COUNTERS_COLLECTION = "quote_counters"; const CUSTOMERS_COLLECTION = "customers"; const COMPANIES_COLLECTION = "companies"; const QUOTE_NUMBER_REGEX = /(?:Re:\s*)?(?:Quotation|Quote)\s+(Q-?\d{4}-?\d{2}-?\d{3})/i; /** * Sentinel customer for emails with no From: header. PB enforces * `required=true` on `quotes.customer`, so we can't pass null. Instead, * every email intake with an empty `from_email` is linked to this single * stable customer. The quoter reassigns in the quote-editing UI if/when * they know who the real sender is. * * The email domain `no-from.wfdash.local` is impossible to confuse with a * real address; the description makes the role explicit. */ const UNKNOWN_SENDER_CUSTOMER_EMAIL = "unknown-sender@no-from.wfdash.local"; const UNKNOWN_SENDER_CUSTOMER_NAME = "Unknown Sender"; const UNKNOWN_SENDER_CUSTOMER_DESCRIPTION = "Auto-created sentinel for emails with no From: header. The quoter reassigns in the quote-editing UI."; export interface EmailIntakeAttachment { filename: string; content_type?: string; size?: number; /** Raw bytes encoded as base64 (no `data:` URL prefix). */ base64: string; } export interface EmailIntakeExtractedItem { description?: string; quantity?: number; unit?: string; notes?: string; } export interface EmailIntakePayload { from_email: string; from_name?: string; subject: string; body_plain?: string; body_html?: string; /** RFC 5322 Message-ID. Required for dedup. */ message_id: string; in_reply_to?: string; received_at?: string; extracted_items?: EmailIntakeExtractedItem[]; attachments?: EmailIntakeAttachment[]; /** Optional override for company matching — used by the AI extraction layer. */ company_hint?: string; } export interface EmailReplyPayload { from_email: string; from_name?: string; subject: string; body_plain?: string; body_html?: string; message_id?: string; in_reply_to?: string; received_at?: string; quoted_text?: string; } export interface CreateQuoteFromEmailResult { quote: Quote; customer: Customer; company: Company | null; items: number; attachments: number; deduped: boolean; quote_number: string; } export interface AppendReplyResult { quote: Quote | null; reply: Record | null; matched_by: "in_reply_to" | "subject" | "none"; } // ────────────────────────────────────────────────────────────────────────────── // Atomic quote number generation // ────────────────────────────────────────────────────────────────────────────── /** * Generates the next quote number (`QYYYYMMNNN`) atomically. * * Uses the `quote_counters` collection with a unique `(year, month)` index. * The `last_seq+` increment operator is evaluated server-side by PocketBase * against the current row state, so concurrent callers cannot collide even * if they read the counter at the same instant. */ export async function generateQuoteNumberServer(): Promise { const pb = await getAdminPocketBase(); const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const counter = await getOrCreateCounter(pb, year, month); const updated = await pb.collection(QUOTE_COUNTERS_COLLECTION).update(counter.id, { "last_seq+": 1, }); return formatQuoteNumber(year, month, updated.last_seq); } async function getOrCreateCounter( pb: Awaited>, year: number, month: number ) { try { return await pb .collection(QUOTE_COUNTERS_COLLECTION) .getFirstListItem(`year=${year} && month=${month}`); } catch (err) { // First request for this month — backfill the counter from any // pre-existing quotes in that month, then create it. Without the // backfill, the new code would start at 001 and collide with // existing quotes. The `last_seq` we seed with is the max sequence // for the (year, month) pair; the next `last_seq+` increment lands // on the correct next number. The migration // `2026-07-10-restore-counters.ts` also backfills — keeping the // logic in both places is a safety belt: the migration runs once // for an admin-initiated recovery, and this self-heals in the // background if the collection is ever wiped while the service // is live. const backfill = await getMaxQuoteSequenceInMonth(pb, year, month); try { return await pb.collection(QUOTE_COUNTERS_COLLECTION).create({ year, month, last_seq: backfill, }); } catch { // Concurrent caller beat us to it — re-fetch the row they made. return await pb .collection(QUOTE_COUNTERS_COLLECTION) .getFirstListItem(`year=${year} && month=${month}`); } } } /** * Returns the highest sequence number found in `quotes.quote_number` * for the given (year, month) — or 0 if no quotes exist for that * month. Used to seed the counter when it's first created for a * (year, month) pair, so the generator continues from the existing * sequence rather than resetting to 001. * * Accepts the dashless `QYYYYMMNNN` format (current). Legacy dashed * `Q-YYYY-MM-NNN` rows are also parsed — they have a different regex * so both are handled in one pass. */ async function getMaxQuoteSequenceInMonth( pb: Awaited>, year: number, month: number ): Promise { const mm = String(month).padStart(2, "0"); const dashlessPrefix = `Q${year}${mm}`; const dashedPrefix = `Q-${year}-${mm}-`; try { const all = await pb.collection(QUOTES_COLLECTION).getFullList<{ quote_number?: string; }>({ filter: `quote_number ~ "${dashlessPrefix}" || quote_number ~ "${dashedPrefix}"`, }); let max = 0; // Two regexes — one for the no-dash format (the current contract), // one for the legacy dashed format that some old rows still carry. const reNoDash = new RegExp(`^Q${year}${mm}(\\d+)$`); const reDashed = new RegExp(`^Q-${year}-${mm}-(\\d+)$`); for (const q of all) { const num = q.quote_number || ""; const m = num.match(reNoDash) || num.match(reDashed); if (m) { const n = parseInt(m[1], 10); if (Number.isFinite(n) && n > max) max = n; } } return max; } catch { return 0; } } function formatQuoteNumber(year: number, month: number, seq: number): string { return `Q${year}${String(month).padStart(2, "0")}${String(seq).padStart(3, "0")}`; } // ────────────────────────────────────────────────────────────────────────────── // Customer matching // ────────────────────────────────────────────────────────────────────────────── /** * Finds an existing customer by exact email match, or creates a new one. * * Looks up `email`, then `alternative_email`, then `buyer_email` — case * insensitive but otherwise an exact equality match. If no match, creates a * new customer with a `description` flag marking it as auto-created from * email intake (for audit). * * Never fuzzy-merges. Two customers with the same domain are kept separate * until a human reviews them. */ export async function findOrCreateCustomer(input: { email: string; name?: string; companyHint?: string; }): Promise<{ customer: Customer; created: boolean }> { const pb = await getAdminPocketBase(); const email = (input.email || "").trim().toLowerCase(); if (!email) { throw new Error("Customer email is required for findOrCreateCustomer."); } const exact = await findCustomerByExactEmail(pb, email); if (exact) { return { customer: exact, created: false }; } const senderDomain = email.split("@")[1] || ""; const fallbackName = input.name?.trim() || senderDomain || email; // Resolve the company hint to a relation id, not a free-text name — // `customer.company` is now a PB relation (2026-06-28 schema change). // If no matching company exists, the link is left empty for the // operator to fill in via the customer form. const hintName = input.companyHint?.trim() || senderDomain; let companyId = ""; if (hintName) { const match = (await findCompanyByCaseInsensitiveExact(pb, hintName)) || (await findCompanyByNormalized(pb, hintName)); if (match) companyId = match.id; } const created = await pb.collection(CUSTOMERS_COLLECTION).create({ customer: fallbackName, email, company: companyId, dateadded: new Date().toISOString().slice(0, 10), description: "Auto-created from email intake.", }); return { customer: created as Customer, created: true }; } /** * Returns the singleton "Unknown Sender" customer used when an inbound email * has no From: header. Created on first use, reused for all subsequent * unknown-sender emails. The race-safety pattern (try-create, re-fetch on * unique-index failure) mirrors `generateQuoteNumberServer`. */ async function findOrCreateUnknownSenderCustomer( pb: Awaited> ): Promise<{ customer: Customer; created: boolean }> { const existing = await findCustomerByExactEmail(pb, UNKNOWN_SENDER_CUSTOMER_EMAIL); if (existing) { return { customer: existing, created: false }; } try { const created = await pb.collection(CUSTOMERS_COLLECTION).create({ customer: UNKNOWN_SENDER_CUSTOMER_NAME, email: UNKNOWN_SENDER_CUSTOMER_EMAIL, // Link to a literal "Unknown" company record if one exists; // otherwise leave the relation empty. The UI surfaces a warning // when this customer has no company link. company: await resolveCompanyId(pb, "Unknown"), dateadded: new Date().toISOString().slice(0, 10), description: UNKNOWN_SENDER_CUSTOMER_DESCRIPTION, }); return { customer: created as Customer, created: true }; } catch (err) { // Race: a concurrent intake created the sentinel first. Re-fetch. const refetched = await findCustomerByExactEmail(pb, UNKNOWN_SENDER_CUSTOMER_EMAIL); if (refetched) { return { customer: refetched, created: false }; } throw err; } } /** * Resolve a company name to a relation id. Empty string when no * matching company record exists. */ async function resolveCompanyId( pb: Awaited>, name: string ): Promise { const match = (await findCompanyByCaseInsensitiveExact(pb, name)) || (await findCompanyByNormalized(pb, name)); return match?.id || ""; } async function findCustomerByExactEmail( pb: Awaited>, email: string ): Promise { const esc = email.replace(/"/g, '\\"'); // Try the primary email field first, then the two alternates. Each is a // separate query — cheap because of PocketBase's text index. for (const field of ["email", "alternative_email", "buyer_email"] as const) { try { const hit = await pb.collection(CUSTOMERS_COLLECTION).getFirstListItem(`${field} = "${esc}"`); if (hit) return hit as Customer; } catch { // 404 — keep looking. } } return null; } // ────────────────────────────────────────────────────────────────────────────── // Company matching // ────────────────────────────────────────────────────────────────────────────── /** * Finds an existing company by normalized name, or creates a new one. * * Two-step match: * 1. Case-insensitive exact match on `company` field. * 2. Normalized match (lowercase, trim, strip common suffixes) on `company`. * * If neither matches, creates a new record. Never fuzzy-merges. */ export async function findOrCreateCompany(input: { name?: string; address?: string; }): Promise<{ company: Company | null; created: boolean }> { const pb = await getAdminPocketBase(); const name = (input.name || "").trim(); if (!name) { return { company: null, created: false }; } const exactHit = await findCompanyByCaseInsensitiveExact(pb, name); if (exactHit) return { company: exactHit, created: false }; const normalizedHit = await findCompanyByNormalized(pb, name); if (normalizedHit) return { company: normalizedHit, created: false }; const created = await pb.collection(COMPANIES_COLLECTION).create({ company: name, address: input.address || "", vendor: "", }); return { company: created as Company, created: true }; } async function findCompanyByCaseInsensitiveExact( pb: Awaited>, name: string ): Promise { try { const hit = await pb .collection(COMPANIES_COLLECTION) .getFirstListItem(`company = "${name.replace(/"/g, '\\"')}"`); return hit as Company; } catch { return null; } } async function findCompanyByNormalized( pb: Awaited>, name: string ): Promise { const normalized = normalizeCompanyName(name); if (!normalized) return null; const all = await pb.collection(COMPANIES_COLLECTION).getFullList({ fields: "id,company" }); for (const c of all) { if (normalizeCompanyName(c.company) === normalized) { return c; } } return null; } /** * Normalizes a company name for comparison: lowercase, trim, collapse spaces, * strip common legal suffixes, strip trailing punctuation. Intentionally * conservative — only strips things that are unambiguously noise. */ export function normalizeCompanyName(input: string): string { return (input || "") .toLowerCase() .replace(/\s+/g, " ") .replace(/[.,]+$/g, "") .replace( /\b(pty ltd|proprietary limited|ltd|limited|inc|incorporated|llc|corp|corporation|holdings|group|co\.?za|company)\b\.?/gi, "" ) .replace(/\s+/g, " ") .trim(); } // ────────────────────────────────────────────────────────────────────────────── // Quote creation from email // ────────────────────────────────────────────────────────────────────────────── /** * Creates a Quote (and customer, company, items, attachments) from a parsed * email payload. Idempotent on `message_id`. * * The human-opt-in pattern: AI-extracted items are created with * `included_in_quote = false` and all prices zero. A human must review and * opt each line in via the existing quote processing UI. We never fabricate * cost or selling prices. */ export async function createQuoteFromEmail( payload: EmailIntakePayload ): Promise { // The n8n Parse Email node stamps `(no subject)` / `(no body)` placeholders // when an inbound email is missing those headers (a malformed / RFC 5321- // violating send). We accept whatever string the caller sends, including // the placeholder, rather than 500ing — the office can then see the // placeholder quote in the queue and triage manually. The type contract // still says `subject: string`, and the n8n side is now contractually // obligated to always send a non-empty string. // Some senders (notably iCloud/Mail.app) strip the RFC 5322 Message-ID // header. We still need a stable dedup key, so synthesize one from the // real email identity. The `synth-` prefix and `@wfdash.local` suffix // make it visually obvious in PocketBase that this isn't a real header. const effectiveMessageId = payload.message_id?.trim() || synthesizeMessageId(payload); // Some senders also strip the From: header entirely. PB enforces // `required=true` on `quotes.customer`, so an empty `from_email` cannot // be passed through as `null`. We compute a single `fromEmail` value and // fall back to a sentinel "Unknown Sender" customer at the resolution // step below. The `email_sender` field on the quote stores the literal // empty string, not a placeholder — the sentinel is only used for the // relation. const fromEmail = (payload.from_email || "").trim().toLowerCase(); const pb = await getAdminPocketBase(); // ── 1. Idempotency: if we already have a quote for this Message-ID, return it. const existing = await findQuoteByMessageId(pb, effectiveMessageId); if (existing) { const itemsCount = await countQuoteItems(pb, existing.id); const attsCount = await countQuoteAttachments(pb, existing.id); const customer = existing.customer ? ((await pb.collection(CUSTOMERS_COLLECTION).getOne(existing.customer)) as Customer) : null; const company = existing.company ? ((await pb.collection(COMPANIES_COLLECTION).getOne(existing.company)) as Company) : null; return { quote: existing, customer: customer as Customer, company: company as Company | null, items: itemsCount, attachments: attsCount, deduped: true, quote_number: existing.quote_number, }; } // ── 2. Resolve or create the customer. Empty from_email → sentinel. const { customer } = fromEmail ? await findOrCreateCustomer({ email: fromEmail, name: payload.from_name, companyHint: payload.company_hint, }) : await findOrCreateUnknownSenderCustomer(pb); // ── 3. Resolve or create the company. Only the AI's `company_hint` is // trusted — the subject is never a fallback, because unparseable emails // would otherwise create junk `companies` rows. The quoter picks the // company in the form when the AI couldn't find one. const { company } = await findOrCreateCompany({ name: payload.company_hint, }); // ── 4. Generate a quote number. const quoteNumber = await generateQuoteNumberServer(); // ── 5. Create the quote. const receivedAt = payload.received_at ? new Date(payload.received_at) : new Date(); const quote = await pb.collection(QUOTES_COLLECTION).create({ quote_number: quoteNumber, customer: customer.id, company: company?.id || null, description: payload.subject, status: "new", notes: "", email_sender: fromEmail, email_subject: payload.subject, email_body: payload.body_plain || payload.body_html || "", has_attachments: (payload.attachments?.length ?? 0) > 0, email_message_id: effectiveMessageId, company_letterhead: "CNL", }); // ── 6. Pre-populate quote items from the AI extraction. Human opt-in only. const items = (payload.extracted_items || []).filter((i) => (i.description || "").trim()); for (const item of items) { await pb.collection(QUOTE_ITEMS_COLLECTION).create({ quote: quote.id, description: item.description, quote_number: quoteNumber, quantity: Number.isFinite(item.quantity) && (item.quantity as number) > 0 ? item.quantity : 1, cost_price: 0, selling_price: 0, markup: 0, notes: item.notes || "", is_processed: false, }); } // ── 7. Attachments are uploaded in a follow-up step, NOT here. // // The previous design embedded all attachments in the single // `quotes.create_from_email` POST as base64 in the JSON body. A single // 5 MB PDF became ~6.7 MB of base64 in one HTTP request, which the // Next.js route handler rejected at its default 1 MB body limit, so // the whole create call failed and no quote row was written. // // The n8n pipeline now calls `quotes.create_from_email` (which creates // the quote row + items) and then loops over the attachments, calling // `attachments.upload_for_quote` once per file with a small per-call // body. Failures are isolated per attachment and don't roll back the // quote. // // We deliberately accept `attachments` in the payload for back-compat // with any caller that still sends them, but we no longer act on them // here. The result's `attachments` count is always 0 from this path. return { quote: quote as Quote, customer, company: company ?? null, items: items.length, attachments: 0, deduped: false, quote_number: quoteNumber, }; } async function uploadQuoteAttachment( pb: Awaited>, input: { quoteId: string; filename: string; contentType?: string; size?: number; base64: string; messageId: string; } ): Promise> { const buffer = Buffer.from(input.base64, "base64"); const file = new File([buffer], input.filename, { type: input.contentType || "application/octet-stream", }); // The `quote_attachments.uploaded_at` date field is declared as // onCreate-auto in `docs/pocketbase/quote_attachments.collection.json`, // but the live PB collection in production (set up before the docs // schema was finalised) doesn't carry that auto-fill behaviour, and // PB rejects the create with `validation_required` if we don't send a // value. We send `new Date().toISO()` so the field is populated for // both schemas; PB ignores it on the doc schema and stores it on the // live one. Cheap and forward-compatible. const created = await pb.collection(QUOTE_ATTACHMENTS_COLLECTION).create({ quote: input.quoteId, filename: input.filename, file, content_type: input.contentType || "application/octet-stream", size_bytes: input.size ?? buffer.length, email_message_id: input.messageId, kind: classifyAttachment(input.contentType, input.filename), uploaded_at: new Date().toISOString(), }); // Flip the parent quote's `has_attachments` flag so the office sees // the "📎" badge on the quote list. We update unconditionally rather // than reading first — `has_attachments` is a bool column, so // setting it to `true` on every call is idempotent and avoids a // round-trip per attachment. try { await pb.collection("quotes").update(input.quoteId, { has_attachments: true }); } catch (e) { // Non-fatal: the attachment row is the source of truth, and the // flag will get re-synced on the next upload. Log but don't throw. console.warn( `[uploadQuoteAttachment] failed to set has_attachments=true on quote ${input.quoteId}:`, (e as Error)?.message || e ); } return created as Record; } /** * Public entry point used by the `attachments.upload_for_quote` MCP tool. * Uploads a single attachment to the `quote_attachments` collection, linked * to an existing quote. The caller (n8n) is expected to call this once per * attachment after `quotes.create_from_email` returns the new quote id. * * Errors propagate (the MCP tool surfaces them) so the office sees which * files didn't make it. A failed attachment does NOT roll back the quote — * the quote is more valuable than a single bad file, and a quoter can * always re-upload the missing file via a future API. */ export async function uploadAttachmentForQuote(input: { quoteId: string; filename: string; contentType?: string; size?: number; base64: string; messageId?: string; }): Promise> { if (!input.quoteId) throw new Error("quoteId is required."); if (!input.filename) throw new Error("filename is required."); if (!input.base64) throw new Error("base64 is required."); const pb = await getAdminPocketBase(); return uploadQuoteAttachment(pb, { quoteId: input.quoteId, filename: input.filename, contentType: input.contentType, size: input.size, base64: input.base64, messageId: input.messageId || "", }); } function classifyAttachment(contentType: string | undefined, filename: string): string { const ct = (contentType || "").toLowerCase(); const fn = (filename || "").toLowerCase(); if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf"; if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp)$/.test(fn)) return "image"; if ( ct.includes("officedocument") || ct.includes("msword") || ct.includes("ms-excel") || /\.(docx?|xlsx?)$/.test(fn) ) { return "doc"; } return "other"; } async function countQuoteItems( pb: Awaited>, quoteId: string ): Promise { const list = await pb.collection(QUOTE_ITEMS_COLLECTION).getList(1, 1, { filter: `quote = "${quoteId}"`, }); return list.totalItems; } async function countQuoteAttachments( pb: Awaited>, quoteId: string ): Promise { const list = await pb.collection(QUOTE_ATTACHMENTS_COLLECTION).getList(1, 1, { filter: `quote = "${quoteId}"`, }); return list.totalItems; } // ────────────────────────────────────────────────────────────────────────────── // Reply handling // ────────────────────────────────────────────────────────────────────────────── /** * Appends a reply to an existing quote. Match priority: * 1. `In-Reply-To` header → look up `quotes.email_message_id` * 2. `QYYYYMMNNN` extracted from subject → look up `quotes.quote_number` * 3. Otherwise returns `{ matched_by: 'none' }` so the caller can move the * email to the Unmatched folder. * * The reply is stored as a `quote_replies` row. We never mutate * `quotes.notes` — the human-readable thread is joined at render time. */ export async function appendReplyToQuote( payload: EmailReplyPayload ): Promise { const pb = await getAdminPocketBase(); const receivedAt = payload.received_at ? new Date(payload.received_at) : new Date(); const { quote, matched_by } = await resolveReplyTarget(pb, payload); if (!quote) { return { quote: null, reply: null, matched_by: "none" }; } const reply = await pb.collection(QUOTE_REPLIES_COLLECTION).create({ quote: quote.id, from_email: payload.from_email, from_name: payload.from_name || "", subject: payload.subject, body_plain: payload.body_plain || "", body_html: payload.body_html || "", received_at: receivedAt.toISOString(), email_message_id: payload.message_id || "", in_reply_to: payload.in_reply_to || "", quoted_text: payload.quoted_text || "", }); await pb.collection(QUOTES_COLLECTION).update(quote.id, { has_new_reply: true, last_reply_at: receivedAt.toISOString(), }); return { quote: quote as Quote, reply, matched_by }; } async function resolveReplyTarget( pb: Awaited>, payload: EmailReplyPayload ): Promise<{ quote: Quote | null; matched_by: AppendReplyResult["matched_by"] }> { if (payload.in_reply_to) { const q = await findQuoteByMessageId(pb, payload.in_reply_to); if (q) return { quote: q, matched_by: "in_reply_to" }; } const quoteNumber = extractQuoteNumberFromSubject(payload.subject); if (quoteNumber) { try { const q = await pb .collection(QUOTES_COLLECTION) .getFirstListItem(`quote_number = "${quoteNumber}"`); return { quote: q as Quote, matched_by: "subject" }; } catch { // fall through } } return { quote: null, matched_by: "none" }; } export function extractQuoteNumberFromSubject(subject: string): string | null { const m = (subject || "").match(QUOTE_NUMBER_REGEX); if (!m) return null; // Return the full matched number string as-is (preserves whatever // format the sender typed — dashed or dashless — so the exact-match // lookup against quotes.quote_number resolves for both old and new // quotes). return m[1]; } // ────────────────────────────────────────────────────────────────────────────── // Lookups // ────────────────────────────────────────────────────────────────────────────── /** * Looks up a quote by the inbound email's `Message-ID` header. Returns null * if no quote matches. Indexed by `quotes.email_message_id`. */ export async function findQuoteByMessageId( pbArg?: Awaited>, messageId?: string ): Promise; export async function findQuoteByMessageId(messageId: string): Promise; export async function findQuoteByMessageId( pbOrMessageId?: Awaited> | string, maybeMessageId?: string ): Promise { let pb: Awaited>; let messageId: string; if (typeof pbOrMessageId === "string") { pb = await getAdminPocketBase(); messageId = pbOrMessageId; } else { pb = pbOrMessageId || (await getAdminPocketBase()); messageId = maybeMessageId || ""; } if (!messageId) return null; const esc = messageId.replace(/"/g, '\\"'); try { const hit = await pb .collection(QUOTES_COLLECTION) .getFirstListItem(`email_message_id = "${esc}"`); return hit as Quote; } catch { return null; } } // ────────────────────────────────────────────────────────────────────────────── // Message-ID synthesis (fallback when the inbound email has no header) // ────────────────────────────────────────────────────────────────────────────── /** * Builds a stable, deterministic Message-ID for an email that arrived without * one. The hash is over `from_email + subject + received_at` so the same * email processed twice dedups to the same id. The `synth-` prefix and * `@wfdash.local` suffix make it visually obvious in PocketBase that this * isn't a real RFC 5322 Message-ID. * * Collisions are not a correctness concern: two real emails with identical * sender + subject + received_at are the same email. The hash is FNV-1a 32-bit * which is overkill for this input space but cheap and stable across runs. */ export function synthesizeMessageId(payload: { from_email: string; subject: string; received_at?: string; }): string { const input = `${payload.from_email}|${payload.subject}|${payload.received_at ?? ""}`; let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { hash ^= input.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return `synth-${(hash >>> 0).toString(16).padStart(8, "0")}@wfdash.local`; } // ────────────────────────────────────────────────────────────────────────────── // Outbound send-email pipeline // ────────────────────────────────────────────────────────────────────────────── /** * Customer IDs / names that mean "the email intake parked the quote * here because the inbound email was missing From: or the operator * hasn't picked a real one yet." Sending to these is a data leak. * * Mirrors the same sentinel set used by the PDF route so the two * paths agree on what counts as "real". */ const PLACEHOLDER_CUSTOMER_IDS = new Set([ "2tmt34gzj44v9f8", // SELECT/CREATE CUSTOMER (process-page initial selection) "dcpb38w4mk6vrm5", // Unknown Sender (email intake sentinel) ]); const PLACEHOLDER_CUSTOMER_NAMES = new Set([ "SELECT/CREATE CUSTOMER", "Unknown Sender", "No Header", ]); /** * Loose RFC 5322-ish check. We don't try to be exhaustive — we just * want to catch obvious typos ("foo@" or "foo,bar") before n8n has to * bounce off SMTP. Real validation happens at SMTP time. */ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function looksLikeEmail(s: string): boolean { return EMAIL_RE.test((s || "").trim()); } /** * Statuses where the operator is allowed to trigger a new send. We * explicitly disallow `sending` (already in flight), `emailed` * (already done), and the various terminal statuses (`complete`, * `cancelled`, `rejected`). * * `partial_approved` was added 2026-07-24 alongside the partial- * approval flow. The rep approves a partial quote → status flips to * `partial_approved` → they retry the email send if the first one * failed. The same retry semantics as `mail_failed` apply. */ const RESENDABLE_STATUSES = new Set([ "approval_pending", "partial_approval_pending", "approved", "partial_approved", "mail_failed", ]); export interface SendQuoteEmailPayload { /** Optional override for the primary To: address. Defaults to customer.email. */ to?: string; cc: string; bcc: string; subject: string; message: string; /** Per-modal checkbox: include the per-letterhead supplier authorization letter. */ attachAuthLetters: boolean; /** * IDs of `quote_attachments` rows to include in the outbound email. * The PDF is always included separately (auto-located by kind="pdf" * at send time). IDs that don't belong to this quote are silently * filtered out so a tampered client can't smuggle in attachments * from another quote. */ selectedAttachmentIds: string[]; } export interface SendQuoteEmailFailure { code: | "placeholder_customer" | "no_recipient" | "invalid_recipient" | "no_pdf" | "invalid_status" | "quote_not_found"; message: string; } export interface SendQuoteEmailResult { quote: Quote; /** Null on success; populated when pre-send validation rejected the send. */ failure: SendQuoteEmailFailure | null; } /** * Pre-send validation + status transition for the outbound email. * * The actual SMTP send happens in n8n (the `Quote Outbound Send` * workflow), triggered by a PB hook on `status = "sending"`. This * service is the synchronous front-door: * * 1. Validate recipients, customer, status, and PDF. * 2. If validation fails, flip status to `mail_failed` + write * `email_error` synchronously and return `{ failure }`. Don't * bother n8n — there's nothing to send. * 3. If validation passes, flip status to `sending`, persist the * operator's composition choices (CC/BCC/Subject/Message/ * selectedAttachmentIds/attachAuthLetters), stamp * `sending_started_at`, and clear `email_error`. n8n picks up * the change via PB hook and runs the SMTP send. * * We deliberately write `email_outbound_attachment_ids` here (not in * n8n) so the retry flow sends the SAME attachments the operator * chose the first time, not whatever happens to be attached at * retry time. Without this, a check-then-uncheck between send and * retry could cause the customer to receive a different attachment * set on retry than on the original attempt. * * `email_outbound_message_id` is NOT written here — n8n generates * it at SMTP-time and writes it on success/failure. This keeps the * RFC 5322 Message-ID guarantee (must be unique per message) in the * sender's hands. */ export async function sendQuoteEmail( quoteId: string, payload: SendQuoteEmailPayload ): Promise { const pb = await getAdminPocketBase(); // ── 1. Load quote + customer let quote: Quote; try { quote = await pb.collection(QUOTES_COLLECTION).getOne(quoteId); } catch { return { quote: null as unknown as Quote, failure: { code: "quote_not_found", message: "Quote does not exist." }, }; } // Load the customer separately (the getOne above doesn't expand). const customer = quote.customer ? ((await pb.collection(CUSTOMERS_COLLECTION).getOne(quote.customer)) as Customer) : null; // ── 2. Validate status ──────────────────────────────────────── // Two paths to a valid "ready to send" state: // a) Regular quote — must be in RESENDABLE_STATUSES // (approval_pending / approved / mail_failed). The // operator runs the full claim → process → submit for // approval → approve → send pipeline. // b) Delivery-note quote — skips the approval queue. The // office claims, prices, and sends. So we accept any // pre-send DN status here: `delivery_note` (just created // and not yet claimed), `claimed` (operator claimed but // hasn't priced), `processed` (priced, ready to send). // We also still allow the regular RESENDABLE_STATUSES // so retries from `mail_failed` keep working. // // The status flip downstream (step 8) goes straight to // `sending` — we don't pass through `approval_pending` or // `approved` for DN quotes, because the items are already // with the customer and the operator has already verified // them on the delivery note. const isDeliveryNoteQuote = !!quote.delivery_note; const dnResendableStatuses = new Set([ "delivery_note", "claimed", "processed", ]); const allowedStatuses = isDeliveryNoteQuote ? new Set([...RESENDABLE_STATUSES, ...dnResendableStatuses]) : RESENDABLE_STATUSES; if (!allowedStatuses.has(quote.status)) { return markPreSendFailure(pb, quote, { code: "invalid_status", message: `Cannot send from status "${quote.status}". Allowed: ${[...allowedStatuses].join(", ")}.`, }); } // ── 3. Validate customer is real (not a sentinel). Split into // two distinct failure modes so the UI can be precise: // - placeholder_customer: customer is the Unknown Sender // sentinel or one of the other placeholder rows. The // operator must pick a real customer before sending. // - no_recipient: customer is real but has no email // address (primary + alternative both empty). Fix is // to add an email to the customer record. if ( !customer || PLACEHOLDER_CUSTOMER_IDS.has(customer.id) || PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) ) { return markPreSendFailure(pb, quote, { code: "placeholder_customer", message: "Quote is still pointed at a placeholder / unnamed customer. Pick the real customer before sending.", }); } // ── 4. Validate recipient email(s). The operator may override `to`, // but we always require *some* valid address. const primary = (payload.to || customer.email || "").trim(); const alt = (customer.alternative_email || "").trim(); const toAddress = looksLikeEmail(primary) ? primary : looksLikeEmail(alt) ? alt : ""; if (!toAddress) { return markPreSendFailure(pb, quote, { code: "no_recipient", message: "No valid recipient email on this quote (primary and alternative are both empty or invalid).", }); } // ── 5. Validate CC / BCC entries (loose check; SMTP will reject // truly malformed addresses) for (const list of [payload.cc, payload.bcc]) { for (const raw of (list || "").split(",")) { const v = raw.trim(); if (v && !looksLikeEmail(v)) { return markPreSendFailure(pb, quote, { code: "invalid_recipient", message: `"${v}" doesn't look like an email address.`, }); } } } // ── 6. Validate PDF exists (most recent kind=pdf attachment). The // outbound send includes the PDF as the first attachment; no // PDF = nothing useful to send. const pdfs = await pb .collection(QUOTE_ATTACHMENTS_COLLECTION) .getList(1, 1, { filter: `quote = "${quoteId}" && kind = "pdf"`, sort: "-uploaded_at", }); if (pdfs.items.length === 0) { return markPreSendFailure(pb, quote, { code: "no_pdf", message: "No PDF generated for this quote yet. Generate the PDF before sending.", }); } // ── 7. Filter selectedAttachmentIds to only those that actually // belong to this quote. Tampered clients can't smuggle in // attachments from other quotes. const requestedIds = Array.from(new Set(payload.selectedAttachmentIds || [])); let validIds: string[] = []; if (requestedIds.length > 0) { const quotedAtts = await pb .collection(QUOTE_ATTACHMENTS_COLLECTION) .getFullList({ filter: `quote = "${quoteId}"`, fields: "id", }); const ownIds = new Set(quotedAtts.map((a) => ({ id: (a as { id: string }).id })).map((x) => x.id)); validIds = requestedIds.filter((id) => ownIds.has(id)); } // ── 8. Flip status to `sending` + persist composition. One PATCH // so the audit trail stays atomic. const updated = (await pb.collection(QUOTES_COLLECTION).update(quoteId, { status: "sending", sending_started_at: new Date().toISOString(), email_error: "", email_outbound_attachment_ids: JSON.stringify(validIds), // Persist operator's last-used composition so reopening the // modal shows what was sent. email_cc: payload.cc || "", email_bcc: payload.bcc || "", email_subject_outbound: payload.subject || "", email_message: payload.message || "", attach_auth_letters: !!payload.attachAuthLetters, })) as Quote; return { quote: updated, failure: null }; } /** * Retry an outbound send after a `mail_failed`. Same validation as * `sendQuoteEmail` but without re-reading the modal payload — we * reuse whatever composition the operator submitted last time * (already persisted on the quote row). * * Status guard: only `mail_failed` can be retried; everything else * is either a no-op (`emailed`, `sending`) or a different transition * (`approved` → use `sendQuoteEmail` to flip and send). */ export async function retryQuoteEmail(quoteId: string): Promise { const pb = await getAdminPocketBase(); let quote: Quote; try { quote = await pb.collection(QUOTES_COLLECTION).getOne(quoteId); } catch { return { quote: null as unknown as Quote, failure: { code: "quote_not_found", message: "Quote does not exist." }, }; } if (quote.status !== "mail_failed") { return markPreSendFailure(pb, quote, { code: "invalid_status", message: `Cannot retry from status "${quote.status}". Only mail_failed quotes can be retried.`, }); } const updated = (await pb.collection(QUOTES_COLLECTION).update(quoteId, { status: "sending", sending_started_at: new Date().toISOString(), email_error: "", email_failed_at: "", })) as Quote; return { quote: updated, failure: null }; } /** * Synchronously flip a quote to `mail_failed` and write the failure * reason. Used by `sendQuoteEmail` when pre-send validation rejects * the send — we never reach n8n in this path, so the failure has * to be recorded by us. * * Returns a `SendQuoteEmailResult` shaped exactly like the success * path so the API route doesn't have to branch. */ async function markPreSendFailure( pb: Awaited>, quote: Quote, failure: SendQuoteEmailFailure ): Promise { const updated = (await pb.collection(QUOTES_COLLECTION).update(quote.id, { status: "mail_failed", email_error: `[pre-send] ${failure.message}`, email_failed_at: new Date().toISOString(), })) as Quote; return { quote: updated, failure }; } /** * Records the *outcome* of an SMTP send attempt. Called by the n8n * `Quote Outbound Send` workflow (or any other worker that's been * handed the send). * * `outcome = "success"` flips to `emailed` + stamps delivery fields. * `outcome = "failure"` flips to `mail_failed` + stamps error. * * This is intentionally separate from `sendQuoteEmail` so the n8n * path doesn't have to replay validation — by the time n8n runs, * pre-send checks already passed. */ export interface RecordSendOutcomeInput { quoteId: string; outcome: "success" | "failure"; /** SMTP-issued Message-ID. Stored on success AND failure (so failures are traceable in mail logs). */ messageId?: string; /** Failure reason (SMTP response text). Required when outcome = "failure". */ errorMessage?: string; } export async function recordSendOutcome( input: RecordSendOutcomeInput ): Promise { const pb = await getAdminPocketBase(); if (input.outcome === "success") { const patch: Record = { status: "emailed", email_delivered: true, email_delivered_at: new Date().toISOString(), sending_started_at: "", email_error: "", email_failed_at: "", }; if (input.messageId) patch.email_outbound_message_id = input.messageId; return (await pb.collection(QUOTES_COLLECTION).update(input.quoteId, patch)) as Quote; } // outcome = "failure" const reason = (input.errorMessage || "Unknown SMTP failure").slice(0, 4900); const patch: Record = { status: "mail_failed", email_failed_at: new Date().toISOString(), email_error: `[smtp] ${reason}`, sending_started_at: "", }; if (input.messageId) patch.email_outbound_message_id = input.messageId; return (await pb.collection(QUOTES_COLLECTION).update(input.quoteId, patch)) as Quote; } // ────────────────────────────────────────────────────────────────────────────── // Partial-approval split — created 2026-07-24 // ────────────────────────────────────────────────────────────────────────────── /** * Result of `splitQuoteForContinuation`. The caller (the /split * route) returns this to the UI, which surfaces a toast and * optionally links to the new quote. */ export interface SplitResult { /** The newly-created continuation quote. */ continuation: Quote; /** How many items were moved from the parent to the continuation. */ itemsMoved: number; /** How many items stayed on the parent (priced + approved_in_batch). */ itemsKept: number; } /** * Create a continuation quote from a partial-approved parent. * * The parent is sealed (status: `partial_approved`, no new items can * be added). The continuation inherits the parent's customer / rep / * company / RFQ / notes / description, gets a fresh `quote_number` * via `generateQuoteNumberServer()`, and starts at `status = "new"` * with `parent_quote` pointing back at the parent. * * Items on the parent that are NOT yet priced (`is_processed = false`, * `moved_to_quote = null`) are cloned to the continuation. Items that * ARE priced (`is_processed = true`) stay on the parent — they're * the ones the rep just approved and emailed. * * Items moved off the parent get PATCHed with * `moved_to_quote = ` so the UI can hide them from * the parent's "active items" list while preserving them in the DB * for audit. They are NOT deleted — the user's hard rule is * "PATCH over DELETE wherever possible". * * The continuation's items get `is_processed = false` and * `moved_to_quote = null` (clones — not a record of having been * moved). They show up on /sprint the next time the operator * opens it. * * Edge cases: * - Parent has no unpriced items: returns `{ continuation: parent, itemsMoved: 0 }`. * We DO NOT create an empty quote — the split is a no-op. Caller * should treat this as a successful "nothing to do" and skip the * toast. * - Parent already has a `parent_quote` set (i.e. is itself a * continuation): we still allow the split. The chain is * flat (Q-1 → Q-2 → Q-3) rather than nested. */ export async function splitQuoteForContinuation( parentId: string ): Promise { const pb = await getAdminPocketBase(); const parent = await pb.collection(QUOTES_COLLECTION).getOne(parentId); // Pull all items on the parent. We want the unpriced ones (the // priced ones already belong to the partial-approved batch). The // `moved_to_quote` filter excludes items already moved by a prior // split — defends against double-clicks. const allItems = await pb .collection(QUOTE_ITEMS_COLLECTION) .getFullList({ filter: `quote = "${parentId}" && (moved_to_quote = null || moved_to_quote = "")`, }); const toMove = allItems.filter( (it) => !it.is_processed || it.approved_in_batch !== true ); // Priced + approved_in_batch=true items stay on the parent. // Everything else (unpriced, or priced-but-not-yet-approved in a // prior split) moves. const keep = allItems.filter( (it) => it.is_processed && it.approved_in_batch === true ); if (toMove.length === 0) { // Nothing to do — return the parent as the "continuation" with // an itemsMoved=0 signal. Caller can no-op on this. return { continuation: parent, itemsMoved: 0, itemsKept: keep.length }; } // Create the continuation quote. Carry over the customer/company // identity, the RFQ number (so the customer sees continuity), and // the description / notes. Reset status, dates, and the per-quote // workflow flags. const newQuoteNumber = await generateQuoteNumberServer(); const now = new Date().toISOString(); const continuation = (await pb.collection(QUOTES_COLLECTION).create({ quote_number: newQuoteNumber, status: "new", is_template: false, quote_date: now, customer: parent.customer || null, company: parent.company || null, company_letterhead: parent.company_letterhead || "", description: parent.description || "", notes: parent.notes || "", markup: parent.markup ?? null, external_number: parent.external_number || "", rfq_number: parent.rfq_number || "", quote_reference: parent.quote_reference || "", parent_quote: parent.id, validity_days: parent.validity_days ?? null, })) as Quote; // Clone each "to move" item to the continuation. The clones get // is_processed=false, approved_in_batch=false, moved_to_quote=null. for (const it of toMove) { const clone: Record = { quote: continuation.id, description: it.description, quantity: it.quantity, quote_number: newQuoteNumber, quote_reference: it.quote_reference, notes: it.notes, // Reset workflow flags is_processed: false, approved_in_batch: false, moved_to_quote: null, }; if (it.supplier) clone.supplier = it.supplier; await pb.collection(QUOTE_ITEMS_COLLECTION).create(clone); } // Mark the moved items on the parent as moved. They stay in PB // for audit but are hidden from the parent's "active" item list // (filter: moved_to_quote = null). The pricing_notes field gets // a breadcrumb so anyone reading the row knows where it went. for (const it of toMove) { await pb.collection(QUOTE_ITEMS_COLLECTION).update(it.id, { moved_to_quote: continuation.id, pricing_notes: (it.pricing_notes ? it.pricing_notes + "\n" : "") + `Moved to ${newQuoteNumber} via partial-approval split on ${now}`, }); } return { continuation, itemsMoved: toMove.length, itemsKept: keep.length, }; } /** * Mark every priced item on a quote as `approved_in_batch = true`. * Used by the partial-approve endpoint right before the email is * kicked off. Idempotent: re-running on a quote where every priced * item is already approved_in_batch=true is a no-op. * * Returns the count of items flipped. The caller uses this to * surface a "N items marked for this batch" toast. */ export async function markPricedItemsApprovedInBatch( quoteId: string ): Promise { const pb = await getAdminPocketBase(); const items = await pb .collection(QUOTE_ITEMS_COLLECTION) .getFullList({ filter: `quote = "${quoteId}" && is_processed = true && (moved_to_quote = null || moved_to_quote = "")`, }); const toFlip = items.filter((it) => it.approved_in_batch !== true); // Fire-and-forget the per-item updates. PB will serialise them. // For a typical 15-item quote this is < 100ms. await Promise.all( toFlip.map((it) => pb.collection(QUOTE_ITEMS_COLLECTION).update(it.id, { approved_in_batch: true, }) ) ); return toFlip.length; }