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" ;
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" ;
2026-08-05 14:18:31 +00:00
import type { Quote , QuoteItem , Customer , Company } from "@/lib/types" ;
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 ;
}
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-20 08:18:44 +00:00
// The recent-window cutoff is the Path A scoping — see
// SEARCH_RECENT_WINDOW_DAYS. Only applied when the operator is
// actually searching (otherwise the bare listing is the
// honest, full archive view).
const recencyFilter = includeOlder
? ""
: ` updated >= " ${ searchRecentCutoff ( ) } " ` ;
2026-08-05 21:00:54 +00:00
if ( ! search ) {
const expand = "customer,customer.rep,company" ;
const [ pageResp , completedResp , allResp ] = await Promise . all ( [
pb . collection ( QUOTES_COLLECTION ) . getList < Quote > ( page , perPage , {
filter : viewFilter ,
sort ,
expand ,
} ) ,
pb . collection ( QUOTES_COLLECTION ) . getList < unknown > ( 1 , 1 , {
filter : completedFilter ,
} ) ,
pb . collection ( QUOTES_COLLECTION ) . getList < unknown > ( 1 , 1 , {
filter : allFilter ,
} ) ,
] ) ;
return {
items : pageResp.items ,
totalItems : pageResp.totalItems ,
totalPages : pageResp.totalPages ,
counts : {
completed : completedResp.totalItems ,
all : allResp.totalItems ,
} ,
view ,
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
// Step 1: fetch the FULL counted-status set (every status in any
// tab) so we can report header counts for BOTH `completed` and
// `all` in one walk. The matching set for the chosen view is a
// subset of this data — filtering happens in memory below.
//
// Why not two separate queries (one per view)? Doubling the
// order_items scan for what is mostly a UI label is wasteful
// when the data is identical for both views. We pay one scan
// and split in memory.
2026-08-20 08:18:44 +00:00
//
// When the operator is searching with the default recent-window
// scoping, the recency filter rides on this query too. That
// turns the unbounded "full counted-status set" into a bounded
// recent set — typically 200-500 rows instead of 8.5k — which
// is why the archive search stops timing out.
const allItemsFilter = recencyFilter
? ` ( ${ allFilter } ) && ${ recencyFilter } `
: allFilter ;
feat(quotes): move completed+all to dedicated /quotes/archive page
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.
This commit moves both buckets to a dedicated /quotes/archive
page so:
- Main /quotes is fast (active workflow: new, claimed,
pending_approval, cancelled, delivery_note, email_failed,
problem, templates = 542 quotes vs 8,466)
- Archive can be honest about pagination
- The badge counts on the main page stop being dominated by
'Completed (7,849)'
# Service layer
- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
- view: 'completed' (default, 7,849) or 'all' (8,466)
- Strategy: one getFullList on quotes (fields: id,status) to
populate the matching set AND the header counts in a single
walk, chunk by 100 ids (PB's URL/filter cap is between
4.5-6.7 KB), fire parallel getFullList queries on quotes
with deep expand, sort + paginate in memory
- Search hits quote_number, quote_reference, rfq_number,
company, customer
- /api/v1/quotes/archive thin route: page, perPage, search,
sort, view
# Page
- /quotes/archive/page.tsx: card list with header counts
(completed / all badges), view toggle (Completed | All),
search input, pagination footer. Click a quote to open
/quotes/[id] in detail.
# Main /quotes updates
- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
/quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in quotes-service.test.ts:
- Default 'completed' view returns only completed quotes,
counts header includes both
- 'all' view returns every counted-status quote
- Search by quote_number and quote_reference
- Empty result when no quotes match
# Also: webview tweaks by another agent
- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)
Full suite: 1331/1331 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
const allItems = await pb
. collection ( QUOTES_COLLECTION )
. getFullList < { id? : string ; status? : string } > ( {
2026-08-20 08:18:44 +00:00
filter : allItemsFilter ,
feat(quotes): move completed+all to dedicated /quotes/archive page
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.
This commit moves both buckets to a dedicated /quotes/archive
page so:
- Main /quotes is fast (active workflow: new, claimed,
pending_approval, cancelled, delivery_note, email_failed,
problem, templates = 542 quotes vs 8,466)
- Archive can be honest about pagination
- The badge counts on the main page stop being dominated by
'Completed (7,849)'
# Service layer
- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
- view: 'completed' (default, 7,849) or 'all' (8,466)
- Strategy: one getFullList on quotes (fields: id,status) to
populate the matching set AND the header counts in a single
walk, chunk by 100 ids (PB's URL/filter cap is between
4.5-6.7 KB), fire parallel getFullList queries on quotes
with deep expand, sort + paginate in memory
- Search hits quote_number, quote_reference, rfq_number,
company, customer
- /api/v1/quotes/archive thin route: page, perPage, search,
sort, view
# Page
- /quotes/archive/page.tsx: card list with header counts
(completed / all badges), view toggle (Completed | All),
search input, pagination footer. Click a quote to open
/quotes/[id] in detail.
# Main /quotes updates
- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
/quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in quotes-service.test.ts:
- Default 'completed' view returns only completed quotes,
counts header includes both
- 'all' view returns every counted-status quote
- Search by quote_number and quote_reference
- Empty result when no quotes match
# Also: webview tweaks by another agent
- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)
Full suite: 1331/1331 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
// `id` is needed for the matching-set dedupe below. `status`
// drives the per-status distinct counts. Both are small
// fields so the wire payload stays tight.
fields : "id,status" ,
batch : 5000 ,
} ) ;
// Count distinct quotes per status (header totals) and gather
// the matching set. The matching set is deduped (one row per
// quote — status filter is `status = X` which matches the status
// column on the quote itself, not a related row).
const completedIds = new Set < string > ( ) ;
const allIds = new Set < string > ( ) ;
const distinctByStatus : Record < string , number > = { } ;
for ( const item of allItems ) {
const id = item . id ;
const status = item . status ;
if ( ! id ) continue ;
if ( status && COMPLETED_STATUSES . includes ( status ) ) {
completedIds . add ( id ) ;
}
if ( status ) {
allIds . add ( id ) ;
distinctByStatus [ status ] = ( distinctByStatus [ status ] || 0 ) + 1 ;
}
}
// Pick the matching set based on the view.
const matchingIds = view === "completed" ? [ . . . completedIds ] : [ . . . allIds ] ;
if ( matchingIds . length === 0 ) {
return {
items : [ ] ,
totalItems : 0 ,
totalPages : 0 ,
counts : {
completed : completedIds.size ,
all : allIds.size ,
} ,
view ,
2026-08-20 08:18:44 +00:00
recencyLimited : ! includeOlder ,
recencyCutoff : includeOlder ? null : searchRecentCutoff ( ) ,
feat(quotes): move completed+all to dedicated /quotes/archive page
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.
This commit moves both buckets to a dedicated /quotes/archive
page so:
- Main /quotes is fast (active workflow: new, claimed,
pending_approval, cancelled, delivery_note, email_failed,
problem, templates = 542 quotes vs 8,466)
- Archive can be honest about pagination
- The badge counts on the main page stop being dominated by
'Completed (7,849)'
# Service layer
- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
- view: 'completed' (default, 7,849) or 'all' (8,466)
- Strategy: one getFullList on quotes (fields: id,status) to
populate the matching set AND the header counts in a single
walk, chunk by 100 ids (PB's URL/filter cap is between
4.5-6.7 KB), fire parallel getFullList queries on quotes
with deep expand, sort + paginate in memory
- Search hits quote_number, quote_reference, rfq_number,
company, customer
- /api/v1/quotes/archive thin route: page, perPage, search,
sort, view
# Page
- /quotes/archive/page.tsx: card list with header counts
(completed / all badges), view toggle (Completed | All),
search input, pagination footer. Click a quote to open
/quotes/[id] in detail.
# Main /quotes updates
- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
/quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in quotes-service.test.ts:
- Default 'completed' view returns only completed quotes,
counts header includes both
- 'all' view returns every counted-status quote
- Search by quote_number and quote_reference
- Empty result when no quotes match
# Also: webview tweaks by another agent
- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)
Full suite: 1331/1331 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
} ;
}
// Step 2: fetch matching quote details. 100 ids per chunk
// (PB's URL/filter cap sits between 4.5– 6.7 KB; 100 ids lands
// at ~4.6 KB). Fire all chunks in parallel.
const expand = "customer,customer.rep,company" ;
const ARCHIVE_CHUNK_SIZE = 100 ;
const chunks : string [ ] [ ] = [ ] ;
for ( let i = 0 ; i < matchingIds . length ; i += ARCHIVE_CHUNK_SIZE ) {
chunks . push ( matchingIds . slice ( i , i + ARCHIVE_CHUNK_SIZE ) ) ;
}
const candidateQuotes = (
await Promise . all (
chunks . map ( ( chunk ) = > {
const filter = chunk
. map ( ( id ) = > ` id = " ${ id . replace ( /"/g , '\\"' ) } " ` )
. join ( " || " ) ;
return pb . collection ( QUOTES_COLLECTION ) . getFullList < Quote > ( {
filter ,
sort ,
expand ,
} ) ;
} ) ,
)
) . flat ( ) ;
// Step 3: search filter. The list view reads quote_number,
// customer, company, and the rep label — so the search hits
// those (and contact_name, if we ever add a contact_name field).
const searchLower = search . toLowerCase ( ) ;
let filtered = candidateQuotes ;
if ( search ) {
filtered = candidateQuotes . filter ( ( q ) = > {
if ( ( q . quote_number || "" ) . toLowerCase ( ) . includes ( searchLower ) ) return true ;
if ( ( q . quote_reference || "" ) . toLowerCase ( ) . includes ( searchLower ) ) return true ;
if ( ( q . rfq_number || "" ) . toLowerCase ( ) . includes ( searchLower ) ) return true ;
if ( ( q . expand ? . company ? . company || "" ) . toLowerCase ( ) . includes ( searchLower ) ) return true ;
if ( ( q . expand ? . customer ? . customer || "" ) . toLowerCase ( ) . includes ( searchLower ) ) return true ;
return false ;
} ) ;
}
// Step 4: paginate. (PB sorted each chunk by `sort`, but the
// chunks were concatenated in id order, which is arbitrary. Re-
// sort in memory so the page is deterministic.)
const sortKey = sort . replace ( /^-/ , "" ) ;
const sortDir = sort . startsWith ( "-" ) ? - 1 : 1 ;
filtered . sort ( ( a , b ) = > {
const av = ( a as unknown as Record < string , unknown > ) [ sortKey ] ;
const bv = ( b as unknown as Record < string , unknown > ) [ sortKey ] ;
if ( av === bv ) return 0 ;
2026-08-04 18:36:50 +00:00
// PB returns `null` for unset fields (e.g. `updated`, `email_body`)
// and `undefined` for absent fields. Treat both as "missing"
// and sink them to the end of an ascending sort, the start of
// a descending sort. Avoids the `av > bv` comparison where one
// side is null and TS strict mode rejects the >.
if ( av == null ) return 1 ;
if ( bv == null ) return - 1 ;
if ( av > bv ) return 1 * sortDir ;
if ( av < bv ) return - 1 * sortDir ;
return 0 ;
feat(quotes): move completed+all to dedicated /quotes/archive page
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.
This commit moves both buckets to a dedicated /quotes/archive
page so:
- Main /quotes is fast (active workflow: new, claimed,
pending_approval, cancelled, delivery_note, email_failed,
problem, templates = 542 quotes vs 8,466)
- Archive can be honest about pagination
- The badge counts on the main page stop being dominated by
'Completed (7,849)'
# Service layer
- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
- view: 'completed' (default, 7,849) or 'all' (8,466)
- Strategy: one getFullList on quotes (fields: id,status) to
populate the matching set AND the header counts in a single
walk, chunk by 100 ids (PB's URL/filter cap is between
4.5-6.7 KB), fire parallel getFullList queries on quotes
with deep expand, sort + paginate in memory
- Search hits quote_number, quote_reference, rfq_number,
company, customer
- /api/v1/quotes/archive thin route: page, perPage, search,
sort, view
# Page
- /quotes/archive/page.tsx: card list with header counts
(completed / all badges), view toggle (Completed | All),
search input, pagination footer. Click a quote to open
/quotes/[id] in detail.
# Main /quotes updates
- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
/quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in quotes-service.test.ts:
- Default 'completed' view returns only completed quotes,
counts header includes both
- 'all' view returns every counted-status quote
- Search by quote_number and quote_reference
- Empty result when no quotes match
# Also: webview tweaks by another agent
- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)
Full suite: 1331/1331 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
} ) ;
const totalItems = filtered . length ;
const totalPages = Math . max ( 1 , Math . ceil ( totalItems / perPage ) ) ;
const start = ( page - 1 ) * perPage ;
const pageQuotes = filtered . slice ( start , start + perPage ) ;
return {
items : pageQuotes ,
totalItems ,
totalPages ,
counts : {
completed : completedIds.size ,
all : allIds.size ,
} ,
view ,
2026-08-20 08:18:44 +00:00
recencyLimited : ! includeOlder ,
recencyCutoff : includeOlder ? null : searchRecentCutoff ( ) ,
feat(quotes): move completed+all to dedicated /quotes/archive page
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.
This commit moves both buckets to a dedicated /quotes/archive
page so:
- Main /quotes is fast (active workflow: new, claimed,
pending_approval, cancelled, delivery_note, email_failed,
problem, templates = 542 quotes vs 8,466)
- Archive can be honest about pagination
- The badge counts on the main page stop being dominated by
'Completed (7,849)'
# Service layer
- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
- view: 'completed' (default, 7,849) or 'all' (8,466)
- Strategy: one getFullList on quotes (fields: id,status) to
populate the matching set AND the header counts in a single
walk, chunk by 100 ids (PB's URL/filter cap is between
4.5-6.7 KB), fire parallel getFullList queries on quotes
with deep expand, sort + paginate in memory
- Search hits quote_number, quote_reference, rfq_number,
company, customer
- /api/v1/quotes/archive thin route: page, perPage, search,
sort, view
# Page
- /quotes/archive/page.tsx: card list with header counts
(completed / all badges), view toggle (Completed | All),
search input, pagination footer. Click a quote to open
/quotes/[id] in detail.
# Main /quotes updates
- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
/quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in quotes-service.test.ts:
- Default 'completed' view returns only completed quotes,
counts header includes both
- 'all' view returns every counted-status quote
- Search by quote_number and quote_reference
- Empty result when no quotes match
# Also: webview tweaks by another agent
- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)
Full suite: 1331/1331 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:25:13 +00:00
} ;
}
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 ;
} ) : Promise < { customer : Customer ; created : boolean } > {
const pb = await getAdminPocketBase ( ) ;
const email = ( input . email || "" ) . trim ( ) . toLowerCase ( ) ;
if ( ! email ) {
throw new Error ( "Customer email is required for findOrCreateCustomer." ) ;
}
const exact = await findCustomerByExactEmail ( pb , email ) ;
if ( exact ) {
return { customer : exact , created : false } ;
}
const senderDomain = email . split ( "@" ) [ 1 ] || "" ;
const fallbackName = input . name ? . trim ( ) || senderDomain || email ;
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 ;
}
// ──────────────────────────────────────────────────────────────────────────────
// 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 ,
} )
: 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 ;
}
// ──────────────────────────────────────────────────────────────────────────────
// 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 ;
/ * *
* IDs of ` quote_attachments ` rows to include in the outbound email .
* The PDF is always included separately ( auto - located by kind = "pdf"
* at send time ) . IDs that don ' t belong to this quote are silently
* filtered out so a tampered client can ' t smuggle in attachments
* from another quote .
* /
selectedAttachmentIds : string [ ] ;
}
export interface SendQuoteEmailFailure {
code :
| "placeholder_customer"
| "no_recipient"
| "invalid_recipient"
| "no_pdf"
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 [ ] = [ ] ;
if ( ! it . is_processed ) missing . push ( "not processed" ) ;
if ( ! it . supplier ) missing . push ( "no supplier" ) ;
if ( ! ( Number ( it . cost_price ) > 0 ) ) missing . push ( "cost price" ) ;
if ( ! ( Number ( it . selling_price ) > 0 ) ) missing . push ( "selling price" ) ;
if ( missing . length > 0 ) {
issues . push ( {
itemId : String ( it . id ) ,
description : it.description || "(no description)" ,
missing ,
} ) ;
}
}
if ( issues . length > 0 ) {
return {
code : "items_not_ready" ,
message : ` Cannot generate PDF: ${ issues . length } item(s) are missing required fields. Price and supplier every line first. ` ,
details : {
itemIds : issues.map ( ( i ) = > i . itemId ) ,
issues ,
} ,
} ;
}
// No PDF and no detected reason — fall back to the opaque `no_pdf`
// so the agent has something to report. In practice one of the
// three gates above always fires first.
return {
code : "no_pdf" ,
message :
"No PDF generated for this quote yet. Generate the PDF before sending." ,
} ;
}
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. ` ,
} ) ;
}
}
}
// ── 6. Validate PDF exists (most recent kind=pdf attachment). The
// outbound send includes the PDF as the first attachment; no
// PDF = nothing useful to send.
2026-08-15 21:57:51 +00:00
//
// Closed 2026-08-15: the previous path bailed with a bare
// "no_pdf" message and the agent had no way to tell the
// operator what was actually missing. We now run the same
// readiness check that the PDF render route does
// (placeholder customer, no items, items_not_ready) and
// surface the per-line `missing` lists back to the agent,
// which can present them to the operator verbatim. The
// operator decides whether to fix the gaps or render the
// PDF themselves; the server doesn't auto-render — Dave
// explicitly opted out of that on 2026-08-15 (don't send
// partial / stale data without operator eyes on it).
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 )
. getList ( 1 , 1 , {
filter : ` quote = " ${ quoteId } " && kind = "pdf" ` ,
sort : "-uploaded_at" ,
} ) ;
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
}
// ── 7. Filter selectedAttachmentIds to only those that actually
// belong to this quote. Tampered clients can't smuggle in
// attachments from other quotes.
const requestedIds = Array . from ( new Set ( payload . selectedAttachmentIds || [ ] ) ) ;
let validIds : string [ ] = [ ] ;
if ( requestedIds . length > 0 ) {
const quotedAtts = await pb
. collection ( QUOTE_ATTACHMENTS_COLLECTION )
. getFullList ( {
filter : ` quote = " ${ quoteId } " ` ,
fields : "id" ,
} ) ;
const ownIds = new Set ( quotedAtts . map ( ( a ) = > ( { id : ( a as { id : string } ) . id } ) ) . map ( ( x ) = > x . id ) ) ;
validIds = requestedIds . filter ( ( id ) = > ownIds . has ( id ) ) ;
}
// ── 8. Flip status to `sending` + persist composition. One PATCH
// so the audit trail stays atomic.
const updated = ( await pb . collection ( QUOTES_COLLECTION ) . update ( quoteId , {
status : "sending" ,
sending_started_at : new Date ( ) . toISOString ( ) ,
email_error : "" ,
email_outbound_attachment_ids : JSON.stringify ( validIds ) ,
// Persist operator's last-used composition so reopening the
// modal shows what was sent.
email_cc : payload.cc || "" ,
email_bcc : payload.bcc || "" ,
email_subject_outbound : payload.subject || "" ,
email_message : payload.message || "" ,
attach_auth_letters : ! ! payload . attachAuthLetters ,
} ) ) as Quote ;
return { quote : updated , failure : null } ;
}
/ * *
* Retry an outbound send after a ` mail_failed ` . Same validation as
* ` sendQuoteEmail ` but without re - reading the modal payload — we
* reuse whatever composition the operator submitted last time
* ( already persisted on the quote row ) .
*
* Status guard : only ` mail_failed ` can be retried ; everything else
* is either a no - op ( ` emailed ` , ` sending ` ) or a different transition
* ( ` approved ` → use ` sendQuoteEmail ` to flip and send ) .
* /
export async function retryQuoteEmail ( quoteId : string ) : Promise < SendQuoteEmailResult > {
const pb = await getAdminPocketBase ( ) ;
let quote : Quote ;
try {
quote = await pb . collection ( QUOTES_COLLECTION ) . getOne < Quote > ( quoteId ) ;
} catch {
return {
quote : null as unknown as Quote ,
failure : { code : "quote_not_found" , message : "Quote does not exist." } ,
} ;
}
if ( quote . status !== "mail_failed" ) {
return markPreSendFailure ( pb , quote , {
code : "invalid_status" ,
message : ` Cannot retry from status " ${ quote . status } ". Only mail_failed quotes can be retried. ` ,
} ) ;
}
const updated = ( await pb . collection ( QUOTES_COLLECTION ) . update ( quoteId , {
status : "sending" ,
sending_started_at : new Date ( ) . toISOString ( ) ,
email_error : "" ,
email_failed_at : "" ,
} ) ) as Quote ;
return { quote : updated , failure : null } ;
}
/ * *
* Synchronously flip a quote to ` mail_failed ` and write the failure
* reason . Used by ` sendQuoteEmail ` when pre - send validation rejects
* the send — we never reach n8n in this path , so the failure has
* to be recorded by us .
*
* Returns a ` SendQuoteEmailResult ` shaped exactly like the success
* path so the API route doesn ' t have to branch .
* /
async function markPreSendFailure (
pb : Awaited < ReturnType < typeof getAdminPocketBase > > ,
quote : Quote ,
failure : SendQuoteEmailFailure
) : Promise < SendQuoteEmailResult > {
const updated = ( await pb . collection ( QUOTES_COLLECTION ) . update ( quote . id , {
status : "mail_failed" ,
email_error : ` [pre-send] ${ failure . message } ` ,
email_failed_at : new Date ( ) . toISOString ( ) ,
} ) ) as Quote ;
return { quote : updated , failure } ;
}
/ * *
* Records the * outcome * of an SMTP send attempt . Called by the n8n
* ` Quote Outbound Send ` workflow ( or any other worker that ' s been
* handed the send ) .
*
* ` outcome = "success" ` flips to ` emailed ` + stamps delivery fields .
* ` outcome = "failure" ` flips to ` mail_failed ` + stamps error .
*
* This is intentionally separate from ` sendQuoteEmail ` so the n8n
* path doesn ' t have to replay validation — by the time n8n runs ,
* pre - send checks already passed .
* /
export interface RecordSendOutcomeInput {
quoteId : string ;
outcome : "success" | "failure" ;
/** SMTP-issued Message-ID. Stored on success AND failure (so failures are traceable in mail logs). */
messageId? : string ;
/** Failure reason (SMTP response text). Required when outcome = "failure". */
errorMessage? : string ;
}
export async function recordSendOutcome (
input : RecordSendOutcomeInput
) : Promise < Quote > {
const pb = await getAdminPocketBase ( ) ;
if ( input . outcome === "success" ) {
const patch : Record < string , unknown > = {
status : "emailed" ,
email_delivered : true ,
email_delivered_at : new Date ( ) . toISOString ( ) ,
sending_started_at : "" ,
email_error : "" ,
email_failed_at : "" ,
} ;
if ( input . messageId ) patch . email_outbound_message_id = input . messageId ;
return ( await pb . collection ( QUOTES_COLLECTION ) . update ( input . quoteId , patch ) ) as Quote ;
}
// outcome = "failure"
const reason = ( input . errorMessage || "Unknown SMTP failure" ) . slice ( 0 , 4900 ) ;
const patch : Record < string , unknown > = {
status : "mail_failed" ,
email_failed_at : new Date ( ) . toISOString ( ) ,
email_error : ` [smtp] ${ reason } ` ,
sending_started_at : "" ,
} ;
if ( input . messageId ) patch . email_outbound_message_id = input . messageId ;
return ( await pb . collection ( QUOTES_COLLECTION ) . update ( input . quoteId , patch ) ) as Quote ;
}
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
}
// Only `new` quotes can transition via this tool. The full set of
// valid paths to the approval queue is:
// - `new` → here (via `submit`)
// - `partial_approval_pending` → `partial_approved` (via `partial_approve`)
// - `partial_approved` → `approved` (via the operator's review flow)
// Quotes that are already `approved`, `rejected`, `cancelled`,
// `emailed`, etc. have to be re-quoted rather than re-submitted.
if ( quote . status !== "new" ) {
throw new SubmitQuoteError (
` Cannot submit a quote in status " ${ quote . status } ". Only "new" quotes can be submitted for approval. Re-quote if the customer needs a fresh approval cycle. ` ,
422 ,
"invalid_status" ,
) ;
}
// Fetch every line on the quote (small N — typical quotes have
// < 30 items) and filter in JS. The PB filter parser mock in
// `quotes-service.test.ts` doesn't support `!= null` semantics
// cleanly, and the extra round-trip saved by a complex filter isn't
// worth the brittle test surface. The shape returned is
// `{ id, selling_price, moved_to_quote }` — we use `fields` to keep
// the payload small.
const allItems = await pb
. collection ( QUOTE_ITEMS_COLLECTION )
. getFullList ( {
filter : ` quote = " ${ quoteId } " ` ,
fields : "id,selling_price,moved_to_quote" ,
} ) ;
const unpricedItems = allItems . filter ( ( it ) = > {
const sp = Number ( it . selling_price ) || 0 ;
const moved = String ( it . moved_to_quote || "" ) . trim ( ) ;
return sp <= 0 && moved === "" ;
} ) ;
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.
if ( unpricedItems . length > 0 && ! options ? . partial ) {
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 ,
) ;
}
2026-08-15 21:57:51 +00:00
// Pick the target status. Partial flag wins (operator intent) —
// even if every item happened to be priced, we honour the flag.
// Without the flag and with all items priced: full approval.
const targetStatus : "approval_pending" | "partial_approval_pending" =
options ? . partial ? "partial_approval_pending" : "approval_pending" ;
2026-08-15 17:01:52 +00:00
// 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 ;
}
export interface CreateQuoteResult {
quote : Quote ;
customer : Customer ;
company : Customer | null ;
items : QuoteItem [ ] ;
quote_number : string ;
}
/ * *
* Create a walk - in quote from scratch . No source email , no inbox routing .
* The operator supplies the customer , company , and items directly .
*
* Mirrors ` createQuoteFromEmail ` ( which is the email - path , same status
* pipeline ) . The callers split into two flows :
* - ` quotes.create_from_email ` — n8n scrapes inbox , AI extracts items .
* - ` quotes.create ` ( this one ) — operator types " make me a quote for
* John " on the dashboard or via the agent .
*
* Resolution rules :
* - ` customer ` : looks up by id ( 15 - char PB id ) first ; if not an id ,
* case - insensitive exact match on ` customer.customer ` . If no match ,
* creates a new customer with that name + empty email .
* - ` company ` : same pattern . If no match , creates a new company .
* - ` items ` : each row is written via ` createQuoteItem ` ( the service
* layer shared with ` quote_items.create ` ) , so the F . 1 auto - derive
* ` selling_price ` rule and the F . 2 whitelist ( category /
* matched_price_list_id ) apply unchanged .
*
* Quote lifecycle : starts at ` status="new" ` , ` validity_days=30 ` ,
* ` company_letterhead ` defaults to ` "CNL" ` . The operator then runs
* ` re_price ` / ` quotes.re_price ` to populate prices , then through the
* existing claim → price → submit → approve → send pipeline .
* /
export async function createQuote (
input : CreateQuoteInput
) : Promise < CreateQuoteResult > {
if ( ! input . customer || ! input . customer . trim ( ) ) {
throw new Error ( "customer is required (id or name)." ) ;
}
if ( ! Array . isArray ( input . items ) || input . items . length === 0 ) {
throw new Error ( "items must be a non-empty array." ) ;
}
const pb = await getAdminPocketBase ( ) ;
// ── 1. Resolve customer (id OR name → exact lookup → create).
const customer = await resolveOrCreateCustomerByIdOrName (
pb ,
input . customer . trim ( )
) ;
// ── 2. Resolve company (id OR name → exact lookup → create). Optional.
let company : Customer | null = null ;
if ( input . company && input . company . trim ( ) ) {
company = await resolveOrCreateCompanyByIdOrName (
pb ,
input . company . trim ( )
) ;
}
// ── 3. Generate a quote number.
const quoteNumber = await generateQuoteNumberServer ( ) ;
// ── 4. Create the quote row.
const quote = await pb . collection ( QUOTES_COLLECTION ) . create ( {
quote_number : quoteNumber ,
customer : customer.id ,
company : company?.id || null ,
description : input.description || "" ,
status : "new" ,
notes : "" ,
validity_days : input.validity_days ? ? QUOTE_VALID_DAYS ,
company_letterhead : input.letterhead || "CNL" ,
has_attachments : false ,
email_subject : input.description || "" ,
email_body : "" ,
} ) ;
// ── 5. Create the line items via the shared service layer so the
// F.1 auto-derive + F.2 whitelist apply unchanged.
const createdItems : QuoteItem [ ] = [ ] ;
for ( const item of input . items ) {
const result = await createQuoteItem ( {
quoteId : quote.id ,
description : item.description ,
quantity : item.quantity ,
unit : item.unit ,
sku : item.sku ,
supplier : item.supplier ,
cost_price : item.cost_price ,
selling_price : item.selling_price ,
markup : item.markup ,
notes : item.notes ,
pricing_source : item.pricing_source ,
pricing_notes : item.pricing_notes ,
} ) ;
createdItems . push ( result . quote_items as unknown as QuoteItem ) ;
}
return {
quote : quote as Quote ,
customer ,
company : company as Customer | null ,
items : createdItems ,
quote_number : quoteNumber ,
} ;
}
/ * *
* Resolve a customer input ( id OR name ) to a Customer row . If the input
* is a 15 - char PB id , use it verbatim . Otherwise case - insensitive exact
* match on ` customer.customer ` . If no match , create a new customer with
* that name and empty email / phone .
* /
async function resolveOrCreateCustomerByIdOrName (
pb : Awaited < ReturnType < typeof getAdminPocketBase > > ,
input : string
) : Promise < Customer > {
const PB_ID_PATTERN = /^[A-Za-z0-9]{15}$/ ;
if ( PB_ID_PATTERN . test ( input ) ) {
try {
return ( await pb
. collection ( CUSTOMERS_COLLECTION )
. getOne ( input ) ) as Customer ;
} catch {
throw new Error ( ` customer id " ${ input } " not found. ` ) ;
}
}
const escaped = input . replace ( /"/g , '\\"' ) ;
const matches = await pb
. collection ( CUSTOMERS_COLLECTION )
. getList < Customer > ( 1 , 1 , {
filter : ` (customer = " ${ escaped } " || customer ~ " ${ escaped } ") ` ,
fields : "id,customer,email,number,company" ,
} ) ;
if ( matches . items . length > 0 ) return matches . items [ 0 ] ;
const created = await pb . collection ( CUSTOMERS_COLLECTION ) . create ( {
customer : input ,
email : "" ,
number : "" ,
dateadded : new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ,
description : "Auto-created from walk-in quote intake." ,
} ) ;
return created as Customer ;
}
/ * *
* Resolve a company input ( id OR name ) to a Customer row . Same pattern
* as the customer resolver . Returns null when no ` company ` input was
* supplied ( the caller passes null back to the quote creation ) .
* /
async function resolveOrCreateCompanyByIdOrName (
pb : Awaited < ReturnType < typeof getAdminPocketBase > > ,
input : string
) : Promise < Customer | null > {
const PB_ID_PATTERN = /^[A-Za-z0-9]{15}$/ ;
if ( PB_ID_PATTERN . test ( input ) ) {
try {
const expanded = await pb
. collection ( COMPANIES_COLLECTION )
. getOne ( input ) ;
// The caller wants a "Customer" shape for the convenience of the
// response. Cast — only the id is read downstream.
return expanded as unknown as Customer ;
} catch {
throw new Error ( ` company id " ${ input } " not found. ` ) ;
}
}
const escaped = input . replace ( /"/g , '\\"' ) ;
const matches = await pb
. collection ( COMPANIES_COLLECTION )
. getList < Customer > ( 1 , 1 , {
filter : ` (company = " ${ escaped } " || company ~ " ${ escaped } ") ` ,
fields : "id,company" ,
} ) ;
if ( matches . items . length > 0 ) return matches . items [ 0 ] ;
const created = await pb . collection ( COMPANIES_COLLECTION ) . create ( {
company : input ,
address : "" ,
vendor : "" ,
coordinates : { lon : 0 , lat : 0 } ,
} ) ;
return created as unknown as Customer ;
}
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 ;
}