2026-06-16 18:46:28 +00:00
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
|
|
|
|
|
|
// The mock state has to be hoisted to the top of the file because Vitest
|
|
|
|
|
// hoists `vi.mock` factories above all imports.
|
|
|
|
|
const { store, pbMock, clearStore } = vi.hoisted(() => {
|
|
|
|
|
const store: Record<string, Map<string, Record<string, unknown>>> = {};
|
|
|
|
|
let seq = 0;
|
|
|
|
|
const nextId = () => `mock-${++seq}`;
|
|
|
|
|
|
|
|
|
|
const getOrCreate = (collection: string) => {
|
|
|
|
|
if (!store[collection]) store[collection] = new Map();
|
|
|
|
|
return store[collection];
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
// The mock `where` is intentionally minimal — supports the patterns the
|
|
|
|
|
// production service actually issues: `field = "value"` (with or
|
|
|
|
|
// Recursive filter parser. The production service builds filters
|
|
|
|
|
// like `is_template != true && (status = "a" || status = "b")` —
|
|
|
|
|
// parens bind the OR to the surrounding AND. A naive
|
|
|
|
|
// top-level `||` split would break the parens; this parser
|
|
|
|
|
// walks the string and evaluates each piece as a boolean
|
|
|
|
|
// expression, recursing into parens.
|
|
|
|
|
type FilterAST =
|
|
|
|
|
| { kind: "and"; left: FilterAST; right: FilterAST }
|
|
|
|
|
| { kind: "or"; left: FilterAST; right: FilterAST }
|
|
|
|
|
| { kind: "not"; expr: FilterAST }
|
|
|
|
|
| { kind: "cmp"; field: string; op: "=" | "!=" | "=~" | "!~"; value: string };
|
|
|
|
|
|
|
|
|
|
// Strip outer parens if they wrap the whole string. e.g.
|
|
|
|
|
// `(a = "x")` → `a = "x"`. Lets callers wrap groups in parens
|
|
|
|
|
// for readability without us thinking they need recursive eval.
|
|
|
|
|
// Only strips if the matching `(` is at index 0 AND the matching
|
|
|
|
|
// `)` is at the END — anything else means the parens don't wrap
|
|
|
|
|
// the whole expression.
|
|
|
|
|
const stripOuterParens = (s: string): string => {
|
|
|
|
|
const trimmed = s.trim();
|
|
|
|
|
if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return s;
|
|
|
|
|
let depth = 0;
|
|
|
|
|
let lastCloseAt = -1;
|
|
|
|
|
for (let i = 0; i < trimmed.length; i++) {
|
|
|
|
|
if (trimmed[i] === "(") depth++;
|
|
|
|
|
else if (trimmed[i] === ")") {
|
|
|
|
|
depth--;
|
|
|
|
|
lastCloseAt = i;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Wrap the whole string iff depth is 0 at the end AND the
|
|
|
|
|
// opening `(` is at position 0 AND the matching close is the
|
|
|
|
|
// last char.
|
|
|
|
|
if (depth !== 0) return s;
|
|
|
|
|
if (lastCloseAt !== trimmed.length - 1) return s;
|
|
|
|
|
return trimmed.slice(1, -1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseFilter = (raw: string): FilterAST => {
|
|
|
|
|
const s = stripOuterParens(raw.trim());
|
|
|
|
|
|
|
|
|
|
// Find the LOWEST-precedence top-level operator. `||` is lower
|
|
|
|
|
// than `&&` in PB, so we look for `||` first and recurse.
|
|
|
|
|
// Loop bound: `i < s.length - 1` would skip the last char; we
|
|
|
|
|
// need to scan every char (including the final `)`) to keep
|
|
|
|
|
// `depth` accurate for nested-paren tracking.
|
|
|
|
|
let depth = 0;
|
|
|
|
|
for (let i = 0; i < s.length; i++) {
|
|
|
|
|
const c = s[i];
|
|
|
|
|
if (c === "(") depth++;
|
|
|
|
|
else if (c === ")") depth--;
|
|
|
|
|
else if (depth === 0) {
|
|
|
|
|
// Two-char operator `||`
|
|
|
|
|
if (c === "|" && s[i + 1] === "|") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "or",
|
|
|
|
|
left: parseFilter(s.slice(0, i)),
|
|
|
|
|
right: parseFilter(s.slice(i + 2)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < s.length; i++) {
|
|
|
|
|
const c = s[i];
|
|
|
|
|
if (c === "(") depth++;
|
|
|
|
|
else if (c === ")") depth--;
|
|
|
|
|
else if (depth === 0) {
|
|
|
|
|
if (c === "&" && s[i + 1] === "&") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "and",
|
|
|
|
|
left: parseFilter(s.slice(0, i)),
|
|
|
|
|
right: parseFilter(s.slice(i + 2)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Leaf — a comparison. `!=`, `=~`, `!~`, or `=`.
|
|
|
|
|
// Value can be empty (e.g. `field != ""`) so we use `*?` not `+?`.
|
|
|
|
|
const m = s.match(/^(\w+)\s*(=~|!~|=|!=)\s*"?([^"]*?)"?$/);
|
|
|
|
|
if (!m) {
|
|
|
|
|
throw new Error(`Mock filter parser: cannot parse "${s}"`);
|
|
|
|
|
}
|
|
|
|
|
const [, field, op, value] = m;
|
|
|
|
|
return { kind: "cmp", field, op: op as "=" | "!=" | "=~" | "!~", value };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const evalFilter = (item: Record<string, unknown>, ast: FilterAST): boolean => {
|
|
|
|
|
switch (ast.kind) {
|
|
|
|
|
case "and": return evalFilter(item, ast.left) && evalFilter(item, ast.right);
|
|
|
|
|
case "or": return evalFilter(item, ast.left) || evalFilter(item, ast.right);
|
|
|
|
|
case "not": return !evalFilter(item, ast.expr);
|
|
|
|
|
case "cmp": {
|
|
|
|
|
const actualStr = String(item[ast.field] ?? "");
|
|
|
|
|
switch (ast.op) {
|
|
|
|
|
case "=": return actualStr === ast.value;
|
|
|
|
|
case "!=": return actualStr !== ast.value;
|
|
|
|
|
case "=~": return actualStr.includes(ast.value);
|
|
|
|
|
case "!~": return !actualStr.includes(ast.value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-16 18:46:28 +00:00
|
|
|
const where = (collection: string, filter: string) => {
|
|
|
|
|
const map = getOrCreate(collection);
|
|
|
|
|
const items = Array.from(map.values());
|
|
|
|
|
if (!filter || !filter.trim()) return items;
|
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 ast = parseFilter(filter);
|
|
|
|
|
return items.filter((item) => evalFilter(item, ast));
|
2026-06-16 18:46:28 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pbMock = {
|
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
|
|
|
autoCancellation: () => undefined,
|
2026-06-16 18:46:28 +00:00
|
|
|
collection: (name: string) => ({
|
|
|
|
|
async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) {
|
|
|
|
|
const items = where(name, opts.filter || "");
|
|
|
|
|
const start = (page - 1) * perPage;
|
|
|
|
|
return {
|
|
|
|
|
items: items.slice(start, start + perPage),
|
|
|
|
|
totalItems: items.length,
|
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
|
|
|
totalPages: Math.max(1, Math.ceil(items.length / perPage)),
|
2026-06-16 18:46:28 +00:00
|
|
|
page,
|
|
|
|
|
perPage,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async getFullList(opts: { filter?: string; fields?: string } = {}) {
|
|
|
|
|
return where(name, opts.filter || "");
|
|
|
|
|
},
|
|
|
|
|
async getFirstListItem(filter: string) {
|
|
|
|
|
const items = where(name, filter);
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
const err = new Error("not found") as Error & { status: number };
|
|
|
|
|
err.status = 404;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
return items[0];
|
|
|
|
|
},
|
|
|
|
|
async getOne(id: string) {
|
|
|
|
|
const item = getOrCreate(name).get(id);
|
|
|
|
|
if (!item) {
|
|
|
|
|
const err = new Error("not found") as Error & { status: number };
|
|
|
|
|
err.status = 404;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
return item;
|
|
|
|
|
},
|
|
|
|
|
async create(data: Record<string, unknown>) {
|
|
|
|
|
// Enforce the unique (year, month) index for quote_counters so the
|
|
|
|
|
// service's race-handling path can be exercised.
|
|
|
|
|
if (name === "quote_counters") {
|
|
|
|
|
const map = getOrCreate(name);
|
|
|
|
|
for (const item of map.values()) {
|
|
|
|
|
if (item.year === data.year && item.month === data.month) {
|
|
|
|
|
const err = new Error("unique constraint") as Error & { status: number };
|
|
|
|
|
err.status = 409;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const item = {
|
|
|
|
|
id: nextId(),
|
|
|
|
|
created: new Date().toISOString(),
|
|
|
|
|
updated: new Date().toISOString(),
|
|
|
|
|
...data,
|
|
|
|
|
};
|
|
|
|
|
getOrCreate(name).set(item.id, item);
|
|
|
|
|
return item;
|
|
|
|
|
},
|
|
|
|
|
async update(id: string, data: Record<string, unknown>) {
|
|
|
|
|
const map = getOrCreate(name);
|
|
|
|
|
const existing = map.get(id);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
const err = new Error("not found") as Error & { status: number };
|
|
|
|
|
err.status = 404;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
for (const [key, value] of Object.entries(data)) {
|
|
|
|
|
if (key.endsWith("+")) {
|
|
|
|
|
const real = key.slice(0, -1);
|
|
|
|
|
existing[real] = Number(existing[real] ?? 0) + Number(value);
|
|
|
|
|
} else {
|
|
|
|
|
existing[key] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
existing.updated = new Date().toISOString();
|
|
|
|
|
// Return a clone so concurrent callers each see the value at the
|
|
|
|
|
// moment their update ran, not the final mutated value.
|
|
|
|
|
return { ...existing };
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearStore = () => {
|
|
|
|
|
for (const key of Object.keys(store)) delete store[key];
|
|
|
|
|
seq = 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return { store, pbMock, clearStore };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
|
|
|
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
generateQuoteNumberServer,
|
|
|
|
|
findOrCreateCustomer,
|
|
|
|
|
findOrCreateCompany,
|
|
|
|
|
createQuoteFromEmail,
|
|
|
|
|
appendReplyToQuote,
|
|
|
|
|
findQuoteByMessageId,
|
|
|
|
|
extractQuoteNumberFromSubject,
|
|
|
|
|
normalizeCompanyName,
|
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
|
|
|
sendQuoteEmail,
|
|
|
|
|
retryQuoteEmail,
|
|
|
|
|
recordSendOutcome,
|
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
|
|
|
getQuotesDashboardData,
|
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
|
|
|
getQuotesArchiveData,
|
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
|
|
|
getQuoteStatusTabCounts,
|
|
|
|
|
clearQuoteStatusTabCountsCache,
|
|
|
|
|
QUOTE_TAB_STATUSES,
|
|
|
|
|
QUOTE_TAB_ORDER,
|
2026-06-16 18:46:28 +00:00
|
|
|
} from "./quotes-service";
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
clearStore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("normalizeCompanyName", () => {
|
|
|
|
|
it("lowercases, trims, collapses whitespace, strips trailing punctuation", () => {
|
|
|
|
|
expect(normalizeCompanyName(" Acme Holdings. ")).toBe("acme");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("strips common legal suffixes", () => {
|
|
|
|
|
expect(normalizeCompanyName("Acme Pty Ltd")).toBe("acme");
|
|
|
|
|
expect(normalizeCompanyName("Acme Proprietary Limited")).toBe("acme");
|
|
|
|
|
expect(normalizeCompanyName("Acme Corp.")).toBe("acme");
|
|
|
|
|
expect(normalizeCompanyName("Acme Incorporated")).toBe("acme");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns empty string for blank input", () => {
|
|
|
|
|
expect(normalizeCompanyName("")).toBe("");
|
|
|
|
|
expect(normalizeCompanyName(" ")).toBe("");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("extractQuoteNumberFromSubject", () => {
|
|
|
|
|
it("extracts Q-YYYY-MM-NNN from a fresh quote subject", () => {
|
|
|
|
|
expect(extractQuoteNumberFromSubject("Quotation Q-2026-06-001")).toBe("Q-2026-06-001");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("extracts from a reply subject (Re: prefix)", () => {
|
|
|
|
|
expect(extractQuoteNumberFromSubject("Re: Quotation Q-2026-06-001")).toBe("Q-2026-06-001");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns null when no quote number is present", () => {
|
|
|
|
|
expect(extractQuoteNumberFromSubject("Just a normal email")).toBeNull();
|
|
|
|
|
expect(extractQuoteNumberFromSubject("Quote Q-26-6-1 (wrong format)")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("generateQuoteNumberServer", () => {
|
|
|
|
|
it("starts the month at 001", async () => {
|
|
|
|
|
const n = await generateQuoteNumberServer();
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const year = now.getFullYear();
|
|
|
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
2026-07-10 20:21:50 +00:00
|
|
|
expect(n).toBe(`Q${year}${month}001`);
|
2026-06-16 18:46:28 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("increments on each call", async () => {
|
|
|
|
|
const a = await generateQuoteNumberServer();
|
|
|
|
|
const b = await generateQuoteNumberServer();
|
|
|
|
|
const c = await generateQuoteNumberServer();
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
2026-07-10 20:21:50 +00:00
|
|
|
const prefix = `Q${now.getFullYear()}${month}`;
|
2026-06-16 18:46:28 +00:00
|
|
|
expect(a).toBe(`${prefix}001`);
|
|
|
|
|
expect(b).toBe(`${prefix}002`);
|
|
|
|
|
expect(c).toBe(`${prefix}003`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("produces unique numbers under concurrent calls (no race)", async () => {
|
|
|
|
|
const results = await Promise.all(
|
|
|
|
|
Array.from({ length: 10 }, () => generateQuoteNumberServer())
|
|
|
|
|
);
|
|
|
|
|
const unique = new Set(results);
|
|
|
|
|
expect(unique.size).toBe(10);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
2026-07-10 20:21:50 +00:00
|
|
|
const prefix = `Q${now.getFullYear()}${month}`;
|
2026-06-16 18:46:28 +00:00
|
|
|
for (const n of results) {
|
|
|
|
|
expect(n.startsWith(prefix)).toBe(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("findOrCreateCustomer", () => {
|
|
|
|
|
it("finds an existing customer by exact email match", async () => {
|
|
|
|
|
const existing = await pbMock.collection("customers").create({
|
|
|
|
|
customer: "Acme Mining",
|
|
|
|
|
email: "buyer@acme.co.za",
|
|
|
|
|
company: "Acme",
|
|
|
|
|
});
|
|
|
|
|
const { customer, created } = await findOrCreateCustomer({
|
|
|
|
|
email: "buyer@acme.co.za",
|
|
|
|
|
name: "Should Not Be Used",
|
|
|
|
|
});
|
|
|
|
|
expect(created).toBe(false);
|
|
|
|
|
expect(customer.id).toBe(existing.id);
|
|
|
|
|
expect(customer.customer).toBe("Acme Mining");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("finds by alternative_email when primary email misses", async () => {
|
|
|
|
|
const existing = await pbMock.collection("customers").create({
|
|
|
|
|
customer: "Acme Mining",
|
|
|
|
|
email: "primary@acme.co.za",
|
|
|
|
|
alternative_email: "alt@acme.co.za",
|
|
|
|
|
company: "Acme",
|
|
|
|
|
});
|
|
|
|
|
const { customer, created } = await findOrCreateCustomer({ email: "alt@acme.co.za" });
|
|
|
|
|
expect(created).toBe(false);
|
|
|
|
|
expect(customer.id).toBe(existing.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("finds by buyer_email when primary and alt miss", async () => {
|
|
|
|
|
const existing = await pbMock.collection("customers").create({
|
|
|
|
|
customer: "Acme Mining",
|
|
|
|
|
email: "primary@acme.co.za",
|
|
|
|
|
buyer_email: "buyer@acme.co.za",
|
|
|
|
|
company: "Acme",
|
|
|
|
|
});
|
|
|
|
|
const { customer, created } = await findOrCreateCustomer({ email: "buyer@acme.co.za" });
|
|
|
|
|
expect(created).toBe(false);
|
|
|
|
|
expect(customer.id).toBe(existing.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("creates a new customer when no match exists", async () => {
|
2026-06-29 11:06:01 +00:00
|
|
|
// Pre-create the company record the hint refers to — `customer.company`
|
|
|
|
|
// is now a relation, so the hint must resolve to a real company id
|
|
|
|
|
// (or be left empty).
|
|
|
|
|
const company = await pbMock.collection("companies").create({
|
|
|
|
|
company: "Unknown Co",
|
|
|
|
|
address: "",
|
|
|
|
|
vendor: "",
|
|
|
|
|
});
|
2026-06-16 18:46:28 +00:00
|
|
|
const { customer, created } = await findOrCreateCustomer({
|
|
|
|
|
email: "new@unknown.co.za",
|
|
|
|
|
name: "New Person",
|
|
|
|
|
companyHint: "Unknown Co",
|
|
|
|
|
});
|
|
|
|
|
expect(created).toBe(true);
|
|
|
|
|
expect(customer.email).toBe("new@unknown.co.za");
|
|
|
|
|
expect(customer.customer).toBe("New Person");
|
2026-06-29 11:06:01 +00:00
|
|
|
expect(customer.company).toBe(company.id);
|
2026-06-16 18:46:28 +00:00
|
|
|
expect(customer.description).toMatch(/Auto-created/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("never fuzzy-merges on company name", async () => {
|
|
|
|
|
// Two customers with similar emails from different domains should stay
|
|
|
|
|
// separate.
|
|
|
|
|
const a = await findOrCreateCustomer({ email: "john@acme.com" });
|
|
|
|
|
const b = await findOrCreateCustomer({ email: "jane@beta.com" });
|
|
|
|
|
expect(a.customer.id).not.toBe(b.customer.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("throws when email is missing", async () => {
|
|
|
|
|
await expect(findOrCreateCustomer({ email: "" })).rejects.toThrow(/email is required/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("findOrCreateCompany", () => {
|
|
|
|
|
it("finds an existing company by case-insensitive exact match", async () => {
|
|
|
|
|
const existing = await pbMock.collection("companies").create({
|
|
|
|
|
company: "Acme Holdings",
|
|
|
|
|
address: "1 Main Rd",
|
|
|
|
|
vendor: "",
|
|
|
|
|
});
|
|
|
|
|
const { company, created } = await findOrCreateCompany({ name: "acme holdings" });
|
|
|
|
|
expect(created).toBe(false);
|
|
|
|
|
expect(company?.id).toBe(existing.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("finds by normalized match (stripped suffix)", async () => {
|
|
|
|
|
const existing = await pbMock.collection("companies").create({
|
|
|
|
|
company: "Acme",
|
|
|
|
|
address: "1 Main Rd",
|
|
|
|
|
vendor: "",
|
|
|
|
|
});
|
|
|
|
|
const { company, created } = await findOrCreateCompany({ name: "Acme Pty Ltd" });
|
|
|
|
|
expect(created).toBe(false);
|
|
|
|
|
expect(company?.id).toBe(existing.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("creates a new company when no match", async () => {
|
|
|
|
|
const { company, created } = await findOrCreateCompany({
|
|
|
|
|
name: "Brand New Co",
|
|
|
|
|
address: "5 New St",
|
|
|
|
|
});
|
|
|
|
|
expect(created).toBe(true);
|
|
|
|
|
expect(company?.company).toBe("Brand New Co");
|
|
|
|
|
expect(company?.address).toBe("5 New St");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns null company when name is blank", async () => {
|
|
|
|
|
const { company, created } = await findOrCreateCompany({ name: "" });
|
|
|
|
|
expect(company).toBeNull();
|
|
|
|
|
expect(created).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("never fuzzy-merges on partial overlap", async () => {
|
|
|
|
|
await pbMock.collection("companies").create({
|
|
|
|
|
company: "Acme Mining Supplies",
|
|
|
|
|
address: "",
|
|
|
|
|
vendor: "",
|
|
|
|
|
});
|
|
|
|
|
const { created } = await findOrCreateCompany({ name: "Acme Tools" });
|
|
|
|
|
// "Acme" is shared but the full strings are different — must create.
|
|
|
|
|
expect(created).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("createQuoteFromEmail", () => {
|
|
|
|
|
const basePayload = {
|
|
|
|
|
from_email: "buyer@newco.co.za",
|
|
|
|
|
from_name: "Buyer Person",
|
|
|
|
|
subject: "RFQ: Mining equipment",
|
|
|
|
|
body_plain: "Please quote on the following items.",
|
|
|
|
|
message_id: "<abc123@example.com>",
|
|
|
|
|
in_reply_to: undefined as string | undefined,
|
|
|
|
|
received_at: new Date().toISOString(),
|
|
|
|
|
extracted_items: [
|
|
|
|
|
{ description: "20mm cable ties (pack of 100)", quantity: 5, unit: "pack" },
|
|
|
|
|
{ description: "Cable gland M20", quantity: 10, unit: "each" },
|
|
|
|
|
],
|
|
|
|
|
attachments: [
|
|
|
|
|
{
|
|
|
|
|
filename: "rfq.pdf",
|
|
|
|
|
content_type: "application/pdf",
|
|
|
|
|
size: 1024,
|
|
|
|
|
// 1 byte of base64 = "AA=="
|
|
|
|
|
base64: "AA==",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("creates a new quote, customer, company, items, and attachments", async () => {
|
|
|
|
|
const result = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
company_hint: "NewCo Mining",
|
|
|
|
|
});
|
|
|
|
|
expect(result.deduped).toBe(false);
|
2026-07-10 20:21:50 +00:00
|
|
|
expect(result.quote_number).toMatch(/^Q\d{4}\d{2}001$/);
|
2026-06-16 18:46:28 +00:00
|
|
|
expect(result.items).toBe(2);
|
2026-07-30 22:01:24 +00:00
|
|
|
// Attachments are no longer created in this path — the n8n flow calls
|
|
|
|
|
// `attachments.upload_for_quote` once per file in a follow-up step, so
|
|
|
|
|
// the create call must return 0 to avoid a 5 MB base64 POST that the
|
|
|
|
|
// App Router rejects. The caller is responsible for the upload.
|
|
|
|
|
expect(result.attachments).toBe(0);
|
2026-06-16 18:46:28 +00:00
|
|
|
expect(result.customer.email).toBe("buyer@newco.co.za");
|
|
|
|
|
expect(result.company?.company).toBe("NewCo Mining");
|
|
|
|
|
expect(result.quote.email_sender).toBe("buyer@newco.co.za");
|
|
|
|
|
expect(result.quote.email_subject).toBe("RFQ: Mining equipment");
|
|
|
|
|
expect(result.quote.email_message_id).toBe("<abc123@example.com>");
|
|
|
|
|
expect(result.quote.has_attachments).toBe(true);
|
|
|
|
|
expect(result.quote.status).toBe("new");
|
|
|
|
|
expect(result.quote.notes).toBe(""); // not a notes blob
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-23 06:30:38 +00:00
|
|
|
it("creates AI-extracted items as drafts (is_processed=false, zero prices)", async () => {
|
2026-06-16 18:46:28 +00:00
|
|
|
const result = await createQuoteFromEmail(basePayload);
|
|
|
|
|
const items = await pbMock.collection("quote_items").getFullList({
|
|
|
|
|
filter: `quote = "${result.quote.id}"`,
|
|
|
|
|
});
|
|
|
|
|
expect(items.length).toBe(2);
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
expect(item.is_processed).toBe(false);
|
|
|
|
|
// No fabricated cost or selling price.
|
|
|
|
|
expect(Number(item.cost_price)).toBe(0);
|
|
|
|
|
expect(Number(item.selling_price)).toBe(0);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("dedupes by email_message_id — second call returns the same quote", async () => {
|
|
|
|
|
const first = await createQuoteFromEmail(basePayload);
|
|
|
|
|
const second = await createQuoteFromEmail(basePayload);
|
|
|
|
|
expect(second.deduped).toBe(true);
|
|
|
|
|
expect(second.quote.id).toBe(first.quote.id);
|
|
|
|
|
expect(second.quote_number).toBe(first.quote_number);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("matches an existing customer by email (does not create duplicate)", async () => {
|
|
|
|
|
const existing = await pbMock.collection("customers").create({
|
|
|
|
|
customer: "Existing Buyer",
|
|
|
|
|
email: "buyer@newco.co.za",
|
|
|
|
|
company: "Old Co",
|
|
|
|
|
});
|
|
|
|
|
const result = await createQuoteFromEmail(basePayload);
|
|
|
|
|
expect(result.customer.id).toBe(existing.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses company_hint from payload when provided", async () => {
|
|
|
|
|
const result = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
company_hint: " Acme Pty Ltd ",
|
|
|
|
|
});
|
|
|
|
|
expect(result.company?.company).toBe("Acme Pty Ltd");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves company null when no company_hint is provided (no subject fallback)", async () => {
|
|
|
|
|
// The user explicitly does not want us to invent a company name from the
|
|
|
|
|
// email subject — that's the kind of fabrication we're trying to avoid.
|
|
|
|
|
// When the AI can't find a company in the body, the quoter picks it
|
|
|
|
|
// in the form.
|
|
|
|
|
const result = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
subject: "RFQ from Acme Mining",
|
|
|
|
|
});
|
|
|
|
|
expect(result.company).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("tolerates missing from_email, message_id, and subject (treats empty subject as a real value)", async () => {
|
|
|
|
|
// The n8n Parse Email node stamps `(no subject)` when the inbound email
|
|
|
|
|
// has no Subject header, so WFDASH receives a real string. The schema
|
|
|
|
|
// invariant is "subject is a string", not "subject is non-empty" —
|
|
|
|
|
// and even an empty string is now tolerated at runtime, because the
|
|
|
|
|
// n8n side is contractually obligated to send a placeholder.
|
|
|
|
|
await expect(createQuoteFromEmail({ ...basePayload, message_id: "" })).resolves.toBeTruthy();
|
|
|
|
|
await expect(createQuoteFromEmail({ ...basePayload, from_email: "" })).resolves.toBeTruthy();
|
|
|
|
|
await expect(
|
|
|
|
|
createQuoteFromEmail({ ...basePayload, from_email: "", message_id: "" })
|
|
|
|
|
).resolves.toBeTruthy();
|
|
|
|
|
// Empty subject is now tolerated (was hard-rejected before). The
|
|
|
|
|
// office sees a placeholder quote in the queue.
|
|
|
|
|
await expect(createQuoteFromEmail({ ...basePayload, subject: "" })).resolves.toBeTruthy();
|
|
|
|
|
// Whitespace-only subject is also tolerated — the description is stored
|
|
|
|
|
// verbatim so a future regression that drops the Parse Email trim
|
|
|
|
|
// doesn't take the whole pipeline down.
|
|
|
|
|
await expect(createQuoteFromEmail({ ...basePayload, subject: " " })).resolves.toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts a placeholder subject like '(no subject)' and stores it on the quote", async () => {
|
|
|
|
|
// The n8n side stamps `(no subject)` for emails with no Subject header.
|
|
|
|
|
// WFDASH must accept that literal string as a real subject, store it
|
|
|
|
|
// verbatim on the quote, and create the row so the office sees it.
|
|
|
|
|
const result = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
subject: "(no subject)",
|
|
|
|
|
body_plain: "",
|
|
|
|
|
extracted_items: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.quote.description).toBe("(no subject)");
|
|
|
|
|
expect(result.quote.email_subject).toBe("(no subject)");
|
|
|
|
|
expect(result.items).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("placeholder subject + empty body + empty from_email all combine cleanly", async () => {
|
|
|
|
|
// A fully-blank RFC 5321-violating send: no subject, no body, no From.
|
|
|
|
|
// All three layers of tolerance combine: placeholder subject, no body,
|
|
|
|
|
// sentinel "Unknown Sender" customer. The quote is created.
|
|
|
|
|
const result = await createQuoteFromEmail({
|
|
|
|
|
from_email: "",
|
|
|
|
|
from_name: "",
|
|
|
|
|
subject: "(no subject)",
|
|
|
|
|
body_plain: "",
|
|
|
|
|
body_html: "",
|
|
|
|
|
message_id: "<blank-a@example.com>",
|
|
|
|
|
received_at: "2026-06-12T18:30:00.000Z",
|
|
|
|
|
extracted_items: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.customer.customer).toBe("Unknown Sender");
|
|
|
|
|
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
|
|
|
|
|
expect(result.quote.description).toBe("(no subject)");
|
|
|
|
|
expect(result.quote.email_subject).toBe("(no subject)");
|
|
|
|
|
expect(result.quote.email_sender).toBe("");
|
|
|
|
|
expect(result.items).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("creates a quote with a sentinel 'Unknown Sender' customer when from_email is empty", async () => {
|
|
|
|
|
const result = await createQuoteFromEmail({ ...basePayload, from_email: "" });
|
|
|
|
|
expect(result.deduped).toBe(false);
|
|
|
|
|
expect(result.customer.customer).toBe("Unknown Sender");
|
|
|
|
|
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
|
|
|
|
|
// The literal empty string is stored, not a placeholder — the sentinel
|
|
|
|
|
// is only used for the `customer` relation.
|
|
|
|
|
expect(result.quote.email_sender).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("treats whitespace-only from_email as missing", async () => {
|
|
|
|
|
const result = await createQuoteFromEmail({ ...basePayload, from_email: " " });
|
|
|
|
|
expect(result.customer.customer).toBe("Unknown Sender");
|
|
|
|
|
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("two emails with no from_email both link to the same sentinel customer", async () => {
|
|
|
|
|
// Different subjects bypass the message_id dedup, so two distinct
|
|
|
|
|
// quotes are created — but both should reuse the same sentinel.
|
|
|
|
|
const r1 = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
from_email: "",
|
|
|
|
|
subject: "Unknown sender email A",
|
|
|
|
|
message_id: "<no-from-a@example.com>",
|
|
|
|
|
});
|
|
|
|
|
const r2 = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
from_email: "",
|
|
|
|
|
subject: "Unknown sender email B",
|
|
|
|
|
message_id: "<no-from-b@example.com>",
|
|
|
|
|
});
|
|
|
|
|
expect(r1.deduped).toBe(false);
|
|
|
|
|
expect(r2.deduped).toBe(false);
|
|
|
|
|
expect(r1.quote.id).not.toBe(r2.quote.id);
|
|
|
|
|
expect(r1.customer.id).toBe(r2.customer.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("a real from_email still wins over the sentinel", async () => {
|
|
|
|
|
const result = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
from_email: "real-buyer@somewhere.co.za",
|
|
|
|
|
});
|
|
|
|
|
expect(result.customer.email).toBe("real-buyer@somewhere.co.za");
|
|
|
|
|
expect(result.customer.customer).not.toBe("Unknown Sender");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("existing sentinel survives a fresh run (no duplicate Unknown Sender row)", async () => {
|
|
|
|
|
// Pre-create the sentinel so the findOrCreate path hits the existing
|
|
|
|
|
// branch. Verify no second customer is created.
|
|
|
|
|
const customersBefore = await pbMock.collection("customers").getFullList();
|
|
|
|
|
await pbMock.collection("customers").create({
|
|
|
|
|
customer: "Unknown Sender",
|
|
|
|
|
email: "unknown-sender@no-from.wfdash.local",
|
|
|
|
|
company: "Unknown",
|
|
|
|
|
description: "Pre-existing sentinel.",
|
|
|
|
|
});
|
|
|
|
|
const customersAfter = await pbMock.collection("customers").getFullList();
|
|
|
|
|
expect(customersAfter.length).toBe(customersBefore.length + 1);
|
|
|
|
|
|
|
|
|
|
const result = await createQuoteFromEmail({ ...basePayload, from_email: "" });
|
|
|
|
|
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
|
|
|
|
|
expect(result.customer.description).toBe("Pre-existing sentinel.");
|
|
|
|
|
|
|
|
|
|
const customersFinal = await pbMock.collection("customers").getFullList();
|
|
|
|
|
expect(customersFinal.length).toBe(customersAfter.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("synthesizes a stable message_id when the inbound email has no Message-ID header", async () => {
|
|
|
|
|
// iCloud / Mail.app sometimes strip the Message-ID header. The pipeline
|
|
|
|
|
// must still create the quote and dedup correctly on retry.
|
|
|
|
|
const noId1 = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
message_id: "",
|
|
|
|
|
});
|
|
|
|
|
const noId2 = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
message_id: "",
|
|
|
|
|
});
|
|
|
|
|
expect(noId1.quote.email_message_id).toMatch(/^synth-[0-9a-f]{8}@wfdash\.local$/);
|
|
|
|
|
expect(noId2.deduped).toBe(true);
|
|
|
|
|
expect(noId2.quote.id).toBe(noId1.quote.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("treats whitespace-only message_id as missing and synthesizes", async () => {
|
|
|
|
|
const result = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
message_id: " ",
|
|
|
|
|
});
|
|
|
|
|
expect(result.quote.email_message_id).toMatch(/^synth-[0-9a-f]{8}@wfdash\.local$/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps a real (non-empty) message_id verbatim — never synthesizes over a real one", async () => {
|
|
|
|
|
const result = await createQuoteFromEmail({
|
|
|
|
|
...basePayload,
|
|
|
|
|
message_id: "<real-msg-id@mail.example.com>",
|
|
|
|
|
});
|
|
|
|
|
expect(result.quote.email_message_id).toBe("<real-msg-id@mail.example.com>");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("appendReplyToQuote", () => {
|
|
|
|
|
it("matches by In-Reply-To header (strongest signal)", async () => {
|
|
|
|
|
const created = await createQuoteFromEmail({
|
|
|
|
|
from_email: "buyer@a.com",
|
|
|
|
|
from_name: "Buyer",
|
|
|
|
|
subject: "Quotation needed",
|
|
|
|
|
body_plain: "",
|
|
|
|
|
message_id: "<orig@example.com>",
|
|
|
|
|
});
|
|
|
|
|
const result = await appendReplyToQuote({
|
|
|
|
|
from_email: "buyer@a.com",
|
|
|
|
|
from_name: "Buyer",
|
|
|
|
|
subject: "Re: Some subject without a number",
|
|
|
|
|
body_plain: "Thanks, looks good.",
|
|
|
|
|
message_id: "<reply1@example.com>",
|
|
|
|
|
in_reply_to: "<orig@example.com>",
|
|
|
|
|
received_at: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
expect(result.matched_by).toBe("in_reply_to");
|
|
|
|
|
expect(result.quote?.id).toBe(created.quote.id);
|
|
|
|
|
expect((result.reply as Record<string, unknown>).in_reply_to).toBe("<orig@example.com>");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back to subject regex when In-Reply-To is missing", async () => {
|
|
|
|
|
const created = await createQuoteFromEmail({
|
|
|
|
|
from_email: "buyer@b.com",
|
|
|
|
|
subject: "Need a quote",
|
|
|
|
|
body_plain: "",
|
|
|
|
|
message_id: "<orig2@example.com>",
|
|
|
|
|
});
|
|
|
|
|
const result = await appendReplyToQuote({
|
|
|
|
|
from_email: "buyer@b.com",
|
|
|
|
|
subject: `Re: Quotation ${created.quote_number}`,
|
|
|
|
|
body_plain: "OK, please proceed.",
|
|
|
|
|
message_id: "<reply2@example.com>",
|
|
|
|
|
});
|
|
|
|
|
expect(result.matched_by).toBe("subject");
|
|
|
|
|
expect(result.quote?.id).toBe(created.quote.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updates has_new_reply and last_reply_at on the matched quote", async () => {
|
|
|
|
|
const created = await createQuoteFromEmail({
|
|
|
|
|
from_email: "buyer@c.com",
|
|
|
|
|
subject: "Need a quote",
|
|
|
|
|
body_plain: "",
|
|
|
|
|
message_id: "<orig3@example.com>",
|
|
|
|
|
});
|
|
|
|
|
const replyDate = "2026-06-11T10:00:00.000Z";
|
|
|
|
|
await appendReplyToQuote({
|
|
|
|
|
from_email: "buyer@c.com",
|
|
|
|
|
subject: `Re: Quotation ${created.quote_number}`,
|
|
|
|
|
body_plain: "Approved.",
|
|
|
|
|
message_id: "<reply3@example.com>",
|
|
|
|
|
received_at: replyDate,
|
|
|
|
|
});
|
|
|
|
|
const updated = await pbMock.collection("quotes").getOne(created.quote.id);
|
|
|
|
|
expect(updated.has_new_reply).toBe(true);
|
|
|
|
|
expect(updated.last_reply_at).toBe(replyDate);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns matched_by='none' when no match — caller moves to Unmatched", async () => {
|
|
|
|
|
const result = await appendReplyToQuote({
|
|
|
|
|
from_email: "unknown@x.com",
|
|
|
|
|
subject: "Some random reply",
|
|
|
|
|
body_plain: "Hello",
|
|
|
|
|
});
|
|
|
|
|
expect(result.matched_by).toBe("none");
|
|
|
|
|
expect(result.quote).toBeNull();
|
|
|
|
|
expect(result.reply).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("stores the reply as a structured quote_replies row, not as notes text", async () => {
|
|
|
|
|
const created = await createQuoteFromEmail({
|
|
|
|
|
from_email: "buyer@d.com",
|
|
|
|
|
subject: "Need a quote",
|
|
|
|
|
body_plain: "",
|
|
|
|
|
message_id: "<orig4@example.com>",
|
|
|
|
|
});
|
|
|
|
|
await appendReplyToQuote({
|
|
|
|
|
from_email: "buyer@d.com",
|
|
|
|
|
subject: `Re: Quotation ${created.quote_number}`,
|
|
|
|
|
body_plain: "Reply body content",
|
|
|
|
|
quoted_text: "Original quoted text",
|
|
|
|
|
message_id: "<reply4@example.com>",
|
|
|
|
|
});
|
|
|
|
|
const replies = await pbMock.collection("quote_replies").getFullList({
|
|
|
|
|
filter: `quote = "${created.quote.id}"`,
|
|
|
|
|
});
|
|
|
|
|
expect(replies.length).toBe(1);
|
|
|
|
|
expect(replies[0].body_plain).toBe("Reply body content");
|
|
|
|
|
expect(replies[0].quoted_text).toBe("Original quoted text");
|
|
|
|
|
// The quote's notes field should NOT contain the reply body.
|
|
|
|
|
expect(created.quote.notes).toBe("");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("findQuoteByMessageId", () => {
|
|
|
|
|
it("returns the matching quote", async () => {
|
|
|
|
|
const created = await createQuoteFromEmail({
|
|
|
|
|
from_email: "buyer@e.com",
|
|
|
|
|
subject: "Need a quote",
|
|
|
|
|
body_plain: "",
|
|
|
|
|
message_id: "<findme@example.com>",
|
|
|
|
|
});
|
|
|
|
|
const found = await findQuoteByMessageId("<findme@example.com>");
|
|
|
|
|
expect(found?.id).toBe(created.quote.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns null when no match", async () => {
|
|
|
|
|
const found = await findQuoteByMessageId("<nope@example.com>");
|
|
|
|
|
expect(found).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns null for empty Message-ID", async () => {
|
|
|
|
|
expect(await findQuoteByMessageId("")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
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
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Helper: create a quote in the requested status with a real-looking
|
|
|
|
|
* customer + PDF already attached. The pipeline tests below mutate
|
|
|
|
|
* this base case (delete the PDF, blank the email, etc.) to exercise
|
|
|
|
|
* specific failure paths.
|
|
|
|
|
*/
|
|
|
|
|
async function seedReadyQuote(overrides: {
|
|
|
|
|
status?: string;
|
|
|
|
|
customerEmail?: string | null;
|
|
|
|
|
customerName?: string;
|
|
|
|
|
withPdf?: boolean;
|
|
|
|
|
} = {}) {
|
|
|
|
|
const customer = await pbMock.collection("customers").create({
|
|
|
|
|
customer: overrides.customerName ?? "Acme Mining",
|
|
|
|
|
email:
|
|
|
|
|
overrides.customerEmail === null
|
|
|
|
|
? ""
|
|
|
|
|
: overrides.customerEmail ?? "buyer@acme.co.za",
|
|
|
|
|
alternative_email: "",
|
|
|
|
|
buyer_email: "",
|
|
|
|
|
number: "",
|
|
|
|
|
company: "Acme",
|
|
|
|
|
dateadded: "",
|
|
|
|
|
lastvisiteddate: "",
|
|
|
|
|
rep: "",
|
|
|
|
|
coordinates: "",
|
|
|
|
|
description: "",
|
|
|
|
|
});
|
|
|
|
|
const q = await pbMock.collection("quotes").create({
|
|
|
|
|
quote_number: "Q-2026-06-099",
|
|
|
|
|
customer: customer.id,
|
|
|
|
|
status: overrides.status ?? "approval_pending",
|
|
|
|
|
notes: "",
|
|
|
|
|
email_sender: "",
|
|
|
|
|
email_subject: "",
|
|
|
|
|
email_body: "",
|
|
|
|
|
has_attachments: false,
|
|
|
|
|
company_letterhead: "CNL",
|
|
|
|
|
});
|
|
|
|
|
if (overrides.withPdf !== false) {
|
|
|
|
|
await pbMock.collection("quote_attachments").create({
|
|
|
|
|
quote: q.id,
|
|
|
|
|
filename: "quote.pdf",
|
|
|
|
|
file: ["pdf-token"],
|
|
|
|
|
content_type: "application/pdf",
|
|
|
|
|
size_bytes: 12345,
|
|
|
|
|
email_message_id: "",
|
|
|
|
|
kind: "pdf",
|
|
|
|
|
uploaded_at: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { quote: q, customer };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("sendQuoteEmail", () => {
|
|
|
|
|
it("flips status to sending on a happy path and persists composition", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote();
|
|
|
|
|
const result = await sendQuoteEmail(quote.id, {
|
|
|
|
|
cc: "buyer2@acme.co.za, ceo@acme.co.za",
|
|
|
|
|
bcc: "rep@wfdash.local",
|
|
|
|
|
subject: "Re: Quote Q-2026-06-099",
|
|
|
|
|
message: "Please find attached.",
|
|
|
|
|
attachAuthLetters: true,
|
|
|
|
|
selectedAttachmentIds: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.failure).toBeNull();
|
|
|
|
|
expect(result.quote.status).toBe("sending");
|
|
|
|
|
expect(result.quote.email_cc).toBe("buyer2@acme.co.za, ceo@acme.co.za");
|
|
|
|
|
expect(result.quote.email_subject_outbound).toBe("Re: Quote Q-2026-06-099");
|
|
|
|
|
expect(result.quote.attach_auth_letters).toBe(true);
|
|
|
|
|
expect(result.quote.sending_started_at).toBeTruthy();
|
|
|
|
|
expect(result.quote.email_error).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("captures selectedAttachmentIds into email_outbound_attachment_ids JSON, filters foreign IDs", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote();
|
|
|
|
|
const a1 = await pbMock.collection("quote_attachments").create({
|
|
|
|
|
quote: quote.id,
|
|
|
|
|
filename: "spec.pdf",
|
|
|
|
|
file: ["t1"],
|
|
|
|
|
content_type: "application/pdf",
|
|
|
|
|
size_bytes: 1,
|
|
|
|
|
email_message_id: "",
|
|
|
|
|
kind: "doc",
|
|
|
|
|
direction: "outbound",
|
|
|
|
|
});
|
|
|
|
|
const a2 = await pbMock.collection("quote_attachments").create({
|
|
|
|
|
quote: quote.id,
|
|
|
|
|
filename: "drawing.dwg",
|
|
|
|
|
file: ["t2"],
|
|
|
|
|
content_type: "application/octet-stream",
|
|
|
|
|
size_bytes: 2,
|
|
|
|
|
email_message_id: "",
|
|
|
|
|
kind: "other",
|
|
|
|
|
direction: "outbound",
|
|
|
|
|
});
|
|
|
|
|
// Belongs to a DIFFERENT quote — must be filtered out.
|
|
|
|
|
const a3 = await pbMock.collection("quote_attachments").create({
|
|
|
|
|
quote: "DIFFERENT_QUOTE_ID",
|
|
|
|
|
filename: "smuggled.pdf",
|
|
|
|
|
file: ["t3"],
|
|
|
|
|
content_type: "application/pdf",
|
|
|
|
|
size_bytes: 3,
|
|
|
|
|
email_message_id: "",
|
|
|
|
|
kind: "doc",
|
|
|
|
|
});
|
|
|
|
|
const result = await sendQuoteEmail(quote.id, {
|
|
|
|
|
cc: "",
|
|
|
|
|
bcc: "",
|
|
|
|
|
subject: "",
|
|
|
|
|
message: "",
|
|
|
|
|
attachAuthLetters: false,
|
|
|
|
|
selectedAttachmentIds: [a1.id, a2.id, a3.id],
|
|
|
|
|
});
|
|
|
|
|
expect(result.failure).toBeNull();
|
|
|
|
|
const parsed = JSON.parse(result.quote.email_outbound_attachment_ids);
|
|
|
|
|
expect(parsed.sort()).toEqual([a1.id, a2.id].sort());
|
|
|
|
|
expect(parsed).not.toContain(a3.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects with no_recipient when customer has no email and no alternative", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote({ customerEmail: "" });
|
|
|
|
|
const result = await sendQuoteEmail(quote.id, {
|
|
|
|
|
cc: "",
|
|
|
|
|
bcc: "",
|
|
|
|
|
subject: "",
|
|
|
|
|
message: "",
|
|
|
|
|
attachAuthLetters: true,
|
|
|
|
|
selectedAttachmentIds: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.failure?.code).toBe("no_recipient");
|
|
|
|
|
expect(result.quote.status).toBe("mail_failed");
|
|
|
|
|
expect(result.quote.email_error).toMatch(/No valid recipient/);
|
|
|
|
|
expect(result.quote.email_failed_at).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects with placeholder_customer when customer is the Unknown Sender sentinel", async () => {
|
|
|
|
|
const sentinel = await pbMock.collection("customers").create({
|
|
|
|
|
id: "dcpb38w4mk6vrm5",
|
|
|
|
|
customer: "Unknown Sender",
|
|
|
|
|
email: "buyer@x.com",
|
|
|
|
|
alternative_email: "",
|
|
|
|
|
buyer_email: "",
|
|
|
|
|
number: "",
|
|
|
|
|
company: "",
|
|
|
|
|
dateadded: "",
|
|
|
|
|
lastvisiteddate: "",
|
|
|
|
|
rep: "",
|
|
|
|
|
coordinates: "",
|
|
|
|
|
description: "",
|
|
|
|
|
});
|
|
|
|
|
const q = await pbMock.collection("quotes").create({
|
|
|
|
|
quote_number: "Q-2026-06-100",
|
|
|
|
|
customer: sentinel.id,
|
|
|
|
|
status: "approval_pending",
|
|
|
|
|
});
|
|
|
|
|
const result = await sendQuoteEmail(q.id, {
|
|
|
|
|
cc: "",
|
|
|
|
|
bcc: "",
|
|
|
|
|
subject: "",
|
|
|
|
|
message: "",
|
|
|
|
|
attachAuthLetters: false,
|
|
|
|
|
selectedAttachmentIds: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.failure?.code).toBe("placeholder_customer");
|
|
|
|
|
expect(result.quote.status).toBe("mail_failed");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects with invalid_recipient when CC contains garbage", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote();
|
|
|
|
|
const result = await sendQuoteEmail(quote.id, {
|
|
|
|
|
cc: "good@x.com, not-an-email, also-bad",
|
|
|
|
|
bcc: "",
|
|
|
|
|
subject: "",
|
|
|
|
|
message: "",
|
|
|
|
|
attachAuthLetters: true,
|
|
|
|
|
selectedAttachmentIds: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.failure?.code).toBe("invalid_recipient");
|
|
|
|
|
expect(result.failure?.message).toContain("not-an-email");
|
|
|
|
|
expect(result.quote.status).toBe("mail_failed");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects with no_pdf when no PDF attachment exists", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote({ withPdf: false });
|
|
|
|
|
const result = await sendQuoteEmail(quote.id, {
|
|
|
|
|
cc: "",
|
|
|
|
|
bcc: "",
|
|
|
|
|
subject: "",
|
|
|
|
|
message: "",
|
|
|
|
|
attachAuthLetters: true,
|
|
|
|
|
selectedAttachmentIds: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.failure?.code).toBe("no_pdf");
|
|
|
|
|
expect(result.quote.status).toBe("mail_failed");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects with invalid_status when quote is already emailed", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote({ status: "emailed" });
|
|
|
|
|
const result = await sendQuoteEmail(quote.id, {
|
|
|
|
|
cc: "",
|
|
|
|
|
bcc: "",
|
|
|
|
|
subject: "",
|
|
|
|
|
message: "",
|
|
|
|
|
attachAuthLetters: true,
|
|
|
|
|
selectedAttachmentIds: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.failure?.code).toBe("invalid_status");
|
|
|
|
|
expect(result.quote.status).toBe("mail_failed");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns quote_not_found for a bogus id", async () => {
|
|
|
|
|
const result = await sendQuoteEmail("does-not-exist", {
|
|
|
|
|
cc: "",
|
|
|
|
|
bcc: "",
|
|
|
|
|
subject: "",
|
|
|
|
|
message: "",
|
|
|
|
|
attachAuthLetters: true,
|
|
|
|
|
selectedAttachmentIds: [],
|
|
|
|
|
});
|
|
|
|
|
expect(result.failure?.code).toBe("quote_not_found");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("retryQuoteEmail", () => {
|
|
|
|
|
it("flips a mail_failed quote back to sending and clears error", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote({ status: "mail_failed" });
|
|
|
|
|
await pbMock.collection("quotes").update(quote.id, {
|
|
|
|
|
email_error: "[smtp] Connection refused",
|
|
|
|
|
email_failed_at: "2026-06-23T10:00:00.000Z",
|
|
|
|
|
});
|
|
|
|
|
const result = await retryQuoteEmail(quote.id);
|
|
|
|
|
expect(result.failure).toBeNull();
|
|
|
|
|
expect(result.quote.status).toBe("sending");
|
|
|
|
|
expect(result.quote.email_error).toBe("");
|
|
|
|
|
expect(result.quote.email_failed_at).toBe("");
|
|
|
|
|
expect(result.quote.sending_started_at).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects with invalid_status when the quote isn't mail_failed", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote({ status: "approval_pending" });
|
|
|
|
|
const result = await retryQuoteEmail(quote.id);
|
|
|
|
|
expect(result.failure?.code).toBe("invalid_status");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects with quote_not_found for a bogus id", async () => {
|
|
|
|
|
const result = await retryQuoteEmail("missing");
|
|
|
|
|
expect(result.failure?.code).toBe("quote_not_found");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("recordSendOutcome", () => {
|
|
|
|
|
it("flips to emailed on success + stamps message_id + clears error", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote({ status: "sending" });
|
|
|
|
|
await pbMock.collection("quotes").update(quote.id, {
|
|
|
|
|
sending_started_at: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
const updated = await recordSendOutcome({
|
|
|
|
|
quoteId: quote.id,
|
|
|
|
|
outcome: "success",
|
|
|
|
|
messageId: "<out-123@wfdash.local>",
|
|
|
|
|
});
|
|
|
|
|
expect(updated.status).toBe("emailed");
|
|
|
|
|
expect(updated.email_delivered).toBe(true);
|
|
|
|
|
expect(updated.email_delivered_at).toBeTruthy();
|
|
|
|
|
expect(updated.email_outbound_message_id).toBe("<out-123@wfdash.local>");
|
|
|
|
|
expect(updated.email_error).toBe("");
|
|
|
|
|
expect(updated.sending_started_at).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("flips to mail_failed on SMTP failure + records reason + clears sending_started_at", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote({ status: "sending" });
|
|
|
|
|
await pbMock.collection("quotes").update(quote.id, {
|
|
|
|
|
sending_started_at: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
const updated = await recordSendOutcome({
|
|
|
|
|
quoteId: quote.id,
|
|
|
|
|
outcome: "failure",
|
|
|
|
|
errorMessage: "550 5.1.1 User unknown",
|
|
|
|
|
messageId: "<out-failed@wfdash.local>",
|
|
|
|
|
});
|
|
|
|
|
expect(updated.status).toBe("mail_failed");
|
|
|
|
|
expect(updated.email_failed_at).toBeTruthy();
|
|
|
|
|
expect(updated.email_error).toMatch(/550 5\.1\.1 User unknown/);
|
|
|
|
|
expect(updated.email_outbound_message_id).toBe("<out-failed@wfdash.local>");
|
|
|
|
|
expect(updated.sending_started_at).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("truncates long error messages so we don't exceed PB's 5000-byte cap", async () => {
|
|
|
|
|
const { quote } = await seedReadyQuote({ status: "sending" });
|
|
|
|
|
const huge = "x".repeat(10_000);
|
|
|
|
|
const updated = await recordSendOutcome({
|
|
|
|
|
quoteId: quote.id,
|
|
|
|
|
outcome: "failure",
|
|
|
|
|
errorMessage: huge,
|
|
|
|
|
});
|
|
|
|
|
expect(updated.email_error.length).toBeLessThan(5000);
|
|
|
|
|
});
|
|
|
|
|
});
|
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 — tab filter, counts cache.
|
|
|
|
|
//
|
|
|
|
|
// Same surface as the orders dashboard tests: prove the server
|
|
|
|
|
// narrows by tab (so the footer pagination matches the badge count)
|
|
|
|
|
// and that the counts cache works.
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("QUOTE_TAB_STATUSES (canonical tab → statuses mapping)", () => {
|
|
|
|
|
it("absorbs cancelled + rejected under the cancelled tab (was bucketed under completed before)", async () => {
|
|
|
|
|
// Regression guard for the long-standing bug where the
|
|
|
|
|
// Cancelled tab badge was always empty: the old
|
|
|
|
|
// getQuoteStatusCounts mapped `cancelled` and `rejected` to
|
|
|
|
|
// "completed", so the page's "cancelled" tab saw zero count
|
|
|
|
|
// even when 60+ rejected quotes existed.
|
|
|
|
|
expect(QUOTE_TAB_STATUSES.cancelled).toEqual(
|
|
|
|
|
expect.arrayContaining(["cancelled", "rejected"]),
|
|
|
|
|
);
|
|
|
|
|
expect(QUOTE_TAB_STATUSES.completed).not.toContain("cancelled");
|
|
|
|
|
expect(QUOTE_TAB_STATUSES.completed).not.toContain("rejected");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("includes every status the page tab renders, with no tab having an empty status list unless it's a special tab", async () => {
|
|
|
|
|
// delivery_note, all, and templates are special-cased (no
|
|
|
|
|
// status filter). Every other tab must have at least one status.
|
|
|
|
|
for (const tab of QUOTE_TAB_ORDER) {
|
|
|
|
|
if (tab === "delivery_note" || tab === "all" || tab === "templates") {
|
|
|
|
|
expect(QUOTE_TAB_STATUSES[tab]).toEqual([]);
|
|
|
|
|
} else {
|
|
|
|
|
expect(QUOTE_TAB_STATUSES[tab].length).toBeGreaterThan(0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("getQuoteStatusTabCounts", () => {
|
|
|
|
|
beforeEach(() => clearQuoteStatusTabCountsCache());
|
|
|
|
|
|
|
|
|
|
it("buckets quotes into the right tab based on status", async () => {
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-emailed", quote_number: "Q1", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-emailed-2", quote_number: "Q2", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-approved", quote_number: "Q3", status: "approved",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-rejected", quote_number: "Q4", status: "rejected",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-cancelled", quote_number: "Q5", status: "cancelled",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-new", quote_number: "Q6", status: "new",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const counts = await getQuoteStatusTabCounts(pbMock as never);
|
|
|
|
|
expect(counts.completed).toBe(3); // 2 emailed + 1 approved
|
|
|
|
|
expect(counts.cancelled).toBe(2); // 1 rejected + 1 cancelled
|
|
|
|
|
expect(counts.new).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns the same counts on a second call (cache hit)", async () => {
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q1", quote_number: "Q1", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const first = await getQuoteStatusTabCounts(pbMock as never);
|
|
|
|
|
// Mutate the underlying store — the cache should still serve
|
|
|
|
|
// the stale value.
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q2", quote_number: "Q2", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
const cached = await getQuoteStatusTabCounts(pbMock as never);
|
|
|
|
|
expect(cached.completed).toBe(first.completed);
|
|
|
|
|
|
|
|
|
|
// Bypass the cache to see the new state.
|
|
|
|
|
const fresh = await getQuoteStatusTabCounts(pbMock as never, { bypassCache: true });
|
|
|
|
|
expect(fresh.completed).toBe(first.completed + 1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("getQuotesDashboardData — end-to-end", () => {
|
|
|
|
|
beforeEach(() => clearQuoteStatusTabCountsCache());
|
|
|
|
|
|
|
|
|
|
it("returns tab-scoped items + global counts in a single response", async () => {
|
|
|
|
|
// Set up: 1 emailed, 1 approved (both → completed tab), 1 new
|
|
|
|
|
// (new tab), 1 rejected (cancelled tab).
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q1", quote_number: "Q1", status: "emailed",
|
|
|
|
|
created: "2026-01-01T00:00:00Z",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q2", quote_number: "Q2", status: "approved",
|
|
|
|
|
created: "2026-01-02T00:00:00Z",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q3", quote_number: "Q3", status: "new",
|
|
|
|
|
created: "2026-01-03T00:00:00Z",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q4", quote_number: "Q4", status: "rejected",
|
|
|
|
|
created: "2026-01-04T00:00:00Z",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await getQuotesDashboardData({
|
|
|
|
|
page: 1,
|
|
|
|
|
perPage: 20,
|
|
|
|
|
tab: "completed",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Counts are global (always were) — the badge still tells the truth.
|
|
|
|
|
expect(result.counts.completed).toBe(2);
|
|
|
|
|
expect(result.counts.new).toBe(1);
|
|
|
|
|
expect(result.counts.cancelled).toBe(1);
|
|
|
|
|
|
|
|
|
|
// Items are scoped to the active tab.
|
|
|
|
|
expect(result.items.map((q) => q.id).sort()).toEqual(["q1", "q2"]);
|
|
|
|
|
expect(result.totalItems).toBe(2);
|
|
|
|
|
expect(result.totalPages).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("scopes the Cancelled tab to rejected + cancelled statuses (the long-standing bug)", async () => {
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-rejected", quote_number: "Q1", status: "rejected",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-cancelled", quote_number: "Q2", status: "cancelled",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-emailed", quote_number: "Q3", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await getQuotesDashboardData({
|
|
|
|
|
page: 1,
|
|
|
|
|
perPage: 20,
|
|
|
|
|
tab: "cancelled",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.counts.cancelled).toBe(2); // rejected + cancelled both counted here
|
|
|
|
|
expect(result.counts.completed).toBe(1); // emailed stays under completed
|
|
|
|
|
expect(result.items.map((q) => q.id).sort()).toEqual([
|
|
|
|
|
"q-cancelled",
|
|
|
|
|
"q-rejected",
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
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.
|
|
|
|
|
//
|
|
|
|
|
// The archive holds 7,849 of 8,466 quotes (the "completed" and "all"
|
|
|
|
|
// tabs on the main view). These tests pin the matching set and the
|
|
|
|
|
// view toggle.
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("getQuotesArchiveData", () => {
|
|
|
|
|
it("returns completed quotes by default with the 'completed' view header counts", async () => {
|
|
|
|
|
// 2 emailed (completed), 1 new (active), 1 cancelled (bucketed
|
|
|
|
|
// under cancelled in the original tab mapping — counts toward
|
|
|
|
|
// the 'all' view but not 'completed').
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-1", quote_number: "Q-2025-001", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-2", quote_number: "Q-2025-002", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-3", quote_number: "Q-2025-003", status: "new",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-4", quote_number: "Q-2025-004", status: "cancelled",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await getQuotesArchiveData({ page: 1, perPage: 20 });
|
|
|
|
|
// Default view is "completed" — only the 2 emailed quotes.
|
|
|
|
|
expect(result.view).toBe("completed");
|
|
|
|
|
expect(result.items.map((q) => q.id).sort()).toEqual(["q-1", "q-2"]);
|
|
|
|
|
expect(result.totalItems).toBe(2);
|
|
|
|
|
expect(result.counts.completed).toBe(2);
|
|
|
|
|
expect(result.counts.all).toBe(4); // 2 emailed + 1 new + 1 cancelled
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns every counted-status quote for the 'all' view", async () => {
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-new", quote_number: "Q-2025-NEW", status: "new",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-emailed", quote_number: "Q-2025-EM", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-problem", quote_number: "Q-2025-PR", status: "problem",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await getQuotesArchiveData({
|
|
|
|
|
page: 1,
|
|
|
|
|
perPage: 20,
|
|
|
|
|
view: "all",
|
|
|
|
|
});
|
|
|
|
|
expect(result.view).toBe("all");
|
|
|
|
|
expect(result.items.length).toBe(3);
|
|
|
|
|
expect(result.counts.all).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("filters by search (quote_number, reference, customer, company)", async () => {
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-1", quote_number: "Q-FINDME-001", status: "emailed",
|
|
|
|
|
quote_reference: "ref-A",
|
|
|
|
|
});
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-2", quote_number: "Q-OTHER-002", status: "emailed",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const byNumber = await getQuotesArchiveData({
|
|
|
|
|
page: 1, perPage: 20, search: "FINDME",
|
|
|
|
|
});
|
|
|
|
|
expect(byNumber.items.map((q) => q.id)).toEqual(["q-1"]);
|
|
|
|
|
|
|
|
|
|
const byRef = await getQuotesArchiveData({
|
|
|
|
|
page: 1, perPage: 20, search: "ref-A",
|
|
|
|
|
});
|
|
|
|
|
expect(byRef.items.map((q) => q.id)).toEqual(["q-1"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns an empty result when no quotes match the view", async () => {
|
|
|
|
|
// All new — no completed quotes to show.
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
id: "q-1", quote_number: "Q-2025-001", status: "new",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await getQuotesArchiveData({ page: 1, perPage: 20 });
|
|
|
|
|
expect(result.items).toEqual([]);
|
|
|
|
|
expect(result.totalItems).toBe(0);
|
|
|
|
|
expect(result.counts.completed).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
});
|