WFDASH-WEBAPP/src/lib/server/quotes-service.ts
SJCoetz 12fa65a481 feat: add new order, invoice, and dashboard management tools
- Add server-side `/api/v1/dashboard` endpoint to reduce client-side payload and round trips
- Implement invoice upload and order-item SKU update APIs with complete test suites
- Refactor order and order-items list endpoints (including MCP tools) to support proper PocketBase filter and sort params
- Fix pagination validation for order-items route to clamp page ≥1 and perPage 1-500
- Add redirects in next.config.ts to map underscore collection paths to hyphenated API routes
- Improve buyer tasks page to avoid redundant tab count refetches when switching tabs
- Refactor collection confirm components with editable SKUs and updated accessibility labels
- Add cached admin PocketBase auth to minimize repeated authentication calls
- Add performance testing scripts for dashboard endpoint latency
- Update quotes list route to default to excluding archived counts
- Fix photo strip component text and labels for non-invoice documents
- Refactor sprint page to lazy load supplier/customer data for pricing modal
- Clean up unused imports in test files
- Add full test coverage for all new APIs, services, and components
2026-08-05 23:00:54 +02:00

2031 lines
77 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 {
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"
| "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",
"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"],
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: [],
};
/**
* 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 (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,
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;
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;
}
/**
* 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;
} = {},
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 completedFilter = COMPLETED_STATUSES.map((s) => `status = "${s}"`).join(" || ");
const allFilter = ALL_COUNTED_STATUSES.map((s) => `status = "${s}"`).join(" || ");
const viewFilter = view === "completed" ? completedFilter : allFilter;
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,
};
}
// 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.
const allItems = await pb
.collection(QUOTES_COLLECTION)
.getFullList<{ id?: string; status?: string }>({
filter: allFilter,
// `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,
};
}
// 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,
};
}
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`.
*/
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.
const quote = await pb.collection(QUOTES_COLLECTION).create({
quote_number: quoteNumber,
customer: customer.id,
company: company?.id || null,
description: payload.subject,
status: "new",
notes: "",
email_sender: fromEmail,
email_subject: payload.subject,
email_body: payload.body_plain || payload.body_html || "",
has_attachments: (payload.attachments?.length ?? 0) > 0,
email_message_id: effectiveMessageId,
company_letterhead: "CNL",
});
// ── 6. Pre-populate quote items from the AI extraction. Human opt-in only.
// 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";
}
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. Defaults to customer.email. */
to?: string;
cc: string;
bcc: string;
subject: string;
message: string;
/** Per-modal checkbox: include the per-letterhead supplier authorization letter. */
attachAuthLetters: boolean;
/**
* IDs of `quote_attachments` rows to include in the outbound email.
* The PDF is always included separately (auto-located by kind="pdf"
* at send time). IDs that don't belong to this quote are silently
* filtered out so a tampered client can't smuggle in attachments
* from another quote.
*/
selectedAttachmentIds: string[];
}
export interface SendQuoteEmailFailure {
code:
| "placeholder_customer"
| "no_recipient"
| "invalid_recipient"
| "no_pdf"
| "invalid_status"
| "quote_not_found";
message: string;
}
export interface SendQuoteEmailResult {
quote: Quote;
/** Null on success; populated when pre-send validation rejected the send. */
failure: SendQuoteEmailFailure | null;
}
/**
* Pre-send validation + status transition for the outbound email.
*
* The actual SMTP send happens in n8n (the `Quote Outbound Send`
* workflow), triggered by a PB hook on `status = "sending"`. This
* service is the synchronous front-door:
*
* 1. Validate recipients, customer, status, and PDF.
* 2. If validation fails, flip status to `mail_failed` + write
* `email_error` synchronously and return `{ failure }`. Don't
* bother n8n — there's nothing to send.
* 3. If validation passes, flip status to `sending`, persist the
* operator's composition choices (CC/BCC/Subject/Message/
* selectedAttachmentIds/attachAuthLetters), stamp
* `sending_started_at`, and clear `email_error`. n8n picks up
* the change via PB hook and runs the SMTP send.
*
* We deliberately write `email_outbound_attachment_ids` here (not in
* n8n) so the retry flow sends the SAME attachments the operator
* chose the first time, not whatever happens to be attached at
* retry time. Without this, a check-then-uncheck between send and
* retry could cause the customer to receive a different attachment
* set on retry than on the original attempt.
*
* `email_outbound_message_id` is NOT written here — n8n generates
* it at SMTP-time and writes it on success/failure. This keeps the
* RFC 5322 Message-ID guarantee (must be unique per message) in the
* sender's hands.
*/
export async function sendQuoteEmail(
quoteId: string,
payload: SendQuoteEmailPayload
): Promise<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.
const primary = (payload.to || customer.email || "").trim();
const alt = (customer.alternative_email || "").trim();
const toAddress = looksLikeEmail(primary) ? primary : looksLikeEmail(alt) ? alt : "";
if (!toAddress) {
return markPreSendFailure(pb, quote, {
code: "no_recipient",
message: "No valid recipient email on this quote (primary and alternative are both empty or invalid).",
});
}
// ── 5. Validate CC / BCC entries (loose check; SMTP will reject
// truly malformed addresses)
for (const list of [payload.cc, payload.bcc]) {
for (const raw of (list || "").split(",")) {
const v = raw.trim();
if (v && !looksLikeEmail(v)) {
return markPreSendFailure(pb, quote, {
code: "invalid_recipient",
message: `"${v}" doesn't look like an email address.`,
});
}
}
}
// ── 6. Validate PDF exists (most recent kind=pdf attachment). The
// outbound send includes the PDF as the first attachment; no
// PDF = nothing useful to send.
const pdfs = await pb
.collection(QUOTE_ATTACHMENTS_COLLECTION)
.getList(1, 1, {
filter: `quote = "${quoteId}" && kind = "pdf"`,
sort: "-uploaded_at",
});
if (pdfs.items.length === 0) {
return markPreSendFailure(pb, quote, {
code: "no_pdf",
message:
"No PDF generated for this quote yet. Generate the PDF before sending.",
});
}
// ── 7. Filter selectedAttachmentIds to only those that actually
// belong to this quote. Tampered clients can't smuggle in
// attachments from other quotes.
const requestedIds = Array.from(new Set(payload.selectedAttachmentIds || []));
let validIds: string[] = [];
if (requestedIds.length > 0) {
const quotedAtts = await pb
.collection(QUOTE_ATTACHMENTS_COLLECTION)
.getFullList({
filter: `quote = "${quoteId}"`,
fields: "id",
});
const ownIds = new Set(quotedAtts.map((a) => ({ id: (a as { id: string }).id })).map((x) => x.id));
validIds = requestedIds.filter((id) => ownIds.has(id));
}
// ── 8. Flip status to `sending` + persist composition. One PATCH
// so the audit trail stays atomic.
const updated = (await pb.collection(QUOTES_COLLECTION).update(quoteId, {
status: "sending",
sending_started_at: new Date().toISOString(),
email_error: "",
email_outbound_attachment_ids: JSON.stringify(validIds),
// Persist operator's last-used composition so reopening the
// modal shows what was sent.
email_cc: payload.cc || "",
email_bcc: payload.bcc || "",
email_subject_outbound: payload.subject || "",
email_message: payload.message || "",
attach_auth_letters: !!payload.attachAuthLetters,
})) as Quote;
return { quote: updated, failure: null };
}
/**
* Retry an outbound send after a `mail_failed`. Same validation as
* `sendQuoteEmail` but without re-reading the modal payload — we
* reuse whatever composition the operator submitted last time
* (already persisted on the quote row).
*
* Status guard: only `mail_failed` can be retried; everything else
* is either a no-op (`emailed`, `sending`) or a different transition
* (`approved` → use `sendQuoteEmail` to flip and send).
*/
export async function retryQuoteEmail(quoteId: string): Promise<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;
}