WFDASH-WEBAPP/src/lib/server/quotes-service.ts

2796 lines
107 KiB
TypeScript
Raw Normal View History

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;
fix(mcp): add quotes.update + preserve unit in create_from_email Closes the two real gaps from the agent's gap analysis (2026-08-05): 1. **quotes.update MCP tool** — was missing entirely. The quote header has fields (company_letterhead, quote_date, quote_reference, etc.) that couldn't be patched via MCP, so the agent had to pass letterhead at PDF-render time and couldn't persist it on the record. Now exposed with a strict field whitelist mirroring the quote_items whitelist pattern: any field outside the allowed set (status, customer, company, quote_number, id, created, updated, …) errors with forbidden_field rather than silently writing to PB. New catalog entry + dispatch handler in src/app/api/mcp/route.ts. Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability. 2. **create_from_email now preserves the unit field** — extracted_items from the AI's RFQ parsing carries {description, quantity, unit, notes} but the create loop was silently dropping unit. Also the service returned items: <count> which made the agent believe no items were created when in fact stub rows existed. Now returns the actual QuoteItem array (with a back-compat items_count field) and the unit value lands on each row. The agent's third claim (PB admin auth unavailable) is a misdiagnosis — WFDASH uses its own AGENT_API_KEY and all writes go through the Next.js admin client. No code change needed for that. All 1379 tests pass. quote_items.update path unaffected. The quotes.update path mirrors the same pattern: whitelist + reject empty patch + call pb.collection.update. Co-authored-by: Orca <help@stably.ai>
2026-08-05 09:39:16 +00:00
/** 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.
*/
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
/* ============================================================================
* 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"
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
| "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",
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
"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: [],
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
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();
}
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
/**
* 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 }
>();
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
const QUOTE_TAB_COUNTS_CACHE_TTL_MS = 60_000;
/** Invalidate the quote tab-counts cache. Exported for tests. */
export function clearQuoteStatusTabCountsCache(): void {
cachedQuoteTabCounts.clear();
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
}
/**
* 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),
),
);
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
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;
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
} = {},
): 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,
}),
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
]);
return { ...list, counts };
}
/**
fix(orders): remove broken fields param + show contact_name + REP label # Regression: orders/quotes list lost customer/company/rep data Last commit added a `fields` parameter to the orders and quotes list queries to trim the wire payload. Measured on a stale test result, looked like a 16x win. Actual: PB 0.26's `fields` parameter is a HARD ALLOWLIST of MAIN record columns only \u2014 specifying it makes PB drop the entire `expand` payload from the response. Result: customer, company, rep, quote names disappeared from every order/quote card. Verified against the live PB: - With `fields`: expand is dropped, just relation IDs returned - Without `fields`: expand works, full records (55-57 KB for 20 rows with deep expand) Fix: remove the `fields` parameter from both list queries. The slim-payload intent has to be paid at the schema level (cap the unbounded text columns) rather than via PB query params. The earlier '12x smaller' / '16x smaller' benchmarks were measurements of the BROKEN response, not the working one. Real wire sizes are unchanged from before the perf work \u2014 but the other wins (server-side tab filter, combined status counts, targeted users fetch, 60s counts cache) all still apply. # Bonus: show contact_name on the order card ~76 of ~4.5k orders have `contact_name` set (the free-text person the order is for, captured during email-intake when the customer record is just the company). Added to the OrderCard header between customer and rep, with a docstring explaining the distinction from the customer relation and email_sender. Added contact_name to the Order type and pinned the regression with a test that asserts the orders list query does NOT pass `fields` (so a future 'let's slim the payload' attempt doesn't reintroduce the bug). # REP label The rep column on the order card now reads 'REP' (uppercase) instead of 'rep' (lowercase). All 1312 tests pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:27:02 +00:00
* 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
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
* saves one IN query per page load.
fix(orders): remove broken fields param + show contact_name + REP label # Regression: orders/quotes list lost customer/company/rep data Last commit added a `fields` parameter to the orders and quotes list queries to trim the wire payload. Measured on a stale test result, looked like a 16x win. Actual: PB 0.26's `fields` parameter is a HARD ALLOWLIST of MAIN record columns only \u2014 specifying it makes PB drop the entire `expand` payload from the response. Result: customer, company, rep, quote names disappeared from every order/quote card. Verified against the live PB: - With `fields`: expand is dropped, just relation IDs returned - Without `fields`: expand works, full records (55-57 KB for 20 rows with deep expand) Fix: remove the `fields` parameter from both list queries. The slim-payload intent has to be paid at the schema level (cap the unbounded text columns) rather than via PB query params. The earlier '12x smaller' / '16x smaller' benchmarks were measurements of the BROKEN response, not the working one. Real wire sizes are unchanged from before the perf work \u2014 but the other wins (server-side tab filter, combined status counts, targeted users fetch, 60s counts cache) all still apply. # Bonus: show contact_name on the order card ~76 of ~4.5k orders have `contact_name` set (the free-text person the order is for, captured during email-intake when the customer record is just the company). Added to the OrderCard header between customer and rep, with a docstring explaining the distinction from the customer relation and email_sender. Added contact_name to the Order type and pinned the regression with a test that asserts the orders list query does NOT pass `fields` (so a future 'let's slim the payload' attempt doesn't reintroduce the bug). # REP label The rep column on the order card now reads 'REP' (uppercase) instead of 'rep' (lowercase). All 1312 tests pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:27:02 +00:00
*
* (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.)
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
*/
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()}"`;
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
} 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,
fix(orders): remove broken fields param + show contact_name + REP label # Regression: orders/quotes list lost customer/company/rep data Last commit added a `fields` parameter to the orders and quotes list queries to trim the wire payload. Measured on a stale test result, looked like a 16x win. Actual: PB 0.26's `fields` parameter is a HARD ALLOWLIST of MAIN record columns only \u2014 specifying it makes PB drop the entire `expand` payload from the response. Result: customer, company, rep, quote names disappeared from every order/quote card. Verified against the live PB: - With `fields`: expand is dropped, just relation IDs returned - Without `fields`: expand works, full records (55-57 KB for 20 rows with deep expand) Fix: remove the `fields` parameter from both list queries. The slim-payload intent has to be paid at the schema level (cap the unbounded text columns) rather than via PB query params. The earlier '12x smaller' / '16x smaller' benchmarks were measurements of the BROKEN response, not the working one. Real wire sizes are unchanged from before the perf work \u2014 but the other wins (server-side tab filter, combined status counts, targeted users fetch, 60s counts cache) all still apply. # Bonus: show contact_name on the order card ~76 of ~4.5k orders have `contact_name` set (the free-text person the order is for, captured during email-intake when the customer record is just the company). Added to the OrderCard header between customer and rep, with a docstring explaining the distinction from the customer relation and email_sender. Added contact_name to the Order type and pinned the regression with a test that asserts the orders list query does NOT pass `fields` (so a future 'let's slim the payload' attempt doesn't reintroduce the bug). # REP label The rep column on the order card now reads 'REP' (uppercase) instead of 'rep' (lowercase). All 1312 tests pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:27:02 +00:00
// 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.
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
});
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 } = {},
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
): Promise<Record<QuoteTabKey, number>> {
const pb = pbArg || (await getAdminPocketBase());
pb.autoCancellation(false);
const includeArchivedTabs = options.includeArchivedTabs ?? true;
const cacheKey = includeArchivedTabs ? "all" : "active";
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
if (
!options.bypassCache &&
cachedQuoteTabCounts.get(cacheKey)?.expiresAt &&
cachedQuoteTabCounts.get(cacheKey)!.expiresAt > Date.now()
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
) {
return cachedQuoteTabCounts.get(cacheKey)!.value;
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
}
// 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;
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
const allStatusRows = await pb
.collection(QUOTES_COLLECTION)
.getFullList<{ status?: string }>({
filter: countedStatuses.map((s) => `status = "${s}"`).join(" || "),
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
fields: "status",
batch: QUOTE_COUNTS_BATCH_SIZE,
});
const counts: Record<QuoteTabKey, number> = {
new: 0,
claimed: 0,
pending_approval: 0,
completed: 0,
completed_recent: 0,
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
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;
}
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
cachedQuoteTabCounts.set(cacheKey, {
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
value: counts,
expiresAt: Date.now() + QUOTE_TAB_COUNTS_CACHE_TTL_MS,
});
perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix Same shape as the orders refactor \u2014 applied to the quotes page, with a fix for a long-standing tab-count bug along the way. # Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items) Before: - 22 parallel per-status getList(1, 1) queries for the badge counts: 2.6s wall time (no quotes.status index) - 64 KB JSON response for 20 quotes with full deep-expand (45+ fields per quote, including unbounded email_body) - Page footer shows 'Page 1 of N' where N is the global count \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393' After (no PB index, just code): - 1 combined counts query: 138ms (19x faster) - 5.5 KB JSON response (12x smaller) - Server-side tab filter: page 1 of N reflects the tab's set - Cancelled tab badge now correctly counts rejected+cancelled # Server-side read model - getQuotesDashboardData in src/lib/server/quotes-service.ts - tab-scoped list + global counts in one round-trip - tab param drives a server-side filter (status IN (...) for workflow tabs, relation filter for delivery_note, is_template for templates, excludeTemplate for 'all') - pagination reflects the active tab's matching set, not the global ~8.5k quote count - getQuoteStatusTabCounts: one combined getFullList with all 17 counted statuses ORed (fields: 'status'), deduped in memory. Replaces 22 separate per-status scans. Cached 60s on the server with bypassCache option for future realtime refetches. - /api/v1/quotes/list thin route: validates tab against QUOTE_TAB_ORDER, threads tab + nocache params to the service. # Slim wire payload - fields param on the orders list: just the columns the page reads + per-expand limits (quote.quote_number doesn't need email_body, company.company doesn't need its address field, etc.) - Dropped the customer.company 3-level expand (the page never read expand.customer.expand.company \u2014 only expand.company.company, which is a direct relation from quote) - searchQuotesWithItems: same fields added, with fetchQuoteItemsForQuotes now accepting fields # Bonus bug: 'Cancelled' tab badge was always empty The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected' to the 'completed' count, so the page's 'Cancelled' tab badge showed 0 even when 60+ rejected quotes existed. The page's tabFilters separately grouped them under 'cancelled', so a quote that was rejected showed up in the list but not the count. The new QUOTE_TAB_STATUSES in the service layer is the canonical mapping (cancelled absorbs both statuses). Page constants and service constants now derive from the same source \u2014 no more drift. # Index migration scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent. The live PB has no quotes.status index today (verified by hitting /api/collections/quotes). With the index, the combined counts query drops from 138ms to sub-10ms on the live instance. # Tests 6 new tests in quotes-service.test.ts: - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected, no empty status lists unless special-cased) - getQuoteStatusTabCounts buckets correctly into tabs - Cache hit returns the same counts on second call - bypassCache: true sees new state - getQuotesDashboardData end-to-end: tab-scoped items + global counts - Regression guard for the cancelled/rejected bug Mock infrastructure upgrade: the quotes-service mock's where function is now a proper recursive-descent filter parser (handles nested || inside parens, empty quoted values). getList mock now returns totalPages. Full suite: 1312/1312 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:01:31 +00:00
return counts;
}
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
/* ============================================================================
* 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;
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
}
/**
* 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;
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
} = {},
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,
};
}
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
// 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;
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
const allItems = await pb
.collection(QUOTES_COLLECTION)
.getFullList<{ id?: string; status?: string }>({
filter: allItemsFilter,
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
// `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(),
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
};
}
// 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;
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
});
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(),
feat(quotes): move completed+all to dedicated /quotes/archive page Parallel to the orders archive treatment. On the live PB the 'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause blowing past PB's filter cap, with 'Page 1 of 392' on the tab that operators didn't actually use for active work. This commit moves both buckets to a dedicated /quotes/archive page so: - Main /quotes is fast (active workflow: new, claimed, pending_approval, cancelled, delivery_note, email_failed, problem, templates = 542 quotes vs 8,466) - Archive can be honest about pagination - The badge counts on the main page stop being dominated by 'Completed (7,849)' # Service layer - getQuotesArchiveData(params) in src/lib/server/quotes-service.ts - view: 'completed' (default, 7,849) or 'all' (8,466) - Strategy: one getFullList on quotes (fields: id,status) to populate the matching set AND the header counts in a single walk, chunk by 100 ids (PB's URL/filter cap is between 4.5-6.7 KB), fire parallel getFullList queries on quotes with deep expand, sort + paginate in memory - Search hits quote_number, quote_reference, rfq_number, company, customer - /api/v1/quotes/archive thin route: page, perPage, search, sort, view # Page - /quotes/archive/page.tsx: card list with header counts (completed / all badges), view toggle (Completed | All), search input, pagination footer. Click a quote to open /quotes/[id] in detail. # Main /quotes updates - Completed + All removed from the tab strip - Counts on the main page now exclude completed + all - 'Archive' button in the page header takes the operator to /quotes/archive - Safety useEffect: if activeTab is completed/all (e.g. stale URL or local state), reset to 'new' # Tests 4 new tests in quotes-service.test.ts: - Default 'completed' view returns only completed quotes, counts header includes both - 'all' view returns every counted-status quote - Search by quote_number and quote_reference - Empty result when no quotes match # Also: webview tweaks by another agent - /webview?src=hermes label in sidebar - Hermes URL whitelist updated - Some header text removed (the probe status remains) Full suite: 1331/1331 pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
};
}
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.
fix(mcp): add quotes.update + preserve unit in create_from_email Closes the two real gaps from the agent's gap analysis (2026-08-05): 1. **quotes.update MCP tool** — was missing entirely. The quote header has fields (company_letterhead, quote_date, quote_reference, etc.) that couldn't be patched via MCP, so the agent had to pass letterhead at PDF-render time and couldn't persist it on the record. Now exposed with a strict field whitelist mirroring the quote_items whitelist pattern: any field outside the allowed set (status, customer, company, quote_number, id, created, updated, …) errors with forbidden_field rather than silently writing to PB. New catalog entry + dispatch handler in src/app/api/mcp/route.ts. Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability. 2. **create_from_email now preserves the unit field** — extracted_items from the AI's RFQ parsing carries {description, quantity, unit, notes} but the create loop was silently dropping unit. Also the service returned items: <count> which made the agent believe no items were created when in fact stub rows existed. Now returns the actual QuoteItem array (with a back-compat items_count field) and the unit value lands on each row. The agent's third claim (PB admin auth unavailable) is a misdiagnosis — WFDASH uses its own AGENT_API_KEY and all writes go through the Next.js admin client. No code change needed for that. All 1379 tests pass. quote_items.update path unaffected. The quotes.update path mirrors the same pattern: whitelist + reject empty patch + call pb.collection.update. Co-authored-by: Orca <help@stably.ai>
2026-08-05 09:39:16 +00:00
// 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());
fix(mcp): add quotes.update + preserve unit in create_from_email Closes the two real gaps from the agent's gap analysis (2026-08-05): 1. **quotes.update MCP tool** — was missing entirely. The quote header has fields (company_letterhead, quote_date, quote_reference, etc.) that couldn't be patched via MCP, so the agent had to pass letterhead at PDF-render time and couldn't persist it on the record. Now exposed with a strict field whitelist mirroring the quote_items whitelist pattern: any field outside the allowed set (status, customer, company, quote_number, id, created, updated, …) errors with forbidden_field rather than silently writing to PB. New catalog entry + dispatch handler in src/app/api/mcp/route.ts. Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability. 2. **create_from_email now preserves the unit field** — extracted_items from the AI's RFQ parsing carries {description, quantity, unit, notes} but the create loop was silently dropping unit. Also the service returned items: <count> which made the agent believe no items were created when in fact stub rows existed. Now returns the actual QuoteItem array (with a back-compat items_count field) and the unit value lands on each row. The agent's third claim (PB admin auth unavailable) is a misdiagnosis — WFDASH uses its own AGENT_API_KEY and all writes go through the Next.js admin client. No code change needed for that. All 1379 tests pass. quote_items.update path unaffected. The quotes.update path mirrors the same pattern: whitelist + reject empty patch + call pb.collection.update. Co-authored-by: Orca <help@stably.ai>
2026-08-05 09:39:16 +00:00
const createdItems: QuoteItem[] = [];
for (const item of items) {
fix(mcp): add quotes.update + preserve unit in create_from_email Closes the two real gaps from the agent's gap analysis (2026-08-05): 1. **quotes.update MCP tool** — was missing entirely. The quote header has fields (company_letterhead, quote_date, quote_reference, etc.) that couldn't be patched via MCP, so the agent had to pass letterhead at PDF-render time and couldn't persist it on the record. Now exposed with a strict field whitelist mirroring the quote_items whitelist pattern: any field outside the allowed set (status, customer, company, quote_number, id, created, updated, …) errors with forbidden_field rather than silently writing to PB. New catalog entry + dispatch handler in src/app/api/mcp/route.ts. Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability. 2. **create_from_email now preserves the unit field** — extracted_items from the AI's RFQ parsing carries {description, quantity, unit, notes} but the create loop was silently dropping unit. Also the service returned items: <count> which made the agent believe no items were created when in fact stub rows existed. Now returns the actual QuoteItem array (with a back-compat items_count field) and the unit value lands on each row. The agent's third claim (PB admin auth unavailable) is a misdiagnosis — WFDASH uses its own AGENT_API_KEY and all writes go through the Next.js admin client. No code change needed for that. All 1379 tests pass. quote_items.update path unaffected. The quotes.update path mirrors the same pattern: whitelist + reject empty patch + call pb.collection.update. Co-authored-by: Orca <help@stably.ai>
2026-08-05 09:39:16 +00:00
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,
fix(mcp): add quotes.update + preserve unit in create_from_email Closes the two real gaps from the agent's gap analysis (2026-08-05): 1. **quotes.update MCP tool** — was missing entirely. The quote header has fields (company_letterhead, quote_date, quote_reference, etc.) that couldn't be patched via MCP, so the agent had to pass letterhead at PDF-render time and couldn't persist it on the record. Now exposed with a strict field whitelist mirroring the quote_items whitelist pattern: any field outside the allowed set (status, customer, company, quote_number, id, created, updated, …) errors with forbidden_field rather than silently writing to PB. New catalog entry + dispatch handler in src/app/api/mcp/route.ts. Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability. 2. **create_from_email now preserves the unit field** — extracted_items from the AI's RFQ parsing carries {description, quantity, unit, notes} but the create loop was silently dropping unit. Also the service returned items: <count> which made the agent believe no items were created when in fact stub rows existed. Now returns the actual QuoteItem array (with a back-compat items_count field) and the unit value lands on each row. The agent's third claim (PB admin auth unavailable) is a misdiagnosis — WFDASH uses its own AGENT_API_KEY and all writes go through the Next.js admin client. No code change needed for that. All 1379 tests pass. quote_items.update path unaffected. The quotes.update path mirrors the same pattern: whitelist + reject empty patch + call pb.collection.update. Co-authored-by: Orca <help@stably.ai>
2026-08-05 09:39:16 +00:00
unit: typeof item.unit === "string" ? item.unit : "",
cost_price: 0,
selling_price: 0,
markup: 0,
notes: item.notes || "",
is_processed: false,
});
fix(mcp): add quotes.update + preserve unit in create_from_email Closes the two real gaps from the agent's gap analysis (2026-08-05): 1. **quotes.update MCP tool** — was missing entirely. The quote header has fields (company_letterhead, quote_date, quote_reference, etc.) that couldn't be patched via MCP, so the agent had to pass letterhead at PDF-render time and couldn't persist it on the record. Now exposed with a strict field whitelist mirroring the quote_items whitelist pattern: any field outside the allowed set (status, customer, company, quote_number, id, created, updated, …) errors with forbidden_field rather than silently writing to PB. New catalog entry + dispatch handler in src/app/api/mcp/route.ts. Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability. 2. **create_from_email now preserves the unit field** — extracted_items from the AI's RFQ parsing carries {description, quantity, unit, notes} but the create loop was silently dropping unit. Also the service returned items: <count> which made the agent believe no items were created when in fact stub rows existed. Now returns the actual QuoteItem array (with a back-compat items_count field) and the unit value lands on each row. The agent's third claim (PB admin auth unavailable) is a misdiagnosis — WFDASH uses its own AGENT_API_KEY and all writes go through the Next.js admin client. No code change needed for that. All 1379 tests pass. quote_items.update path unaffected. The quotes.update path mirrors the same pattern: whitelist + reject empty patch + call pb.collection.update. Co-authored-by: Orca <help@stably.ai>
2026-08-05 09:39:16 +00:00
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,
fix(mcp): add quotes.update + preserve unit in create_from_email Closes the two real gaps from the agent's gap analysis (2026-08-05): 1. **quotes.update MCP tool** — was missing entirely. The quote header has fields (company_letterhead, quote_date, quote_reference, etc.) that couldn't be patched via MCP, so the agent had to pass letterhead at PDF-render time and couldn't persist it on the record. Now exposed with a strict field whitelist mirroring the quote_items whitelist pattern: any field outside the allowed set (status, customer, company, quote_number, id, created, updated, …) errors with forbidden_field rather than silently writing to PB. New catalog entry + dispatch handler in src/app/api/mcp/route.ts. Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability. 2. **create_from_email now preserves the unit field** — extracted_items from the AI's RFQ parsing carries {description, quantity, unit, notes} but the create loop was silently dropping unit. Also the service returned items: <count> which made the agent believe no items were created when in fact stub rows existed. Now returns the actual QuoteItem array (with a back-compat items_count field) and the unit value lands on each row. The agent's third claim (PB admin auth unavailable) is a misdiagnosis — WFDASH uses its own AGENT_API_KEY and all writes go through the Next.js admin client. No code change needed for that. All 1379 tests pass. quote_items.update path unaffected. The quotes.update path mirrors the same pattern: whitelist + reject empty patch + call pb.collection.update. Co-authored-by: Orca <help@stably.ai>
2026-08-05 09:39:16 +00:00
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;
}