WFDASH-WEBAPP/src/lib/server/quotes-service.ts
Dave 663c018a70 feat(quotes): add "Include older" toggle for searching full quote history
- Introduced a new toggle in the quotes and archive pages to allow users to include older quotes beyond the default 90-day cutoff.
- Updated the search parameters in the quotes and archive APIs to handle the new `includeOlder` flag, enabling full history searches.
- Enhanced the user interface to reflect the current search scope, providing clear feedback on whether the search is limited to recent quotes or includes the full archive.
- Adjusted the quotes search service to support the new functionality, ensuring efficient retrieval of older quotes when requested.
- Added relevant tests to verify the correct behavior of the new search feature and its integration with existing functionality.
2026-08-20 10:18:44 +02:00

2795 lines
107 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { getAdminPocketBase } from "./pocketbase-admin";
import { QUOTE_VALID_DAYS } from "@/lib/pdf/format";
import { createQuoteItem } from "./quote-items-service";
import {
PLACEHOLDER_CUSTOMER_IDS,
PLACEHOLDER_CUSTOMER_NAMES,
} from "@/lib/placeholder-customers";
import type { Quote, QuoteItem, Customer, Company } from "@/lib/types";
/**
* Server-side quote pipeline used by the MCP layer (n8n) and internal API routes.
*
* This module is the canonical home for all email-to-quote business logic.
* It deliberately avoids the pitfalls of the old Django implementation:
*
* - No fuzzy customer/company matching — only exact + normalized.
* - No fabricated cost or selling prices on AI-extracted items.
* - No racy `last + 1` quote number generation — uses a PocketBase-side
* atomic counter.
* - Replies are written to the `quote_replies` collection, never to a
* text blob on `quotes.notes`.
* - Idempotency: the same email (by Message-ID) cannot create two quotes.
*/
const QUOTES_COLLECTION = "quotes";
const QUOTE_ITEMS_COLLECTION = "quote_items";
const QUOTE_ATTACHMENTS_COLLECTION = "quote_attachments";
const QUOTE_REPLIES_COLLECTION = "quote_replies";
const QUOTE_COUNTERS_COLLECTION = "quote_counters";
const CUSTOMERS_COLLECTION = "customers";
const COMPANIES_COLLECTION = "companies";
const QUOTE_NUMBER_REGEX = /(?:Re:\s*)?(?:Quotation|Quote)\s+(Q-?\d{4}-?\d{2}-?\d{3})/i;
/**
* Sentinel customer for emails with no From: header. PB enforces
* `required=true` on `quotes.customer`, so we can't pass null. Instead,
* every email intake with an empty `from_email` is linked to this single
* stable customer. The quoter reassigns in the quote-editing UI if/when
* they know who the real sender is.
*
* The email domain `no-from.wfdash.local` is impossible to confuse with a
* real address; the description makes the role explicit.
*/
const UNKNOWN_SENDER_CUSTOMER_EMAIL = "unknown-sender@no-from.wfdash.local";
const UNKNOWN_SENDER_CUSTOMER_NAME = "Unknown Sender";
const UNKNOWN_SENDER_CUSTOMER_DESCRIPTION =
"Auto-created sentinel for emails with no From: header. The quoter reassigns in the quote-editing UI.";
export interface EmailIntakeAttachment {
filename: string;
content_type?: string;
size?: number;
/** Raw bytes encoded as base64 (no `data:` URL prefix). */
base64: string;
}
export interface EmailIntakeExtractedItem {
description?: string;
quantity?: number;
unit?: string;
notes?: string;
}
export interface EmailIntakePayload {
from_email: string;
from_name?: string;
subject: string;
body_plain?: string;
body_html?: string;
/** RFC 5322 Message-ID. Required for dedup. */
message_id: string;
in_reply_to?: string;
received_at?: string;
extracted_items?: EmailIntakeExtractedItem[];
attachments?: EmailIntakeAttachment[];
/** Optional override for company matching — used by the AI extraction layer. */
company_hint?: string;
}
export interface EmailReplyPayload {
from_email: string;
from_name?: string;
subject: string;
body_plain?: string;
body_html?: string;
message_id?: string;
in_reply_to?: string;
received_at?: string;
quoted_text?: string;
}
export interface CreateQuoteFromEmailResult {
quote: Quote;
customer: Customer;
company: Company | null;
/** 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 = 90;
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` is
* intentionally NOT expanded — the page only reads
* `expand.company.company` (the direct relation from quote), not
* `expand.customer.expand.company`. Dropping the 3-level expand
* saves one IN query per page load.
*
* (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.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;
// The recent-window cutoff is the Path A scoping — see
// SEARCH_RECENT_WINDOW_DAYS. Only applied when the operator is
// actually searching (otherwise the bare listing is the
// honest, full archive view).
const recencyFilter = includeOlder
? ""
: `updated >= "${searchRecentCutoff()}"`;
if (!search) {
const expand = "customer,customer.rep,company";
const [pageResp, completedResp, allResp] = await Promise.all([
pb.collection(QUOTES_COLLECTION).getList<Quote>(page, perPage, {
filter: viewFilter,
sort,
expand,
}),
pb.collection(QUOTES_COLLECTION).getList<unknown>(1, 1, {
filter: completedFilter,
}),
pb.collection(QUOTES_COLLECTION).getList<unknown>(1, 1, {
filter: allFilter,
}),
]);
return {
items: pageResp.items,
totalItems: pageResp.totalItems,
totalPages: pageResp.totalPages,
counts: {
completed: completedResp.totalItems,
all: allResp.totalItems,
},
view,
recencyLimited: false,
recencyCutoff: null,
};
}
// Step 1: fetch the FULL counted-status set (every status in any
// tab) so we can report header counts for BOTH `completed` and
// `all` in one walk. The matching set for the chosen view is a
// subset of this data — filtering happens in memory below.
//
// Why not two separate queries (one per view)? Doubling the
// order_items scan for what is mostly a UI label is wasteful
// when the data is identical for both views. We pay one scan
// and split in memory.
//
// When the operator is searching with the default recent-window
// scoping, the recency filter rides on this query too. That
// turns the unbounded "full counted-status set" into a bounded
// recent set — typically 200-500 rows instead of 8.5k — which
// is why the archive search stops timing out.
const allItemsFilter = recencyFilter
? `(${allFilter}) && ${recencyFilter}`
: allFilter;
const allItems = await pb
.collection(QUOTES_COLLECTION)
.getFullList<{ id?: string; status?: string }>({
filter: allItemsFilter,
// `id` is needed for the matching-set dedupe below. `status`
// drives the per-status distinct counts. Both are small
// fields so the wire payload stays tight.
fields: "id,status",
batch: 5000,
});
// Count distinct quotes per status (header totals) and gather
// the matching set. The matching set is deduped (one row per
// quote — status filter is `status = X` which matches the status
// column on the quote itself, not a related row).
const completedIds = new Set<string>();
const allIds = new Set<string>();
const distinctByStatus: Record<string, number> = {};
for (const item of allItems) {
const id = item.id;
const status = item.status;
if (!id) continue;
if (status && COMPLETED_STATUSES.includes(status)) {
completedIds.add(id);
}
if (status) {
allIds.add(id);
distinctByStatus[status] = (distinctByStatus[status] || 0) + 1;
}
}
// Pick the matching set based on the view.
const matchingIds = view === "completed" ? [...completedIds] : [...allIds];
if (matchingIds.length === 0) {
return {
items: [],
totalItems: 0,
totalPages: 0,
counts: {
completed: completedIds.size,
all: allIds.size,
},
view,
recencyLimited: !includeOlder,
recencyCutoff: includeOlder ? null : searchRecentCutoff(),
};
}
// Step 2: fetch matching quote details. 100 ids per chunk
// (PB's URL/filter cap sits between 4.5–6.7 KB; 100 ids lands
// at ~4.6 KB). Fire all chunks in parallel.
const expand = "customer,customer.rep,company";
const ARCHIVE_CHUNK_SIZE = 100;
const chunks: string[][] = [];
for (let i = 0; i < matchingIds.length; i += ARCHIVE_CHUNK_SIZE) {
chunks.push(matchingIds.slice(i, i + ARCHIVE_CHUNK_SIZE));
}
const candidateQuotes = (
await Promise.all(
chunks.map((chunk) => {
const filter = chunk
.map((id) => `id = "${id.replace(/"/g, '\\"')}"`)
.join(" || ");
return pb.collection(QUOTES_COLLECTION).getFullList<Quote>({
filter,
sort,
expand,
});
}),
)
).flat();
// Step 3: search filter. The list view reads quote_number,
// customer, company, and the rep label — so the search hits
// those (and contact_name, if we ever add a contact_name field).
const searchLower = search.toLowerCase();
let filtered = candidateQuotes;
if (search) {
filtered = candidateQuotes.filter((q) => {
if ((q.quote_number || "").toLowerCase().includes(searchLower)) return true;
if ((q.quote_reference || "").toLowerCase().includes(searchLower)) return true;
if ((q.rfq_number || "").toLowerCase().includes(searchLower)) return true;
if ((q.expand?.company?.company || "").toLowerCase().includes(searchLower)) return true;
if ((q.expand?.customer?.customer || "").toLowerCase().includes(searchLower)) return true;
return false;
});
}
// Step 4: paginate. (PB sorted each chunk by `sort`, but the
// chunks were concatenated in id order, which is arbitrary. Re-
// sort in memory so the page is deterministic.)
const sortKey = sort.replace(/^-/, "");
const sortDir = sort.startsWith("-") ? -1 : 1;
filtered.sort((a, b) => {
const av = (a as unknown as Record<string, unknown>)[sortKey];
const bv = (b as unknown as Record<string, unknown>)[sortKey];
if (av === bv) return 0;
// PB returns `null` for unset fields (e.g. `updated`, `email_body`)
// and `undefined` for absent fields. Treat both as "missing"
// and sink them to the end of an ascending sort, the start of
// a descending sort. Avoids the `av > bv` comparison where one
// side is null and TS strict mode rejects the >.
if (av == null) return 1;
if (bv == null) return -1;
if (av > bv) return 1 * sortDir;
if (av < bv) return -1 * sortDir;
return 0;
});
const totalItems = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const start = (page - 1) * perPage;
const pageQuotes = filtered.slice(start, start + perPage);
return {
items: pageQuotes,
totalItems,
totalPages,
counts: {
completed: completedIds.size,
all: allIds.size,
},
view,
recencyLimited: !includeOlder,
recencyCutoff: includeOlder ? null : searchRecentCutoff(),
};
}
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;
}): Promise<{ customer: Customer; created: boolean }> {
const pb = await getAdminPocketBase();
const email = (input.email || "").trim().toLowerCase();
if (!email) {
throw new Error("Customer email is required for findOrCreateCustomer.");
}
const exact = await findCustomerByExactEmail(pb, email);
if (exact) {
return { customer: exact, created: false };
}
const senderDomain = email.split("@")[1] || "";
const fallbackName = input.name?.trim() || senderDomain || email;
// Resolve the company hint to a relation id, not a free-text name —
// `customer.company` is now a PB relation (2026-06-28 schema change).
// If no matching company exists, the link is left empty for the
// operator to fill in via the customer form.
const hintName = input.companyHint?.trim() || senderDomain;
let companyId = "";
if (hintName) {
const match =
(await findCompanyByCaseInsensitiveExact(pb, hintName)) ||
(await findCompanyByNormalized(pb, hintName));
if (match) companyId = match.id;
}
const created = await pb.collection(CUSTOMERS_COLLECTION).create({
customer: fallbackName,
email,
company: companyId,
dateadded: new Date().toISOString().slice(0, 10),
description: "Auto-created from email intake.",
});
return { customer: created as Customer, created: true };
}
/**
* Returns the singleton "Unknown Sender" customer used when an inbound email
* has no From: header. Created on first use, reused for all subsequent
* unknown-sender emails. The race-safety pattern (try-create, re-fetch on
* unique-index failure) mirrors `generateQuoteNumberServer`.
*
* 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;
}
// ──────────────────────────────────────────────────────────────────────────────
// 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,
})
: 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;
}
// ──────────────────────────────────────────────────────────────────────────────
// 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;
/**
* IDs of `quote_attachments` rows to include in the outbound email.
* The PDF is always included separately (auto-located by kind="pdf"
* at send time). IDs that don't belong to this quote are silently
* filtered out so a tampered client can't smuggle in attachments
* from another quote.
*/
selectedAttachmentIds: string[];
}
export interface SendQuoteEmailFailure {
code:
| "placeholder_customer"
| "no_recipient"
| "invalid_recipient"
| "no_pdf"
| "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) {
const missing: string[] = [];
if (!it.is_processed) missing.push("not processed");
if (!it.supplier) missing.push("no supplier");
if (!(Number(it.cost_price) > 0)) missing.push("cost price");
if (!(Number(it.selling_price) > 0)) missing.push("selling price");
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 and supplier 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. Validate PDF exists (most recent kind=pdf attachment). The
// outbound send includes the PDF as the first attachment; no
// PDF = nothing useful to send.
//
// Closed 2026-08-15: the previous path bailed with a bare
// "no_pdf" message and the agent had no way to tell the
// operator what was actually missing. We now run the same
// readiness check that the PDF render route does
// (placeholder customer, no items, items_not_ready) and
// surface the per-line `missing` lists back to the agent,
// which can present them to the operator verbatim. The
// operator decides whether to fix the gaps or render the
// PDF themselves; the server doesn't auto-render — Dave
// explicitly opted out of that on 2026-08-15 (don't send
// partial / stale data without operator eyes on it).
const pdfs = await pb
.collection(QUOTE_ATTACHMENTS_COLLECTION)
.getList(1, 1, {
filter: `quote = "${quoteId}" && kind = "pdf"`,
sort: "-uploaded_at",
});
if (pdfs.items.length === 0) {
const readiness = await diagnosePdfReadiness(pb, quoteId, quote);
return markPreSendFailure(pb, quote, readiness);
}
// ── 7. Filter selectedAttachmentIds to only those that actually
// belong to this quote. Tampered clients can't smuggle in
// attachments from other quotes.
const requestedIds = Array.from(new Set(payload.selectedAttachmentIds || []));
let validIds: string[] = [];
if (requestedIds.length > 0) {
const quotedAtts = await pb
.collection(QUOTE_ATTACHMENTS_COLLECTION)
.getFullList({
filter: `quote = "${quoteId}"`,
fields: "id",
});
const ownIds = new Set(quotedAtts.map((a) => ({ id: (a as { id: string }).id })).map((x) => x.id));
validIds = requestedIds.filter((id) => ownIds.has(id));
}
// ── 8. Flip status to `sending` + persist composition. One PATCH
// so the audit trail stays atomic.
const updated = (await pb.collection(QUOTES_COLLECTION).update(quoteId, {
status: "sending",
sending_started_at: new Date().toISOString(),
email_error: "",
email_outbound_attachment_ids: JSON.stringify(validIds),
// Persist operator's last-used composition so reopening the
// modal shows what was sent.
email_cc: payload.cc || "",
email_bcc: payload.bcc || "",
email_subject_outbound: payload.subject || "",
email_message: payload.message || "",
attach_auth_letters: !!payload.attachAuthLetters,
})) as Quote;
return { quote: updated, failure: null };
}
/**
* Retry an outbound send after a `mail_failed`. Same validation as
* `sendQuoteEmail` but without re-reading the modal payload — we
* reuse whatever composition the operator submitted last time
* (already persisted on the quote row).
*
* Status guard: only `mail_failed` can be retried; everything else
* is either a no-op (`emailed`, `sending`) or a different transition
* (`approved` → use `sendQuoteEmail` to flip and send).
*/
export async function retryQuoteEmail(quoteId: string): Promise<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 } : {}),
};
}
// Only `new` quotes can transition via this tool. The full set of
// valid paths to the approval queue is:
// - `new` → here (via `submit`)
// - `partial_approval_pending` → `partial_approved` (via `partial_approve`)
// - `partial_approved` → `approved` (via the operator's review flow)
// Quotes that are already `approved`, `rejected`, `cancelled`,
// `emailed`, etc. have to be re-quoted rather than re-submitted.
if (quote.status !== "new") {
throw new SubmitQuoteError(
`Cannot submit a quote in status "${quote.status}". Only "new" quotes can be submitted for approval. Re-quote if the customer needs a fresh approval cycle.`,
422,
"invalid_status",
);
}
// 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.
if (unpricedItems.length > 0 && !options?.partial) {
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,
);
}
// Pick the target status. Partial flag wins (operator intent) —
// even if every item happened to be priced, we honour the flag.
// Without the flag and with all items priced: full approval.
const targetStatus: "approval_pending" | "partial_approval_pending" =
options?.partial ? "partial_approval_pending" : "approval_pending";
// 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;
}
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. Generate a quote number.
const quoteNumber = await generateQuoteNumberServer();
// ── 4. Create the quote row.
const quote = await pb.collection(QUOTES_COLLECTION).create({
quote_number: quoteNumber,
customer: customer.id,
company: company?.id || null,
description: input.description || "",
status: "new",
notes: "",
validity_days: input.validity_days ?? QUOTE_VALID_DAYS,
company_letterhead: input.letterhead || "CNL",
has_attachments: false,
email_subject: input.description || "",
email_body: "",
});
// ── 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 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;
}