import { getAdminPocketBase } from "./pocketbase-admin"; import { quoteItemReadinessGaps } from "./quote-pdf-service"; import { compressFile } from "@/lib/server/attachment-compressor"; import { QUOTE_VALID_DAYS } from "@/lib/pdf/format"; import { createQuoteItem } from "./quote-items-service"; import { searchQuotes } from "./quotes-search-service"; import { generateQuotePdf, isQuotePdfStale, } from "./quote-pdf-service"; import { PLACEHOLDER_CUSTOMER_IDS, PLACEHOLDER_CUSTOMER_NAMES, } from "@/lib/placeholder-customers"; import type { Quote, QuoteItem, Customer, Company, QuoteStatus } from "@/lib/types"; import { RESUBMITTABLE_STATUSES } from "@/lib/quote-status"; /** * 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; /** * Optional override for customer matching — used by the AI extraction * layer when the inbound email is forwarded by a rep (e.g. Gary) who * named the actual customer in the subject line. When set, this name * is resolved against existing customers (case-insensitive exact, then * a normalised-token pass) BEFORE the `from_email` sender is used as * the customer. If it resolves, the linked customer is the named * person, not the rep. If it doesn't resolve, the resolution falls * back to the sender (Gary) so the quote still lands somewhere * attributable rather than on the Unknown Sender sentinel. */ person_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; /** The created quote_items rows (as QuoteItem objects, with the * stubbed prices / no supplier set — these are placeholders for the * office to price). The agent previously saw only a count; returning * the array lets it confirm what was written and follow up with * `quote_items.update` for each. */ items: QuoteItem[]; /** Convenience: `items.length`. Kept for back-compat with the old * count-only shape. */ items_count: 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. */ /* ============================================================================ * Quotes dashboard read model * ---------------------------------------------------------------------------- * Read-side service for the `/quotes` page. Mirrors the pattern established * in `orders-service.ts` (server-side tab filter + slim expand + cached * combined counts) but adapted to the quotes data model. * * Why a new module-level section instead of extending `pb-helpers.ts`: * - `pb-helpers.ts` is the legacy layer called directly by Client * Components; `AGENTS.md` says new endpoints / features go through * the service layer. The previous implementation also had several * wire-payload issues (full quote record × 20 in the list response, * no field limits on the deep expand) that are easier to fix in * one place than to retrofit onto the helper. * - The old `getQuoteStatusCounts` fired 22 parallel `getList(1, 1, ...)` * queries, one per PB status. With no `quotes.status` index on the * live PB, that's 22 full scans over ~8.5k quotes = ~2.6s wall time * on every page load. The combined query below collapses to 1 scan * (~150ms measured on the live instance). * * Tab model: 10 tabs on /quotes, each mapped to one or more PB statuses. * The mapping is canonical here (`QUOTE_TAB_STATUSES`) — the page's * `tabFilters` constant now derives from this. Fixes the long-standing * bug where `cancelled` + `rejected` were bucketed under "completed" * in the counts, so the Cancelled tab badge was always empty. * ========================================================================== */ export type QuoteTabKey = | "new" | "claimed" | "pending_approval" | "completed" | "completed_recent" | "cancelled" | "delivery_note" | "email_failed" | "problem" | "all" | "templates"; /** * The /quotes page's tabs, in the operator-facing display order. The * left-to-right order matches the workflow lifecycle (intake → claim → * approval → success → recovery → failures → catch-all → meta). The * `all` tab sums every status except templates; `templates` is its * own thing because templates are scaffolding for FUTURE quotes, not * in-flight work. */ export const QUOTE_TAB_ORDER: QuoteTabKey[] = [ "new", "claimed", "pending_approval", "completed", "completed_recent", "cancelled", "delivery_note", "email_failed", "problem", "all", "templates", ]; /** * Tab key → list of PB statuses that belong on that tab. An empty * array means the tab uses a non-status filter (delivery_note = * relation filter, all = no filter, templates = is_template = true). * * `cancelled` now correctly absorbs the `rejected` and `cancelled` * statuses — the old `getQuoteStatusCounts` mapped both to * "completed", which is why the Cancelled tab badge was always empty. * The page's `tabFilters` separately groups them under "cancelled", * so a quote that was rejected showed up in the list but not the * count. The new mapping keeps count and list in lock-step. */ export const QUOTE_TAB_STATUSES: Record = { new: ["new", "follow_up"], claimed: ["claimed"], pending_approval: ["processed", "approval_pending", "partial_approval_pending", "sending"], completed: ["approved", "complete", "partial_approved", "emailed"], // "Completed this week" — same status set as `completed`, narrowed // by recency. Banked by the office in mid-2026 because: // - the global "Completed" tab is 7,849 rows (≈93% of the live // collection), so searching it is slow; // - the common "where's the Acme quote I just emailed" lookup // lives here, not in the bulk history; // - this is a *filter* on completed, not a new status, so the // underlying `deriveTabKey` in the search service still buckets // hits into `completed` for the per-tab breakdown. // The date cutoff is computed in `customTabFilter` (search) and in // the list endpoint below — `updated` is the closest stable proxy // for "completed" since PB doesn't track a per-status change time. completed_recent: [], cancelled: ["cancelled", "rejected"], // Delivery Note tab is relation-based (every quote that prices a // delivery note, regardless of where it sits in the lifecycle). // The list filter is `delivery_note != ""` rather than a status // IN (...) clause. The count is derived from the same filter. delivery_note: [], email_failed: ["email_failed", "mail_failed", "unmatched_bounce"], problem: ["problem"], // "All" = no status filter. Treated as the catch-all excluding // templates (templates have their own tab and aren't in-flight work). all: [], // Templates tab uses `is_template = true`, NOT a status filter // (the `status` field for templates can be anything). templates: [], }; /** Number of days the "Completed this week" tab looks back. Single * source of truth so the cache count, the list filter, and the * search pushdown all agree on the cutoff. Tweak here if the * operator wants a different window. */ export const COMPLETED_RECENT_WINDOW_DAYS = 7; /** ISO timestamp for the "completed_recent" cutoff — `updated >= * this` is what the filter is built on. Fresh per request so the * tab survives a long-lived process (the page is daily-use; a * Next.js server should comfortably reload between days). */ export function completedRecentCutoff(now: Date = new Date()): string { const cutoff = new Date(now.getTime() - COMPLETED_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1000); return cutoff.toISOString(); } /* ============================================================================ * Search recency window (Path A) * ---------------------------------------------------------------------------- * The full quote collection is ~8.5k rows. Searching it in real time * is fast after the pushdown refactor, but the *operational* feel is * bad: the operator's daily question is "where's the Acme quote I * emailed yesterday?" — never "find me a quote from 18 months ago * whose name I half-remember". The 90-day default makes the search * box do what the operator actually wants it to do, and the * "Include older" toggle lets them dig deeper when they really need * to (third-party phoned about a long-closed order, etc.). * * The window is shared by the search service AND the archive so the * two surfaces agree on what "recent" means. Same single source of * truth as `COMPLETED_RECENT_WINDOW_DAYS` for the new tabs. * ========================================================================== */ export const SEARCH_RECENT_WINDOW_DAYS = 60; export function searchRecentCutoff(now: Date = new Date()): string { return new Date( now.getTime() - SEARCH_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1000, ).toISOString(); } /** * All statuses that drive any tab's count, deduplicated. Used to * build the combined `status = "..." || ...` filter for the * status-counts query so we make ONE PB call instead of one per status. */ const QUOTE_COUNTED_STATUSES: string[] = Array.from( new Set( Object.values(QUOTE_TAB_STATUSES).flat().filter((s) => s.length > 0), ), ); /** * Cached counts for the tabs. Same lifetime/rationale as the orders * tab-counts cache: counts are global, so 60s TTL survives rapid * tab-switching without going stale on the operator's own changes * (realtime / refetch with `bypassCache: true` invalidates). */ const cachedQuoteTabCounts = new Map< "all" | "active", { value: Record; expiresAt: number } >(); const QUOTE_TAB_COUNTS_CACHE_TTL_MS = 60_000; /** Invalidate the quote tab-counts cache. Exported for tests. */ export function clearQuoteStatusTabCountsCache(): void { cachedQuoteTabCounts.clear(); } /** * Batch size for the combined counts query. Bumped from the PB default * of 500 — at 8.5k+ quotes the default would cost ~17 internal * round-trips; 5000 keeps it at 2-3. */ const QUOTE_COUNTS_BATCH_SIZE = 5000; const ARCHIVE_QUOTE_TABS = new Set(["completed", "all"]); const ACTIVE_QUOTE_COUNTED_STATUSES: string[] = Array.from( new Set( QUOTE_TAB_ORDER .filter((tab) => !ARCHIVE_QUOTE_TABS.has(tab)) .flatMap((tab) => QUOTE_TAB_STATUSES[tab]) .filter((status) => status.length > 0), ), ); export interface QuotesDashboardData { items: Quote[]; totalItems: number; totalPages: number; counts: Record; } /** * Read model for the internal /quotes page. * * Mirrors `getOrdersDashboardData` (orders-service.ts): a search * field-free path uses PB's native pagination; a tab path narrows * to the tab's status set so the footer pagination matches the * badge count. Search bypasses tab filtering (operator hunting * for a specific quote, regardless of status) and reduces to a * single page. */ export async function getQuotesDashboardData( params: { page?: number; perPage?: number; search?: string; sort?: string; tab?: QuoteTabKey; bypassCountsCache?: boolean; includeArchivedCounts?: boolean; } = {}, ): Promise { const pb = await getAdminPocketBase(); pb.autoCancellation(false); const [list, counts] = await Promise.all([ listQuotesForDashboard(params, pb), getQuoteStatusTabCounts(pb, { bypassCache: params.bypassCountsCache, includeArchivedTabs: params.includeArchivedCounts ?? true, }), ]); return { ...list, counts }; } /** * Expand relation for the list-page response. * * `customer.company` WAS deliberately dropped to save one IN query per * page load, with the page reading `expand.company.company` (the * quote's own denormalised copy) instead. That shortcut is why the * list showed the wrong company: `quotes.company` is not re-cascaded * when a quote's customer changes, so it goes stale and stays stale — * Q202610315 listed "Mre" while its customer sat at Harmony. It is * also the surface a rep reads to decide whether to approve, so a * stale company there is worse than a slightly larger query. * * The 3-level expand is back. If this ever needs trimming, the fix is * to drop `quote.company` from the schema rather than to read a field * that nothing keeps correct. * * (No `fields` projection here — see the comment at the call site. * PB 0.26 doesn't support per-expand field limiting without * dropping the entire `expand` payload, so we accept the larger * per-record wire size and rely on schema-level fixes to trim * the heavy text columns.) */ const QUOTE_LIST_EXPAND = "customer,customer.company,customer.rep,company"; async function listQuotesForDashboard( params: { page?: number; perPage?: number; search?: string; sort?: string; tab?: QuoteTabKey; }, pbArg?: Awaited>, ): Promise> { const pb = pbArg || (await getAdminPocketBase()); pb.autoCancellation(false); const page = Math.max(1, params.page || 1); const perPage = Math.min(100, Math.max(1, params.perPage || 20)); const search = (params.search || "").trim(); const sort = params.sort || "-created"; const tab = params.tab; // Build the tab's status filter clause. Three classes of tab: // - delivery_note: relation filter (not status) // - templates: is_template = true // - all: no status filter // - any other: status IN (TAB_STATUSES[tab]) const tabStatuses = tab ? QUOTE_TAB_STATUSES[tab] : []; let tabFilter = ""; if (tab === "delivery_note") { tabFilter = `delivery_note != ""`; } else if (tab === "templates") { tabFilter = `is_template = true`; } else if (tab === "all") { tabFilter = `is_template != true`; } else if (tab === "completed_recent") { // Same status set as `completed` but narrowed by `updated` so the // tab is small enough to search without hitting the full // collection. `updated` is the closest stable proxy for the // "completed" timestamp since PB doesn't track per-status change // on the quote itself. const completedStatuses = QUOTE_TAB_STATUSES.completed .map((s) => `status = "${s}"`) .join(" || "); tabFilter = `(${completedStatuses}) && updated >= "${completedRecentCutoff()}"`; } else if (tabStatuses.length > 0) { tabFilter = `(${tabStatuses.map((s) => `status = "${s}"`).join(" || ")})`; } // Search + tab combines. Search is a free-text OR across the // quote_number / quote_reference / rfq_number fields. When BOTH // are active, the PB filter chains them with AND. let searchFilter = ""; if (search) { searchFilter = `(quote_number ~ "${search}" || quote_reference ~ "${search}" || rfq_number ~ "${search}")`; } // For non-special tabs and "all", we also exclude templates so // they don't pollute the workflow view (the Templates tab shows // them explicitly). For the "templates" tab itself, this is // already handled by `is_template = true`. const excludeTemplate = tab !== "templates" ? `is_template != true` : ""; const clauses = [excludeTemplate, tabFilter, searchFilter] .filter((c) => c.length > 0) .join(" && "); // For tab + no-search: PB paginates the filtered set, so the // footer total reflects the tab's matching count (not the global // ~8.5k). For search (with or without tab): single page, total // computed from the filtered items. const result = await pb .collection(QUOTES_COLLECTION) .getList(page, perPage, { filter: clauses, sort, expand: QUOTE_LIST_EXPAND, // No `fields` here: PB 0.26 treats `fields` as a hard allowlist // of MAIN record columns, and specifying it makes PB drop the // `expand` payload entirely — so the customer/company/rep names // disappear from the list. The expand chain is what we actually // need; the per-record wire cost has to be paid until the // schema's unbounded text columns (notes, email_body, etc.) // are trimmed at the field level. }); if (search) { return { items: result.items, totalItems: result.items.length, totalPages: 1, }; } return { items: result.items, totalItems: result.totalItems, totalPages: result.totalPages, }; } /** * Counts per tab. Replaces the old `getQuoteStatusCounts` (22 * parallel `getList(1, 1, ...)` queries). Strategy: * * 1. One combined `status = "x" || status = "y" || ...` query, * `fields: "status"`, batch 5000. Loads ~all quotes that * contribute to any tab's count. ~150ms measured on the live * PB (vs ~2.6s for 22 separate queries without an index). * * 2. Bucket in memory. The dataset is small — only the `status` * field per row — so the dedupe-by-tab is cheap. * * 3. Special-case the `delivery_note` and `templates` tabs with * their own targeted queries (the status-based filter doesn't * apply to either). These two are tiny in volume so the cost * is negligible. * * Cached for 60s; the realtime / refetch path passes `bypassCache: * true` so a change the operator just made updates the badge * immediately. */ export async function getQuoteStatusTabCounts( pbArg?: Awaited>, options: { bypassCache?: boolean; includeArchivedTabs?: boolean } = {}, ): Promise> { const pb = pbArg || (await getAdminPocketBase()); pb.autoCancellation(false); const includeArchivedTabs = options.includeArchivedTabs ?? true; const cacheKey = includeArchivedTabs ? "all" : "active"; if ( !options.bypassCache && cachedQuoteTabCounts.get(cacheKey)?.expiresAt && cachedQuoteTabCounts.get(cacheKey)!.expiresAt > Date.now() ) { return cachedQuoteTabCounts.get(cacheKey)!.value; } // Combined status query — ONE PB call, ONE filter scan, instead // of 22 separate per-status scans. const countedStatuses = includeArchivedTabs ? QUOTE_COUNTED_STATUSES : ACTIVE_QUOTE_COUNTED_STATUSES; const allStatusRows = await pb .collection(QUOTES_COLLECTION) .getFullList<{ status?: string }>({ filter: countedStatuses.map((s) => `status = "${s}"`).join(" || "), fields: "status", batch: QUOTE_COUNTS_BATCH_SIZE, }); const counts: Record = { new: 0, claimed: 0, pending_approval: 0, completed: 0, completed_recent: 0, cancelled: 0, delivery_note: 0, email_failed: 0, problem: 0, all: 0, templates: 0, }; let allTotal = 0; for (const row of allStatusRows) { const status = row.status; if (!status) continue; allTotal++; for (const tab of QUOTE_TAB_ORDER) { const tabStatuses = QUOTE_TAB_STATUSES[tab]; if (tabStatuses.length === 0) continue; // special tab, handled below if (tabStatuses.includes(status)) { counts[tab]++; break; // each status lands in exactly one workflow tab } } } // Special tabs (relation-based, not status-based). // Templates: `is_template = true`. Excluded from the combined // query above so the workflow count isn't polluted. const templatesResp = await pb .collection(QUOTES_COLLECTION) .getList(1, 1, { filter: "is_template = true" }); counts.templates = templatesResp.totalItems; // Delivery note: `delivery_note != ""`. Each quote with a // delivery note attached. Status-agnostic — DN-backed quotes // can be claimed, processed, emailed, etc. const dnResp = await pb .collection(QUOTES_COLLECTION) .getList(1, 1, { filter: `delivery_note != ""` }); counts.delivery_note = dnResp.totalItems; // "Completed this week" — completed + recency. Filter is the same // status-set + `updated` cutoff the list endpoint uses (built via // the helper so the two stay in lock-step). PB's `getList(1, 1)` // is the cheapest count query — only the total comes back over // the wire, no rows. const completedRecentFilter = `(${QUOTE_TAB_STATUSES.completed .map((s) => `status = "${s}"`) .join(" || ")}) && updated >= "${completedRecentCutoff()}"`; const recentResp = await pb .collection(QUOTES_COLLECTION) .getList(1, 1, { filter: completedRecentFilter }); counts.completed_recent = recentResp.totalItems; if (includeArchivedTabs) { // "All" is the catch-all EXCLUDING templates. This matches the // page's previous behaviour (templates aren't in-flight work, // so they don't contribute to the workflow rollup). counts.all = allTotal; } else { counts.completed = 0; counts.all = 0; } cachedQuoteTabCounts.set(cacheKey, { value: counts, expiresAt: Date.now() + QUOTE_TAB_COUNTS_CACHE_TTL_MS, }); return counts; } /* ============================================================================ * Quotes archive * ---------------------------------------------------------------------------- * Read model for `/quotes/archive` — a dedicated route for historical * quotes. The "Completed" and "All" tabs on the main `/quotes` view * held 7,849 of 8,466 quotes (93%) — a "Page 1 of 392" experience * that mirrored the orders situation. Same fix as the orders archive: * move the bulk to a dedicated page, keep the main page focused on * the active workflow. * * Two views on the archive page (toggle in the header): * - "Completed" (7,849 quotes) — the historical bulk * - "All" (8,466 quotes) — everything, including the active * workflow tabs. Useful for "show me everything * ordered" reports; the user can narrow via the * active-workflow tabs on the main page. * * Strategy: same as the orders archive. Fetch the matching set * (one getFullList, dedupe in memory), chunk by 100 ids (PB's URL * cap is between 4.5-6.7 KB), fire parallel getFullList queries * with deep expand, sort + paginate in memory. * ========================================================================== */ export type QuotesArchiveView = "completed" | "all"; export interface QuotesArchiveData { items: Quote[]; totalItems: number; totalPages: number; counts: { completed: number; all: number; }; /** Echoed back so the page can render the right sub-heading. */ view: QuotesArchiveView; /** True when the search was bounded by `SEARCH_RECENT_WINDOW_DAYS`. * False when the operator explicitly opted in to the full archive. * Echoed so the page can show the right hint in the header. */ recencyLimited: boolean; /** ISO timestamp the cutoff used. Null when the search included * the full archive. */ recencyCutoff: string | null; } /** * The statuses that mark a quote as "completed" in the workflow. * Reused from `QUOTE_TAB_STATUSES.completed` so the archive's * matching set stays in lock-step with the original tab definition * — if we ever add a new "done" status, the archive picks it up * automatically. */ const COMPLETED_STATUSES = QUOTE_TAB_STATUSES.completed; /** * Every status we count for the "All" view — the union of every * tab's status list. Same as the main page's combined-counts query * (see `getQuoteStatusTabCounts` above). */ const ALL_COUNTED_STATUSES: string[] = Array.from( new Set( Object.values(QUOTE_TAB_STATUSES) .flat() .filter((s) => s.length > 0), ), ); export async function getQuotesArchiveData( params: { page?: number; perPage?: number; search?: string; sort?: string; view?: QuotesArchiveView; /** When false (default), the recent-window cutoff * (`SEARCH_RECENT_WINDOW_DAYS`) is AND'd into the filter so the * search hits the bounded recent universe. The page passes * `true` when the operator explicitly wants to search the full * archive. Default false matches the path-A scoping we apply * to the main page's search. */ includeOlder?: boolean; } = {}, pbArg?: Awaited>, ): Promise { const pb = pbArg || (await getAdminPocketBase()); pb.autoCancellation(false); const page = Math.max(1, params.page || 1); const perPage = Math.min(100, Math.max(1, params.perPage || 20)); const search = (params.search || "").trim(); const sort = params.sort || "-created"; const view: QuotesArchiveView = params.view === "all" ? "all" : "completed"; const includeOlder = params.includeOlder === true; const completedFilter = COMPLETED_STATUSES.map((s) => `status = "${s}"`).join(" || "); const allFilter = ALL_COUNTED_STATUSES.map((s) => `status = "${s}"`).join(" || "); const viewFilter = view === "completed" ? completedFilter : allFilter; // Header badge counts ("X completed / Y all"). Always the true global // totals — two count-only queries (perPage=1), cheap even against the // full 8.5k-row table — regardless of whether the operator is // searching, so the badges don't collapse to a recency-scoped number // mid-search. Fired alongside the page/search fetch below (not // awaited separately) so this branch pays one round-trip, not two. const countsQuery = Promise.all([ pb.collection(QUOTES_COLLECTION).getList(1, 1, { filter: completedFilter }), pb.collection(QUOTES_COLLECTION).getList(1, 1, { filter: allFilter }), ]).then(([completedCountResp, allCountResp]) => ({ completed: completedCountResp.totalItems, all: allCountResp.totalItems, })); if (!search) { const expand = "customer,customer.rep,company"; const [pageResp, counts] = await Promise.all([ pb.collection(QUOTES_COLLECTION).getList(page, perPage, { filter: viewFilter, sort, expand, }), countsQuery, ]); return { items: pageResp.items, totalItems: pageResp.totalItems, totalPages: pageResp.totalPages, counts, view, recencyLimited: false, recencyCutoff: null, }; } // Search: delegate to the ranked pushdown search service — the same // pipeline the main /quotes list uses (filter server-side via PB `~`, // score, rank; see quotes-search-service.ts for the full rationale). // `view` ("completed" | "all") is itself a valid `QuoteTabKey`, so // this scopes the search to exactly the archive's matching set. // // This replaces the old approach of fetching the ENTIRE recency- // windowed matching set (thousands of rows once "Include older" is // on) and substring-filtering it in memory — that's what made // archive search take several seconds (measured 8.5s for a 1-char // query against the live PB) instead of the sub-second pushdown // search gets on the main list. const [result, counts] = await Promise.all([ searchQuotes({ query: search, tab: view, page, perPage, includeOlder }), countsQuery, ]); return { items: result.hits.map((h) => h.quote), // `totalReturned` is the full ranked match count (pre pagination- // slice), not just the current page — unlike the old code, which // reported the CURRENT PAGE's length as the match count and had no // way to reach results past page 1 while searching. totalItems: result.totalReturned, totalPages: Math.max(1, Math.ceil(result.totalReturned / perPage)), counts, view, recencyLimited: result.recencyLimited, recencyCutoff: result.recencyCutoff, }; } 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 { // 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; /** * Optional name to resolve BEFORE the email. Used when the inbound * email is forwarded by a rep (e.g. Gary) who named the actual * customer in the subject line — the from_email then matches the rep, * not the customer, so `person_hint` lets us link the quote to the * right person. Resolution is case-insensitive substring match on * the `customer.customer` field, with a token-drop fallback so a * 3-token subject like "Jose Elia Redpath" still finds the 2-token * customer "Elia Jose". On a tie (multiple matches) we prefer the * one whose company matches `companyHint`. If no match, falls * through to the sender's email — the rep's existing customer row * wins, so the quote still lands attributable. */ personHint?: 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."); } // Rep-forwarded case runs FIRST when a person_hint is supplied. The // n8n workflow only sets `person_hint` when it has decided the email // is forwarded and the named person is the real customer — so when // the hint resolves, the named person is always preferred over the // rep's email. Without this, every Gary-forwarded RFQ would land on // Gary's existing customer row. const personHint = (input.personHint || "").trim(); if (personHint) { const personMatch = await findCustomerByNameHint(pb, personHint, input.companyHint); if (personMatch) { return { customer: personMatch, created: false }; } } 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`. * * Exported (not module-private) so the ingestion service * (`quotes-from-email-service.ts`) can reuse the same sentinel without * duplicating the create-or-fetch logic. Keeping a single source of * truth here means an email with no From: header always lands on the * same row, regardless of which entry point (MCP `quotes.create_from_email` * vs. `/api/v1/quotes/from-email`) ingested it. */ export 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; } /** * Resolve a free-text person name (e.g. "John Smith" extracted from the * email subject) to an existing customer row. Two-step match with a * token-drop fallback for multi-token subjects: * * 1. Case-insensitive substring match on `customer.customer` via PB's * `~` operator. Tries the longest prefix first ("Jose Elia * Redpath"), then drops one trailing token at a time ("Jose * Elia", then "Jose") until either a match is found or only one * token remains. This catches the common SA rep-forwarded * pattern where the subject is "FirstName LastName CompanyName" * and the customer DB has "LastName FirstName" — pure substring * on the 3-token string misses but the 1-token suffix still * hits. * 2. If multiple candidates come back (two "John Smith"s in the * database), prefer the one whose company relation matches * `companyHint` (case-insensitive exact on `company.company`). * Falls back to the first candidate if no company hint was given * or no company match was found. * * Returns `null` when no candidate matches. The caller falls through * to the sender's email — the rep (Gary) becomes the customer, which * is at least attributable and the quoter can reassign in the WFDASH * UI. */ async function findCustomerByNameHint( pb: Awaited>, personHint: string, companyHint?: string ): Promise { const trimmed = personHint.trim(); if (!trimmed) return null; // Build the prefix ladder: longest, longest-1, longest-2, ... down to // one token. We skip the single-token rung for hints longer than 3 // tokens to avoid wildly-over-broad substring matches (e.g. // "Mary" matching every Maria / Marie / Mariana in the book). const tokens = trimmed.split(/\s+/).filter(Boolean); const rungs: string[] = []; for (let len = tokens.length; len >= 2; len--) { rungs.push(tokens.slice(0, len).join(" ")); } // Single-token rung only for 2- and 3-token hints — covers the // common "Jose Elia Redpath" → "Jose" → "Elia Jose" case without // over-matching on full sentences. if (tokens.length >= 2 && tokens.length <= 3) { rungs.push(tokens[0]); } for (const rung of rungs) { const esc = rung.replace(/"/g, '\\"'); let candidates: Customer[] = []; try { candidates = ( await pb.collection(CUSTOMERS_COLLECTION).getList(1, 5, { filter: `customer ~ "${esc}"`, fields: "id,customer,company", expand: "company", }) ).items; } catch { return null; } if (candidates.length === 0) continue; if (candidates.length === 1) return candidates[0]; // Tie-break by company hint when available. The AI usually passes // both, so the hint effectively narrows the candidate set. const hint = (companyHint || "").trim(); if (hint) { const hintEsc = hint.replace(/"/g, '\\"'); for (const c of candidates) { const companyName = (c.expand?.company?.company || "") as string; if (companyName && companyName.toLowerCase() === hintEsc.toLowerCase()) { return c; } } } // No company hint or no company match — return the first candidate // rather than creating a duplicate. Quoter can disambiguate. return candidates[0]; } 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 existingItems = await pb .collection(QUOTE_ITEMS_COLLECTION) .getFullList({ filter: `quote = "${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: existingItems, items_count: existingItems.length, 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, personHint: payload.person_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. // // Default `validity_days` to `QUOTE_VALID_DAYS` (30) explicitly: // PocketBase returns `0` for unset number fields, and the PDF // formatter's `??` falls through on `0` — so every quote created // before this fix landed shipped with a "Valid until: today" // expiry. Writing the default here keeps new quotes consistent // regardless of any old data. Legacy rows with `0` are patched by // the one-time backfill in `scripts/migrations/...`. const quote = await pb.collection(QUOTES_COLLECTION).create({ quote_number: quoteNumber, customer: customer.id, company: company?.id || null, description: payload.subject, status: "new", notes: "", validity_days: QUOTE_VALID_DAYS, 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. // We collect the created rows so the caller can see what was actually // written (the agent previously saw only a count and assumed nothing // was created; see gap analysis 2026-08-05). const items = (payload.extracted_items || []).filter((i) => (i.description || "").trim()); const createdItems: QuoteItem[] = []; for (const item of items) { const created = 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, unit: typeof item.unit === "string" ? item.unit : "", cost_price: 0, selling_price: 0, markup: 0, notes: item.notes || "", is_processed: false, }); createdItems.push(created as QuoteItem); } // ── 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: createdItems, items_count: createdItems.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"; } /** * Cheap `totalItems` lookup against `quote_attachments` for the given * quote. Exported so the ingestion service can report the current * attachment count on a dedup re-ingest (callers that follow up with * `attachments.upload_for_quote` see the count tick up correctly). */ export 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; } // ────────────────────────────────────────────────────────────────────────────── // Operator upload fields on the quote row (`outbound_attachments`, // `internal_attachments`). // // The old `quote_attachments.direction` enum conflated three different // audience categories into a single value. These two sibling file // fields on the parent `quotes` row physically separate customer-facing // attachments (anything in `outbound_attachments` is sent to the // customer) from operator-only ones (`internal_attachments`, supplier // quotes, pricing evidence). There is no longer any way to "accidentally // include an internal file" because the read paths only look at // `outbound_attachments`. // // `quote_attachments` itself remains the inbound (RFQ email) archive // + the historical written record; new operator uploads do not touch it. // ────────────────────────────────────────────────────────────────────────────── /** Coerce PB's "could be string, could be array" file-field response to an array. */ function normaliseAttachmentArray( raw: string | string[] | null | undefined, ): string[] { if (raw == null) return []; return Array.isArray(raw) ? raw : [raw]; } /** * Append a file to `quotes.outbound_attachments` (customer-facing). * Reads the current array, writes (current + new) via multipart FormData, * and returns the diff so the caller can echo the new token. * * Mirrors `addStockPurchaseAttachment` so the two patterns stay in * sync — if you change one, change the other. */ export async function addOutboundQuoteAttachment(input: { quoteId: string; file: File; }): Promise<{ attachments: string[]; added: string[] }> { const pb = await getAdminPocketBase(); const before = await pb .collection(QUOTES_COLLECTION) .getOne<{ outbound_attachments?: string[] | string }>(input.quoteId, { fields: "id,outbound_attachments", }); const beforeArr = normaliseAttachmentArray(before?.outbound_attachments); const fd = new FormData(); // Compress before append — new outbound attachments are born // compressed so the PB token is stable from day one. See // `attachment-compressor.ts` for the URL-churn rationale. const { file: compressedFile } = await compressFile(input.file); fd.append("outbound_attachments", compressedFile); const updated = await pb .collection(QUOTES_COLLECTION) .update<{ outbound_attachments?: string[] | string }>( input.quoteId, fd, ); const afterArr = normaliseAttachmentArray(updated?.outbound_attachments); return { attachments: afterArr, added: afterArr.filter((t) => !beforeArr.includes(t)), }; } /** * Append a file to `quotes.internal_attachments` (operator-only). * Same shape as `addOutboundQuoteAttachment` — see that function for * the read/write/diff pattern. */ export async function addInternalQuoteAttachment(input: { quoteId: string; file: File; }): Promise<{ attachments: string[]; added: string[] }> { const pb = await getAdminPocketBase(); const before = await pb .collection(QUOTES_COLLECTION) .getOne<{ internal_attachments?: string[] | string }>(input.quoteId, { fields: "id,internal_attachments", }); const beforeArr = normaliseAttachmentArray(before?.internal_attachments); const fd = new FormData(); // Compress before append — see `addOutboundQuoteAttachment` for // the rationale (same pattern across all operator attachment // uploads; new uploads are born compressed). const { file: compressedFile } = await compressFile(input.file); fd.append("internal_attachments", compressedFile); const updated = await pb .collection(QUOTES_COLLECTION) .update<{ internal_attachments?: string[] | string }>( input.quoteId, fd, ); const afterArr = normaliseAttachmentArray(updated?.internal_attachments); return { attachments: afterArr, added: afterArr.filter((t) => !beforeArr.includes(t)), }; } /** * Remove a single file token from `quotes.outbound_attachments`. * PB does not expose a per-file delete from a multi-file field via the * SDK, so we filter the array client-side and write the rest back. * The original PB file on disk is NOT deleted (same limitation as * the stock-purchase / PO routes — see addStockPurchaseAttachment * JSDoc for the same caveat). */ export async function removeOutboundQuoteAttachment(input: { quoteId: string; token: string; }): Promise<{ attachments: string[] }> { const pb = await getAdminPocketBase(); const row = await pb .collection(QUOTES_COLLECTION) .getOne<{ outbound_attachments?: string[] | string }>(input.quoteId, { fields: "id,outbound_attachments", }); const arr = normaliseAttachmentArray(row?.outbound_attachments); if (!arr.includes(input.token)) { throw new Error(`Attachment not found: ${input.token}`); } const remaining = arr.filter((t) => t !== input.token); await pb.collection(QUOTES_COLLECTION).update(input.quoteId, { outbound_attachments: remaining, }); return { attachments: remaining }; } /** * Remove a single file token from `quotes.internal_attachments`. * See `removeOutboundQuoteAttachment` for the SDK limitation caveat. */ export async function removeInternalQuoteAttachment(input: { quoteId: string; token: string; }): Promise<{ attachments: string[] }> { const pb = await getAdminPocketBase(); const row = await pb .collection(QUOTES_COLLECTION) .getOne<{ internal_attachments?: string[] | string }>(input.quoteId, { fields: "id,internal_attachments", }); const arr = normaliseAttachmentArray(row?.internal_attachments); if (!arr.includes(input.token)) { throw new Error(`Attachment not found: ${input.token}`); } const remaining = arr.filter((t) => t !== input.token); await pb.collection(QUOTES_COLLECTION).update(input.quoteId, { internal_attachments: remaining, }); return { attachments: remaining }; } // ────────────────────────────────────────────────────────────────────────────── // 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. * * The actual sets live in `@/lib/placeholder-customers` so the * picker, the PDF guard, and this service-layer validation can't * drift. We re-import the sets at the top of the file; the comment * stays here so anyone grepping for "placeholder customer" lands on * the right place. */ /** * 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. Falls back to * customer.email → customer.alternative_email → quote.email_sender. */ to?: string; /** Comma-separated. Defaults to "" (no cc). */ cc?: string; /** Comma-separated. Defaults to "" (no bcc). */ bcc?: string; /** Defaults to the quote's stored `email_subject` if set, else a * templated "Quote {number} — …" fallback. */ subject?: string; /** Defaults to the quote's stored `email_body` if set, else empty. */ message?: string; /** Per-modal checkbox: include the per-letterhead supplier authorization letter. */ attachAuthLetters: boolean; /** * PB file tokens from `quotes.outbound_attachments` to include in * the outbound email. The PDF is always included separately * (auto-located by kind="pdf" at send time, in the * `quote_attachments` collection — a system-managed file with its * own lifecycle). Tokens that don't belong to this quote's * `outbound_attachments` field are silently filtered out so a * tampered client can't smuggle in tokens from another quote. * * Before 2026-09-09 these were `quote_attachments` row IDs and * the bug was that operator-uploaded "proof" files silently * qualified (every proof upload wrote `direction="outbound"`). * Validating against the new field proves the operator explicitly * marked the file for the customer. */ selectedAttachmentIds: string[]; } export interface SendQuoteEmailFailure { code: | "placeholder_customer" | "no_recipient" | "invalid_recipient" | "no_pdf" | "no_items" | "items_not_ready" | "invalid_status" | "quote_not_found"; message: string; /** Populated when the failure was caused by a PDF that can't be * rendered (code `placeholder_customer`, `no_items`, or * `items_not_ready`). Lists every line that blocked the render * with the missing fields, so the agent can present the gap back * to the operator verbatim instead of the operator having to dig * through the quote line by line. * * Same shape as the `details.issues` array from * `POST /api/v1/quotes/[id]/pdf` — one source of truth so the UI * and the agent render the same list. */ details?: { customerId?: string; customerName?: string; itemIds?: string[]; issues?: Array<{ itemId: string; description: string; missing: 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. */ /** * Diagnose why a quote's PDF can't be generated. Used by * `sendQuoteEmail` when there's no PDF yet — surfaces the same gates * the render route runs (placeholder customer, no items, * items_not_ready) so the agent can present the gaps to the operator * verbatim instead of the operator having to dig through each line. * * Returns a `SendQuoteEmailFailure` shape (NOT thrown) so the caller * can pipe it straight into `markPreSendFailure`. The `details.issues` * array uses the same per-line shape as `pdf/route.ts`'s * `ValidationErrorBody.details.issues` — one source of truth so the * UI's "missing fields" badges and the agent's "these N items are * missing X" prompt render the same list. * * Why this is here and not in pdf/route.ts: the render route is * request-scoped (POST handler); this helper is service-scoped and * runs against an already-loaded quote row. No HTTP fetch loop. */ async function diagnosePdfReadiness( pb: Awaited>, quoteId: string, quote: Quote, ): Promise { // 1. Customer gate. Same logic as pdf/route.ts — placeholder id / // placeholder name / missing email all block the render. let customer: Customer | undefined; if (quote.expand?.customer) { customer = quote.expand.customer as Customer; } else { try { customer = await pb .collection(CUSTOMERS_COLLECTION) .getOne(quote.customer); } catch { // No customer row — treat as placeholder. } } if ( !customer || PLACEHOLDER_CUSTOMER_IDS.has(customer.id) || PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) || !customer.email ) { return { code: "placeholder_customer", message: "This quote is still pointed at a placeholder / unnamed customer. Pick the real customer on the process page before sending.", details: { customerId: customer?.id, customerName: customer?.customer || "(blank)", }, }; } // 2. Items gate. Same "is the line ready to quote?" check pdf/route.ts // runs: is_processed, supplier, cost_price > 0, selling_price > 0. // Lines already moved to a continuation are excluded — those are // no longer "this quote's items". const allItems = await pb .collection(QUOTE_ITEMS_COLLECTION) .getFullList({ filter: `quote = "${quoteId}" && (moved_to_quote = null || moved_to_quote = "")`, sort: "created", }); if (allItems.length === 0) { return { code: "no_items", message: "This quote has no items yet. Add line items on the quote detail page first.", details: { itemIds: [], issues: [], }, }; } const issues: Array<{ itemId: string; description: string; missing: string[]; }> = []; for (const it of allItems) { // ONE readiness rule, shared with the PDF route via // `quoteItemReadinessGaps`. It used to be written out here AND in // quote-pdf-service.ts, and the two copies drifted — the supplier // requirement was dropped from this one and left in that one, so a // quote could be sent but never rendered. const missing = quoteItemReadinessGaps(it, { isNoQuote: Boolean(quote.is_no_quote), }); if (missing.length > 0) { issues.push({ itemId: String(it.id), description: it.description || "(no description)", missing, }); } } if (issues.length > 0) { return { code: "items_not_ready", message: `Cannot generate PDF: ${issues.length} item(s) are missing required fields. Price every line first.`, details: { itemIds: issues.map((i) => i.itemId), issues, }, }; } // No PDF and no detected reason — fall back to the opaque `no_pdf` // so the agent has something to report. In practice one of the // three gates above always fires first. return { code: "no_pdf", message: "No PDF generated for this quote yet. Generate the PDF before sending.", }; } 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. Walk four sources // in order so walk-in quotes (no customer.email set) still go // out via quote.email_sender (the inbound from: address stashed // at ingestion time). const sources: string[] = [ payload.to || "", customer.email || "", customer.alternative_email || "", quote.email_sender || "", ]; const toAddress = sources .map((s) => s.trim()) .find((s) => looksLikeEmail(s)) || ""; if (!toAddress) { return markPreSendFailure(pb, quote, { code: "no_recipient", message: "No valid recipient email on this quote (payload.to, customer.email, customer.alternative_email, and quote.email_sender are all 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. PDF presence + staleness check ──────────────────────── // // The outbound send attaches the most recent `kind="pdf"` // attachment to the email. We have to defend against TWO // failure modes here: // // (a) No PDF generated yet → diagnose what's blocking the // render (placeholder customer, no items, items not // ready) and bail with the same shape the route uses. // // (b) PDF exists but is STALE — i.e. the operator (or rep // on the approvals page) edited markup / cost / selling / // supplier / description / quote_notes / letterhead / // customer AFTER the last render. Without the staleness // check the email ships a PDF with the operator's old // numbers — the customer sees something different from // what the operator just approved. That's the bug // reported 2026-09-07. // // When stale, we call `generateQuotePdf` from the service // layer to render a fresh PDF before the send kicks off. The // service runs the same readiness gates as the manual // `/api/v1/quotes/[id]/pdf` POST, so a regen that hits // `items_not_ready` (operator blanked prices in the time // since the last render) refuses to render and we surface // that as a `mail_failed` — we never silently fall back to // the stale PDF. The previous "don't auto-render on send" // stance (Dave 2026-08-15) was about not rendering against // partial / placeholder data without operator eyes; this // fix preserves that — if the regen fails the operator // gets a clear failure message and stays in control. const pdfs = await pb .collection(QUOTE_ATTACHMENTS_COLLECTION) .getList<{ id: string; uploaded_at?: string }>(1, 1, { filter: `quote = "${quoteId}" && kind = "pdf"`, sort: "-uploaded_at", fields: "id,uploaded_at", }); if (pdfs.items.length === 0) { const readiness = await diagnosePdfReadiness(pb, quoteId, quote); return markPreSendFailure(pb, quote, readiness); } // Cheap staleness probe (2 PB queries). Skipped when the // pre-send path is already under time pressure — but in // practice the `pdfs` query above is the dominant cost, so // adding two more parallel queries is fine. If the probe // says stale we regenerate before continuing. const stale = await isQuotePdfStale(quoteId); if (stale) { const regen = await generateQuotePdf(quoteId); if (regen.kind === "readiness_failure") { // The PDF can't be re-rendered (operator blanked prices // between the last render and the send click, or the // customer got swapped to a placeholder). Treat this as // a pre-send failure — same shape as the "no PDF" path // above, so the UI's banner + retry flow handle it // uniformly. We surface the per-line issues list from // the readiness failure so the operator sees exactly // which rows are blocking the render. return markPreSendFailure(pb, quote, { code: regen.code, message: "Could not regenerate the PDF with the latest changes — the email was not sent. " + regen.message, details: regen.details, }); } if (regen.kind === "render_error") { // The render itself crashed (PB outage, render crash). // Same shape — refuse to send, don't ship the stale PDF. return markPreSendFailure(pb, quote, { code: "items_not_ready", message: "PDF regeneration failed: " + regen.message + (regen.detail ? ` (${regen.detail})` : ""), }); } // regen.kind === "ok": a fresh PDF is now in PB; the // existing pdfs.items query above will pick it up via the // kind="pdf" lookup n8n runs at SMTP-time. } // ── 7. Filter selectedAttachmentIds to only those that actually // belong to this quote AND live in `quotes.outbound_attachments`. // Tampered clients can't smuggle in tokens from other quotes. // // Before 2026-09-09 these were `quote_attachments` row IDs — // the upload route wrote every "proof" file as // `direction="outbound"` and the send modal pre-selected all // of them, which leaked supplier-pricing files to the // customer. The new storage split (operator uploads go to // `quotes.outbound_attachments` and `quotes.internal_attachments`, // `quote_attachments` becomes the inbound archive + system // PDF) closes that gap: validating against the new field // proves the operator explicitly marked the file for the // customer. // // `quote.outbound_attachments` is a PB file field whose // values are file tokens (`_` strings). // We accept any token that's present in the field — the // upload route has already gated this through admin PB, so // the token format is trustworthy. const requestedIds = Array.from(new Set(payload.selectedAttachmentIds || [])); let validIds: string[] = []; if (requestedIds.length > 0) { const quoteRow = await pb .collection(QUOTES_COLLECTION) .getOne<{ outbound_attachments?: string[] | string }>(quoteId, { fields: "id,outbound_attachments", }); const ownTokens = new Set( Array.isArray(quoteRow?.outbound_attachments) ? quoteRow.outbound_attachments : quoteRow?.outbound_attachments ? [quoteRow.outbound_attachments] : [], ); validIds = requestedIds.filter((id) => ownTokens.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; } // ────────────────────────────────────────────────────────────────────────────── // Submit-for-approval (new → approval_pending) // ────────────────────────────────────────────────────────────────────────────── export type SubmitQuoteFailureCode = | "quote_not_found" | "invalid_status" | "unpriced_items"; export interface SubmitQuoteResult { quote: Quote; /** True when the call actually flipped the status. False when the * quote was already `approval_pending` (idempotent re-submit). */ statusChanged: boolean; /** Status the quote was in before this call (or after, if it was a * no-op). Always populated — the agent uses it to show the diff * in the audit log without re-fetching the quote. */ previousStatus: Quote["status"]; /** Echo of the actor passed in. Lands on `quotes.submitted_by` when * the PB admin has added the column; falls back silently otherwise. * Echoed here regardless so the agent always has the name in the * response envelope for the audit trail. */ submittedBy?: string; /** ISO timestamp of the submit call. Lands on `quotes.submitted_at` * when the PB column exists; echoed here regardless. */ submittedAt: string; /** Target status the call aimed for. For the full-approval path this * is `approval_pending`; for the partial path (`partial: true` * flag) this is `partial_approval_pending` — even when every item * happened to be priced, since the operator's intent was partial. */ targetStatus: "approval_pending" | "partial_approval_pending"; /** IDs of `quote_items` rows that blocked submission (selling_price * ≤ 0 on a not-moved line). Populated only when `failure !== null`. */ offendingItemIds?: string[]; } export class SubmitQuoteError extends Error { constructor( message: string, public readonly status: number, public readonly code: SubmitQuoteFailureCode, public readonly offendingItemIds: string[] = [], ) { super(message); this.name = "SubmitQuoteError"; } } /** * Flip a quote from `new` to `approval_pending` once every line is * priced. Idempotent — re-submitting an already-`approval_pending` * quote is a no-op (no PB write, returns `statusChanged: false`). * * Why this is its own tool (and not just `quotes.update({status: "approval_pending"})`): * - The "all items priced" gate is the operator's safety net — a * quote with `selling_price = 0` lines must surface a clear * "fix these ids" error, not silently go to the approval queue. * - Idempotency matters because the walk-in recipe (create → price * → submit) is fuzzy: the agent may call submit twice after * verifying with the operator, and the UI re-fires it on retry. * - The status flip is the canonical audit signal. We don't stamp * `submitted_at` / `submitted_by` here because the PB schema * doesn't have those columns yet (see FEATURE-REQUESTS.md "submit * audit fields"); the added `Quote.submitted_at` / `submitted_by` * fields will start persisting the moment ops adds them via PB * admin. * * Returns `SubmitQuoteError` (typed) for the three failure modes so * the REST route can map to clean HTTP 404/422 + `code` discriminators * the agent can branch on. */ export async function submitQuote( quoteId: string, actor?: { id: string; name: string }, options?: { partial?: boolean }, ): Promise { if (!quoteId || !quoteId.trim()) { throw new SubmitQuoteError( "quoteId is required", 400, "quote_not_found", ); } const pb = await getAdminPocketBase(); let quote: Quote; try { quote = await pb.collection(QUOTES_COLLECTION).getOne(quoteId); } catch { throw new SubmitQuoteError( `Quote ${quoteId} not found`, 404, "quote_not_found", ); } // Always populate previousStatus — the agent uses it for the audit // trail even on the no-op / idempotent path. const previousStatus = quote.status; const submittedAt = new Date().toISOString(); // Idempotency: already-pending quote is a no-op. Returning 200 with // statusChanged: false lets the UI safely re-submit without showing // a "double-submit" error. if (quote.status === "approval_pending" || quote.status === "partial_approval_pending") { return { quote, statusChanged: false, previousStatus, submittedAt, // If the caller asked for partial and the quote is already in // partial_approval_pending, the target is partial; otherwise // it's whatever the quote is currently in. targetStatus: quote.status === "partial_approval_pending" ? "partial_approval_pending" : "approval_pending", ...(actor?.name ? { submittedBy: actor.name } : {}), }; } /** * Re-submittable statuses (added 2026-09-07). Before this set * existed, `submitQuote` only accepted `new` — once a quote had * been emailed and the customer came back asking for changes, * the operator's only path was to re-quote from scratch. That * throws away the original quote_number, all email tracking, * and the customer's audit trail. Re-submitting on the same * quote preserves every one of those — the only thing that * changes is the PDF (regenerated on next view) and the * `submitted_at` stamp. * * The set itself lives in `@/lib/quote-status` so the client UI * (banner gate) draws from the same source of truth — a new * status added there surfaces in both places without a second * edit. The membership + rationale are documented next to the * export. */ const isFreshSubmission = quote.status === "new"; const isResubmission = RESUBMITTABLE_STATUSES.has(quote.status); if (!isFreshSubmission && !isResubmission) { throw new SubmitQuoteError( `Cannot submit a quote in status "${quote.status}". Only new quotes and post-send re-submissions can go through the approval queue — re-quote for cancelled or claimed quotes.`, 422, "invalid_status", ); } // Re-submissions always go to full `approval_pending` regardless // of the `partial` flag — once a quote has been through the // pipeline once, "submit only some items" no longer makes sense. // We still honour `partial: true` on a FRESH submission (the // existing walk-in partial-approval recipe), but ignore it for // a re-submit. The flag is silently swallowed rather than 422'd // because the UI button is the same "Resubmit for Approval" // affordance across both cases — the operator doesn't need to // think about which mode they're in. const targetStatus: "approval_pending" | "partial_approval_pending" = isFreshSubmission && options?.partial ? "partial_approval_pending" : "approval_pending"; // Fetch every line on the quote (small N — typical quotes have // < 30 items) and filter in JS. The PB filter parser mock in // `quotes-service.test.ts` doesn't support `!= null` semantics // cleanly, and the extra round-trip saved by a complex filter isn't // worth the brittle test surface. The shape returned is // `{ id, selling_price, moved_to_quote }` — we use `fields` to keep // the payload small. const allItems = await pb .collection(QUOTE_ITEMS_COLLECTION) .getFullList({ filter: `quote = "${quoteId}"`, fields: "id,selling_price,moved_to_quote", }); const unpricedItems = allItems.filter((it) => { const sp = Number(it.selling_price) || 0; const moved = String(it.moved_to_quote || "").trim(); return sp <= 0 && moved === ""; }); // Gate: only `partial: true` lets unpriced items through. The full // submission path (the default) refuses with the existing // `unpriced_items` 422 — no regression. The partial path is the // missing state-machine gap that the test matrix surfaced 2026-08-15: // without it, no tool existed to flip `new → partial_approval_pending`, // which made the whole partial-approve + split workflow unreachable // via MCP. The `partial: true` flag on the same `submit` tool keeps // the state machine documented in one place — there isn't a parallel // `submit_partial` tool to drift out of sync. // // For re-submissions, the gate applies identically — the operator // might have re-priced an item down to 0 while testing the new // numbers, and we don't want to send an incomplete quote back to // the rep. `partial: true` is silently ignored on resubmissions // (see the targetStatus comment above), so the only way to bypass // the gate is to actually price the lines. if (unpricedItems.length > 0 && !options?.partial && !quote.is_no_quote) { const offendingItemIds = unpricedItems.map((it) => String(it.id)); throw new SubmitQuoteError( `Cannot submit: ${offendingItemIds.length} item(s) have selling_price = 0. Price every line before submitting.`, 422, "unpriced_items", offendingItemIds, ); } // Fluid write: Quote.submitted_at / submitted_by are optional fields // on the Quote type. If/when ops adds them to the PB schema, these // will start persisting. Right now they're silently dropped, which // is fine — the status flip is the canonical audit signal. const updatePayload: Record = { status: targetStatus, }; if (actor?.name) { updatePayload.submitted_by = actor.name; } updatePayload.submitted_at = submittedAt; const updated = (await pb.collection(QUOTES_COLLECTION).update( quoteId, updatePayload, )) as Quote; return { quote: updated, statusChanged: true, previousStatus, submittedAt, targetStatus, ...(actor?.name ? { submittedBy: actor.name } : {}), }; } // ────────────────────────────────────────────────────────────────────────────── // Walk-in quote creation (no source email) // ────────────────────────────────────────────────────────────────────────────── export interface CreateQuoteInputItem { description: string; quantity?: number; unit?: string; sku?: string; supplier?: string; category?: string; cost_price?: number; selling_price?: number; markup?: number; notes?: string; pricing_source?: string; pricing_notes?: string; matched_price_list_id?: string; } export interface CreateQuoteInput { /** Customer id OR name. If a name is passed and no customer exists, * a new one is created. The lookup is case-insensitive exact. */ customer: string; /** Company id OR name. If a name is passed and no company exists, * a new one is created. The lookup is case-insensitive exact. */ company?: string; /** Letterhead key. Defaults to "CNL". Future option: "ISHERWOOD". */ letterhead?: "CNL" | "ISHERWOOD"; /** Free-text description (typically the RFQ subject). */ description?: string; /** One or more line items. At least one is required. */ items: CreateQuoteInputItem[]; /** Optional override — defaults to validity_days=30. */ validity_days?: number; /** Optional operator-supplied quote number (e.g. for re-quoting the * same customer under a stable reference). If omitted, the server * generates a fresh number from the atomic counter. Collisions with * an existing `quotes.quote_number` throw `QuoteNumberExistsError` * (caught at the REST layer → 409). */ quote_number?: string; /** Customer-side reference (their RFQ number, PO reference, etc.). * Persists to `quotes.rfq_number`. */ rfq_number?: string; /** Operator-facing free-text note rendered on the customer PDF * (distinct from the `notes` audit-log field). Persists to * `quotes.quote_notes`. */ quote_notes?: string; /** Notes string (the audit-log field). Most callers should leave this * empty on create — the rejection pipeline writes here. Walk-in * creation accepts it for completeness. */ notes?: string; /** Optional status override — defaults to "new". The full QuoteStatus * union is accepted; callers that want a stricter surface (e.g. the * REST route) validate the subset they expose. */ status?: QuoteStatus; is_no_quote?: boolean; } /** * Thrown by `createQuote` when the operator-supplied `quote_number` * collides with an existing quote. Maps to HTTP 409 at the REST layer * (`/api/v1/quotes/create`). The `existingId` field carries the * colliding row's PB id so the client can deep-link to it. * * Mirrors `OrderNumberCollisionError` (orders-service.ts). */ export class QuoteNumberExistsError extends Error { readonly existingId: string; readonly attemptedNumber: string; constructor(existingId: string, attemptedNumber: string) { super( `Quote number "${attemptedNumber}" is already in use by another quote.`, ); this.name = "QuoteNumberExistsError"; this.existingId = existingId; this.attemptedNumber = attemptedNumber; } } export interface CreateQuoteResult { quote: Quote; customer: Customer; company: Customer | null; items: QuoteItem[]; quote_number: string; } /** * Create a walk-in quote from scratch. No source email, no inbox routing. * The operator supplies the customer, company, and items directly. * * Mirrors `createQuoteFromEmail` (which is the email-path, same status * pipeline). The callers split into two flows: * - `quotes.create_from_email` — n8n scrapes inbox, AI extracts items. * - `quotes.create` (this one) — operator types "make me a quote for * John" on the dashboard or via the agent. * * Resolution rules: * - `customer`: looks up by id (15-char PB id) first; if not an id, * case-insensitive exact match on `customer.customer`. If no match, * creates a new customer with that name + empty email. * - `company`: same pattern. If no match, creates a new company. * - `items`: each row is written via `createQuoteItem` (the service * layer shared with `quote_items.create`), so the F.1 auto-derive * `selling_price` rule and the F.2 whitelist (category / * matched_price_list_id) apply unchanged. * * Quote lifecycle: starts at `status="new"`, `validity_days=30`, * `company_letterhead` defaults to `"CNL"`. The operator then runs * `re_price` / `quotes.re_price` to populate prices, then through the * existing claim → price → submit → approve → send pipeline. */ export async function createQuote( input: CreateQuoteInput ): Promise { if (!input.customer || !input.customer.trim()) { throw new Error("customer is required (id or name)."); } if (!Array.isArray(input.items) || input.items.length === 0) { throw new Error("items must be a non-empty array."); } const pb = await getAdminPocketBase(); // ── 1. Resolve customer (id OR name → exact lookup → create). const customer = await resolveOrCreateCustomerByIdOrName( pb, input.customer.trim() ); // ── 2. Resolve company (id OR name → exact lookup → create). Optional. let company: Customer | null = null; if (input.company && input.company.trim()) { company = await resolveOrCreateCompanyByIdOrName( pb, input.company.trim() ); } // ── 3. Resolve quote number. The operator may supply one (e.g. for a // re-quote with a stable customer-side reference); otherwise we // generate a fresh one from the atomic counter. const operatorSuppliedNumber = typeof input.quote_number === "string" && input.quote_number.trim() ? input.quote_number.trim() : null; const quoteNumber = operatorSuppliedNumber ?? (await generateQuoteNumberServer()); // ── 4. Create the quote row. PB enforces a unique index on // `quotes.quote_number`; the try/catch below maps the DB-level // UNIQUE violation back to the typed `QuoteNumberExistsError` so the // REST layer can respond 409 with the colliding row's id. // The let + try/catch shape means the type must be explicit; we // mirror the inference the const-form would have produced (the // generic RecordModel cast as `Quote` at the return point below). let quote: Record & { id: string }; try { quote = await pb.collection(QUOTES_COLLECTION).create({ quote_number: quoteNumber, customer: customer.id, company: company?.id || null, description: input.description || "", status: input.status ?? "new", notes: input.notes ?? "", rfq_number: input.rfq_number ?? "", quote_notes: input.quote_notes ?? "", validity_days: input.validity_days ?? QUOTE_VALID_DAYS, company_letterhead: input.letterhead || "CNL", has_attachments: false, email_subject: input.description || "", email_body: "", is_no_quote: input.is_no_quote, }); } catch (err) { // Race-safe: even when the operator didn't supply a number, the // generated one could theoretically collide if the counter // implementation changes. Catch the unique-constraint violation, // re-query for the existing row's id, and re-throw typed. if (err && /UNIQUE constraint failed/i.test(String(err))) { const escaped = quoteNumber.replace(/"/g, '\\"'); const conflict = await pb .collection(QUOTES_COLLECTION) .getList<{ id: string }>(1, 1, { filter: `quote_number = "${escaped}"`, }); if (conflict.items[0]) { throw new QuoteNumberExistsError(conflict.items[0].id, quoteNumber); } } throw err; } // ── 5. Create the line items via the shared service layer so the // F.1 auto-derive + F.2 whitelist apply unchanged. const createdItems: QuoteItem[] = []; for (const item of input.items) { const result = await createQuoteItem({ quoteId: quote.id, description: item.description, quantity: item.quantity, unit: item.unit, sku: item.sku, supplier: item.supplier, cost_price: item.cost_price, selling_price: item.selling_price, markup: item.markup, notes: item.notes, pricing_source: item.pricing_source, pricing_notes: item.pricing_notes, }); createdItems.push(result.quote_items as unknown as QuoteItem); } return { quote: quote as unknown as Quote, customer, company: company as Customer | null, items: createdItems, quote_number: quoteNumber, }; } /** * Resolve a customer input (id OR name) to a Customer row. If the input * is a 15-char PB id, use it verbatim. Otherwise case-insensitive exact * match on `customer.customer`. If no match, create a new customer with * that name and empty email/phone. */ async function resolveOrCreateCustomerByIdOrName( pb: Awaited>, input: string ): Promise { const PB_ID_PATTERN = /^[A-Za-z0-9]{15}$/; if (PB_ID_PATTERN.test(input)) { try { return (await pb .collection(CUSTOMERS_COLLECTION) .getOne(input)) as Customer; } catch { throw new Error(`customer id "${input}" not found.`); } } const escaped = input.replace(/"/g, '\\"'); const matches = await pb .collection(CUSTOMERS_COLLECTION) .getList(1, 1, { filter: `(customer = "${escaped}" || customer ~ "${escaped}")`, fields: "id,customer,email,number,company", }); if (matches.items.length > 0) return matches.items[0]; const created = await pb.collection(CUSTOMERS_COLLECTION).create({ customer: input, email: "", number: "", dateadded: new Date().toISOString().slice(0, 10), description: "Auto-created from walk-in quote intake.", }); return created as Customer; } /** * Resolve a company input (id OR name) to a Customer row. Same pattern * as the customer resolver. Returns null when no `company` input was * supplied (the caller passes null back to the quote creation). */ async function resolveOrCreateCompanyByIdOrName( pb: Awaited>, input: string ): Promise { const PB_ID_PATTERN = /^[A-Za-z0-9]{15}$/; if (PB_ID_PATTERN.test(input)) { try { const expanded = await pb .collection(COMPANIES_COLLECTION) .getOne(input); // The caller wants a "Customer" shape for the convenience of the // response. Cast — only the id is read downstream. return expanded as unknown as Customer; } catch { throw new Error(`company id "${input}" not found.`); } } const escaped = input.replace(/"/g, '\\"'); const matches = await pb .collection(COMPANIES_COLLECTION) .getList(1, 1, { filter: `(company = "${escaped}" || company ~ "${escaped}")`, fields: "id,company", }); if (matches.items.length > 0) return matches.items[0]; const created = await pb.collection(COMPANIES_COLLECTION).create({ company: input, address: "", vendor: "", coordinates: { lon: 0, lat: 0 }, }); return created as unknown as Customer; } /** * Set the operator-facing free-text note on a quote. * * Distinct from `quotes.notes` (the rejection audit log written by * `review-approve/route.ts:194` and parsed by `parseLatestRejection` * on the detail page). The new `quote_notes` field is plain operator * text surfaced on the customer PDF. * * Trims the input and treats empty / whitespace-only as `""` so the * field is cleared (not stored as `" "`) when the operator deletes * the note. Mirrors the trim-then-fallback pattern in * `stock-booking-service.ts:179`. * * Audit (who / when) is intentionally NOT tracked here — the field is * plain operator text, not policy. If a future requirement asks for * "who changed the note and when", add an `audit_logs` entry in the * route handler instead of bloating this signature. * * Added 2026-08-17. */ export async function setQuoteNotes( quoteId: string, notes: string, ): Promise { const trimmed = typeof notes === "string" ? notes.trim() : ""; const pb = await getAdminPocketBase(); const updated = await pb.collection(QUOTES_COLLECTION).update(quoteId, { quote_notes: trimmed, }); return updated; } /** * Set or clear the no-quote flag on a quote. Reverts to false (a real * quote) when called with `false`. Stamps `updated` automatically. */ export async function setIsNoQuote( quoteId: string, value: boolean, ): Promise { const pb = await getAdminPocketBase(); return pb.collection(QUOTES_COLLECTION).update(quoteId, { is_no_quote: value }); } /* ============================================================================ * Repeat-request detection ("have we quoted this already?") * ---------------------------------------------------------------------------- * This is NOT the usual data-hygiene dedupe (that's `findDuplicateOrders` * in orders-service.ts, which hunts for accidental double-intakes). * * The business case is mining supply tendering: when a mine puts a spot * out, SEVERAL different engineering companies each ask us to quote the * SAME item list, because they're all bidding for that spot. The office * needs to spot "we already priced this exact list for Blyvoor last * week" the moment the second company asks, so they can go look up what * they charged and price the new one deliberately. * * Two consequences for the matching rules, both of which invert the * usual dedupe instincts: * * 1. Matches are CROSS-COMPANY on purpose. Two different customers * sending the same list is the signal, not a false positive. * 2. The email-body pass is CROSS-SENDER for the same reason — the * bidders are usually forwarding the same tender document from * the mine, so the body text is identical from different * addresses. * * `email_message_id` is included for completeness (it's free once the * records are loaded) but is expected to fire almost never: the mail * workflow moves each processed email to a folder it doesn't re-scan, * so a true same-Message-ID double-ingest is rare. * ========================================================================== */ /** * How far back the "completed" slice of the scan reaches. Active work * (new / claimed / pending approval) is always scanned regardless of * age; completed quotes only stay in scope this long. Anything older * lives on /quotes/archive and isn't worth flagging. * * Deliberately its own constant rather than reusing * `COMPLETED_RECENT_WINDOW_DAYS` (7, the "Completed this week" tab) or * `SEARCH_RECENT_WINDOW_DAYS` (90, the search cutoff) — this window is * a business call about how long a tender stays relevant, not a * performance cutoff. */ export const DUPLICATE_SCAN_COMPLETED_WINDOW_DAYS = 50; export function duplicateScanCompletedCutoff(now: Date = new Date()): string { return new Date( now.getTime() - DUPLICATE_SCAN_COMPLETED_WINDOW_DAYS * 24 * 60 * 60 * 1000, ).toISOString(); } export type QuoteDuplicateKind = /** Same normalised line-item set (description + qty), any customer. */ | "item_content" /** Same normalised inbound email body, any sender. */ | "email_body" /** Same RFC 5322 Message-ID — a true double-ingest of one email. */ | "email_message_id"; export interface DuplicateQuoteRecord { id: string; quoteNumber: string; status: string; created: string; customerName: string | null; companyName: string | null; itemCount: number; totalQuantity: number; /** Sum of selling_price × quantity. 0 while the quote is unpriced — * which is normal for the `new` end of the pipeline. The operator * uses this to compare what the earlier quote went out at. */ totalValue: number; } export interface DuplicateQuoteGroup { kind: QuoteDuplicateKind; /** Stable identity for the group (the fingerprint / message-id). */ key: string; /** Human-readable summary, e.g. "3 lines · 120 units". */ label: string; /** Oldest first — the first record is the original quote the * operator most likely wants to open and compare prices against. */ records: DuplicateQuoteRecord[]; } /** Statuses in scope regardless of age — the live workflow. */ const DUPLICATE_SCAN_ACTIVE_STATUSES: string[] = Array.from( new Set([ ...QUOTE_TAB_STATUSES.new, ...QUOTE_TAB_STATUSES.claimed, ...QUOTE_TAB_STATUSES.pending_approval, ]), ); /** * Normalise a free-text line so trivial differences (case, spacing, * punctuation, the odd trailing full-stop) don't stop two identical * requests from matching. Deliberately conservative: it does NOT stem * words or drop numbers, because in mining supply the numbers ARE the * spec ("M20 x 50mm" must not collapse into "M20 x 60mm"). */ function normaliseForFingerprint(raw: string): string { return (raw || "") .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .trim(); } /** * Line descriptions that carry no product information and must never * contribute to a fingerprint. * * The big one is `items from email request ` — a placeholder the * n8n intake writes when the extractor couldn't pull line items out of * the inbound email. It is NOT written by this codebase (it arrives as * data), and it is common: a live sample of 1,500 quotes had 30 rows * reading "items from email request request for a quote asap" alone. * Without this guard every unextracted email matches every other * unextracted email and the feature reports hundreds of phantom * repeats — which is exactly what the first live run did. */ const PLACEHOLDER_ITEM_PATTERNS: RegExp[] = [ /^items? from email request\b/, /^(quote|quotation|rfq|please quote|pricing)$/, /^see attach(ed|ment)?\b/, /^as per (attached|email|below)\b/, ]; /** * Minimum normalised length for a line to be fingerprintable. Tuned * against live data: real one-liners the office genuinely re-quotes * ("shell rimula r4x 15w40 20l" = 26 chars, "tpkt160508r gn yg612 * milling insert p m k" = 41) sit comfortably above this, while junk * like a bare "quote" (5) falls out. */ const MIN_FINGERPRINT_LINE_LENGTH = 10; /** * Fingerprint for the item-content pass: every line reduced to * `qty×description` (SKU folded in when present), sorted so line ORDER * doesn't matter, then joined. Two quotes with the same fingerprint * asked for the same things in the same amounts. * * Uses `description` (the customer's original RFQ wording) rather than * `quote_description` (the operator's cleaned-up PDF text) — the * original is what's identical across two bidders forwarding the same * tender; the cleaned version only exists after someone has worked the * quote. * * Returns null when nothing survives the noise filters, which * correctly excludes the quote from the pass rather than letting it * match every other content-free quote. */ function itemContentFingerprint(items: QuoteItem[]): string | null { const parts = items .map((it) => { const desc = normaliseForFingerprint(it.description || ""); const sku = normaliseForFingerprint(it.sku || ""); // A real SKU is itself a strong identifier, so a line with one // stays eligible even if its prose is thin. if (!sku && desc.length < MIN_FINGERPRINT_LINE_LENGTH) return null; if (!sku && PLACEHOLDER_ITEM_PATTERNS.some((re) => re.test(desc))) return null; const body = sku ? `${sku}|${desc}` : desc; if (!body) return null; const qty = Math.round(Number(it.quantity) || 1); return `${qty}x${body}`; }) .filter((p): p is string => p !== null); // A quote with no usable line text can't be fingerprinted — otherwise // every empty draft would "match" every other empty draft. if (parts.length === 0) return null; parts.sort(); return parts.join("~"); } /** * Find quotes that represent the same underlying request. See the * block comment above for why the matching is intentionally * cross-company. * * Returns one group per repeated fingerprint, each holding ≥2 quotes, * oldest first. Groups where every record is the same quote id are * impossible by construction (ids are unique), but groups are still * filtered to ≥2 DISTINCT quotes for safety. */ export async function findDuplicateQuotes( pbArg?: Awaited>, ): Promise { const pb = pbArg || (await getAdminPocketBase()); pb.autoCancellation(false); // Scope: live workflow at any age, plus completed within the window. // Templates are excluded — they're scaffolding, and every quote spawned // from one template would otherwise flag against the template itself. // // The window is measured on `created`, NOT `updated`, even though // `updated` is the closer semantic proxy for "when was this // completed". On the live PB a bulk backfill rewrote `updated` on // every row (all 8,964 quotes share a 2026-08-11 06:08 timestamp), // so an `updated >=` cutoff matches the entire collection and the // window silently does nothing — the first live run pulled 8,146 // quotes into a scan that should have seen a few hundred. // `created` is the intake date and is never rewritten, so it's the // only trustworthy recency signal here. const activeFilter = DUPLICATE_SCAN_ACTIVE_STATUSES .map((s) => `status = "${s}"`) .join(" || "); const completedFilter = QUOTE_TAB_STATUSES.completed .map((s) => `status = "${s}"`) .join(" || "); const filter = `is_template != true && ` + `((${activeFilter}) || ((${completedFilter}) && created >= "${duplicateScanCompletedCutoff()}"))`; const quotes = await pb.collection(QUOTES_COLLECTION).getFullList({ filter, sort: "created", expand: "customer,company", batch: 5000, }); if (quotes.length === 0) return []; // One bulk fetch for every in-scope quote's items, then bucket in // memory. The orders equivalent loops one query per record; at ~850 // in-scope quotes that would be ~850 sequential round-trips. const itemsByQuote = new Map(); const allItems = await fetchQuoteItemsForQuotesAdmin( pb, quotes.map((q) => q.id), ); for (const item of allItems) { if (!item.quote) continue; const arr = itemsByQuote.get(item.quote) ?? []; arr.push(item); itemsByQuote.set(item.quote, arr); } const records = new Map(); const contentKeys = new Map(); const bodyKeys = new Map(); const messageIdKeys = new Map(); for (const q of quotes) { const items = itemsByQuote.get(q.id) ?? []; const totalQuantity = items.reduce( (sum, it) => sum + (Math.round(Number(it.quantity) || 1)), 0, ); const totalValue = items.reduce( (sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.quantity) || 1), 0, ); records.set(q.id, { id: q.id, quoteNumber: q.quote_number, status: q.status, created: q.created, customerName: q.expand?.customer?.customer ?? null, companyName: q.expand?.company?.company ?? null, itemCount: items.length, totalQuantity, totalValue, }); const contentKey = itemContentFingerprint(items); if (contentKey) { const arr = contentKeys.get(contentKey) ?? []; arr.push(q.id); contentKeys.set(contentKey, arr); } // Email body: normalised so quoted-reply chrome and whitespace // differences don't break the match. Very short bodies are skipped // — a two-word "please quote" would match half the collection. const bodyKey = normaliseForFingerprint(q.email_body || ""); if (bodyKey.length >= 40) { const arr = bodyKeys.get(bodyKey) ?? []; arr.push(q.id); bodyKeys.set(bodyKey, arr); } const messageId = (q.email_message_id || "").trim(); if (messageId) { const arr = messageIdKeys.get(messageId) ?? []; arr.push(q.id); messageIdKeys.set(messageId, arr); } } const out: DuplicateQuoteGroup[] = []; const pushGroups = ( kind: QuoteDuplicateKind, keyed: Map, label: (recs: DuplicateQuoteRecord[]) => string, ) => { for (const [key, ids] of keyed) { const unique = Array.from(new Set(ids)); if (unique.length < 2) continue; const recs = unique .map((id) => records.get(id)) .filter((r): r is DuplicateQuoteRecord => r !== undefined); if (recs.length < 2) continue; recs.sort((a, b) => (a.created < b.created ? -1 : a.created > b.created ? 1 : 0)); out.push({ kind, key, label: label(recs), records: recs }); } }; pushGroups("item_content", contentKeys, (recs) => { const { itemCount, totalQuantity } = recs[0]; return `${itemCount} line${itemCount === 1 ? "" : "s"} · ${totalQuantity} unit${totalQuantity === 1 ? "" : "s"}`; }); pushGroups("email_body", bodyKeys, () => "Same email request"); pushGroups("email_message_id", messageIdKeys, () => "Same email (Message-ID)"); // Biggest groups first — the most-repeated tender is the one the // office most wants to see. out.sort((a, b) => b.records.length - a.records.length); return out; } /** * Admin-side bulk fetch of `quote_items` for many quotes. Mirrors * `fetchQuoteItemsForQuotes` in pb-helpers (client PB) and * `fetchCandidateQuoteItems` in quotes-search-service: try PB's `IN` * syntax first, fall back to chunked OR-chains when it's rejected. * * Exported so `order-quote-link-service.ts` can reuse it for the * fuzzy matcher's quote-items fetch instead of building its own * unchunked OR-chain (which blows the PB filter length limit past * ~50 candidate quotes). * * `fields` defaults to this module's own projection but is a caller * parameter, not a shared constant, because a second stakeholder now * depends on specific columns being present: the matcher needs * `description` and `selling_price` for its scoring, which the * duplicate-quote scan (the original caller) has no reason to know * about. Trimming the default for one caller must not silently starve * the other. */ export async function fetchQuoteItemsForQuotesAdmin( pb: Awaited>, quoteIds: string[], fields: string = "id,quote,description,sku,quantity,selling_price", ): Promise { if (quoteIds.length === 0) return []; const escape = (id: string) => id.replace(/"/g, '\\"'); const safeIds = quoteIds.map(escape); // PocketBase filters have NO `IN` operator — `quote IN ("a","b")` is a // 400. An id list is an OR-chain, chunked so the filter stays under // PB's URL length cap. (The old code tried `IN` first and fell back // to exactly this on the resulting 400, so every call paid for a // guaranteed failing round-trip.) const CHUNK = 80; const chunks: string[][] = []; for (let i = 0; i < safeIds.length; i += CHUNK) { chunks.push(safeIds.slice(i, i + CHUNK)); } const results = await Promise.all( chunks.map((chunk) => pb.collection(QUOTE_ITEMS_COLLECTION).getFullList({ filter: chunk.map((id) => `quote = "${id}"`).join(" || "), fields, batch: 5000, }), ), ); return results.flat(); }