WFDASH-WEBAPP/src/lib/server/quotes-service.ts
Dave 89e52bbd9d feat(orders): AI fills order lines from the matched quote
The assistant now prices the order's lines from the quote they are
paired with, as purple drafts a person then reviews.

- order-assistant-core.ts: isOrderLineWritable + buildOrderLineFillPatch.
  Only a pending line with no cost of its own, no person-set pricing
  source and no reviewed fill may be written. Supplier / SKU /
  matched_price_list_id only when the line has none; selling_price only
  when the line has none; markup + markup_percent written together.
  pricing_source becomes "ai_quote". A customer price that differs
  from the quoted price by more than 5% becomes a doubt.
- order-assistant-lines.ts (new): the shared, cycle-free helpers —
  idFilter, aiQuotePairs, writeAiPairs, clearAiPairs and
  fillOrderFromQuote. It lives in its own module so orders-service.ts
  can use it without an import cycle, since order-assistant-service.ts
  already imports setOrderQuote.
- The fill re-reads every line right before writing it, so a cost a
  person typed in the meantime wins. It stays quiet until the migration
  has added order_items.ai_fill — PB rejects a projection that names a
  column the collection does not have.
- setOrderQuote writes the AI's line pairs before pairOrderLines runs,
  so the AI's pairing wins over word matching, then fills the lines.
  A failure there is logged and never fails the link.
- The tick gains a second pass that fills the lines of orders already
  linked to a quote. Only orders the fill actually wrote to count
  towards the budget, so a skipped order cannot block the run.
- The run route fills after the match and reports the counts.

PocketBase filters have no IN operator, so every id list is an
OR-chain. fetchQuoteItemsForQuotesAdmin no longer tries an IN filter
first — that was a guaranteed 400 on every call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:24:56 +02:00

3556 lines
139 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> | 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<QuoteTabKey, string[]> = {
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<QuoteTabKey, number>; 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<QuoteTabKey>(["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<QuoteTabKey, number>;
}
/**
* 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<QuotesDashboardData> {
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<ReturnType<typeof getAdminPocketBase>>,
): Promise<Omit<QuotesDashboardData, "counts">> {
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<Quote>(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<ReturnType<typeof getAdminPocketBase>>,
options: { bypassCache?: boolean; includeArchivedTabs?: boolean } = {},
): Promise<Record<QuoteTabKey, number>> {
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<QuoteTabKey, number> = {
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<unknown>(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<unknown>(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<unknown>(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<ReturnType<typeof getAdminPocketBase>>,
): Promise<QuotesArchiveData> {
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<unknown>(1, 1, { filter: completedFilter }),
pb.collection(QUOTES_COLLECTION).getList<unknown>(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<Quote>(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<string> {
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<ReturnType<typeof getAdminPocketBase>>,
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<ReturnType<typeof getAdminPocketBase>>,
year: number,
month: number
): Promise<number> {
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<ReturnType<typeof getAdminPocketBase>>
): 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<ReturnType<typeof getAdminPocketBase>>,
name: string
): Promise<string> {
const match =
(await findCompanyByCaseInsensitiveExact(pb, name)) ||
(await findCompanyByNormalized(pb, name));
return match?.id || "";
}
async function findCustomerByExactEmail(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
email: string
): Promise<Customer | null> {
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<ReturnType<typeof getAdminPocketBase>>,
personHint: string,
companyHint?: string
): Promise<Customer | null> {
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<Customer>(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<ReturnType<typeof getAdminPocketBase>>,
name: string
): Promise<Company | null> {
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<ReturnType<typeof getAdminPocketBase>>,
name: string
): Promise<Company | null> {
const normalized = normalizeCompanyName(name);
if (!normalized) return null;
const all = await pb.collection(COMPANIES_COLLECTION).getFullList<Company>({ 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<CreateQuoteFromEmailResult> {
// 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<QuoteItem>({ 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<ReturnType<typeof getAdminPocketBase>>,
input: {
quoteId: string;
filename: string;
contentType?: string;
size?: number;
base64: string;
messageId: string;
}
): Promise<Record<string, unknown>> {
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<string, unknown>;
}
/**
* 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<Record<string, unknown>> {
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<ReturnType<typeof getAdminPocketBase>>,
quoteId: string
): Promise<number> {
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<AppendReplyResult> {
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<ReturnType<typeof getAdminPocketBase>>,
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<ReturnType<typeof getAdminPocketBase>>,
messageId?: string
): Promise<Quote | null>;
export async function findQuoteByMessageId(messageId: string): Promise<Quote | null>;
export async function findQuoteByMessageId(
pbOrMessageId?: Awaited<ReturnType<typeof getAdminPocketBase>> | string,
maybeMessageId?: string
): Promise<Quote | null> {
let pb: Awaited<ReturnType<typeof getAdminPocketBase>>;
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<Quote["status"]>([
"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<ReturnType<typeof getAdminPocketBase>>,
quoteId: string,
quote: Quote,
): Promise<SendQuoteEmailFailure> {
// 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<Customer>(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<QuoteItem>({
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<SendQuoteEmailResult> {
const pb = await getAdminPocketBase();
// ── 1. Load quote + customer
let quote: Quote;
try {
quote = await pb.collection(QUOTES_COLLECTION).getOne<Quote>(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<Quote["status"]>([
"delivery_note",
"claimed",
"processed",
]);
const allowedStatuses = isDeliveryNoteQuote
? new Set<Quote["status"]>([...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 (`<random>_<originalName>` 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<SendQuoteEmailResult> {
const pb = await getAdminPocketBase();
let quote: Quote;
try {
quote = await pb.collection(QUOTES_COLLECTION).getOne<Quote>(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<ReturnType<typeof getAdminPocketBase>>,
quote: Quote,
failure: SendQuoteEmailFailure
): Promise<SendQuoteEmailResult> {
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<Quote> {
const pb = await getAdminPocketBase();
if (input.outcome === "success") {
const patch: Record<string, unknown> = {
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<string, unknown> = {
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 = <continuation.id>` 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<SplitResult> {
const pb = await getAdminPocketBase();
const parent = await pb.collection(QUOTES_COLLECTION).getOne<Quote>(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<string, unknown> = {
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<number> {
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<SubmitQuoteResult> {
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<Quote>(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<string, unknown> = {
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<CreateQuoteResult> {
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<string, unknown> & { 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<ReturnType<typeof getAdminPocketBase>>,
input: string
): Promise<Customer> {
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<Customer>(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<ReturnType<typeof getAdminPocketBase>>,
input: string
): Promise<Customer | null> {
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<Customer>(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<Quote> {
const trimmed = typeof notes === "string" ? notes.trim() : "";
const pb = await getAdminPocketBase();
const updated = await pb.collection(QUOTES_COLLECTION).update<Quote>(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<Quote> {
const pb = await getAdminPocketBase();
return pb.collection(QUOTES_COLLECTION).update<Quote>(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 <name>` — 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<ReturnType<typeof getAdminPocketBase>>,
): Promise<DuplicateQuoteGroup[]> {
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<Quote>({
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<string, QuoteItem[]>();
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<string, DuplicateQuoteRecord>();
const contentKeys = new Map<string, string[]>();
const bodyKeys = new Map<string, string[]>();
const messageIdKeys = new Map<string, string[]>();
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<string, string[]>,
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<ReturnType<typeof getAdminPocketBase>>,
quoteIds: string[],
fields: string = "id,quote,description,sku,quantity,selling_price",
): Promise<QuoteItem[]> {
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<QuoteItem>({
filter: chunk.map((id) => `quote = "${id}"`).join(" || "),
fields,
batch: 5000,
}),
),
);
return results.flat();
}