@open-mercato/core 0.7.1-develop.7112.1.d486e6f1b2 → 0.7.1-develop.7120.1.a0154ac412
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/commands/deliver-outbound-message.js +34 -2
- package/dist/modules/communication_channels/commands/deliver-outbound-message.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/outbound-reply-ref.js +49 -0
- package/dist/modules/communication_channels/lib/outbound-reply-ref.js.map +7 -0
- 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 +120 -37
- package/dist/modules/sales/commands/documents.js.map +2 -2
- 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/commands/deliver-outbound-message.ts +45 -1
- 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/outbound-reply-ref.ts +138 -0
- 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 +153 -39
- package/src/modules/warranty_claims/notifications.ts +5 -0
- package/src/modules/wms/notifications.ts +2 -0
|
@@ -59,7 +59,7 @@ export const DEAL_STATUS_DEFAULTS: DictionaryDefault[] = [
|
|
|
59
59
|
{ value: 'open', label: 'Open', color: '#2563eb', icon: 'lucide:circle' },
|
|
60
60
|
{ value: 'closed', label: 'Closed', color: '#6b7280', icon: 'lucide:check-circle' },
|
|
61
61
|
{ value: 'win', label: 'Win', color: '#22c55e', icon: 'lucide:trophy' },
|
|
62
|
-
{ value: '
|
|
62
|
+
{ value: 'lost', label: 'Lost', color: '#ef4444', icon: 'lucide:flag' },
|
|
63
63
|
{ value: 'in_progress', label: 'In progress', color: '#f59e0b', icon: 'lucide:activity' },
|
|
64
64
|
]
|
|
65
65
|
|
|
@@ -70,7 +70,7 @@ export const PIPELINE_STAGE_DEFAULTS: DictionaryDefault[] = [
|
|
|
70
70
|
{ value: 'offering', label: 'Offering', color: '#22c55e', icon: 'lucide:package' },
|
|
71
71
|
{ value: 'negotiations', label: 'Negotiations', color: '#facc15', icon: 'lucide:handshake' },
|
|
72
72
|
{ value: 'win', label: 'Win', color: '#16a34a', icon: 'lucide:award' },
|
|
73
|
-
{ value: '
|
|
73
|
+
{ value: 'lost', label: 'Lost', color: '#ef4444', icon: 'lucide:flag' },
|
|
74
74
|
{ value: 'stalled', label: 'Stalled', color: '#6b7280', icon: 'lucide:alert-circle' },
|
|
75
75
|
]
|
|
76
76
|
|
|
@@ -827,8 +827,8 @@ export const CUSTOMER_EXAMPLES: ExampleCompany[] = [
|
|
|
827
827
|
slug: 'cedar-creek-retreat',
|
|
828
828
|
title: 'Cedar Creek Retreat Expansion',
|
|
829
829
|
description: 'New wellness center build-out including retail area and treatment rooms.',
|
|
830
|
-
status: '
|
|
831
|
-
pipelineStage: '
|
|
830
|
+
status: 'lost',
|
|
831
|
+
pipelineStage: 'lost',
|
|
832
832
|
valueAmount: 98000,
|
|
833
833
|
valueCurrency: 'USD',
|
|
834
834
|
expectedCloseAt: isoDaysFromNow(-70),
|
|
@@ -2549,7 +2549,7 @@ async function seedCustomerStressTest(
|
|
|
2549
2549
|
const dealId = randomUUID()
|
|
2550
2550
|
const valueAmount = toAmount(monetaryBase + randomInt(0, 7500))
|
|
2551
2551
|
const expectedCloseAt =
|
|
2552
|
-
dealStatus === 'win' || dealStatus === 'closed' || dealStatus === '
|
|
2552
|
+
dealStatus === 'win' || dealStatus === 'closed' || dealStatus === 'lost'
|
|
2553
2553
|
? randomPastDate(120)
|
|
2554
2554
|
: randomFutureDate(120)
|
|
2555
2555
|
const dealRow: CustomerDealRow = {
|
|
@@ -120,9 +120,9 @@ function resolveRequestedClosureOutcome(input: DealUpdateInput): DealClosureOutc
|
|
|
120
120
|
if (input.closureOutcome === 'won' || input.closureOutcome === 'lost') {
|
|
121
121
|
return input.closureOutcome
|
|
122
122
|
}
|
|
123
|
-
// `win` / `
|
|
124
|
-
// spellings the AI stage tool persists. Both must derive the same
|
|
125
|
-
// every closed deal lands in the pipeline's terminal stage (#5107).
|
|
123
|
+
// `win` / `lost` are what the UI closure flows persist; `won` / `lost` are the
|
|
124
|
+
// spellings the AI stage tool persists (`loose` before 0.7.1). Both must derive the same
|
|
125
|
+
// closure outcome so every closed deal lands in the pipeline's terminal stage (#5107).
|
|
126
126
|
return dealClosureOutcomeFromStatus(input.status)
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -846,6 +846,7 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
846
846
|
// subscribers (workflow event triggers, business-rules triggers) drop a
|
|
847
847
|
// null-scoped event before trigger matching.
|
|
848
848
|
const newStatus = record.status
|
|
849
|
+
// `loose` stays mapped here for deals written before the 0.7.1 rename.
|
|
849
850
|
const normalizedStatus = newStatus === 'win' ? 'won' : newStatus === 'loose' ? 'lost' : newStatus
|
|
850
851
|
if (previousStatus !== newStatus && (normalizedStatus === 'won' || normalizedStatus === 'lost')) {
|
|
851
852
|
const closureEvent = normalizedStatus === 'won' ? 'customers.deal.won' : 'customers.deal.lost'
|
|
@@ -276,7 +276,7 @@ export function createDealLinkAdapter(options: DealAdapterOptions): LinkEntityAd
|
|
|
276
276
|
{ id: 'all', label: 'All' },
|
|
277
277
|
{ id: 'open', label: 'Open' },
|
|
278
278
|
{ id: 'win', label: 'Won' },
|
|
279
|
-
{ id: '
|
|
279
|
+
{ id: 'lost', label: 'Lost' },
|
|
280
280
|
{ id: 'orphan', label: 'Orphan', dotColor: '#eb9426' /* TODO(ds-review): #eb9426 — requires LinkEntityDialog to support semantic token classNames */ },
|
|
281
281
|
],
|
|
282
282
|
defaultId: 'all',
|
|
@@ -286,7 +286,7 @@ export function createDealLinkAdapter(options: DealAdapterOptions): LinkEntityAd
|
|
|
286
286
|
if (filterId === 'orphan') return meta.isOrphan === true
|
|
287
287
|
// Server-side status filter already applied; this keeps all rows if server honored the filter.
|
|
288
288
|
// Fallback client check on stage in case server returns mixed statuses.
|
|
289
|
-
if (filterId === 'open' || filterId === 'win' || filterId === '
|
|
289
|
+
if (filterId === 'open' || filterId === 'win' || filterId === 'lost') {
|
|
290
290
|
return (meta.stage ?? '').toLowerCase().includes(filterId) || true
|
|
291
291
|
}
|
|
292
292
|
return true
|
|
@@ -1380,7 +1380,7 @@
|
|
|
1380
1380
|
"customers.deals.kanban.quickDeal.probability": "Probability",
|
|
1381
1381
|
"customers.deals.kanban.quickDeal.status": "Status",
|
|
1382
1382
|
"customers.deals.kanban.quickDeal.status.in_progress": "In Bearbeitung",
|
|
1383
|
-
"customers.deals.kanban.quickDeal.status.
|
|
1383
|
+
"customers.deals.kanban.quickDeal.status.lost": "Verloren",
|
|
1384
1384
|
"customers.deals.kanban.quickDeal.status.open": "Offen",
|
|
1385
1385
|
"customers.deals.kanban.quickDeal.status.win": "Gewonnen",
|
|
1386
1386
|
"customers.deals.kanban.quickDeal.submit": "Add deal",
|
|
@@ -1380,7 +1380,7 @@
|
|
|
1380
1380
|
"customers.deals.kanban.quickDeal.probability": "Probability",
|
|
1381
1381
|
"customers.deals.kanban.quickDeal.status": "Status",
|
|
1382
1382
|
"customers.deals.kanban.quickDeal.status.in_progress": "In progress",
|
|
1383
|
-
"customers.deals.kanban.quickDeal.status.
|
|
1383
|
+
"customers.deals.kanban.quickDeal.status.lost": "Lost",
|
|
1384
1384
|
"customers.deals.kanban.quickDeal.status.open": "Open",
|
|
1385
1385
|
"customers.deals.kanban.quickDeal.status.win": "Won",
|
|
1386
1386
|
"customers.deals.kanban.quickDeal.submit": "Add deal",
|
|
@@ -1380,7 +1380,7 @@
|
|
|
1380
1380
|
"customers.deals.kanban.quickDeal.probability": "Probability",
|
|
1381
1381
|
"customers.deals.kanban.quickDeal.status": "Status",
|
|
1382
1382
|
"customers.deals.kanban.quickDeal.status.in_progress": "En curso",
|
|
1383
|
-
"customers.deals.kanban.quickDeal.status.
|
|
1383
|
+
"customers.deals.kanban.quickDeal.status.lost": "Perdido",
|
|
1384
1384
|
"customers.deals.kanban.quickDeal.status.open": "Abierto",
|
|
1385
1385
|
"customers.deals.kanban.quickDeal.status.win": "Ganado",
|
|
1386
1386
|
"customers.deals.kanban.quickDeal.submit": "Add deal",
|
|
@@ -1380,7 +1380,7 @@
|
|
|
1380
1380
|
"customers.deals.kanban.quickDeal.probability": "확률",
|
|
1381
1381
|
"customers.deals.kanban.quickDeal.status": "상태",
|
|
1382
1382
|
"customers.deals.kanban.quickDeal.status.in_progress": "진행 중",
|
|
1383
|
-
"customers.deals.kanban.quickDeal.status.
|
|
1383
|
+
"customers.deals.kanban.quickDeal.status.lost": "실패",
|
|
1384
1384
|
"customers.deals.kanban.quickDeal.status.open": "열림",
|
|
1385
1385
|
"customers.deals.kanban.quickDeal.status.win": "성사",
|
|
1386
1386
|
"customers.deals.kanban.quickDeal.submit": "거래 추가",
|
|
@@ -1380,7 +1380,7 @@
|
|
|
1380
1380
|
"customers.deals.kanban.quickDeal.probability": "Prawdopodobieństwo",
|
|
1381
1381
|
"customers.deals.kanban.quickDeal.status": "Status",
|
|
1382
1382
|
"customers.deals.kanban.quickDeal.status.in_progress": "W toku",
|
|
1383
|
-
"customers.deals.kanban.quickDeal.status.
|
|
1383
|
+
"customers.deals.kanban.quickDeal.status.lost": "Przegrany",
|
|
1384
1384
|
"customers.deals.kanban.quickDeal.status.open": "Otwarty",
|
|
1385
1385
|
"customers.deals.kanban.quickDeal.status.win": "Wygrany",
|
|
1386
1386
|
"customers.deals.kanban.quickDeal.submit": "+ Dodaj szansę",
|
|
@@ -19,7 +19,8 @@ export const TERMINAL_PIPELINE_STAGE_LABELS: Record<DealClosureOutcome, Readonly
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Map a deal status spelling (
|
|
22
|
+
* Map a deal status spelling (`win`/`lost`, the AI tool's `won`, or the pre-0.7.1
|
|
23
|
+
* `loose`) to its closure
|
|
23
24
|
* outcome. Case-insensitive because callers such as the AI tool pass free-form model
|
|
24
25
|
* text through, while every persisted status is lower-case.
|
|
25
26
|
*/
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
// Single source of truth for deal open/closed (terminal) semantics.
|
|
2
2
|
//
|
|
3
3
|
// `customer_deals.status` is a lenient text column and the supported writers do not agree on
|
|
4
|
-
// one spelling: the closure hooks and the kanban board persist `win` / `
|
|
5
|
-
// `customers.update_deal_stage` persists `won` / `lost`,
|
|
6
|
-
// `closed
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// one spelling: the closure hooks and the kanban board persist `win` / `lost`, the AI tool
|
|
5
|
+
// `customers.update_deal_stage` persists `won` / `lost`, the seeded vocabulary also carries
|
|
6
|
+
// `closed`, and rows written before 0.7.1 carry the misspelled `loose`. The terminal set
|
|
7
|
+
// below therefore covers every spelling a status writer persists, so status-based readers
|
|
8
|
+
// can share one classification. `closureOutcome` is a separate signal, and callers that
|
|
9
|
+
// combine both columns must handle it explicitly.
|
|
9
10
|
//
|
|
10
11
|
// A status not known to code counts as OPEN. Deal stages are configurable per tenant, so the
|
|
11
12
|
// lenient column can hold values this module has never seen — treating them as open keeps them
|
|
12
13
|
// visible in active-deal counts instead of silently reclassifying them as closed.
|
|
13
14
|
|
|
14
15
|
export const DEAL_STATUS_WIN = 'win' as const
|
|
15
|
-
export const
|
|
16
|
+
export const DEAL_STATUS_LOST = 'lost' as const
|
|
16
17
|
export const DEAL_STATUS_CLOSED = 'closed' as const
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated Misspelling of the lost-deal status, canonical until 0.7.0. Writers now
|
|
21
|
+
* persist {@link DEAL_STATUS_LOST}. This export stays so downstream modules keep
|
|
22
|
+
* compiling and readers keep matching rows written before the 0.7.1 migration; it is
|
|
23
|
+
* scheduled for removal no earlier than 0.9.0.
|
|
24
|
+
*/
|
|
25
|
+
export const DEAL_STATUS_LOSE = 'loose' as const
|
|
26
|
+
|
|
27
|
+
// `won` is the spelling the AI stage tool writes; `win` is what the UI closure flows
|
|
28
|
+
// persist. Both are accepted so neither writer produces an unreadable status.
|
|
20
29
|
export const WON_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_WIN, 'won']
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
// `lost` is canonical. `loose` is the pre-0.7.1 spelling the UI closure flows and the
|
|
32
|
+
// kanban board persisted; unmigrated rows and tenant dictionaries still carry it.
|
|
33
|
+
export const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOST, DEAL_STATUS_LOSE]
|
|
23
34
|
|
|
24
35
|
export const CLOSED_DEAL_STATUS_LIST: readonly string[] = [
|
|
25
36
|
...WON_DEAL_STATUS_LIST,
|
|
@@ -50,10 +61,10 @@ export function isOpenDealStatus(value: string | null | undefined): boolean {
|
|
|
50
61
|
export function canonicalDealStatus(value: string): string {
|
|
51
62
|
const lower = value.toLowerCase()
|
|
52
63
|
if (lower === 'won') return DEAL_STATUS_WIN
|
|
53
|
-
if (lower === '
|
|
64
|
+
if (lower === 'loose') return DEAL_STATUS_LOST
|
|
54
65
|
// Canonical spellings normalize to lower-case too, so an upper-case selection still
|
|
55
66
|
// matches the lower-case persisted values and dictionary options.
|
|
56
|
-
if (lower === 'win' || lower === '
|
|
67
|
+
if (lower === 'win' || lower === 'lost' || lower === 'open' || lower === 'closed') return lower
|
|
57
68
|
return value
|
|
58
69
|
}
|
|
59
70
|
|
|
@@ -61,7 +72,7 @@ export function canonicalDealStatus(value: string): string {
|
|
|
61
72
|
* Expand operator-selected status filter values into the canonical vocabulary so a
|
|
62
73
|
* single selection matches every spelling the supported writers persist (#5107).
|
|
63
74
|
*
|
|
64
|
-
* Alias groups (`win`/`won`, `loose
|
|
75
|
+
* Alias groups (`win`/`won`, `lost`/`loose`, `closed`) are matched case-insensitively,
|
|
65
76
|
* expanded to their canonical spellings, and also keep the caller's original token so
|
|
66
77
|
* the result is always a strict superset of a verbatim match. Unknown values emit both
|
|
67
78
|
* the trimmed original and its lower-case form — mixed-case tenant dictionary statuses
|
|
@@ -146,9 +146,9 @@ export async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput):
|
|
|
146
146
|
const winLossRows = await connection.execute<WinLossRow[]>(
|
|
147
147
|
`SELECT
|
|
148
148
|
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,
|
|
149
|
-
COUNT(*) FILTER (WHERE (status
|
|
149
|
+
COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
|
|
150
150
|
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,
|
|
151
|
-
COUNT(*) FILTER (WHERE (status
|
|
151
|
+
COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
|
|
152
152
|
FROM customer_deals
|
|
153
153
|
WHERE ${scopeWhere}`,
|
|
154
154
|
[
|
|
@@ -168,7 +168,7 @@ export async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput):
|
|
|
168
168
|
`SELECT
|
|
169
169
|
to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,
|
|
170
170
|
COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,
|
|
171
|
-
COUNT(*) FILTER (WHERE status
|
|
171
|
+
COUNT(*) FILTER (WHERE status IN ('lost', 'loose') OR closure_outcome = 'lost') AS lost
|
|
172
172
|
FROM customer_deals
|
|
173
173
|
WHERE ${scopeWhere} AND updated_at >= ?
|
|
174
174
|
GROUP BY 1`,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rename the misspelled lost-deal vocabulary `loose` to `lost`.
|
|
5
|
+
*
|
|
6
|
+
* `loose` was the canonical spelling the UI closure flows, the kanban board and the seeded
|
|
7
|
+
* dictionaries persisted up to 0.7.0. `lost` is what every other surface already used
|
|
8
|
+
* (`closure_outcome`, the AI stage tool, every operator-facing label), so the two spellings
|
|
9
|
+
* had to be reconciled anywhere a reader joined them.
|
|
10
|
+
*
|
|
11
|
+
* Forward-only and conservative. Nothing is deleted, and a row is left alone whenever
|
|
12
|
+
* renaming it would collide with an entry the tenant already has:
|
|
13
|
+
*
|
|
14
|
+
* - `customer_deals.status` / `pipeline_stage`: `loose` -> `lost`.
|
|
15
|
+
* - `customer_dictionary_entries` (`deal_status`, `pipeline_stage`): the `loose` entry is
|
|
16
|
+
* renamed only when the same scope has no `lost` entry, since
|
|
17
|
+
* `customer_dictionary_entries_unique` covers (org, tenant, kind, normalized_value).
|
|
18
|
+
* Its label is corrected only when it is still the seeded `Loose`, so a tenant that
|
|
19
|
+
* renamed the option keeps its own wording.
|
|
20
|
+
* - `customer_pipeline_stages.name`: the seeded `Loose` label becomes `Lost`.
|
|
21
|
+
*
|
|
22
|
+
* Readers keep accepting `loose` (see `lib/dealStatus.ts`), so an instance that skips this
|
|
23
|
+
* migration, or a row this migration deliberately leaves behind, still classifies correctly.
|
|
24
|
+
*/
|
|
25
|
+
export class Migration20260824180000_deal_status_lost extends Migration {
|
|
26
|
+
override async up(): Promise<void> {
|
|
27
|
+
this.addSql(`update "customer_deals" set "status" = 'lost' where lower("status") = 'loose';`);
|
|
28
|
+
this.addSql(`update "customer_deals" set "pipeline_stage" = 'lost' where lower("pipeline_stage") = 'loose';`);
|
|
29
|
+
|
|
30
|
+
this.addSql(`
|
|
31
|
+
update "customer_dictionary_entries" as e
|
|
32
|
+
set "value" = 'lost',
|
|
33
|
+
"normalized_value" = 'lost',
|
|
34
|
+
"label" = case when e."label" = 'Loose' then 'Lost' else e."label" end,
|
|
35
|
+
"updated_at" = now()
|
|
36
|
+
where e."kind" in ('deal_status', 'pipeline_stage')
|
|
37
|
+
and e."normalized_value" = 'loose'
|
|
38
|
+
and not exists (
|
|
39
|
+
select 1 from "customer_dictionary_entries" as other
|
|
40
|
+
where other."organization_id" = e."organization_id"
|
|
41
|
+
and other."tenant_id" = e."tenant_id"
|
|
42
|
+
and other."kind" = e."kind"
|
|
43
|
+
and other."normalized_value" = 'lost'
|
|
44
|
+
);
|
|
45
|
+
`);
|
|
46
|
+
|
|
47
|
+
this.addSql(`update "customer_pipeline_stages" set "name" = 'Lost', "updated_at" = now() where "name" = 'Loose';`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
override async down(): Promise<void> {
|
|
51
|
+
// Reverting is lossy: an instance may have carried `lost` rows before this migration ran,
|
|
52
|
+
// and those are indistinguishable afterwards from the ones it renamed. Readers accept
|
|
53
|
+
// both spellings, so leaving the data on the canonical `lost` is the safe no-op.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -9,7 +9,7 @@ import type { WidgetDataRequest } from '../../../services/widgetDataService'
|
|
|
9
9
|
// `customers/lib/interactionStatus.ts`, which treats an unknown interaction status as open.
|
|
10
10
|
//
|
|
11
11
|
// Every vocabulary that reaches the column has to be listed here:
|
|
12
|
-
// - `win` / `
|
|
12
|
+
// - `win` / `lost` are written by the deal closure UI and the kanban board.
|
|
13
13
|
// - `won` / `lost` are written verbatim by the `customers.update_deal_stage` AI tool, whose
|
|
14
14
|
// free-form `toStage` is passed straight through to `status`.
|
|
15
15
|
// - `closed` is a seeded `deal_status` dictionary value, persisted by the dashboards
|
|
@@ -4,6 +4,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
4
4
|
{
|
|
5
5
|
type: 'eudr.statement.submitted',
|
|
6
6
|
module: 'eudr',
|
|
7
|
+
channels: ['in_app', 'email'],
|
|
7
8
|
titleKey: 'eudr.notifications.statement.submitted.title',
|
|
8
9
|
bodyKey: 'eudr.notifications.statement.submitted.body',
|
|
9
10
|
icon: 'file-check',
|
|
@@ -14,6 +15,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
14
15
|
{
|
|
15
16
|
type: 'eudr.statement.reference_issued',
|
|
16
17
|
module: 'eudr',
|
|
18
|
+
channels: ['in_app', 'email'],
|
|
17
19
|
titleKey: 'eudr.notifications.statement.referenceIssued.title',
|
|
18
20
|
bodyKey: 'eudr.notifications.statement.referenceIssued.body',
|
|
19
21
|
icon: 'badge-check',
|
|
@@ -24,6 +26,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
24
26
|
{
|
|
25
27
|
type: 'eudr.statement.withdrawn',
|
|
26
28
|
module: 'eudr',
|
|
29
|
+
channels: ['in_app', 'email'],
|
|
27
30
|
titleKey: 'eudr.notifications.statement.withdrawn.title',
|
|
28
31
|
bodyKey: 'eudr.notifications.statement.withdrawn.body',
|
|
29
32
|
icon: 'undo',
|
|
@@ -34,6 +37,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
34
37
|
{
|
|
35
38
|
type: 'eudr.risk.non_negligible',
|
|
36
39
|
module: 'eudr',
|
|
40
|
+
channels: ['in_app', 'email'],
|
|
37
41
|
titleKey: 'eudr.notifications.risk.nonNegligible.title',
|
|
38
42
|
bodyKey: 'eudr.notifications.risk.nonNegligible.body',
|
|
39
43
|
icon: 'alert-triangle',
|
|
@@ -44,6 +48,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
44
48
|
{
|
|
45
49
|
type: 'eudr.mitigation.completed',
|
|
46
50
|
module: 'eudr',
|
|
51
|
+
channels: ['in_app', 'email'],
|
|
47
52
|
titleKey: 'eudr.notifications.mitigation.completed.title',
|
|
48
53
|
bodyKey: 'eudr.notifications.mitigation.completed.body',
|
|
49
54
|
icon: 'check-circle',
|
|
@@ -162,6 +162,60 @@ function buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null)
|
|
|
162
162
|
return pairs
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
type TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }
|
|
166
|
+
|
|
167
|
+
// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`
|
|
168
|
+
// would otherwise sign identically to field `a` + hash `b c`.
|
|
169
|
+
const SIGNATURE_SEPARATOR = String.fromCharCode(0)
|
|
170
|
+
|
|
171
|
+
// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a
|
|
172
|
+
// stored NULL has to sign the same as the `null` a freshly built row carries — otherwise every
|
|
173
|
+
// record compares as changed and the skip never fires.
|
|
174
|
+
function tokenSignature(row: TokenRowLike): string {
|
|
175
|
+
return [
|
|
176
|
+
String(row.field ?? ''),
|
|
177
|
+
String(row.token_hash ?? ''),
|
|
178
|
+
row.token == null ? '' : String(row.token),
|
|
179
|
+
].join(SIGNATURE_SEPARATOR)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement
|
|
183
|
+
// defect, and a set comparison reads such a record as already correct and preserves the duplicates
|
|
184
|
+
// forever. Counting sends it through a full rewrite, which collapses them.
|
|
185
|
+
function tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {
|
|
186
|
+
const tally = new Map<string, number>()
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
const signature = tokenSignature(row)
|
|
189
|
+
tally.set(signature, (tally.get(signature) ?? 0) + 1)
|
|
190
|
+
}
|
|
191
|
+
return tally
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {
|
|
195
|
+
const left = a ?? new Map<string, number>()
|
|
196
|
+
const right = b ?? new Map<string, number>()
|
|
197
|
+
if (left.size !== right.size) return false
|
|
198
|
+
for (const [key, count] of left.entries()) {
|
|
199
|
+
if (right.get(key) !== count) return false
|
|
200
|
+
}
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function tallyTokenRows<TRow extends TokenRowLike>(
|
|
205
|
+
rows: Iterable<TRow>,
|
|
206
|
+
keyOf: (row: TRow) => string
|
|
207
|
+
): Map<string, Map<string, number>> {
|
|
208
|
+
const tallies = new Map<string, Map<string, number>>()
|
|
209
|
+
for (const row of rows) {
|
|
210
|
+
const key = keyOf(row)
|
|
211
|
+
const tally = tallies.get(key) ?? new Map<string, number>()
|
|
212
|
+
const signature = tokenSignature(row)
|
|
213
|
+
tally.set(signature, (tally.get(signature) ?? 0) + 1)
|
|
214
|
+
tallies.set(key, tally)
|
|
215
|
+
}
|
|
216
|
+
return tallies
|
|
217
|
+
}
|
|
218
|
+
|
|
165
219
|
export async function replaceSearchTokensForRecord(
|
|
166
220
|
db: Kysely<any>,
|
|
167
221
|
params: BuildTokenOptions,
|
|
@@ -174,6 +228,52 @@ export async function replaceSearchTokensForRecord(
|
|
|
174
228
|
const tenantId = params.tenantId ?? null
|
|
175
229
|
const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)
|
|
176
230
|
|
|
231
|
+
// Same comparison #5402 gave the batch path, over the scope this path actually writes: the
|
|
232
|
+
// delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison
|
|
233
|
+
// has to be narrowed the same way. Reading wider would let a token row under a field this
|
|
234
|
+
// document does not carry — the `cf_` twin the search module used to write, say — read as a
|
|
235
|
+
// difference forever and defeat the skip on every write.
|
|
236
|
+
const scopeTokenQuery = (query: any): any => {
|
|
237
|
+
let scoped = query
|
|
238
|
+
.where('entity_type' as any, '=', params.entityType)
|
|
239
|
+
.where(sql<boolean>`organization_id is not distinct from ${organizationId}`)
|
|
240
|
+
.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
|
|
241
|
+
.where('entity_id' as any, '=', String(params.recordId))
|
|
242
|
+
if (fieldPairs.length) {
|
|
243
|
+
scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))
|
|
244
|
+
}
|
|
245
|
+
return scoped
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Read through the caller's transaction when there is one. A separate connection cannot see that
|
|
249
|
+
// transaction's own uncommitted writes, so it could report rows a pending delete has already
|
|
250
|
+
// removed and talk this call out of re-inserting them.
|
|
251
|
+
const reader = options?.trx ?? db
|
|
252
|
+
|
|
253
|
+
// Count probe first, as in the batch path: it returns one row whatever the table holds, so a
|
|
254
|
+
// record whose stored rows have run away (#4681) is settled without materializing them.
|
|
255
|
+
const storedCountRows = await scopeTokenQuery(
|
|
256
|
+
reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),
|
|
257
|
+
).execute()
|
|
258
|
+
const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)
|
|
259
|
+
|
|
260
|
+
let unchanged = storedCount === rows.length
|
|
261
|
+
if (unchanged && rows.length) {
|
|
262
|
+
const stored = await scopeTokenQuery(
|
|
263
|
+
reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),
|
|
264
|
+
)
|
|
265
|
+
// Counts already match, so this cannot truncate. It bounds the read if a concurrent writer
|
|
266
|
+
// inserts between the probe and here; a truncated read compares as changed, which costs a
|
|
267
|
+
// rewrite rather than a wrong skip.
|
|
268
|
+
.limit(rows.length)
|
|
269
|
+
.execute()
|
|
270
|
+
unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))
|
|
271
|
+
}
|
|
272
|
+
if (unchanged) {
|
|
273
|
+
debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
177
277
|
const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {
|
|
178
278
|
let deleteQuery = executor
|
|
179
279
|
.deleteFrom('search_tokens' as any)
|
|
@@ -223,49 +323,6 @@ export async function deleteSearchTokensForRecord(
|
|
|
223
323
|
.execute()
|
|
224
324
|
}
|
|
225
325
|
|
|
226
|
-
// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`
|
|
227
|
-
// would otherwise sign identically to field `a` + hash `b c`.
|
|
228
|
-
const SIGNATURE_SEPARATOR = String.fromCharCode(0)
|
|
229
|
-
|
|
230
|
-
// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a
|
|
231
|
-
// stored NULL has to sign the same as the `null` a freshly built row carries — otherwise every
|
|
232
|
-
// record compares as changed and the skip never fires.
|
|
233
|
-
function tokenSignature(row: { field?: unknown; token_hash?: unknown; token?: unknown }): string {
|
|
234
|
-
return [
|
|
235
|
-
String(row.field ?? ''),
|
|
236
|
-
String(row.token_hash ?? ''),
|
|
237
|
-
row.token == null ? '' : String(row.token),
|
|
238
|
-
].join(SIGNATURE_SEPARATOR)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement
|
|
242
|
-
// defect, and a set comparison reads such a record as already correct and preserves the duplicates
|
|
243
|
-
// forever. Counting sends it through a full rewrite, which collapses them.
|
|
244
|
-
function tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {
|
|
245
|
-
const left = a ?? new Map<string, number>()
|
|
246
|
-
const right = b ?? new Map<string, number>()
|
|
247
|
-
if (left.size !== right.size) return false
|
|
248
|
-
for (const [key, count] of left.entries()) {
|
|
249
|
-
if (right.get(key) !== count) return false
|
|
250
|
-
}
|
|
251
|
-
return true
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function tallyTokenRows<TRow extends { field?: unknown; token_hash?: unknown; token?: unknown }>(
|
|
255
|
-
rows: Iterable<TRow>,
|
|
256
|
-
keyOf: (row: TRow) => string
|
|
257
|
-
): Map<string, Map<string, number>> {
|
|
258
|
-
const tallies = new Map<string, Map<string, number>>()
|
|
259
|
-
for (const row of rows) {
|
|
260
|
-
const key = keyOf(row)
|
|
261
|
-
const tally = tallies.get(key) ?? new Map<string, number>()
|
|
262
|
-
const signature = tokenSignature(row)
|
|
263
|
-
tally.set(signature, (tally.get(signature) ?? 0) + 1)
|
|
264
|
-
tallies.set(key, tally)
|
|
265
|
-
}
|
|
266
|
-
return tallies
|
|
267
|
-
}
|
|
268
|
-
|
|
269
326
|
export async function replaceSearchTokensForBatch(
|
|
270
327
|
db: Kysely<any>,
|
|
271
328
|
payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>
|