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>
3556 lines
139 KiB
TypeScript
3556 lines
139 KiB
TypeScript
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();
|
||
}
|