2026-06-16 18:46:28 +00:00
import { getAdminPocketBase } from "./pocketbase-admin" ;
2026-08-11 06:32:17 +00:00
import { QUOTE_VALID_DAYS } from "@/lib/pdf/format" ;
2026-08-15 09:50:48 +00:00
import { createQuoteItem } from "./quote-items-service" ;
2026-08-29 00:30:14 +00:00
import { searchQuotes } from "./quotes-search-service" ;
2026-09-07 05:08:27 +00:00
import {
generateQuotePdf ,
isQuotePdfStale ,
} from "./quote-pdf-service" ;
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
import {
PLACEHOLDER_CUSTOMER_IDS ,
PLACEHOLDER_CUSTOMER_NAMES ,
} from "@/lib/placeholder-customers" ;
feat(quotes): finish createQuote refactor for operator-supplied quote_number
The /api/v1/quotes/create route was wired to accept operator-supplied
quote_number + rfq_number + quote_notes + notes + status, but the
service-layer side never matched: QuoteNumberExistsError didn't exist
and CreateQuoteInput ignored the new fields. Production deploy was
failing on `Module has no exported member 'QuoteNumberExistsError'`.
Service changes:
- New QuoteNumberExistsError class (mirrors OrderNumberCollisionError):
carries existingId + attemptedNumber, mapped to 409 by the route
- CreateQuoteInput extended with quote_number, rfq_number, quote_notes,
notes, status; imported QuoteStatus from @/lib/types
- createQuote now uses input.quote_number verbatim when supplied, else
falls back to the existing atomic-counter generator
- createQuote's pb.create call is wrapped in try/catch: PB's UNIQUE
constraint violation on quote_number is re-thrown as the typed error
with the colliding row's id resolved via a follow-up getList
New route test file (9 cases) covers happy path with supplied
quote_number, the 409 collision branch, notes/quote_notes fallback
merge, auth-gate failures, missing customer/items validation, and
generic 500 fallback. Uses importOriginal so the real
QuoteNumberExistsError class survives the service-layer mock.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 02:19:51 +00:00
import type { Quote , QuoteItem , Customer , Company , QuoteStatus } from "@/lib/types" ;
feat(quotes): allow resubmit from any post-submit status, not just rejected
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.
Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.
Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
2026-09-07 14:51:55 +00:00
import { RESUBMITTABLE_STATUSES } from "@/lib/quote-status" ;
2026-06-16 18:46:28 +00:00
/ * *
* 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" ;
2026-07-07 15:22:03 +00:00
const QUOTE_NUMBER_REGEX = /(?:Re:\s*)?(?:Quotation|Quote)\s+(Q-?\d{4}-?\d{2}-?\d{3})/i ;
2026-06-16 18:46:28 +00:00
/ * *
* 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 / w h e n
* 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 ;
2026-08-27 08:45:51 +00:00
/ * *
* Optional override for customer matching — used by the AI extraction
* layer when the inbound email is forwarded by a rep ( e . g . Gary ) who
* named the actual customer in the subject line . When set , this name
* is resolved against existing customers ( case - insensitive exact , then
* a normalised - token pass ) BEFORE the ` from_email ` sender is used as
* the customer . If it resolves , the linked customer is the named
* person , not the rep . If it doesn ' t resolve , the resolution falls
* back to the sender ( Gary ) so the quote still lands somewhere
* attributable rather than on the Unknown Sender sentinel .
* /
person_hint? : string ;
2026-06-16 18:46:28 +00:00
}
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
/ * * T h e c r e a t e d q u o t e _ i t e m s r o w s ( a s Q u o t e I t e m o b j e c t s , w i t h t h e
* 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 [ ] ;
/ * * C o n v e n i e n c e : ` i t e m s . l e n g t h ` . K e p t f o r b a c k - c o m p a t w i t h t h e o l d
* count - only shape . * /
items_count : number ;
2026-06-16 18:46:28 +00:00
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
// ──────────────────────────────────────────────────────────────────────────────
/ * *
2026-07-07 15:22:03 +00:00
* Generates the next quote number ( ` QYYYYMMNNN ` ) atomically .
2026-06-16 18:46:28 +00:00
*
* 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.5 k quotes = ~ 2.6 s wall time
* on every page load . The combined query below collapses to 1 scan
* ( ~ 150 ms 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"
2026-08-19 11:36:41 +00:00
| "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" ,
2026-08-19 11:36:41 +00:00
"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" ] ,
2026-08-19 11:36:41 +00:00
// "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 : [ ] ,
} ;
2026-08-19 11:36:41 +00:00
/ * * N u m b e r o f d a y s t h e " C o m p l e t e d t h i s w e e k " t a b l o o k s b a c k . S i n g l e
* 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 ;
/ * * I S O t i m e s t a m p f o r t h e " c o m p l e t e d _ r e c e n t " c u t o f f — ` u p d a t e d > =
* 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 ( ) ;
}
2026-08-20 08:18:44 +00:00
/ * = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
* Search recency window ( Path A )
* -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
* The full quote collection is ~ 8.5 k 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 60 s TTL survives rapid
* tab - switching without going stale on the operator ' s own changes
* ( realtime / refetch with ` bypassCache: true ` invalidates ) .
* /
2026-08-05 21:00:54 +00:00
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 {
2026-08-05 21:00:54 +00:00
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.5 k + quotes the default would cost ~ 17 internal
* round - trips ; 5000 keeps it at 2 - 3 .
* /
const QUOTE_COUNTS_BATCH_SIZE = 5000 ;
2026-08-05 21:00:54 +00:00
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 ;
2026-08-05 21:00:54 +00:00
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 ) ,
2026-08-05 21:00:54 +00:00
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 ` ;
2026-08-19 11:36:41 +00:00
} 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 . ~ 150 ms measured on the live
* PB ( vs ~ 2.6 s 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 60 s ; 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 > > ,
2026-08-05 21:00:54 +00:00
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 ) ;
2026-08-05 21:00:54 +00:00
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 &&
2026-08-05 21:00:54 +00:00
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
) {
2026-08-05 21:00:54 +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.
2026-08-05 21:00:54 +00:00
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 } > ( {
2026-08-05 21:00:54 +00:00
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 ,
2026-08-19 11:36:41 +00:00
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 ;
2026-08-19 11:36:41 +00:00
// "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 ;
2026-08-05 21:00:54 +00:00
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
2026-08-05 21:00:54 +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 ,
2026-08-05 21:00:54 +00:00
} ) ;
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 ;
2026-08-20 08:18:44 +00:00
/ * * T r u e w h e n t h e s e a r c h w a s b o u n d e d b y ` S E A R C H _ R E C E N T _ W I N D O W _ D A Y S ` .
* 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 ;
/ * * I S O t i m e s t a m p t h e c u t o f f u s e d . N u l l w h e n t h e s e a r c h i n c l u d e d
* 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 ;
2026-08-20 08:18:44 +00:00
/ * * W h e n f a l s e ( d e f a u l t ) , t h e r e c e n t - w i n d o w c u t o f f
* ( ` 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" ;
2026-08-20 08:18:44 +00:00
const includeOlder = params . includeOlder === true ;
2026-08-05 21:00:54 +00:00
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 ;
2026-08-29 00:30:14 +00:00
// Header badge counts ("X completed / Y all"). Always the true global
// totals — two count-only queries (perPage=1), cheap even against the
// full 8.5k-row table — regardless of whether the operator is
// searching, so the badges don't collapse to a recency-scoped number
// mid-search. Fired alongside the page/search fetch below (not
// awaited separately) so this branch pays one round-trip, not two.
const countsQuery = Promise . all ( [
pb . collection ( QUOTES_COLLECTION ) . getList < unknown > ( 1 , 1 , { filter : completedFilter } ) ,
pb . collection ( QUOTES_COLLECTION ) . getList < unknown > ( 1 , 1 , { filter : allFilter } ) ,
] ) . then ( ( [ completedCountResp , allCountResp ] ) = > ( {
completed : completedCountResp.totalItems ,
all : allCountResp.totalItems ,
} ) ) ;
2026-08-05 21:00:54 +00:00
if ( ! search ) {
const expand = "customer,customer.rep,company" ;
2026-08-29 00:30:14 +00:00
const [ pageResp , counts ] = await Promise . all ( [
2026-08-05 21:00:54 +00:00
pb . collection ( QUOTES_COLLECTION ) . getList < Quote > ( page , perPage , {
filter : viewFilter ,
sort ,
expand ,
} ) ,
2026-08-29 00:30:14 +00:00
countsQuery ,
2026-08-05 21:00:54 +00:00
] ) ;
return {
items : pageResp.items ,
totalItems : pageResp.totalItems ,
totalPages : pageResp.totalPages ,
2026-08-29 00:30:14 +00:00
counts ,
2026-08-05 21:00:54 +00:00
view ,
2026-08-20 08:18:44 +00:00
recencyLimited : false ,
recencyCutoff : null ,
2026-08-05 21:00:54 +00:00
} ;
}
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
2026-08-29 00:30:14 +00:00
// Search: delegate to the ranked pushdown search service — the same
// pipeline the main /quotes list uses (filter server-side via PB `~`,
// score, rank; see quotes-search-service.ts for the full rationale).
// `view` ("completed" | "all") is itself a valid `QuoteTabKey`, so
// this scopes the search to exactly the archive's matching set.
2026-08-20 08:18:44 +00:00
//
2026-08-29 00:30:14 +00:00
// This replaces the old approach of fetching the ENTIRE recency-
// windowed matching set (thousands of rows once "Include older" is
// on) and substring-filtering it in memory — that's what made
// archive search take several seconds (measured 8.5s for a 1-char
// query against the live PB) instead of the sub-second pushdown
// search gets on the main list.
const [ result , counts ] = await Promise . all ( [
searchQuotes ( { query : search , tab : view , page , perPage , includeOlder } ) ,
countsQuery ,
] ) ;
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
return {
2026-08-29 00:30:14 +00:00
items : result.hits.map ( ( h ) = > h . quote ) ,
// `totalReturned` is the full ranked match count (pre pagination-
// slice), not just the current page — unlike the old code, which
// reported the CURRENT PAGE's length as the match count and had no
// way to reach results past page 1 while searching.
totalItems : result.totalReturned ,
totalPages : Math.max ( 1 , Math . ceil ( result . totalReturned / perPage ) ) ,
counts ,
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
view ,
2026-08-29 00:30:14 +00:00
recencyLimited : result.recencyLimited ,
recencyCutoff : result.recencyCutoff ,
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
} ;
}
2026-06-16 18:46:28 +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 } ` ) ;
2026-08-05 21:00:54 +00:00
} catch {
2026-07-10 20:21:50 +00:00
// 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 ) ;
2026-06-16 18:46:28 +00:00
try {
return await pb . collection ( QUOTE_COUNTERS_COLLECTION ) . create ( {
year ,
month ,
2026-07-10 20:21:50 +00:00
last_seq : backfill ,
2026-06-16 18:46:28 +00:00
} ) ;
} catch {
2026-07-10 20:21:50 +00:00
// Concurrent caller beat us to it — re-fetch the row they made.
2026-06-16 18:46:28 +00:00
return await pb
. collection ( QUOTE_COUNTERS_COLLECTION )
. getFirstListItem ( ` year= ${ year } && month= ${ month } ` ) ;
}
}
}
2026-07-10 20:21:50 +00:00
/ * *
* 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 ;
}
}
2026-06-16 18:46:28 +00:00
function formatQuoteNumber ( year : number , month : number , seq : number ) : string {
2026-07-07 15:22:03 +00:00
return ` Q ${ year } ${ String ( month ) . padStart ( 2 , "0" ) } ${ String ( seq ) . padStart ( 3 , "0" ) } ` ;
2026-06-16 18:46:28 +00:00
}
// ──────────────────────────────────────────────────────────────────────────────
// 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 ;
2026-08-27 08:45:51 +00:00
/ * *
* Optional name to resolve BEFORE the email . Used when the inbound
* email is forwarded by a rep ( e . g . Gary ) who named the actual
* customer in the subject line — the from_email then matches the rep ,
* not the customer , so ` person_hint ` lets us link the quote to the
2026-08-27 09:11:44 +00:00
* right person . Resolution is case - insensitive substring match on
* the ` customer.customer ` field , with a token - drop fallback so a
* 3 - token subject like "Jose Elia Redpath" still finds the 2 - token
* customer "Elia Jose" . On a tie ( multiple matches ) we prefer the
* one whose company matches ` companyHint ` . If no match , falls
* through to the sender 's email — the rep' s existing customer row
* wins , so the quote still lands attributable .
2026-08-27 08:45:51 +00:00
* /
personHint? : string ;
2026-06-16 18:46:28 +00:00
} ) : 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." ) ;
}
2026-08-27 09:11:44 +00:00
// Rep-forwarded case runs FIRST when a person_hint is supplied. The
// n8n workflow only sets `person_hint` when it has decided the email
// is forwarded and the named person is the real customer — so when
// the hint resolves, the named person is always preferred over the
// rep's email. Without this, every Gary-forwarded RFQ would land on
// Gary's existing customer row.
2026-08-27 08:45:51 +00:00
const personHint = ( input . personHint || "" ) . trim ( ) ;
if ( personHint ) {
const personMatch = await findCustomerByNameHint ( pb , personHint , input . companyHint ) ;
if ( personMatch ) {
return { customer : personMatch , created : false } ;
}
}
2026-08-27 09:11:44 +00:00
const exact = await findCustomerByExactEmail ( pb , email ) ;
if ( exact ) {
return { customer : exact , created : false } ;
}
2026-06-16 18:46:28 +00:00
const senderDomain = email . split ( "@" ) [ 1 ] || "" ;
const fallbackName = input . name ? . trim ( ) || senderDomain || email ;
2026-06-29 11:06:01 +00:00
// 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 ;
}
2026-06-16 18:46:28 +00:00
const created = await pb . collection ( CUSTOMERS_COLLECTION ) . create ( {
customer : fallbackName ,
email ,
2026-06-29 11:06:01 +00:00
company : companyId ,
2026-06-16 18:46:28 +00:00
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 ` .
2026-08-09 14:17:29 +00:00
*
* 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 .
2026-06-16 18:46:28 +00:00
* /
2026-08-09 14:17:29 +00:00
export async function findOrCreateUnknownSenderCustomer (
2026-06-16 18:46:28 +00:00
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 ,
2026-06-29 11:06:01 +00:00
// 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" ) ,
2026-06-16 18:46:28 +00:00
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 ;
}
}
2026-06-29 11:06:01 +00:00
/ * *
* 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 || "" ;
}
2026-06-16 18:46:28 +00:00
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 ;
}
2026-08-27 08:45:51 +00:00
/ * *
* Resolve a free - text person name ( e . g . "John Smith" extracted from the
2026-08-27 09:11:44 +00:00
* email subject ) to an existing customer row . Two - step match with a
* token - drop fallback for multi - token subjects :
2026-08-27 08:45:51 +00:00
*
* 1 . Case - insensitive substring match on ` customer.customer ` via PB ' s
2026-08-27 09:11:44 +00:00
* ` ~ ` operator . Tries the longest prefix first ( " Jose Elia
* Redpath "), then drops one trailing token at a time (" Jose
* Elia ", then " Jose " ) until either a match is found or only one
* token remains . This catches the common SA rep - forwarded
* pattern where the subject is "FirstName LastName CompanyName"
* and the customer DB has "LastName FirstName" — pure substring
* on the 3 - token string misses but the 1 - token suffix still
* hits .
2026-08-27 08:45:51 +00:00
* 2 . If multiple candidates come back ( two "John Smith" s in the
* database ) , prefer the one whose company relation matches
* ` companyHint ` ( case - insensitive exact on ` company.company ` ) .
* Falls back to the first candidate if no company hint was given
* or no company match was found .
*
* Returns ` null ` when no candidate matches . The caller falls through
2026-08-27 09:11:44 +00:00
* to the sender ' s email — the rep ( Gary ) becomes the customer , which
* is at least attributable and the quoter can reassign in the WFDASH
* UI .
2026-08-27 08:45:51 +00:00
* /
async function findCustomerByNameHint (
pb : Awaited < ReturnType < typeof getAdminPocketBase > > ,
personHint : string ,
companyHint? : string
) : Promise < Customer | null > {
const trimmed = personHint . trim ( ) ;
if ( ! trimmed ) return null ;
2026-08-27 09:11:44 +00:00
// Build the prefix ladder: longest, longest-1, longest-2, ... down to
// one token. We skip the single-token rung for hints longer than 3
// tokens to avoid wildly-over-broad substring matches (e.g.
// "Mary" matching every Maria / Marie / Mariana in the book).
const tokens = trimmed . split ( /\s+/ ) . filter ( Boolean ) ;
const rungs : string [ ] = [ ] ;
for ( let len = tokens . length ; len >= 2 ; len -- ) {
rungs . push ( tokens . slice ( 0 , len ) . join ( " " ) ) ;
2026-08-27 08:45:51 +00:00
}
2026-08-27 09:11:44 +00:00
// Single-token rung only for 2- and 3-token hints — covers the
// common "Jose Elia Redpath" → "Jose" → "Elia Jose" case without
// over-matching on full sentences.
if ( tokens . length >= 2 && tokens . length <= 3 ) {
rungs . push ( tokens [ 0 ] ) ;
}
for ( const rung of rungs ) {
const esc = rung . replace ( /"/g , '\\"' ) ;
let candidates : Customer [ ] = [ ] ;
try {
candidates = (
await pb . collection ( CUSTOMERS_COLLECTION ) . getList < Customer > ( 1 , 5 , {
filter : ` customer ~ " ${ esc } " ` ,
fields : "id,customer,company" ,
expand : "company" ,
} )
) . items ;
} catch {
return null ;
}
if ( candidates . length === 0 ) continue ;
if ( candidates . length === 1 ) return candidates [ 0 ] ;
// Tie-break by company hint when available. The AI usually passes
// both, so the hint effectively narrows the candidate set.
const hint = ( companyHint || "" ) . trim ( ) ;
if ( hint ) {
const hintEsc = hint . replace ( /"/g , '\\"' ) ;
for ( const c of candidates ) {
const companyName = ( c . expand ? . company ? . company || "" ) as string ;
if ( companyName && companyName . toLowerCase ( ) === hintEsc . toLowerCase ( ) ) {
return c ;
}
2026-08-27 08:45:51 +00:00
}
}
2026-08-27 09:11:44 +00:00
// No company hint or no company match — return the first candidate
// rather than creating a duplicate. Quoter can disambiguate.
return candidates [ 0 ] ;
2026-08-27 08:45:51 +00:00
}
2026-08-27 09:11:44 +00:00
return null ;
2026-08-27 08:45:51 +00:00
}
2026-06-16 18:46:28 +00:00
// ──────────────────────────────────────────────────────────────────────────────
// 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 ) {
2026-08-05 14:18:31 +00:00
const existingItems = await pb
. collection ( QUOTE_ITEMS_COLLECTION )
. getFullList < QuoteItem > ( { filter : ` quote = " ${ existing . id } " ` } ) ;
2026-06-16 18:46:28 +00:00
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 ,
2026-08-05 14:18:31 +00:00
items : existingItems ,
items_count : existingItems.length ,
2026-06-16 18:46:28 +00:00
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 ,
2026-08-27 08:45:51 +00:00
personHint : payload.person_hint ,
2026-06-16 18:46:28 +00:00
} )
: 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.
2026-08-11 06:32:17 +00:00
//
// 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/...`.
2026-06-16 18:46:28 +00:00
const quote = await pb . collection ( QUOTES_COLLECTION ) . create ( {
quote_number : quoteNumber ,
customer : customer.id ,
company : company?.id || null ,
description : payload.subject ,
status : "new" ,
notes : "" ,
2026-08-11 06:32:17 +00:00
validity_days : QUOTE_VALID_DAYS ,
2026-06-16 18:46:28 +00:00
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).
2026-06-16 18:46:28 +00:00
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 [ ] = [ ] ;
2026-06-16 18:46:28 +00:00
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 ( {
2026-06-16 18:46:28 +00:00
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 : "" ,
2026-06-16 18:46:28 +00:00
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 ) ;
2026-06-16 18:46:28 +00:00
}
// ── 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 ,
2026-06-16 18:46:28 +00:00
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" ;
}
2026-08-09 14:17:29 +00:00
/ * *
* 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 (
2026-06-16 18:46:28 +00:00
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 ;
}
feat(quotes): split attachments into internal vs outbound with operator upload UI
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).
Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.
API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`
MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.
Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.
Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
(`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
`email_outbound_attachment_ids`, downloads each from
`/api/files/quotes/<id>/<token>`, and emits
`options.attachments: [{name, content, type}]` consumed by
`Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
`retryQuoteEmail` can find failed sends (it only accepts the
latter). Previously failed sends were stranded.
Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
(/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
and current `Q202609246` formats; canonicalised digits-only for
PB `~` substring match.
Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.
Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.
Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes
Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
2026-09-09 11:43:56 +00:00
// ──────────────────────────────────────────────────────────────────────────────
// Operator upload fields on the quote row (`outbound_attachments`,
// `internal_attachments`).
//
// The old `quote_attachments.direction` enum conflated three different
// audience categories into a single value. These two sibling file
// fields on the parent `quotes` row physically separate customer-facing
// attachments (anything in `outbound_attachments` is sent to the
// customer) from operator-only ones (`internal_attachments`, supplier
// quotes, pricing evidence). There is no longer any way to "accidentally
// include an internal file" because the read paths only look at
// `outbound_attachments`.
//
// `quote_attachments` itself remains the inbound (RFQ email) archive
// + the historical written record; new operator uploads do not touch it.
// ──────────────────────────────────────────────────────────────────────────────
/** Coerce PB's "could be string, could be array" file-field response to an array. */
function normaliseAttachmentArray (
raw : string | string [ ] | null | undefined ,
) : string [ ] {
if ( raw == null ) return [ ] ;
return Array . isArray ( raw ) ? raw : [ raw ] ;
}
/ * *
* Append a file to ` quotes.outbound_attachments ` ( customer - facing ) .
* Reads the current array , writes ( current + new ) via multipart FormData ,
* and returns the diff so the caller can echo the new token .
*
* Mirrors ` addStockPurchaseAttachment ` so the two patterns stay in
* sync — if you change one , change the other .
* /
export async function addOutboundQuoteAttachment ( input : {
quoteId : string ;
file : File ;
} ) : Promise < { attachments : string [ ] ; added : string [ ] } > {
const pb = await getAdminPocketBase ( ) ;
const before = await pb
. collection ( QUOTES_COLLECTION )
. getOne < { outbound_attachments? : string [ ] | string } > ( input . quoteId , {
fields : "id,outbound_attachments" ,
} ) ;
const beforeArr = normaliseAttachmentArray ( before ? . outbound_attachments ) ;
const fd = new FormData ( ) ;
fd . append ( "outbound_attachments" , input . file ) ;
const updated = await pb
. collection ( QUOTES_COLLECTION )
. update < { outbound_attachments? : string [ ] | string } > (
input . quoteId ,
fd ,
) ;
const afterArr = normaliseAttachmentArray ( updated ? . outbound_attachments ) ;
return {
attachments : afterArr ,
added : afterArr.filter ( ( t ) = > ! beforeArr . includes ( t ) ) ,
} ;
}
/ * *
* Append a file to ` quotes.internal_attachments ` ( operator - only ) .
* Same shape as ` addOutboundQuoteAttachment ` — see that function for
* the read / write / diff pattern .
* /
export async function addInternalQuoteAttachment ( input : {
quoteId : string ;
file : File ;
} ) : Promise < { attachments : string [ ] ; added : string [ ] } > {
const pb = await getAdminPocketBase ( ) ;
const before = await pb
. collection ( QUOTES_COLLECTION )
. getOne < { internal_attachments? : string [ ] | string } > ( input . quoteId , {
fields : "id,internal_attachments" ,
} ) ;
const beforeArr = normaliseAttachmentArray ( before ? . internal_attachments ) ;
const fd = new FormData ( ) ;
fd . append ( "internal_attachments" , input . file ) ;
const updated = await pb
. collection ( QUOTES_COLLECTION )
. update < { internal_attachments? : string [ ] | string } > (
input . quoteId ,
fd ,
) ;
const afterArr = normaliseAttachmentArray ( updated ? . internal_attachments ) ;
return {
attachments : afterArr ,
added : afterArr.filter ( ( t ) = > ! beforeArr . includes ( t ) ) ,
} ;
}
/ * *
* Remove a single file token from ` quotes.outbound_attachments ` .
* PB does not expose a per - file delete from a multi - file field via the
* SDK , so we filter the array client - side and write the rest back .
* The original PB file on disk is NOT deleted ( same limitation as
* the stock - purchase / PO routes — see addStockPurchaseAttachment
* JSDoc for the same caveat ) .
* /
export async function removeOutboundQuoteAttachment ( input : {
quoteId : string ;
token : string ;
} ) : Promise < { attachments : string [ ] } > {
const pb = await getAdminPocketBase ( ) ;
const row = await pb
. collection ( QUOTES_COLLECTION )
. getOne < { outbound_attachments? : string [ ] | string } > ( input . quoteId , {
fields : "id,outbound_attachments" ,
} ) ;
const arr = normaliseAttachmentArray ( row ? . outbound_attachments ) ;
if ( ! arr . includes ( input . token ) ) {
throw new Error ( ` Attachment not found: ${ input . token } ` ) ;
}
const remaining = arr . filter ( ( t ) = > t !== input . token ) ;
await pb . collection ( QUOTES_COLLECTION ) . update ( input . quoteId , {
outbound_attachments : remaining ,
} ) ;
return { attachments : remaining } ;
}
/ * *
* Remove a single file token from ` quotes.internal_attachments ` .
* See ` removeOutboundQuoteAttachment ` for the SDK limitation caveat .
* /
export async function removeInternalQuoteAttachment ( input : {
quoteId : string ;
token : string ;
} ) : Promise < { attachments : string [ ] } > {
const pb = await getAdminPocketBase ( ) ;
const row = await pb
. collection ( QUOTES_COLLECTION )
. getOne < { internal_attachments? : string [ ] | string } > ( input . quoteId , {
fields : "id,internal_attachments" ,
} ) ;
const arr = normaliseAttachmentArray ( row ? . internal_attachments ) ;
if ( ! arr . includes ( input . token ) ) {
throw new Error ( ` Attachment not found: ${ input . token } ` ) ;
}
const remaining = arr . filter ( ( t ) = > t !== input . token ) ;
await pb . collection ( QUOTES_COLLECTION ) . update ( input . quoteId , {
internal_attachments : remaining ,
} ) ;
return { attachments : remaining } ;
}
2026-06-16 18:46:28 +00:00
// ──────────────────────────────────────────────────────────────────────────────
// Reply handling
// ──────────────────────────────────────────────────────────────────────────────
/ * *
* Appends a reply to an existing quote . Match priority :
* 1 . ` In-Reply-To ` header → look up ` quotes.email_message_id `
2026-07-07 15:22:03 +00:00
* 2 . ` QYYYYMMNNN ` extracted from subject → look up ` quotes.quote_number `
2026-06-16 18:46:28 +00:00
* 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 ;
2026-07-07 15:22:03 +00:00
// 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 ] ;
2026-06-16 18:46:28 +00:00
}
// ──────────────────────────────────────────────────────────────────────────────
// 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 (
2026-06-23 06:30:38 +00:00
pbOrMessageId? : Awaited < ReturnType < typeof getAdminPocketBase > > | string ,
2026-06-16 18:46:28 +00:00
maybeMessageId? : string
) : Promise < Quote | null > {
let pb : Awaited < ReturnType < typeof getAdminPocketBase > > ;
let messageId : string ;
if ( typeof pbOrMessageId === "string" ) {
pb = await getAdminPocketBase ( ) ;
messageId = pbOrMessageId ;
} else {
2026-06-23 06:30:38 +00:00
pb = pbOrMessageId || ( await getAdminPocketBase ( ) ) ;
2026-06-16 18:46:28 +00:00
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 - 1 a 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 ` ;
}
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
// ──────────────────────────────────────────────────────────────────────────────
// 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 .
*
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
* 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 .
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
* /
/ * *
* 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 ` ) .
2026-07-27 06:21:20 +00:00
*
* ` 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 .
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
* /
const RESENDABLE_STATUSES = new Set < Quote [ " status " ] > ( [
"approval_pending" ,
2026-07-27 06:21:20 +00:00
"partial_approval_pending" ,
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
"approved" ,
2026-07-27 06:21:20 +00:00
"partial_approved" ,
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
"mail_failed" ,
] ) ;
export interface SendQuoteEmailPayload {
2026-08-15 09:50:48 +00:00
/ * * O p t i o n a l o v e r r i d e f o r t h e p r i m a r y T o : a d d r e s s . F a l l s b a c k t o
* customer . email → customer . alternative_email → quote . email_sender . * /
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
to? : string ;
2026-08-15 09:50:48 +00:00
/** Comma-separated. Defaults to "" (no cc). */
cc? : string ;
/** Comma-separated. Defaults to "" (no bcc). */
bcc? : string ;
/ * * D e f a u l t s t o t h e q u o t e ' s s t o r e d ` e m a i l _ s u b j e c t ` i f s e t , e l s e a
* templated "Quote {number} — …" fallback . * /
subject? : string ;
/** Defaults to the quote's stored `email_body` if set, else empty. */
message? : string ;
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
/** Per-modal checkbox: include the per-letterhead supplier authorization letter. */
attachAuthLetters : boolean ;
/ * *
feat(quotes): split attachments into internal vs outbound with operator upload UI
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).
Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.
API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`
MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.
Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.
Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
(`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
`email_outbound_attachment_ids`, downloads each from
`/api/files/quotes/<id>/<token>`, and emits
`options.attachments: [{name, content, type}]` consumed by
`Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
`retryQuoteEmail` can find failed sends (it only accepts the
latter). Previously failed sends were stranded.
Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
(/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
and current `Q202609246` formats; canonicalised digits-only for
PB `~` substring match.
Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.
Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.
Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes
Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
2026-09-09 11:43:56 +00:00
* PB file tokens from ` quotes.outbound_attachments ` to include in
* the outbound email . The PDF is always included separately
* ( auto - located by kind = "pdf" at send time , in the
* ` quote_attachments ` collection — a system - managed file with its
* own lifecycle ) . Tokens that don 't belong to this quote' s
* ` outbound_attachments ` field are silently filtered out so a
* tampered client can ' t smuggle in tokens from another quote .
*
* Before 2026 - 09 - 09 these were ` quote_attachments ` row IDs and
* the bug was that operator - uploaded "proof" files silently
* qualified ( every proof upload wrote ` direction="outbound" ` ) .
* Validating against the new field proves the operator explicitly
* marked the file for the customer .
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
* /
selectedAttachmentIds : string [ ] ;
}
export interface SendQuoteEmailFailure {
code :
| "placeholder_customer"
| "no_recipient"
| "invalid_recipient"
| "no_pdf"
2026-08-15 21:57:51 +00:00
| "no_items"
| "items_not_ready"
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
| "invalid_status"
| "quote_not_found" ;
message : string ;
2026-08-15 21:57:51 +00:00
/ * * P o p u l a t e d w h e n t h e f a i l u r e w a s c a u s e d b y a P D F t h a t c a n ' t b e
* 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 [ ] ;
} > ;
} ;
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
}
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 .
* /
2026-08-15 21:57:51 +00:00
/ * *
* 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 [ ] = [ ] ;
2026-08-26 21:31:41 +00:00
// Pricing / processed checks are skipped when the operator has
// marked this quote as a decline (`is_no_quote`) — they explicitly
// don't want a priced PDF. The supplier check stays active:
// "no supplier" is a data-quality issue regardless of pricing
// intent, and an unpriced item with no supplier means the line
// was never even looked at.
if ( ! quote . is_no_quote ) {
if ( ! it . is_processed ) missing . push ( "not processed" ) ;
if ( ! ( Number ( it . cost_price ) > 0 ) ) missing . push ( "cost price" ) ;
if ( ! ( Number ( it . selling_price ) > 0 ) ) missing . push ( "selling price" ) ;
}
2026-08-15 21:57:51 +00:00
if ( ! it . supplier ) missing . push ( "no supplier" ) ;
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." ,
} ;
}
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
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 ;
2026-07-27 06:21:20 +00:00
// ── 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 ) ) {
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
return markPreSendFailure ( pb , quote , {
code : "invalid_status" ,
2026-07-27 06:21:20 +00:00
message : ` Cannot send from status " ${ quote . status } ". Allowed: ${ [ . . . allowedStatuses ] . join ( ", " ) } . ` ,
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
} ) ;
}
// ── 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`,
2026-08-15 09:50:48 +00:00
// 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 ) ) || "" ;
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
if ( ! toAddress ) {
return markPreSendFailure ( pb , quote , {
code : "no_recipient" ,
2026-08-15 09:50:48 +00:00
message :
"No valid recipient email on this quote (payload.to, customer.email, customer.alternative_email, and quote.email_sender are all empty or invalid)." ,
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
} ) ;
}
// ── 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. ` ,
} ) ;
}
}
}
2026-09-07 05:08:27 +00:00
// ── 6. PDF presence + staleness check ────────────────────────
//
// The outbound send attaches the most recent `kind="pdf"`
// attachment to the email. We have to defend against TWO
// failure modes here:
//
// (a) No PDF generated yet → diagnose what's blocking the
// render (placeholder customer, no items, items not
// ready) and bail with the same shape the route uses.
2026-08-15 21:57:51 +00:00
//
2026-09-07 05:08:27 +00:00
// (b) PDF exists but is STALE — i.e. the operator (or rep
// on the approvals page) edited markup / cost / selling /
// supplier / description / quote_notes / letterhead /
// customer AFTER the last render. Without the staleness
// check the email ships a PDF with the operator's old
// numbers — the customer sees something different from
// what the operator just approved. That's the bug
// reported 2026-09-07.
//
// When stale, we call `generateQuotePdf` from the service
// layer to render a fresh PDF before the send kicks off. The
// service runs the same readiness gates as the manual
// `/api/v1/quotes/[id]/pdf` POST, so a regen that hits
// `items_not_ready` (operator blanked prices in the time
// since the last render) refuses to render and we surface
// that as a `mail_failed` — we never silently fall back to
// the stale PDF. The previous "don't auto-render on send"
// stance (Dave 2026-08-15) was about not rendering against
// partial / placeholder data without operator eyes; this
// fix preserves that — if the regen fails the operator
// gets a clear failure message and stays in control.
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
const pdfs = await pb
. collection ( QUOTE_ATTACHMENTS_COLLECTION )
2026-09-07 05:08:27 +00:00
. getList < { id : string ; uploaded_at? : string } > ( 1 , 1 , {
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
filter : ` quote = " ${ quoteId } " && kind = "pdf" ` ,
sort : "-uploaded_at" ,
2026-09-07 05:08:27 +00:00
fields : "id,uploaded_at" ,
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
} ) ;
if ( pdfs . items . length === 0 ) {
2026-08-15 21:57:51 +00:00
const readiness = await diagnosePdfReadiness ( pb , quoteId , quote ) ;
return markPreSendFailure ( pb , quote , readiness ) ;
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
}
2026-09-07 05:08:27 +00:00
// Cheap staleness probe (2 PB queries). Skipped when the
// pre-send path is already under time pressure — but in
// practice the `pdfs` query above is the dominant cost, so
// adding two more parallel queries is fine. If the probe
// says stale we regenerate before continuing.
const stale = await isQuotePdfStale ( quoteId ) ;
if ( stale ) {
const regen = await generateQuotePdf ( quoteId ) ;
if ( regen . kind === "readiness_failure" ) {
// The PDF can't be re-rendered (operator blanked prices
// between the last render and the send click, or the
// customer got swapped to a placeholder). Treat this as
// a pre-send failure — same shape as the "no PDF" path
// above, so the UI's banner + retry flow handle it
// uniformly. We surface the per-line issues list from
// the readiness failure so the operator sees exactly
// which rows are blocking the render.
return markPreSendFailure ( pb , quote , {
code : regen.code ,
message :
"Could not regenerate the PDF with the latest changes — the email was not sent. " +
regen . message ,
details : regen.details ,
} ) ;
}
if ( regen . kind === "render_error" ) {
// The render itself crashed (PB outage, render crash).
// Same shape — refuse to send, don't ship the stale PDF.
return markPreSendFailure ( pb , quote , {
code : "items_not_ready" ,
message :
"PDF regeneration failed: " +
regen . message +
( regen . detail ? ` ( ${ regen . detail } ) ` : "" ) ,
} ) ;
}
// regen.kind === "ok": a fresh PDF is now in PB; the
// existing pdfs.items query above will pick it up via the
// kind="pdf" lookup n8n runs at SMTP-time.
}
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
// ── 7. Filter selectedAttachmentIds to only those that actually
feat(quotes): split attachments into internal vs outbound with operator upload UI
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).
Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.
API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`
MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.
Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.
Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
(`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
`email_outbound_attachment_ids`, downloads each from
`/api/files/quotes/<id>/<token>`, and emits
`options.attachments: [{name, content, type}]` consumed by
`Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
`retryQuoteEmail` can find failed sends (it only accepts the
latter). Previously failed sends were stranded.
Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
(/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
and current `Q202609246` formats; canonicalised digits-only for
PB `~` substring match.
Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.
Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.
Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes
Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
2026-09-09 11:43:56 +00:00
// belong to this quote AND live in `quotes.outbound_attachments`.
// Tampered clients can't smuggle in tokens from other quotes.
//
// Before 2026-09-09 these were `quote_attachments` row IDs —
// the upload route wrote every "proof" file as
// `direction="outbound"` and the send modal pre-selected all
// of them, which leaked supplier-pricing files to the
// customer. The new storage split (operator uploads go to
// `quotes.outbound_attachments` and `quotes.internal_attachments`,
// `quote_attachments` becomes the inbound archive + system
// PDF) closes that gap: validating against the new field
// proves the operator explicitly marked the file for the
// customer.
//
// `quote.outbound_attachments` is a PB file field whose
// values are file tokens (`<random>_<originalName>` strings).
// We accept any token that's present in the field — the
// upload route has already gated this through admin PB, so
// the token format is trustworthy.
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
const requestedIds = Array . from ( new Set ( payload . selectedAttachmentIds || [ ] ) ) ;
let validIds : string [ ] = [ ] ;
if ( requestedIds . length > 0 ) {
feat(quotes): split attachments into internal vs outbound with operator upload UI
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).
Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.
API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`
MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.
Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.
Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
(`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
`email_outbound_attachment_ids`, downloads each from
`/api/files/quotes/<id>/<token>`, and emits
`options.attachments: [{name, content, type}]` consumed by
`Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
`retryQuoteEmail` can find failed sends (it only accepts the
latter). Previously failed sends were stranded.
Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
(/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
and current `Q202609246` formats; canonicalised digits-only for
PB `~` substring match.
Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.
Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.
Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes
Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
2026-09-09 11:43:56 +00:00
const quoteRow = await pb
. collection ( QUOTES_COLLECTION )
. getOne < { outbound_attachments? : string [ ] | string } > ( quoteId , {
fields : "id,outbound_attachments" ,
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
} ) ;
feat(quotes): split attachments into internal vs outbound with operator upload UI
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).
Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.
API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`
MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.
Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.
Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
(`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
`email_outbound_attachment_ids`, downloads each from
`/api/files/quotes/<id>/<token>`, and emits
`options.attachments: [{name, content, type}]` consumed by
`Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
`retryQuoteEmail` can find failed sends (it only accepts the
latter). Previously failed sends were stranded.
Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
(/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
and current `Q202609246` formats; canonicalised digits-only for
PB `~` substring match.
Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.
Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.
Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes
Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
2026-09-09 11:43:56 +00:00
const ownTokens = new Set (
Array . isArray ( quoteRow ? . outbound_attachments )
? quoteRow . outbound_attachments
: quoteRow ? . outbound_attachments
? [ quoteRow . outbound_attachments ]
: [ ] ,
) ;
validIds = requestedIds . filter ( ( id ) = > ownTokens . has ( id ) ) ;
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
}
// ── 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 ;
}
2026-07-27 06:21:20 +00:00
// ──────────────────────────────────────────────────────────────────────────────
// 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 ;
}
2026-08-15 09:50:48 +00:00
2026-08-15 17:01:52 +00:00
// ──────────────────────────────────────────────────────────────────────────────
// Submit-for-approval (new → approval_pending)
// ──────────────────────────────────────────────────────────────────────────────
export type SubmitQuoteFailureCode =
| "quote_not_found"
| "invalid_status"
| "unpriced_items" ;
export interface SubmitQuoteResult {
quote : Quote ;
/ * * T r u e w h e n t h e c a l l a c t u a l l y f l i p p e d t h e s t a t u s . F a l s e w h e n t h e
* quote was already ` approval_pending ` ( idempotent re - submit ) . * /
statusChanged : boolean ;
2026-08-15 21:57:51 +00:00
/ * * S t a t u s t h e q u o t e w a s i n b e f o r e t h i s c a l l ( o r a f t e r , i f i t w a s 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" ] ;
/ * * E c h o o f t h e a c t o r p a s s e d i n . L a n d s o n ` q u o t e s . s u b m i t t e d _ b y ` w h e n
* 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 ;
/ * * I S O t i m e s t a m p o f t h e s u b m i t c a l l . L a n d s o n ` q u o t e s . s u b m i t t e d _ a t `
* when the PB column exists ; echoed here regardless . * /
submittedAt : string ;
/ * * T a r g e t s t a t u s t h e c a l l a i m e d f o r . F o r t h e f u l l - a p p r o v a l p a t h t h i s
* 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" ;
2026-08-15 17:01:52 +00:00
/ * * I D s o f ` q u o t e _ i t e m s ` r o w s t h a t b l o c k e d s u b m i s s i o n ( s e l l i n g _ p r i c e
* ≤ 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 } ,
2026-08-15 21:57:51 +00:00
options ? : { partial? : boolean } ,
2026-08-15 17:01:52 +00:00
) : 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" ,
) ;
}
2026-08-15 21:57:51 +00:00
// 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 ( ) ;
2026-08-15 17:01:52 +00:00
// 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.
2026-08-15 21:57:51 +00:00
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 } : { } ) ,
} ;
2026-08-15 17:01:52 +00:00
}
feat(quotes): allow resubmit from any post-submit status, not just rejected
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.
Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.
Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
2026-09-07 14:51:55 +00:00
/ * *
* Re - submittable statuses ( added 2026 - 09 - 07 ) . Before this set
* existed , ` submitQuote ` only accepted ` new ` — once a quote had
* been emailed and the customer came back asking for changes ,
* the operator ' s only path was to re - quote from scratch . That
* throws away the original quote_number , all email tracking ,
* and the customer ' s audit trail . Re - submitting on the same
* quote preserves every one of those — the only thing that
* changes is the PDF ( regenerated on next view ) and the
* ` submitted_at ` stamp .
*
* The set itself lives in ` @/lib/quote-status ` so the client UI
* ( banner gate ) draws from the same source of truth — a new
* status added there surfaces in both places without a second
* edit . The membership + rationale are documented next to the
* export .
* /
const isFreshSubmission = quote . status === "new" ;
const isResubmission = RESUBMITTABLE_STATUSES . has ( quote . status ) ;
if ( ! isFreshSubmission && ! isResubmission ) {
2026-08-15 17:01:52 +00:00
throw new SubmitQuoteError (
feat(quotes): allow resubmit from any post-submit status, not just rejected
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.
Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.
Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
2026-09-07 14:51:55 +00:00
` Cannot submit a quote in status " ${ quote . status } ". Only new quotes and post-send re-submissions can go through the approval queue — re-quote for cancelled or claimed quotes. ` ,
2026-08-15 17:01:52 +00:00
422 ,
"invalid_status" ,
) ;
}
feat(quotes): allow resubmit from any post-submit status, not just rejected
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.
Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.
Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
2026-09-07 14:51:55 +00:00
// Re-submissions always go to full `approval_pending` regardless
// of the `partial` flag — once a quote has been through the
// pipeline once, "submit only some items" no longer makes sense.
// We still honour `partial: true` on a FRESH submission (the
// existing walk-in partial-approval recipe), but ignore it for
// a re-submit. The flag is silently swallowed rather than 422'd
// because the UI button is the same "Resubmit for Approval"
// affordance across both cases — the operator doesn't need to
// think about which mode they're in.
const targetStatus : "approval_pending" | "partial_approval_pending" =
isFreshSubmission && options ? . partial
? "partial_approval_pending"
: "approval_pending" ;
2026-08-15 17:01:52 +00:00
// 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 === "" ;
} ) ;
2026-08-15 21:57:51 +00:00
// 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.
feat(quotes): allow resubmit from any post-submit status, not just rejected
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.
Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.
Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
2026-09-07 14:51:55 +00:00
//
// For re-submissions, the gate applies identically — the operator
// might have re-priced an item down to 0 while testing the new
// numbers, and we don't want to send an incomplete quote back to
// the rep. `partial: true` is silently ignored on resubmissions
// (see the targetStatus comment above), so the only way to bypass
// the gate is to actually price the lines.
2026-08-26 21:27:47 +00:00
if ( unpricedItems . length > 0 && ! options ? . partial && ! quote . is_no_quote ) {
2026-08-15 17:01:52 +00:00
const offendingItemIds = unpricedItems . map ( ( it ) = > String ( it . id ) ) ;
throw new SubmitQuoteError (
` Cannot submit: ${ offendingItemIds . length } item(s) have selling_price = 0. Price every line before submitting. ` ,
422 ,
"unpriced_items" ,
offendingItemIds ,
) ;
}
// Fluid write: Quote.submitted_at / submitted_by are optional fields
// on the Quote type. If/when ops adds them to the PB schema, these
// will start persisting. Right now they're silently dropped, which
// is fine — the status flip is the canonical audit signal.
const updatePayload : Record < string , unknown > = {
2026-08-15 21:57:51 +00:00
status : targetStatus ,
2026-08-15 17:01:52 +00:00
} ;
if ( actor ? . name ) {
updatePayload . submitted_by = actor . name ;
}
2026-08-15 21:57:51 +00:00
updatePayload . submitted_at = submittedAt ;
2026-08-15 17:01:52 +00:00
const updated = ( await pb . collection ( QUOTES_COLLECTION ) . update (
quoteId ,
updatePayload ,
) ) as Quote ;
2026-08-15 21:57:51 +00:00
return {
quote : updated ,
statusChanged : true ,
previousStatus ,
submittedAt ,
targetStatus ,
. . . ( actor ? . name ? { submittedBy : actor.name } : { } ) ,
} ;
2026-08-15 17:01:52 +00:00
}
2026-08-15 09:50:48 +00:00
// ──────────────────────────────────────────────────────────────────────────────
// 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 {
/ * * C u s t o m e r i d O R n a m e . I f a n a m e i s p a s s e d a n d n o c u s t o m e r e x i s t s ,
* a new one is created . The lookup is case - insensitive exact . * /
customer : string ;
/ * * C o m p a n y i d O R n a m e . I f a n a m e i s p a s s e d a n d n o c o m p a n y e x i s t s ,
* 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 ;
feat(quotes): finish createQuote refactor for operator-supplied quote_number
The /api/v1/quotes/create route was wired to accept operator-supplied
quote_number + rfq_number + quote_notes + notes + status, but the
service-layer side never matched: QuoteNumberExistsError didn't exist
and CreateQuoteInput ignored the new fields. Production deploy was
failing on `Module has no exported member 'QuoteNumberExistsError'`.
Service changes:
- New QuoteNumberExistsError class (mirrors OrderNumberCollisionError):
carries existingId + attemptedNumber, mapped to 409 by the route
- CreateQuoteInput extended with quote_number, rfq_number, quote_notes,
notes, status; imported QuoteStatus from @/lib/types
- createQuote now uses input.quote_number verbatim when supplied, else
falls back to the existing atomic-counter generator
- createQuote's pb.create call is wrapped in try/catch: PB's UNIQUE
constraint violation on quote_number is re-thrown as the typed error
with the colliding row's id resolved via a follow-up getList
New route test file (9 cases) covers happy path with supplied
quote_number, the 409 collision branch, notes/quote_notes fallback
merge, auth-gate failures, missing customer/items validation, and
generic 500 fallback. Uses importOriginal so the real
QuoteNumberExistsError class survives the service-layer mock.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 02:19:51 +00:00
/ * * O p t i o n a l o p e r a t o r - s u p p l i e d q u o t e n u m b e r ( e . g . f o r r e - q u o t i n g t h e
* same customer under a stable reference ) . If omitted , the server
* generates a fresh number from the atomic counter . Collisions with
* an existing ` quotes.quote_number ` throw ` QuoteNumberExistsError `
* ( caught at the REST layer → 409 ) . * /
quote_number? : string ;
/ * * C u s t o m e r - s i d e r e f e r e n c e ( t h e i r R F Q n u m b e r , P O r e f e r e n c e , e t c . ) .
* Persists to ` quotes.rfq_number ` . * /
rfq_number? : string ;
/ * * O p e r a t o r - f a c i n g f r e e - t e x t n o t e r e n d e r e d o n t h e c u s t o m e r P D F
* ( distinct from the ` notes ` audit - log field ) . Persists to
* ` quotes.quote_notes ` . * /
quote_notes? : string ;
/ * * N o t e s s t r i n g ( t h e a u d i t - l o g f i e l d ) . M o s t c a l l e r s s h o u l d l e a v e t h i s
* empty on create — the rejection pipeline writes here . Walk - in
* creation accepts it for completeness . * /
notes? : string ;
/ * * O p t i o n a l s t a t u s o v e r r i d e — d e f a u l t s t o " n e w " . T h e f u l l Q u o t e S t a t u s
* union is accepted ; callers that want a stricter surface ( e . g . the
* REST route ) validate the subset they expose . * /
status? : QuoteStatus ;
2026-08-26 21:58:43 +00:00
is_no_quote? : boolean ;
2026-08-15 09:50:48 +00:00
}
feat(quotes): finish createQuote refactor for operator-supplied quote_number
The /api/v1/quotes/create route was wired to accept operator-supplied
quote_number + rfq_number + quote_notes + notes + status, but the
service-layer side never matched: QuoteNumberExistsError didn't exist
and CreateQuoteInput ignored the new fields. Production deploy was
failing on `Module has no exported member 'QuoteNumberExistsError'`.
Service changes:
- New QuoteNumberExistsError class (mirrors OrderNumberCollisionError):
carries existingId + attemptedNumber, mapped to 409 by the route
- CreateQuoteInput extended with quote_number, rfq_number, quote_notes,
notes, status; imported QuoteStatus from @/lib/types
- createQuote now uses input.quote_number verbatim when supplied, else
falls back to the existing atomic-counter generator
- createQuote's pb.create call is wrapped in try/catch: PB's UNIQUE
constraint violation on quote_number is re-thrown as the typed error
with the colliding row's id resolved via a follow-up getList
New route test file (9 cases) covers happy path with supplied
quote_number, the 409 collision branch, notes/quote_notes fallback
merge, auth-gate failures, missing customer/items validation, and
generic 500 fallback. Uses importOriginal so the real
QuoteNumberExistsError class survives the service-layer mock.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 02:19:51 +00:00
/ * *
* Thrown by ` createQuote ` when the operator - supplied ` quote_number `
* collides with an existing quote . Maps to HTTP 409 at the REST layer
* ( ` /api/v1/quotes/create ` ) . The ` existingId ` field carries the
* colliding row ' s PB id so the client can deep - link to it .
*
* Mirrors ` OrderNumberCollisionError ` ( orders - service . ts ) .
* /
export class QuoteNumberExistsError extends Error {
readonly existingId : string ;
readonly attemptedNumber : string ;
constructor ( existingId : string , attemptedNumber : string ) {
super (
` Quote number " ${ attemptedNumber } " is already in use by another quote. ` ,
) ;
this . name = "QuoteNumberExistsError" ;
this . existingId = existingId ;
this . attemptedNumber = attemptedNumber ;
}
}
2026-08-15 09:50:48 +00:00
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 ( )
) ;
}
feat(quotes): finish createQuote refactor for operator-supplied quote_number
The /api/v1/quotes/create route was wired to accept operator-supplied
quote_number + rfq_number + quote_notes + notes + status, but the
service-layer side never matched: QuoteNumberExistsError didn't exist
and CreateQuoteInput ignored the new fields. Production deploy was
failing on `Module has no exported member 'QuoteNumberExistsError'`.
Service changes:
- New QuoteNumberExistsError class (mirrors OrderNumberCollisionError):
carries existingId + attemptedNumber, mapped to 409 by the route
- CreateQuoteInput extended with quote_number, rfq_number, quote_notes,
notes, status; imported QuoteStatus from @/lib/types
- createQuote now uses input.quote_number verbatim when supplied, else
falls back to the existing atomic-counter generator
- createQuote's pb.create call is wrapped in try/catch: PB's UNIQUE
constraint violation on quote_number is re-thrown as the typed error
with the colliding row's id resolved via a follow-up getList
New route test file (9 cases) covers happy path with supplied
quote_number, the 409 collision branch, notes/quote_notes fallback
merge, auth-gate failures, missing customer/items validation, and
generic 500 fallback. Uses importOriginal so the real
QuoteNumberExistsError class survives the service-layer mock.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 02:19:51 +00:00
// ── 3. Resolve quote number. The operator may supply one (e.g. for a
// re-quote with a stable customer-side reference); otherwise we
// generate a fresh one from the atomic counter.
const operatorSuppliedNumber =
typeof input . quote_number === "string" && input . quote_number . trim ( )
? input . quote_number . trim ( )
: null ;
const quoteNumber = operatorSuppliedNumber ? ? ( await generateQuoteNumberServer ( ) ) ;
// ── 4. Create the quote row. PB enforces a unique index on
// `quotes.quote_number`; the try/catch below maps the DB-level
// UNIQUE violation back to the typed `QuoteNumberExistsError` so the
// REST layer can respond 409 with the colliding row's id.
// The let + try/catch shape means the type must be explicit; we
// mirror the inference the const-form would have produced (the
// generic RecordModel cast as `Quote` at the return point below).
let quote : Record < string , unknown > & { id : string } ;
try {
quote = await pb . collection ( QUOTES_COLLECTION ) . create ( {
quote_number : quoteNumber ,
customer : customer.id ,
company : company?.id || null ,
description : input.description || "" ,
status : input.status ? ? "new" ,
notes : input.notes ? ? "" ,
rfq_number : input.rfq_number ? ? "" ,
quote_notes : input.quote_notes ? ? "" ,
validity_days : input.validity_days ? ? QUOTE_VALID_DAYS ,
company_letterhead : input.letterhead || "CNL" ,
has_attachments : false ,
email_subject : input.description || "" ,
email_body : "" ,
is_no_quote : input.is_no_quote ,
} ) ;
} catch ( err ) {
// Race-safe: even when the operator didn't supply a number, the
// generated one could theoretically collide if the counter
// implementation changes. Catch the unique-constraint violation,
// re-query for the existing row's id, and re-throw typed.
if ( err && /UNIQUE constraint failed/i . test ( String ( err ) ) ) {
const escaped = quoteNumber . replace ( /"/g , '\\"' ) ;
const conflict = await pb
. collection ( QUOTES_COLLECTION )
. getList < { id : string } > ( 1 , 1 , {
filter : ` quote_number = " ${ escaped } " ` ,
} ) ;
if ( conflict . items [ 0 ] ) {
throw new QuoteNumberExistsError ( conflict . items [ 0 ] . id , quoteNumber ) ;
}
}
throw err ;
}
2026-08-15 09:50:48 +00:00
// ── 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 {
feat(quotes): finish createQuote refactor for operator-supplied quote_number
The /api/v1/quotes/create route was wired to accept operator-supplied
quote_number + rfq_number + quote_notes + notes + status, but the
service-layer side never matched: QuoteNumberExistsError didn't exist
and CreateQuoteInput ignored the new fields. Production deploy was
failing on `Module has no exported member 'QuoteNumberExistsError'`.
Service changes:
- New QuoteNumberExistsError class (mirrors OrderNumberCollisionError):
carries existingId + attemptedNumber, mapped to 409 by the route
- CreateQuoteInput extended with quote_number, rfq_number, quote_notes,
notes, status; imported QuoteStatus from @/lib/types
- createQuote now uses input.quote_number verbatim when supplied, else
falls back to the existing atomic-counter generator
- createQuote's pb.create call is wrapped in try/catch: PB's UNIQUE
constraint violation on quote_number is re-thrown as the typed error
with the colliding row's id resolved via a follow-up getList
New route test file (9 cases) covers happy path with supplied
quote_number, the 409 collision branch, notes/quote_notes fallback
merge, auth-gate failures, missing customer/items validation, and
generic 500 fallback. Uses importOriginal so the real
QuoteNumberExistsError class survives the service-layer mock.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 02:19:51 +00:00
quote : quote as unknown as Quote ,
2026-08-15 09:50:48 +00:00
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 ;
}
2026-08-17 13:22:16 +00:00
/ * *
* 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 ;
}
2026-08-26 21:23:35 +00:00
/ * *
* Set or clear the no - quote flag on a quote . Reverts to false ( a real
* quote ) when called with ` false ` . Stamps ` updated ` automatically .
* /
export async function setIsNoQuote (
quoteId : string ,
value : boolean ,
) : Promise < Quote > {
const pb = await getAdminPocketBase ( ) ;
return pb . collection ( QUOTES_COLLECTION ) . update < Quote > ( quoteId , { is_no_quote : value } ) ;
}
feat(quotes): add repeat-request detection service
Adds findDuplicateQuotes(), which groups quotes representing the same
underlying request. The business case is mining tendering: several
engineering companies each ask us to price the SAME item list because
they're all bidding for one spot on a mine, and the office needs to
find what the earlier quote went out at.
That inverts the usual dedupe rules, so both are deliberate:
- matching is CROSS-COMPANY (two customers sending the same list is
the signal, not a false positive)
- the email-body pass is CROSS-SENDER (bidders forward the same
tender document from different addresses)
Three passes: item_content (normalised line fingerprint, order
independent), email_body, and email_message_id.
Noise filtering is tuned against live data. The n8n intake writes an
"items from email request <name>" placeholder when it can't extract
line items; without excluding those, every unextracted email matches
every other one. A minimum line length drops junk like a bare "quote"
while keeping real one-liners the office genuinely re-quotes.
The completed-quote window measures `created`, not `updated`: a bulk
backfill rewrote `updated` on all 8,964 rows to a single timestamp, so
an `updated >=` cutoff matches the whole collection.
Not wired to the UI. A live run showed the quotes collection has no
trustworthy per-quote date (94% of `created` and 95% of `updated` are
migration stamps; quote_date and email_received_at are ~empty), so the
recency window can't currently bound the scan, and full-history content
matching returns groups of up to 53 for commonly-quoted commodities.
Landing the service on its own so the matching logic is available for
the on-demand "have we quoted this before?" lookup.
Also fixes a latent bug in the test PB mock's filter parser, which
stripped outer parens even when the leading "(" didn't match the
trailing ")", corrupting `(a) || (b)` into `a) || (b`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:42:37 +00:00
/ * = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
* Repeat - request detection ( "have we quoted this already?" )
* -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
* This is NOT the usual data - hygiene dedupe ( that ' s ` findDuplicateOrders `
* in orders - service . ts , which hunts for accidental double - intakes ) .
*
* The business case is mining supply tendering : when a mine puts a spot
* out , SEVERAL different engineering companies each ask us to quote the
* SAME item list , because they ' re all bidding for that spot . The office
* needs to spot " we already priced this exact list for Blyvoor last
* week " the moment the second company asks , so they can go look up what
* they charged and price the new one deliberately .
*
* Two consequences for the matching rules , both of which invert the
* usual dedupe instincts :
*
* 1 . Matches are CROSS - COMPANY on purpose . Two different customers
* sending the same list is the signal , not a false positive .
* 2 . The email - body pass is CROSS - SENDER for the same reason — the
* bidders are usually forwarding the same tender document from
* the mine , so the body text is identical from different
* addresses .
*
* ` email_message_id ` is included for completeness ( it ' s free once the
* records are loaded ) but is expected to fire almost never : the mail
* workflow moves each processed email to a folder it doesn ' t re - scan ,
* so a true same - Message - ID double - ingest is rare .
* === === === === === === === === === === === === === === === === === === === === === === === === == * /
/ * *
* How far back the "completed" slice of the scan reaches . Active work
* ( new / claimed / pending approval ) is always scanned regardless of
* age ; completed quotes only stay in scope this long . Anything older
* lives on / quotes / archive and isn ' t worth flagging .
*
* Deliberately its own constant rather than reusing
* ` COMPLETED_RECENT_WINDOW_DAYS ` ( 7 , the "Completed this week" tab ) or
* ` SEARCH_RECENT_WINDOW_DAYS ` ( 90 , the search cutoff ) — this window is
* a business call about how long a tender stays relevant , not a
* performance cutoff .
* /
export const DUPLICATE_SCAN_COMPLETED_WINDOW_DAYS = 50 ;
export function duplicateScanCompletedCutoff ( now : Date = new Date ( ) ) : string {
return new Date (
now . getTime ( ) - DUPLICATE_SCAN_COMPLETED_WINDOW_DAYS * 24 * 60 * 60 * 1000 ,
) . toISOString ( ) ;
}
export type QuoteDuplicateKind =
/** Same normalised line-item set (description + qty), any customer. */
| "item_content"
/** Same normalised inbound email body, any sender. */
| "email_body"
/** Same RFC 5322 Message-ID — a true double-ingest of one email. */
| "email_message_id" ;
export interface DuplicateQuoteRecord {
id : string ;
quoteNumber : string ;
status : string ;
created : string ;
customerName : string | null ;
companyName : string | null ;
itemCount : number ;
totalQuantity : number ;
/ * * S u m o f s e l l i n g _ p r i c e × q u a n t i t y . 0 w h i l e t h e q u o t e i s u n p r i c e d —
* which is normal for the ` new ` end of the pipeline . The operator
* uses this to compare what the earlier quote went out at . * /
totalValue : number ;
}
export interface DuplicateQuoteGroup {
kind : QuoteDuplicateKind ;
/** Stable identity for the group (the fingerprint / message-id). */
key : string ;
/** Human-readable summary, e.g. "3 lines · 120 units". */
label : string ;
/ * * O l d e s t f i r s t — t h e f i r s t r e c o r d i s t h e o r i g i n a l q u o t e t h e
* operator most likely wants to open and compare prices against . * /
records : DuplicateQuoteRecord [ ] ;
}
/** Statuses in scope regardless of age — the live workflow. */
const DUPLICATE_SCAN_ACTIVE_STATUSES : string [ ] = Array . from (
new Set ( [
. . . QUOTE_TAB_STATUSES . new ,
. . . QUOTE_TAB_STATUSES . claimed ,
. . . QUOTE_TAB_STATUSES . pending_approval ,
] ) ,
) ;
/ * *
* Normalise a free - text line so trivial differences ( case , spacing ,
* punctuation , the odd trailing full - stop ) don ' t stop two identical
* requests from matching . Deliberately conservative : it does NOT stem
* words or drop numbers , because in mining supply the numbers ARE the
* spec ( "M20 x 50mm" must not collapse into "M20 x 60mm" ) .
* /
function normaliseForFingerprint ( raw : string ) : string {
return ( raw || "" )
. toLowerCase ( )
. replace ( /[^a-z0-9]+/g , " " )
. trim ( ) ;
}
/ * *
* Line descriptions that carry no product information and must never
* contribute to a fingerprint .
*
* The big one is ` items from email request <name> ` — a placeholder the
* n8n intake writes when the extractor couldn ' t pull line items out of
* the inbound email . It is NOT written by this codebase ( it arrives as
* data ) , and it is common : a live sample of 1 , 500 quotes had 30 rows
* reading "items from email request request for a quote asap" alone .
* Without this guard every unextracted email matches every other
* unextracted email and the feature reports hundreds of phantom
* repeats — which is exactly what the first live run did .
* /
const PLACEHOLDER_ITEM_PATTERNS : RegExp [ ] = [
/^items? from email request\b/ ,
/^(quote|quotation|rfq|please quote|pricing)$/ ,
/^see attach(ed|ment)?\b/ ,
/^as per (attached|email|below)\b/ ,
] ;
/ * *
* Minimum normalised length for a line to be fingerprintable . Tuned
* against live data : real one - liners the office genuinely re - quotes
* ( "shell rimula r4x 15w40 20l" = 26 chars , " tpkt160508r gn yg612
* milling insert p m k " = 41 ) sit comfortably above this , while junk
* like a bare "quote" ( 5 ) falls out .
* /
const MIN_FINGERPRINT_LINE_LENGTH = 10 ;
/ * *
* Fingerprint for the item - content pass : every line reduced to
* ` qty× description ` ( SKU folded in when present ) , sorted so line ORDER
* doesn ' t matter , then joined . Two quotes with the same fingerprint
* asked for the same things in the same amounts .
*
* Uses ` description ` ( the customer ' s original RFQ wording ) rather than
* ` quote_description ` ( the operator ' s cleaned - up PDF text ) — the
* original is what ' s identical across two bidders forwarding the same
* tender ; the cleaned version only exists after someone has worked the
* quote .
*
* Returns null when nothing survives the noise filters , which
* correctly excludes the quote from the pass rather than letting it
* match every other content - free quote .
* /
function itemContentFingerprint ( items : QuoteItem [ ] ) : string | null {
const parts = items
. map ( ( it ) = > {
const desc = normaliseForFingerprint ( it . description || "" ) ;
const sku = normaliseForFingerprint ( it . sku || "" ) ;
// A real SKU is itself a strong identifier, so a line with one
// stays eligible even if its prose is thin.
if ( ! sku && desc . length < MIN_FINGERPRINT_LINE_LENGTH ) return null ;
if ( ! sku && PLACEHOLDER_ITEM_PATTERNS . some ( ( re ) = > re . test ( desc ) ) ) return null ;
const body = sku ? ` ${ sku } | ${ desc } ` : desc ;
if ( ! body ) return null ;
const qty = Math . round ( Number ( it . quantity ) || 1 ) ;
return ` ${ qty } x ${ body } ` ;
} )
. filter ( ( p ) : p is string = > p !== null ) ;
// A quote with no usable line text can't be fingerprinted — otherwise
// every empty draft would "match" every other empty draft.
if ( parts . length === 0 ) return null ;
parts . sort ( ) ;
return parts . join ( "~" ) ;
}
/ * *
* Find quotes that represent the same underlying request . See the
* block comment above for why the matching is intentionally
* cross - company .
*
* Returns one group per repeated fingerprint , each holding ≥ 2 quotes ,
* oldest first . Groups where every record is the same quote id are
* impossible by construction ( ids are unique ) , but groups are still
* filtered to ≥ 2 DISTINCT quotes for safety .
* /
export async function findDuplicateQuotes (
pbArg? : Awaited < ReturnType < typeof getAdminPocketBase > > ,
) : Promise < DuplicateQuoteGroup [ ] > {
const pb = pbArg || ( await getAdminPocketBase ( ) ) ;
pb . autoCancellation ( false ) ;
// Scope: live workflow at any age, plus completed within the window.
// Templates are excluded — they're scaffolding, and every quote spawned
// from one template would otherwise flag against the template itself.
//
// The window is measured on `created`, NOT `updated`, even though
// `updated` is the closer semantic proxy for "when was this
// completed". On the live PB a bulk backfill rewrote `updated` on
// every row (all 8,964 quotes share a 2026-08-11 06:08 timestamp),
// so an `updated >=` cutoff matches the entire collection and the
// window silently does nothing — the first live run pulled 8,146
// quotes into a scan that should have seen a few hundred.
// `created` is the intake date and is never rewritten, so it's the
// only trustworthy recency signal here.
const activeFilter = DUPLICATE_SCAN_ACTIVE_STATUSES
. map ( ( s ) = > ` status = " ${ s } " ` )
. join ( " || " ) ;
const completedFilter = QUOTE_TAB_STATUSES . completed
. map ( ( s ) = > ` status = " ${ s } " ` )
. join ( " || " ) ;
const filter =
` is_template != true && ` +
` (( ${ activeFilter } ) || (( ${ completedFilter } ) && created >= " ${ duplicateScanCompletedCutoff ( ) } ")) ` ;
const quotes = await pb . collection ( QUOTES_COLLECTION ) . getFullList < Quote > ( {
filter ,
sort : "created" ,
expand : "customer,company" ,
batch : 5000 ,
} ) ;
if ( quotes . length === 0 ) return [ ] ;
// One bulk fetch for every in-scope quote's items, then bucket in
// memory. The orders equivalent loops one query per record; at ~850
// in-scope quotes that would be ~850 sequential round-trips.
const itemsByQuote = new Map < string , QuoteItem [ ] > ( ) ;
const allItems = await fetchQuoteItemsForQuotesAdmin (
pb ,
quotes . map ( ( q ) = > q . id ) ,
) ;
for ( const item of allItems ) {
if ( ! item . quote ) continue ;
const arr = itemsByQuote . get ( item . quote ) ? ? [ ] ;
arr . push ( item ) ;
itemsByQuote . set ( item . quote , arr ) ;
}
const records = new Map < string , DuplicateQuoteRecord > ( ) ;
const contentKeys = new Map < string , string [ ] > ( ) ;
const bodyKeys = new Map < string , string [ ] > ( ) ;
const messageIdKeys = new Map < string , string [ ] > ( ) ;
for ( const q of quotes ) {
const items = itemsByQuote . get ( q . id ) ? ? [ ] ;
const totalQuantity = items . reduce (
( sum , it ) = > sum + ( Math . round ( Number ( it . quantity ) || 1 ) ) ,
0 ,
) ;
const totalValue = items . reduce (
( sum , it ) = >
sum + ( Number ( it . selling_price ) || 0 ) * ( Number ( it . quantity ) || 1 ) ,
0 ,
) ;
records . set ( q . id , {
id : q.id ,
quoteNumber : q.quote_number ,
status : q.status ,
created : q.created ,
customerName : q.expand?.customer?.customer ? ? null ,
companyName : q.expand?.company?.company ? ? null ,
itemCount : items.length ,
totalQuantity ,
totalValue ,
} ) ;
const contentKey = itemContentFingerprint ( items ) ;
if ( contentKey ) {
const arr = contentKeys . get ( contentKey ) ? ? [ ] ;
arr . push ( q . id ) ;
contentKeys . set ( contentKey , arr ) ;
}
// Email body: normalised so quoted-reply chrome and whitespace
// differences don't break the match. Very short bodies are skipped
// — a two-word "please quote" would match half the collection.
const bodyKey = normaliseForFingerprint ( q . email_body || "" ) ;
if ( bodyKey . length >= 40 ) {
const arr = bodyKeys . get ( bodyKey ) ? ? [ ] ;
arr . push ( q . id ) ;
bodyKeys . set ( bodyKey , arr ) ;
}
const messageId = ( q . email_message_id || "" ) . trim ( ) ;
if ( messageId ) {
const arr = messageIdKeys . get ( messageId ) ? ? [ ] ;
arr . push ( q . id ) ;
messageIdKeys . set ( messageId , arr ) ;
}
}
const out : DuplicateQuoteGroup [ ] = [ ] ;
const pushGroups = (
kind : QuoteDuplicateKind ,
keyed : Map < string , string [ ] > ,
label : ( recs : DuplicateQuoteRecord [ ] ) = > string ,
) = > {
for ( const [ key , ids ] of keyed ) {
const unique = Array . from ( new Set ( ids ) ) ;
if ( unique . length < 2 ) continue ;
const recs = unique
. map ( ( id ) = > records . get ( id ) )
. filter ( ( r ) : r is DuplicateQuoteRecord = > r !== undefined ) ;
if ( recs . length < 2 ) continue ;
recs . sort ( ( a , b ) = > ( a . created < b . created ? - 1 : a.created > b . created ? 1 : 0 ) ) ;
out . push ( { kind , key , label : label ( recs ) , records : recs } ) ;
}
} ;
pushGroups ( "item_content" , contentKeys , ( recs ) = > {
const { itemCount , totalQuantity } = recs [ 0 ] ;
return ` ${ itemCount } line ${ itemCount === 1 ? "" : "s" } · ${ totalQuantity } unit ${ totalQuantity === 1 ? "" : "s" } ` ;
} ) ;
pushGroups ( "email_body" , bodyKeys , ( ) = > "Same email request" ) ;
pushGroups ( "email_message_id" , messageIdKeys , ( ) = > "Same email (Message-ID)" ) ;
// Biggest groups first — the most-repeated tender is the one the
// office most wants to see.
out . sort ( ( a , b ) = > b . records . length - a . records . length ) ;
return out ;
}
/ * *
* Admin - side bulk fetch of ` quote_items ` for many quotes . Mirrors
* ` fetchQuoteItemsForQuotes ` in pb - helpers ( client PB ) and
* ` fetchCandidateQuoteItems ` in quotes - search - service : try PB ' s ` IN `
* syntax first , fall back to chunked OR - chains when it ' s rejected .
2026-08-29 11:32:23 +00:00
*
* Exported so ` order-quote-link-service.ts ` can reuse it for the
* fuzzy matcher ' s quote - items fetch instead of building its own
* unchunked OR - chain ( which blows the PB filter length limit past
* ~ 50 candidate quotes ) .
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
*
* ` fields ` defaults to this module ' s own projection but is a caller
* parameter , not a shared constant , because a second stakeholder now
* depends on specific columns being present : the matcher needs
* ` description ` and ` selling_price ` for its scoring , which the
* duplicate - quote scan ( the original caller ) has no reason to know
* about . Trimming the default for one caller must not silently starve
* the other .
feat(quotes): add repeat-request detection service
Adds findDuplicateQuotes(), which groups quotes representing the same
underlying request. The business case is mining tendering: several
engineering companies each ask us to price the SAME item list because
they're all bidding for one spot on a mine, and the office needs to
find what the earlier quote went out at.
That inverts the usual dedupe rules, so both are deliberate:
- matching is CROSS-COMPANY (two customers sending the same list is
the signal, not a false positive)
- the email-body pass is CROSS-SENDER (bidders forward the same
tender document from different addresses)
Three passes: item_content (normalised line fingerprint, order
independent), email_body, and email_message_id.
Noise filtering is tuned against live data. The n8n intake writes an
"items from email request <name>" placeholder when it can't extract
line items; without excluding those, every unextracted email matches
every other one. A minimum line length drops junk like a bare "quote"
while keeping real one-liners the office genuinely re-quotes.
The completed-quote window measures `created`, not `updated`: a bulk
backfill rewrote `updated` on all 8,964 rows to a single timestamp, so
an `updated >=` cutoff matches the whole collection.
Not wired to the UI. A live run showed the quotes collection has no
trustworthy per-quote date (94% of `created` and 95% of `updated` are
migration stamps; quote_date and email_received_at are ~empty), so the
recency window can't currently bound the scan, and full-history content
matching returns groups of up to 53 for commonly-quoted commodities.
Landing the service on its own so the matching logic is available for
the on-demand "have we quoted this before?" lookup.
Also fixes a latent bug in the test PB mock's filter parser, which
stripped outer parens even when the leading "(" didn't match the
trailing ")", corrupting `(a) || (b)` into `a) || (b`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:42:37 +00:00
* /
2026-08-29 11:32:23 +00:00
export async function fetchQuoteItemsForQuotesAdmin (
feat(quotes): add repeat-request detection service
Adds findDuplicateQuotes(), which groups quotes representing the same
underlying request. The business case is mining tendering: several
engineering companies each ask us to price the SAME item list because
they're all bidding for one spot on a mine, and the office needs to
find what the earlier quote went out at.
That inverts the usual dedupe rules, so both are deliberate:
- matching is CROSS-COMPANY (two customers sending the same list is
the signal, not a false positive)
- the email-body pass is CROSS-SENDER (bidders forward the same
tender document from different addresses)
Three passes: item_content (normalised line fingerprint, order
independent), email_body, and email_message_id.
Noise filtering is tuned against live data. The n8n intake writes an
"items from email request <name>" placeholder when it can't extract
line items; without excluding those, every unextracted email matches
every other one. A minimum line length drops junk like a bare "quote"
while keeping real one-liners the office genuinely re-quotes.
The completed-quote window measures `created`, not `updated`: a bulk
backfill rewrote `updated` on all 8,964 rows to a single timestamp, so
an `updated >=` cutoff matches the whole collection.
Not wired to the UI. A live run showed the quotes collection has no
trustworthy per-quote date (94% of `created` and 95% of `updated` are
migration stamps; quote_date and email_received_at are ~empty), so the
recency window can't currently bound the scan, and full-history content
matching returns groups of up to 53 for commonly-quoted commodities.
Landing the service on its own so the matching logic is available for
the on-demand "have we quoted this before?" lookup.
Also fixes a latent bug in the test PB mock's filter parser, which
stripped outer parens even when the leading "(" didn't match the
trailing ")", corrupting `(a) || (b)` into `a) || (b`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:42:37 +00:00
pb : Awaited < ReturnType < typeof getAdminPocketBase > > ,
quoteIds : string [ ] ,
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
fields : string = "id,quote,description,sku,quantity,selling_price" ,
feat(quotes): add repeat-request detection service
Adds findDuplicateQuotes(), which groups quotes representing the same
underlying request. The business case is mining tendering: several
engineering companies each ask us to price the SAME item list because
they're all bidding for one spot on a mine, and the office needs to
find what the earlier quote went out at.
That inverts the usual dedupe rules, so both are deliberate:
- matching is CROSS-COMPANY (two customers sending the same list is
the signal, not a false positive)
- the email-body pass is CROSS-SENDER (bidders forward the same
tender document from different addresses)
Three passes: item_content (normalised line fingerprint, order
independent), email_body, and email_message_id.
Noise filtering is tuned against live data. The n8n intake writes an
"items from email request <name>" placeholder when it can't extract
line items; without excluding those, every unextracted email matches
every other one. A minimum line length drops junk like a bare "quote"
while keeping real one-liners the office genuinely re-quotes.
The completed-quote window measures `created`, not `updated`: a bulk
backfill rewrote `updated` on all 8,964 rows to a single timestamp, so
an `updated >=` cutoff matches the whole collection.
Not wired to the UI. A live run showed the quotes collection has no
trustworthy per-quote date (94% of `created` and 95% of `updated` are
migration stamps; quote_date and email_received_at are ~empty), so the
recency window can't currently bound the scan, and full-history content
matching returns groups of up to 53 for commonly-quoted commodities.
Landing the service on its own so the matching logic is available for
the on-demand "have we quoted this before?" lookup.
Also fixes a latent bug in the test PB mock's filter parser, which
stripped outer parens even when the leading "(" didn't match the
trailing ")", corrupting `(a) || (b)` into `a) || (b`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:42:37 +00:00
) : Promise < QuoteItem [ ] > {
if ( quoteIds . length === 0 ) return [ ] ;
const escape = ( id : string ) = > id . replace ( /"/g , '\\"' ) ;
const safeIds = quoteIds . map ( escape ) ;
try {
return await pb . collection ( QUOTE_ITEMS_COLLECTION ) . getFullList < QuoteItem > ( {
filter : ` quote IN ( ${ safeIds . map ( ( id ) = > ` " ${ id } " ` ) . join ( "," ) } ) ` ,
fields ,
batch : 5000 ,
} ) ;
} catch {
// PB rejected the IN filter — fall back to chunked OR-chains.
}
const CHUNK = 80 ;
const chunks : string [ ] [ ] = [ ] ;
for ( let i = 0 ; i < safeIds . length ; i += CHUNK ) {
chunks . push ( safeIds . slice ( i , i + CHUNK ) ) ;
}
const results = await Promise . all (
chunks . map ( ( chunk ) = >
pb . collection ( QUOTE_ITEMS_COLLECTION ) . getFullList < QuoteItem > ( {
filter : chunk.map ( ( id ) = > ` quote = " ${ id } " ` ) . join ( " || " ) ,
fields ,
batch : 5000 ,
} ) ,
) ,
) ;
return results . flat ( ) ;
}