@open-mercato/core 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/crmFixtures.js.map +1 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
- package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
- package/dist/modules/communication_channels/lib/test-seed.js +5 -0
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/page.js +4 -3
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/cli.js +5 -5
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
- package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
- package/dist/modules/customers/lib/closureStage.js.map +2 -2
- package/dist/modules/customers/lib/dealStatus.js +6 -4
- package/dist/modules/customers/lib/dealStatus.js.map +2 -2
- package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
- package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
- package/dist/modules/eudr/notifications.js +5 -0
- package/dist/modules/eudr/notifications.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +59 -28
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +146 -99
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +1 -30
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/commands/shared.js +1 -4
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +2 -1
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +2 -2
- package/dist/modules/sales/data/validators.js +3 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/lib/calculations.js +14 -2
- package/dist/modules/sales/lib/calculations.js.map +2 -2
- package/dist/modules/sales/lib/json.js +8 -0
- package/dist/modules/sales/lib/json.js.map +7 -0
- package/dist/modules/sales/lib/lineSnapshots.js +66 -0
- package/dist/modules/sales/lib/lineSnapshots.js.map +7 -0
- package/dist/modules/warranty_claims/notifications.js +5 -0
- package/dist/modules/warranty_claims/notifications.js.map +2 -2
- package/dist/modules/wms/notifications.js +2 -0
- package/dist/modules/wms/notifications.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/crmFixtures.ts +1 -1
- package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
- package/src/modules/communication_channels/lib/adapter.ts +11 -0
- package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
- package/src/modules/communication_channels/lib/test-seed.ts +19 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
- package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
- package/src/modules/customers/cli.ts +5 -5
- package/src/modules/customers/commands/deals.ts +4 -3
- package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
- package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
- package/src/modules/customers/i18n/de.json +1 -1
- package/src/modules/customers/i18n/en.json +1 -1
- package/src/modules/customers/i18n/es.json +1 -1
- package/src/modules/customers/i18n/ko.json +1 -1
- package/src/modules/customers/i18n/pl.json +1 -1
- package/src/modules/customers/lib/closureStage.ts +2 -1
- package/src/modules/customers/lib/dealStatus.ts +23 -12
- package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
- package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
- package/src/modules/eudr/notifications.ts +5 -0
- package/src/modules/query_index/lib/search-tokens.ts +100 -43
- package/src/modules/sales/commands/documents.ts +185 -105
- package/src/modules/sales/commands/returns.ts +1 -31
- package/src/modules/sales/commands/shared.ts +1 -4
- package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +12 -1
- package/src/modules/sales/data/validators.ts +3 -0
- package/src/modules/sales/lib/calculations.ts +33 -7
- package/src/modules/sales/lib/json.ts +4 -0
- package/src/modules/sales/lib/lineSnapshots.ts +98 -0
- package/src/modules/sales/lib/types.ts +20 -0
- package/src/modules/warranty_claims/notifications.ts +5 -0
- package/src/modules/wms/notifications.ts +2 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const DEAL_STATUS_WIN = "win";
|
|
2
|
-
const
|
|
2
|
+
const DEAL_STATUS_LOST = "lost";
|
|
3
3
|
const DEAL_STATUS_CLOSED = "closed";
|
|
4
|
+
const DEAL_STATUS_LOSE = "loose";
|
|
4
5
|
const WON_DEAL_STATUS_LIST = [DEAL_STATUS_WIN, "won"];
|
|
5
|
-
const LOST_DEAL_STATUS_LIST = [
|
|
6
|
+
const LOST_DEAL_STATUS_LIST = [DEAL_STATUS_LOST, DEAL_STATUS_LOSE];
|
|
6
7
|
const CLOSED_DEAL_STATUS_LIST = [
|
|
7
8
|
...WON_DEAL_STATUS_LIST,
|
|
8
9
|
...LOST_DEAL_STATUS_LIST,
|
|
@@ -26,8 +27,8 @@ function isOpenDealStatus(value) {
|
|
|
26
27
|
function canonicalDealStatus(value) {
|
|
27
28
|
const lower = value.toLowerCase();
|
|
28
29
|
if (lower === "won") return DEAL_STATUS_WIN;
|
|
29
|
-
if (lower === "
|
|
30
|
-
if (lower === "win" || lower === "
|
|
30
|
+
if (lower === "loose") return DEAL_STATUS_LOST;
|
|
31
|
+
if (lower === "win" || lower === "lost" || lower === "open" || lower === "closed") return lower;
|
|
31
32
|
return value;
|
|
32
33
|
}
|
|
33
34
|
function expandDealStatusAliases(values) {
|
|
@@ -55,6 +56,7 @@ export {
|
|
|
55
56
|
CLOSED_DEAL_STATUS_LIST,
|
|
56
57
|
DEAL_STATUS_CLOSED,
|
|
57
58
|
DEAL_STATUS_LOSE,
|
|
59
|
+
DEAL_STATUS_LOST,
|
|
58
60
|
DEAL_STATUS_WIN,
|
|
59
61
|
LOST_DEAL_STATUS_LIST,
|
|
60
62
|
WON_DEAL_STATUS_LIST,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/customers/lib/dealStatus.ts"],
|
|
4
|
-
"sourcesContent": ["// Single source of truth for deal open/closed (terminal) semantics.\n//\n// `customer_deals.status` is a lenient text column and the supported writers do not agree on\n// one spelling: the closure hooks and the kanban board persist `win` / `
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["// Single source of truth for deal open/closed (terminal) semantics.\n//\n// `customer_deals.status` is a lenient text column and the supported writers do not agree on\n// one spelling: the closure hooks and the kanban board persist `win` / `lost`, the AI tool\n// `customers.update_deal_stage` persists `won` / `lost`, the seeded vocabulary also carries\n// `closed`, and rows written before 0.7.1 carry the misspelled `loose`. The terminal set\n// below therefore covers every spelling a status writer persists, so status-based readers\n// can share one classification. `closureOutcome` is a separate signal, and callers that\n// combine both columns must handle it explicitly.\n//\n// A status not known to code counts as OPEN. Deal stages are configurable per tenant, so the\n// lenient column can hold values this module has never seen \u2014 treating them as open keeps them\n// visible in active-deal counts instead of silently reclassifying them as closed.\n\nexport const DEAL_STATUS_WIN = 'win' as const\nexport const DEAL_STATUS_LOST = 'lost' as const\nexport const DEAL_STATUS_CLOSED = 'closed' as const\n\n/**\n * @deprecated Misspelling of the lost-deal status, canonical until 0.7.0. Writers now\n * persist {@link DEAL_STATUS_LOST}. This export stays so downstream modules keep\n * compiling and readers keep matching rows written before the 0.7.1 migration; it is\n * scheduled for removal no earlier than 0.9.0.\n */\nexport const DEAL_STATUS_LOSE = 'loose' as const\n\n// `won` is the spelling the AI stage tool writes; `win` is what the UI closure flows\n// persist. Both are accepted so neither writer produces an unreadable status.\nexport const WON_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_WIN, 'won']\n\n// `lost` is canonical. `loose` is the pre-0.7.1 spelling the UI closure flows and the\n// kanban board persisted; unmigrated rows and tenant dictionaries still carry it.\nexport const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOST, DEAL_STATUS_LOSE]\n\nexport const CLOSED_DEAL_STATUS_LIST: readonly string[] = [\n ...WON_DEAL_STATUS_LIST,\n ...LOST_DEAL_STATUS_LIST,\n DEAL_STATUS_CLOSED,\n]\n\nconst WON_DEAL_STATUSES = new Set<string>(WON_DEAL_STATUS_LIST)\nconst LOST_DEAL_STATUSES = new Set<string>(LOST_DEAL_STATUS_LIST)\nconst CLOSED_DEAL_STATUSES = new Set<string>(CLOSED_DEAL_STATUS_LIST)\n\nexport function isWonDealStatus(value: string | null | undefined): boolean {\n return value != null && WON_DEAL_STATUSES.has(value)\n}\n\nexport function isLostDealStatus(value: string | null | undefined): boolean {\n return value != null && LOST_DEAL_STATUSES.has(value)\n}\n\nexport function isClosedDealStatus(value: string | null | undefined): boolean {\n return value != null && CLOSED_DEAL_STATUSES.has(value)\n}\n\nexport function isOpenDealStatus(value: string | null | undefined): boolean {\n return !isClosedDealStatus(value)\n}\n\nexport function canonicalDealStatus(value: string): string {\n const lower = value.toLowerCase()\n if (lower === 'won') return DEAL_STATUS_WIN\n if (lower === 'loose') return DEAL_STATUS_LOST\n // Canonical spellings normalize to lower-case too, so an upper-case selection still\n // matches the lower-case persisted values and dictionary options.\n if (lower === 'win' || lower === 'lost' || lower === 'open' || lower === 'closed') return lower\n return value\n}\n\n/**\n * Expand operator-selected status filter values into the canonical vocabulary so a\n * single selection matches every spelling the supported writers persist (#5107).\n *\n * Alias groups (`win`/`won`, `lost`/`loose`, `closed`) are matched case-insensitively,\n * expanded to their canonical spellings, and also keep the caller's original token so\n * the result is always a strict superset of a verbatim match. Unknown values emit both\n * the trimmed original and its lower-case form \u2014 mixed-case tenant dictionary statuses\n * match exactly while `?status=Open` still hits lower-case stored rows.\n */\nexport function expandDealStatusAliases(values: string[]): string[] {\n const out = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n const lower = trimmed.toLowerCase()\n if (lower === 'win' || lower === 'won') {\n WON_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else if (lower === 'loose' || lower === 'lost') {\n LOST_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else if (lower === 'closed') {\n CLOSED_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))\n out.add(trimmed)\n } else {\n out.add(trimmed)\n out.add(lower)\n }\n }\n return Array.from(out)\n}\n"],
|
|
5
|
+
"mappings": "AAcO,MAAM,kBAAkB;AACxB,MAAM,mBAAmB;AACzB,MAAM,qBAAqB;AAQ3B,MAAM,mBAAmB;AAIzB,MAAM,uBAA0C,CAAC,iBAAiB,KAAK;AAIvE,MAAM,wBAA2C,CAAC,kBAAkB,gBAAgB;AAEpF,MAAM,0BAA6C;AAAA,EACxD,GAAG;AAAA,EACH,GAAG;AAAA,EACH;AACF;AAEA,MAAM,oBAAoB,IAAI,IAAY,oBAAoB;AAC9D,MAAM,qBAAqB,IAAI,IAAY,qBAAqB;AAChE,MAAM,uBAAuB,IAAI,IAAY,uBAAuB;AAE7D,SAAS,gBAAgB,OAA2C;AACzE,SAAO,SAAS,QAAQ,kBAAkB,IAAI,KAAK;AACrD;AAEO,SAAS,iBAAiB,OAA2C;AAC1E,SAAO,SAAS,QAAQ,mBAAmB,IAAI,KAAK;AACtD;AAEO,SAAS,mBAAmB,OAA2C;AAC5E,SAAO,SAAS,QAAQ,qBAAqB,IAAI,KAAK;AACxD;AAEO,SAAS,iBAAiB,OAA2C;AAC1E,SAAO,CAAC,mBAAmB,KAAK;AAClC;AAEO,SAAS,oBAAoB,OAAuB;AACzD,QAAM,QAAQ,MAAM,YAAY;AAChC,MAAI,UAAU,MAAO,QAAO;AAC5B,MAAI,UAAU,QAAS,QAAO;AAG9B,MAAI,UAAU,SAAS,UAAU,UAAU,UAAU,UAAU,UAAU,SAAU,QAAO;AAC1F,SAAO;AACT;AAYO,SAAS,wBAAwB,QAA4B;AAClE,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,UAAM,QAAQ,QAAQ,YAAY;AAClC,QAAI,UAAU,SAAS,UAAU,OAAO;AACtC,2BAAqB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACtD,UAAI,IAAI,OAAO;AAAA,IACjB,WAAW,UAAU,WAAW,UAAU,QAAQ;AAChD,4BAAsB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACvD,UAAI,IAAI,OAAO;AAAA,IACjB,WAAW,UAAU,UAAU;AAC7B,8BAAwB,QAAQ,CAAC,UAAU,IAAI,IAAI,KAAK,CAAC;AACzD,UAAI,IAAI,OAAO;AAAA,IACjB,OAAO;AACL,UAAI,IAAI,OAAO;AACf,UAAI,IAAI,KAAK;AAAA,IACf;AAAA,EACF;AACA,SAAO,MAAM,KAAK,GAAG;AACvB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -78,9 +78,9 @@ async function loadDealsSummaryQueryRows(input) {
|
|
|
78
78
|
const winLossRows = await connection.execute(
|
|
79
79
|
`SELECT
|
|
80
80
|
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,
|
|
81
|
-
COUNT(*) FILTER (WHERE (status
|
|
81
|
+
COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
|
|
82
82
|
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,
|
|
83
|
-
COUNT(*) FILTER (WHERE (status
|
|
83
|
+
COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
|
|
84
84
|
FROM customer_deals
|
|
85
85
|
WHERE ${scopeWhere}`,
|
|
86
86
|
[
|
|
@@ -99,7 +99,7 @@ async function loadDealsSummaryQueryRows(input) {
|
|
|
99
99
|
`SELECT
|
|
100
100
|
to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,
|
|
101
101
|
COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,
|
|
102
|
-
COUNT(*) FILTER (WHERE status
|
|
102
|
+
COUNT(*) FILTER (WHERE status IN ('lost', 'loose') OR closure_outcome = 'lost') AS lost
|
|
103
103
|
FROM customer_deals
|
|
104
104
|
WHERE ${scopeWhere} AND updated_at >= ?
|
|
105
105
|
GROUP BY 1`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/customers/lib/dealsSummaryQueries.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { fetchStuckDealIds } from './stuckDeals'\n\ntype DateWindow = {\n start: Date\n end: Date\n}\n\nexport type OpenPipelineRow = {\n stage: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n owner_user_id: string | null\n}\n\nexport type WindowSumRow = {\n currency: string | null\n current_total: string | number | null\n current_count: string | number\n previous_total: string | number | null\n previous_count: string | number\n}\n\nexport type WinLossRow = {\n current_won: string | number\n current_lost: string | number\n previous_won: string | number\n previous_lost: string | number\n}\n\nexport type WinRateMonthRow = {\n period: string\n won: string | number\n lost: string | number\n}\n\nexport type DealsSummaryQueryRows = {\n openRows: OpenPipelineRow[]\n inflowRows: WindowSumRow[]\n wonRows: WindowSumRow[]\n winLossRows: WinLossRow[]\n seriesRows: WinRateMonthRow[]\n overdueRows: Array<{ id: string }>\n openStuckIds: string[]\n}\n\nexport type DealsSummaryQueryInput = {\n em: CoreEntityManager\n tenantId: string\n organizationIds: string[]\n currentQuarter: DateWindow\n previousQuarter: DateWindow\n seriesStart: Date\n}\n\ntype SqlPredicate = {\n clause: string\n values: string[]\n}\n\nexport function buildOpenDealPredicate(statuses: readonly string[]): SqlPredicate {\n if (statuses.length === 0) {\n throw new Error('[internal] Open-deal predicate requires at least one status')\n }\n\n const placeholders = statuses.map(() => '?').join(',')\n return {\n clause: `status IN (${placeholders}) AND closure_outcome IS NULL`,\n values: [...statuses],\n }\n}\n\nexport async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput): Promise<DealsSummaryQueryRows> {\n if (input.organizationIds.length === 0) {\n throw new Error('[internal] Deals summary query requires an organization scope')\n }\n\n const connection = input.em.getConnection()\n const organizationPlaceholders = input.organizationIds.map(() => '?').join(',')\n const scopeWhere = `tenant_id = ? AND organization_id IN (${organizationPlaceholders}) AND deleted_at IS NULL`\n const scopeValues: Array<string | number | null> = [input.tenantId, ...input.organizationIds]\n const openDealsPredicate = buildOpenDealPredicate(['open', 'in_progress'])\n\n const openRows = await connection.execute<OpenPipelineRow[]>(\n `SELECT\n pipeline_stage AS stage,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n owner_user_id\n FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause}\n GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,\n [...scopeValues, ...openDealsPredicate.values],\n )\n\n const inflowRows = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause}\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ...openDealsPredicate.values,\n ],\n )\n\n const wonRows = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n const winLossRows = await connection.execute<WinLossRow[]>(\n `SELECT\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,\n COUNT(*) FILTER (WHERE (status
|
|
4
|
+
"sourcesContent": ["import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { fetchStuckDealIds } from './stuckDeals'\n\ntype DateWindow = {\n start: Date\n end: Date\n}\n\nexport type OpenPipelineRow = {\n stage: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n owner_user_id: string | null\n}\n\nexport type WindowSumRow = {\n currency: string | null\n current_total: string | number | null\n current_count: string | number\n previous_total: string | number | null\n previous_count: string | number\n}\n\nexport type WinLossRow = {\n current_won: string | number\n current_lost: string | number\n previous_won: string | number\n previous_lost: string | number\n}\n\nexport type WinRateMonthRow = {\n period: string\n won: string | number\n lost: string | number\n}\n\nexport type DealsSummaryQueryRows = {\n openRows: OpenPipelineRow[]\n inflowRows: WindowSumRow[]\n wonRows: WindowSumRow[]\n winLossRows: WinLossRow[]\n seriesRows: WinRateMonthRow[]\n overdueRows: Array<{ id: string }>\n openStuckIds: string[]\n}\n\nexport type DealsSummaryQueryInput = {\n em: CoreEntityManager\n tenantId: string\n organizationIds: string[]\n currentQuarter: DateWindow\n previousQuarter: DateWindow\n seriesStart: Date\n}\n\ntype SqlPredicate = {\n clause: string\n values: string[]\n}\n\nexport function buildOpenDealPredicate(statuses: readonly string[]): SqlPredicate {\n if (statuses.length === 0) {\n throw new Error('[internal] Open-deal predicate requires at least one status')\n }\n\n const placeholders = statuses.map(() => '?').join(',')\n return {\n clause: `status IN (${placeholders}) AND closure_outcome IS NULL`,\n values: [...statuses],\n }\n}\n\nexport async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput): Promise<DealsSummaryQueryRows> {\n if (input.organizationIds.length === 0) {\n throw new Error('[internal] Deals summary query requires an organization scope')\n }\n\n const connection = input.em.getConnection()\n const organizationPlaceholders = input.organizationIds.map(() => '?').join(',')\n const scopeWhere = `tenant_id = ? AND organization_id IN (${organizationPlaceholders}) AND deleted_at IS NULL`\n const scopeValues: Array<string | number | null> = [input.tenantId, ...input.organizationIds]\n const openDealsPredicate = buildOpenDealPredicate(['open', 'in_progress'])\n\n const openRows = await connection.execute<OpenPipelineRow[]>(\n `SELECT\n pipeline_stage AS stage,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n owner_user_id\n FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause}\n GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,\n [...scopeValues, ...openDealsPredicate.values],\n )\n\n const inflowRows = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause}\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ...openDealsPredicate.values,\n ],\n )\n\n const wonRows = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n const winLossRows = await connection.execute<WinLossRow[]>(\n `SELECT\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,\n COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,\n COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost\n FROM customer_deals\n WHERE ${scopeWhere}`,\n [\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.currentQuarter.start.toISOString(),\n input.currentQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n input.previousQuarter.start.toISOString(),\n input.previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n const seriesRows = await connection.execute<WinRateMonthRow[]>(\n `SELECT\n to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,\n COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,\n COUNT(*) FILTER (WHERE status IN ('lost', 'loose') OR closure_outcome = 'lost') AS lost\n FROM customer_deals\n WHERE ${scopeWhere} AND updated_at >= ?\n GROUP BY 1`,\n [...scopeValues, input.seriesStart.toISOString()],\n )\n\n const overdueDealsPredicate = buildOpenDealPredicate(['open'])\n const overdueRows = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND ${overdueDealsPredicate.clause} AND expected_close_at IS NOT NULL AND expected_close_at < CURRENT_DATE`,\n [...scopeValues, ...overdueDealsPredicate.values],\n )\n\n const stuckIdLists = await Promise.all(\n input.organizationIds.map((organizationId) =>\n fetchStuckDealIds(input.em as unknown as PgEntityManager, organizationId, input.tenantId),\n ),\n )\n const stuckIdValues = Array.from(new Set(stuckIdLists.flat()))\n let openStuckIds: string[] = []\n if (stuckIdValues.length > 0) {\n const stuckPlaceholders = stuckIdValues.map(() => '?').join(',')\n const openStuckRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND ${openDealsPredicate.clause} AND id IN (${stuckPlaceholders})`,\n [...scopeValues, ...openDealsPredicate.values, ...stuckIdValues],\n )\n openStuckIds = openStuckRows.map((row) => row.id)\n }\n\n return {\n openRows,\n inflowRows,\n wonRows,\n winLossRows,\n seriesRows,\n overdueRows,\n openStuckIds,\n }\n}\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Migration } from "@mikro-orm/migrations";
|
|
2
|
+
class Migration20260824180000_deal_status_lost extends Migration {
|
|
3
|
+
async up() {
|
|
4
|
+
this.addSql(`update "customer_deals" set "status" = 'lost' where lower("status") = 'loose';`);
|
|
5
|
+
this.addSql(`update "customer_deals" set "pipeline_stage" = 'lost' where lower("pipeline_stage") = 'loose';`);
|
|
6
|
+
this.addSql(`
|
|
7
|
+
update "customer_dictionary_entries" as e
|
|
8
|
+
set "value" = 'lost',
|
|
9
|
+
"normalized_value" = 'lost',
|
|
10
|
+
"label" = case when e."label" = 'Loose' then 'Lost' else e."label" end,
|
|
11
|
+
"updated_at" = now()
|
|
12
|
+
where e."kind" in ('deal_status', 'pipeline_stage')
|
|
13
|
+
and e."normalized_value" = 'loose'
|
|
14
|
+
and not exists (
|
|
15
|
+
select 1 from "customer_dictionary_entries" as other
|
|
16
|
+
where other."organization_id" = e."organization_id"
|
|
17
|
+
and other."tenant_id" = e."tenant_id"
|
|
18
|
+
and other."kind" = e."kind"
|
|
19
|
+
and other."normalized_value" = 'lost'
|
|
20
|
+
);
|
|
21
|
+
`);
|
|
22
|
+
this.addSql(`update "customer_pipeline_stages" set "name" = 'Lost', "updated_at" = now() where "name" = 'Loose';`);
|
|
23
|
+
}
|
|
24
|
+
async down() {
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export {
|
|
28
|
+
Migration20260824180000_deal_status_lost
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=Migration20260824180000_deal_status_lost.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts"],
|
|
4
|
+
"sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\n/**\n * Rename the misspelled lost-deal vocabulary `loose` to `lost`.\n *\n * `loose` was the canonical spelling the UI closure flows, the kanban board and the seeded\n * dictionaries persisted up to 0.7.0. `lost` is what every other surface already used\n * (`closure_outcome`, the AI stage tool, every operator-facing label), so the two spellings\n * had to be reconciled anywhere a reader joined them.\n *\n * Forward-only and conservative. Nothing is deleted, and a row is left alone whenever\n * renaming it would collide with an entry the tenant already has:\n *\n * - `customer_deals.status` / `pipeline_stage`: `loose` -> `lost`.\n * - `customer_dictionary_entries` (`deal_status`, `pipeline_stage`): the `loose` entry is\n * renamed only when the same scope has no `lost` entry, since\n * `customer_dictionary_entries_unique` covers (org, tenant, kind, normalized_value).\n * Its label is corrected only when it is still the seeded `Loose`, so a tenant that\n * renamed the option keeps its own wording.\n * - `customer_pipeline_stages.name`: the seeded `Loose` label becomes `Lost`.\n *\n * Readers keep accepting `loose` (see `lib/dealStatus.ts`), so an instance that skips this\n * migration, or a row this migration deliberately leaves behind, still classifies correctly.\n */\nexport class Migration20260824180000_deal_status_lost extends Migration {\n override async up(): Promise<void> {\n this.addSql(`update \"customer_deals\" set \"status\" = 'lost' where lower(\"status\") = 'loose';`);\n this.addSql(`update \"customer_deals\" set \"pipeline_stage\" = 'lost' where lower(\"pipeline_stage\") = 'loose';`);\n\n this.addSql(`\n update \"customer_dictionary_entries\" as e\n set \"value\" = 'lost',\n \"normalized_value\" = 'lost',\n \"label\" = case when e.\"label\" = 'Loose' then 'Lost' else e.\"label\" end,\n \"updated_at\" = now()\n where e.\"kind\" in ('deal_status', 'pipeline_stage')\n and e.\"normalized_value\" = 'loose'\n and not exists (\n select 1 from \"customer_dictionary_entries\" as other\n where other.\"organization_id\" = e.\"organization_id\"\n and other.\"tenant_id\" = e.\"tenant_id\"\n and other.\"kind\" = e.\"kind\"\n and other.\"normalized_value\" = 'lost'\n );\n `);\n\n this.addSql(`update \"customer_pipeline_stages\" set \"name\" = 'Lost', \"updated_at\" = now() where \"name\" = 'Loose';`);\n }\n\n override async down(): Promise<void> {\n // Reverting is lossy: an instance may have carried `lost` rows before this migration ran,\n // and those are indistinguishable afterwards from the ones it renamed. Readers accept\n // both spellings, so leaving the data on the canonical `lost` is the safe no-op.\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iBAAiB;AAwBnB,MAAM,iDAAiD,UAAU;AAAA,EACtE,MAAe,KAAoB;AACjC,SAAK,OAAO,gFAAgF;AAC5F,SAAK,OAAO,gGAAgG;AAE5G,SAAK,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAeX;AAED,SAAK,OAAO,qGAAqG;AAAA,EACnH;AAAA,EAEA,MAAe,OAAsB;AAAA,EAIrC;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts"],
|
|
4
|
-
"sourcesContent": ["import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'\nimport type { WidgetDataRequest } from '../../../services/widgetDataService'\n\n// Deal statuses that mean the deal is closed and must stop counting as current pipeline.\n//\n// This is a denylist, not an allowlist: `customer_deals.status` is a lenient `text` column\n// fed by the per-tenant `deal_status` dictionary, so a status unknown to code counts as OPEN\n// and keeps contributing to the chart. That keeps tenant-specific stages visible and mirrors\n// `customers/lib/interactionStatus.ts`, which treats an unknown interaction status as open.\n//\n// Every vocabulary that reaches the column has to be listed here:\n// - `win` / `
|
|
4
|
+
"sourcesContent": ["import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'\nimport type { WidgetDataRequest } from '../../../services/widgetDataService'\n\n// Deal statuses that mean the deal is closed and must stop counting as current pipeline.\n//\n// This is a denylist, not an allowlist: `customer_deals.status` is a lenient `text` column\n// fed by the per-tenant `deal_status` dictionary, so a status unknown to code counts as OPEN\n// and keeps contributing to the chart. That keeps tenant-specific stages visible and mirrors\n// `customers/lib/interactionStatus.ts`, which treats an unknown interaction status as open.\n//\n// Every vocabulary that reaches the column has to be listed here:\n// - `win` / `lost` are written by the deal closure UI and the kanban board.\n// - `won` / `lost` are written verbatim by the `customers.update_deal_stage` AI tool, whose\n// free-form `toStage` is passed straight through to `status`.\n// - `closed` is a seeded `deal_status` dictionary value, persisted by the dashboards\n// analytics seed and treated as terminal by the demo-data generator.\nexport const CLOSED_DEAL_STATUSES = ['win', 'loose', 'won', 'lost', 'closed'] as const\n\n// A deal is also closed when `closure_outcome` records the outcome, which the deals-summary\n// KPI already treats as terminal: `api/deals/summary/route.ts` counts a deal as won or lost\n// when EITHER `status` OR `closure_outcome` says so. The two columns can disagree \u2014 the CRUD\n// API accepts `closureOutcome` on its own, and the AI tool writes `status` without ever setting\n// it \u2014 so the chart has to test both signals to agree with those KPI cards (#4668).\n//\n// The `closure_outcome` side is expressed as `IS NULL` rather than as a `neq` denylist for two\n// reasons, and swapping it for `neq` would be a silent regression:\n// - `closure_outcome` is nullable, and every OPEN deal holds NULL there. `neq` renders as\n// `column != ?`, and in SQL `NULL != 'won'` is NULL, not true \u2014 a `neq` filter would drop\n// every open deal and empty the chart. `status` has no such problem: it is `text not null\n// default 'open'`, which is why the status side stays a denylist.\n// - Unlike `status`, this column is not fed by a per-tenant dictionary. Every write path\n// validates it against the same closed `z.enum(['won', 'lost'])` (`data/validators.ts`,\n// `api/deals/[id]/route.ts`, `api/deals/[id]/stats/route.ts`), so a non-null value always\n// means closed and there is no tenant-specific vocabulary to keep visible.\nexport const OPEN_DEAL_CLOSURE_OUTCOME_FILTER = {\n field: 'closureOutcome',\n operator: 'is_null',\n} as const\n\nexport const PIPELINE_STATUS_SCOPES = ['open', 'all'] as const\n\nexport type PipelineStatusScope = typeof PIPELINE_STATUS_SCOPES[number]\n\nexport type PipelineSummarySettings = {\n dateRange: DateRangePreset\n statusScope: PipelineStatusScope\n}\n\nexport const DEFAULT_SETTINGS: PipelineSummarySettings = {\n dateRange: 'this_month',\n statusScope: 'open',\n}\n\nfunction isValidStatusScope(value: unknown): value is PipelineStatusScope {\n return typeof value === 'string' && (PIPELINE_STATUS_SCOPES as readonly string[]).includes(value)\n}\n\nexport function hydrateSettings(raw: unknown): PipelineSummarySettings {\n if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }\n const obj = raw as Record<string, unknown>\n return {\n dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,\n statusScope: isValidStatusScope(obj.statusScope) ? obj.statusScope : DEFAULT_SETTINGS.statusScope,\n }\n}\n\nexport function dehydrateSettings(settings: PipelineSummarySettings): Record<string, unknown> {\n return {\n dateRange: settings.dateRange,\n statusScope: settings.statusScope,\n }\n}\n\nexport function buildPipelineDataRequest(settings: PipelineSummarySettings): WidgetDataRequest {\n const request: WidgetDataRequest = {\n entityType: 'customers:deals',\n metric: {\n field: 'valueAmount',\n aggregate: 'sum',\n },\n groupBy: {\n field: 'pipelineStage',\n resolveLabels: true,\n },\n dateRange: {\n field: 'createdAt',\n preset: settings.dateRange,\n },\n }\n\n if (settings.statusScope === 'open') {\n request.filters = [\n ...CLOSED_DEAL_STATUSES.map((status) => ({\n field: 'status',\n operator: 'neq' as const,\n value: status,\n })),\n { ...OPEN_DEAL_CLOSURE_OUTCOME_FILTER },\n ]\n }\n\n return request\n}\n"],
|
|
5
5
|
"mappings": "AAAA,SAA+B,8BAA8B;AAgBtD,MAAM,uBAAuB,CAAC,OAAO,SAAS,OAAO,QAAQ,QAAQ;AAkBrE,MAAM,mCAAmC;AAAA,EAC9C,OAAO;AAAA,EACP,UAAU;AACZ;AAEO,MAAM,yBAAyB,CAAC,QAAQ,KAAK;AAS7C,MAAM,mBAA4C;AAAA,EACvD,WAAW;AAAA,EACX,aAAa;AACf;AAEA,SAAS,mBAAmB,OAA8C;AACxE,SAAO,OAAO,UAAU,YAAa,uBAA6C,SAAS,KAAK;AAClG;AAEO,SAAS,gBAAgB,KAAuC;AACrE,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO,EAAE,GAAG,iBAAiB;AAClE,QAAM,MAAM;AACZ,SAAO;AAAA,IACL,WAAW,uBAAuB,IAAI,SAAS,IAAI,IAAI,YAAY,iBAAiB;AAAA,IACpF,aAAa,mBAAmB,IAAI,WAAW,IAAI,IAAI,cAAc,iBAAiB;AAAA,EACxF;AACF;AAEO,SAAS,kBAAkB,UAA4D;AAC5F,SAAO;AAAA,IACL,WAAW,SAAS;AAAA,IACpB,aAAa,SAAS;AAAA,EACxB;AACF;AAEO,SAAS,yBAAyB,UAAsD;AAC7F,QAAM,UAA6B;AAAA,IACjC,YAAY;AAAA,IACZ,QAAQ;AAAA,MACN,OAAO;AAAA,MACP,WAAW;AAAA,IACb;AAAA,IACA,SAAS;AAAA,MACP,OAAO;AAAA,MACP,eAAe;AAAA,IACjB;AAAA,IACA,WAAW;AAAA,MACT,OAAO;AAAA,MACP,QAAQ,SAAS;AAAA,IACnB;AAAA,EACF;AAEA,MAAI,SAAS,gBAAgB,QAAQ;AACnC,YAAQ,UAAU;AAAA,MAChB,GAAG,qBAAqB,IAAI,CAAC,YAAY;AAAA,QACvC,OAAO;AAAA,QACP,UAAU;AAAA,QACV,OAAO;AAAA,MACT,EAAE;AAAA,MACF,EAAE,GAAG,iCAAiC;AAAA,IACxC;AAAA,EACF;AAEA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ const notificationTypes = [
|
|
|
2
2
|
{
|
|
3
3
|
type: "eudr.statement.submitted",
|
|
4
4
|
module: "eudr",
|
|
5
|
+
channels: ["in_app", "email"],
|
|
5
6
|
titleKey: "eudr.notifications.statement.submitted.title",
|
|
6
7
|
bodyKey: "eudr.notifications.statement.submitted.body",
|
|
7
8
|
icon: "file-check",
|
|
@@ -12,6 +13,7 @@ const notificationTypes = [
|
|
|
12
13
|
{
|
|
13
14
|
type: "eudr.statement.reference_issued",
|
|
14
15
|
module: "eudr",
|
|
16
|
+
channels: ["in_app", "email"],
|
|
15
17
|
titleKey: "eudr.notifications.statement.referenceIssued.title",
|
|
16
18
|
bodyKey: "eudr.notifications.statement.referenceIssued.body",
|
|
17
19
|
icon: "badge-check",
|
|
@@ -22,6 +24,7 @@ const notificationTypes = [
|
|
|
22
24
|
{
|
|
23
25
|
type: "eudr.statement.withdrawn",
|
|
24
26
|
module: "eudr",
|
|
27
|
+
channels: ["in_app", "email"],
|
|
25
28
|
titleKey: "eudr.notifications.statement.withdrawn.title",
|
|
26
29
|
bodyKey: "eudr.notifications.statement.withdrawn.body",
|
|
27
30
|
icon: "undo",
|
|
@@ -32,6 +35,7 @@ const notificationTypes = [
|
|
|
32
35
|
{
|
|
33
36
|
type: "eudr.risk.non_negligible",
|
|
34
37
|
module: "eudr",
|
|
38
|
+
channels: ["in_app", "email"],
|
|
35
39
|
titleKey: "eudr.notifications.risk.nonNegligible.title",
|
|
36
40
|
bodyKey: "eudr.notifications.risk.nonNegligible.body",
|
|
37
41
|
icon: "alert-triangle",
|
|
@@ -42,6 +46,7 @@ const notificationTypes = [
|
|
|
42
46
|
{
|
|
43
47
|
type: "eudr.mitigation.completed",
|
|
44
48
|
module: "eudr",
|
|
49
|
+
channels: ["in_app", "email"],
|
|
45
50
|
titleKey: "eudr.notifications.mitigation.completed.title",
|
|
46
51
|
bodyKey: "eudr.notifications.mitigation.completed.body",
|
|
47
52
|
icon: "check-circle",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/eudr/notifications.ts"],
|
|
4
|
-
"sourcesContent": ["import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'\n\nexport const notificationTypes: NotificationTypeDefinition[] = [\n {\n type: 'eudr.statement.submitted',\n module: 'eudr',\n titleKey: 'eudr.notifications.statement.submitted.title',\n bodyKey: 'eudr.notifications.statement.submitted.body',\n icon: 'file-check',\n severity: 'info',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.statement.reference_issued',\n module: 'eudr',\n titleKey: 'eudr.notifications.statement.referenceIssued.title',\n bodyKey: 'eudr.notifications.statement.referenceIssued.body',\n icon: 'badge-check',\n severity: 'success',\n actions: [],\n expiresAfterHours: 720,\n },\n {\n type: 'eudr.statement.withdrawn',\n module: 'eudr',\n titleKey: 'eudr.notifications.statement.withdrawn.title',\n bodyKey: 'eudr.notifications.statement.withdrawn.body',\n icon: 'undo',\n severity: 'warning',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.risk.non_negligible',\n module: 'eudr',\n titleKey: 'eudr.notifications.risk.nonNegligible.title',\n bodyKey: 'eudr.notifications.risk.nonNegligible.body',\n icon: 'alert-triangle',\n severity: 'warning',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.mitigation.completed',\n module: 'eudr',\n titleKey: 'eudr.notifications.mitigation.completed.title',\n bodyKey: 'eudr.notifications.mitigation.completed.body',\n icon: 'check-circle',\n severity: 'success',\n actions: [],\n expiresAfterHours: 168,\n },\n]\n\nexport default notificationTypes\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,oBAAkD;AAAA,EAC7D;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AACF;AAEA,IAAO,wBAAQ;",
|
|
4
|
+
"sourcesContent": ["import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'\n\nexport const notificationTypes: NotificationTypeDefinition[] = [\n {\n type: 'eudr.statement.submitted',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.statement.submitted.title',\n bodyKey: 'eudr.notifications.statement.submitted.body',\n icon: 'file-check',\n severity: 'info',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.statement.reference_issued',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.statement.referenceIssued.title',\n bodyKey: 'eudr.notifications.statement.referenceIssued.body',\n icon: 'badge-check',\n severity: 'success',\n actions: [],\n expiresAfterHours: 720,\n },\n {\n type: 'eudr.statement.withdrawn',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.statement.withdrawn.title',\n bodyKey: 'eudr.notifications.statement.withdrawn.body',\n icon: 'undo',\n severity: 'warning',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.risk.non_negligible',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.risk.nonNegligible.title',\n bodyKey: 'eudr.notifications.risk.nonNegligible.body',\n icon: 'alert-triangle',\n severity: 'warning',\n actions: [],\n expiresAfterHours: 168,\n },\n {\n type: 'eudr.mitigation.completed',\n module: 'eudr',\n channels: ['in_app', 'email'],\n titleKey: 'eudr.notifications.mitigation.completed.title',\n bodyKey: 'eudr.notifications.mitigation.completed.body',\n icon: 'check-circle',\n severity: 'success',\n actions: [],\n expiresAfterHours: 168,\n },\n]\n\nexport default notificationTypes\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,oBAAkD;AAAA,EAC7D;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU,CAAC,UAAU,OAAO;AAAA,IAC5B,UAAU;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,UAAU;AAAA,IACV,SAAS,CAAC;AAAA,IACV,mBAAmB;AAAA,EACrB;AACF;AAEA,IAAO,wBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -119,6 +119,42 @@ function buildFieldPairs(recordId, doc) {
|
|
|
119
119
|
}
|
|
120
120
|
return pairs;
|
|
121
121
|
}
|
|
122
|
+
const SIGNATURE_SEPARATOR = String.fromCharCode(0);
|
|
123
|
+
function tokenSignature(row) {
|
|
124
|
+
return [
|
|
125
|
+
String(row.field ?? ""),
|
|
126
|
+
String(row.token_hash ?? ""),
|
|
127
|
+
row.token == null ? "" : String(row.token)
|
|
128
|
+
].join(SIGNATURE_SEPARATOR);
|
|
129
|
+
}
|
|
130
|
+
function tallyOf(rows) {
|
|
131
|
+
const tally = /* @__PURE__ */ new Map();
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
const signature = tokenSignature(row);
|
|
134
|
+
tally.set(signature, (tally.get(signature) ?? 0) + 1);
|
|
135
|
+
}
|
|
136
|
+
return tally;
|
|
137
|
+
}
|
|
138
|
+
function tallyEquals(a, b) {
|
|
139
|
+
const left = a ?? /* @__PURE__ */ new Map();
|
|
140
|
+
const right = b ?? /* @__PURE__ */ new Map();
|
|
141
|
+
if (left.size !== right.size) return false;
|
|
142
|
+
for (const [key, count] of left.entries()) {
|
|
143
|
+
if (right.get(key) !== count) return false;
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
function tallyTokenRows(rows, keyOf) {
|
|
148
|
+
const tallies = /* @__PURE__ */ new Map();
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
const key = keyOf(row);
|
|
151
|
+
const tally = tallies.get(key) ?? /* @__PURE__ */ new Map();
|
|
152
|
+
const signature = tokenSignature(row);
|
|
153
|
+
tally.set(signature, (tally.get(signature) ?? 0) + 1);
|
|
154
|
+
tallies.set(key, tally);
|
|
155
|
+
}
|
|
156
|
+
return tallies;
|
|
157
|
+
}
|
|
122
158
|
async function replaceSearchTokensForRecord(db, params, options) {
|
|
123
159
|
const rows = buildSearchTokenRows(params);
|
|
124
160
|
const config = params.config ?? resolveSearchConfig();
|
|
@@ -126,6 +162,29 @@ async function replaceSearchTokensForRecord(db, params, options) {
|
|
|
126
162
|
const organizationId = params.organizationId ?? null;
|
|
127
163
|
const tenantId = params.tenantId ?? null;
|
|
128
164
|
const fieldPairs = buildFieldPairs(String(params.recordId), params.doc);
|
|
165
|
+
const scopeTokenQuery = (query) => {
|
|
166
|
+
let scoped = query.where("entity_type", "=", params.entityType).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`).where("entity_id", "=", String(params.recordId));
|
|
167
|
+
if (fieldPairs.length) {
|
|
168
|
+
scoped = scoped.where("field", "in", fieldPairs.map(([, field]) => field));
|
|
169
|
+
}
|
|
170
|
+
return scoped;
|
|
171
|
+
};
|
|
172
|
+
const reader = options?.trx ?? db;
|
|
173
|
+
const storedCountRows = await scopeTokenQuery(
|
|
174
|
+
reader.selectFrom("search_tokens").select(sql`count(*)`.as("token_count"))
|
|
175
|
+
).execute();
|
|
176
|
+
const storedCount = Number(storedCountRows[0]?.token_count ?? 0);
|
|
177
|
+
let unchanged = storedCount === rows.length;
|
|
178
|
+
if (unchanged && rows.length) {
|
|
179
|
+
const stored = await scopeTokenQuery(
|
|
180
|
+
reader.selectFrom("search_tokens").select(["field", "token_hash", "token"])
|
|
181
|
+
).limit(rows.length).execute();
|
|
182
|
+
unchanged = tallyEquals(tallyOf(rows), tallyOf(stored));
|
|
183
|
+
}
|
|
184
|
+
if (unchanged) {
|
|
185
|
+
debug("record.skip", { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
129
188
|
const writeTokens = async (executor) => {
|
|
130
189
|
let deleteQuery = executor.deleteFrom("search_tokens").where("entity_type", "=", params.entityType).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`);
|
|
131
190
|
if (fieldPairs.length) {
|
|
@@ -157,34 +216,6 @@ async function deleteSearchTokensForRecord(db, params, options) {
|
|
|
157
216
|
const executor = options?.trx ?? db;
|
|
158
217
|
await executor.deleteFrom("search_tokens").where("entity_type", "=", params.entityType).where("entity_id", "=", String(params.recordId)).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`).execute();
|
|
159
218
|
}
|
|
160
|
-
const SIGNATURE_SEPARATOR = String.fromCharCode(0);
|
|
161
|
-
function tokenSignature(row) {
|
|
162
|
-
return [
|
|
163
|
-
String(row.field ?? ""),
|
|
164
|
-
String(row.token_hash ?? ""),
|
|
165
|
-
row.token == null ? "" : String(row.token)
|
|
166
|
-
].join(SIGNATURE_SEPARATOR);
|
|
167
|
-
}
|
|
168
|
-
function tallyEquals(a, b) {
|
|
169
|
-
const left = a ?? /* @__PURE__ */ new Map();
|
|
170
|
-
const right = b ?? /* @__PURE__ */ new Map();
|
|
171
|
-
if (left.size !== right.size) return false;
|
|
172
|
-
for (const [key, count] of left.entries()) {
|
|
173
|
-
if (right.get(key) !== count) return false;
|
|
174
|
-
}
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
function tallyTokenRows(rows, keyOf) {
|
|
178
|
-
const tallies = /* @__PURE__ */ new Map();
|
|
179
|
-
for (const row of rows) {
|
|
180
|
-
const key = keyOf(row);
|
|
181
|
-
const tally = tallies.get(key) ?? /* @__PURE__ */ new Map();
|
|
182
|
-
const signature = tokenSignature(row);
|
|
183
|
-
tally.set(signature, (tally.get(signature) ?? 0) + 1);
|
|
184
|
-
tallies.set(key, tally);
|
|
185
|
-
}
|
|
186
|
-
return tallies;
|
|
187
|
-
}
|
|
188
219
|
async function replaceSearchTokensForBatch(db, payloads) {
|
|
189
220
|
if (!payloads.length) return;
|
|
190
221
|
const config = resolveSearchConfig();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/query_index/lib/search-tokens.ts"],
|
|
4
|
-
"sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const rows = buildSearchTokenRows(params)\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: { field?: unknown; token_hash?: unknown; token?: unknown }): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends { field?: unknown; token_hash?: unknown; token?: unknown }>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!payloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\ntype TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: TokenRowLike): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {\n const tally = new Map<string, number>()\n for (const row of rows) {\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n }\n return tally\n}\n\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends TokenRowLike>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const rows = buildSearchTokenRows(params)\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)\n\n // Same comparison #5402 gave the batch path, over the scope this path actually writes: the\n // delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison\n // has to be narrowed the same way. Reading wider would let a token row under a field this\n // document does not carry \u2014 the `cf_` twin the search module used to write, say \u2014 read as a\n // difference forever and defeat the skip on every write.\n const scopeTokenQuery = (query: any): any => {\n let scoped = query\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .where('entity_id' as any, '=', String(params.recordId))\n if (fieldPairs.length) {\n scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))\n }\n return scoped\n }\n\n // Read through the caller's transaction when there is one. A separate connection cannot see that\n // transaction's own uncommitted writes, so it could report rows a pending delete has already\n // removed and talk this call out of re-inserting them.\n const reader = options?.trx ?? db\n\n // Count probe first, as in the batch path: it returns one row whatever the table holds, so a\n // record whose stored rows have run away (#4681) is settled without materializing them.\n const storedCountRows = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),\n ).execute()\n const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)\n\n let unchanged = storedCount === rows.length\n if (unchanged && rows.length) {\n const stored = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),\n )\n // Counts already match, so this cannot truncate. It bounds the read if a concurrent writer\n // inserts between the probe and here; a truncated read compares as changed, which costs a\n // rewrite rather than a wrong skip.\n .limit(rows.length)\n .execute()\n unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))\n }\n if (unchanged) {\n debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })\n return\n }\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!payloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|