@open-mercato/core 0.6.8-develop.7072.1.19c2a8bbe0 → 0.6.8-develop.7077.1.94f1126c13
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/modules/auth/api/login.js +4 -1
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/api/session/refresh.js +25 -1
- package/dist/modules/auth/api/session/refresh.js.map +2 -2
- package/dist/modules/customers/ai-tools/deals-pack.js +48 -5
- package/dist/modules/customers/ai-tools/deals-pack.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +11 -4
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/api/deals/route.js +38 -1
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +4 -13
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +85 -14
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js +18 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js.map +7 -0
- package/dist/modules/customers/commands/deals.js +21 -34
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/lib/closureStage.js +45 -0
- package/dist/modules/customers/lib/closureStage.js.map +7 -0
- package/dist/modules/customers/lib/dealStatus.js +30 -0
- package/dist/modules/customers/lib/dealStatus.js.map +2 -2
- package/dist/modules/query_index/lib/search-entity-policy.js +14 -0
- package/dist/modules/query_index/lib/search-entity-policy.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/api/login.ts +9 -1
- package/src/modules/auth/api/session/refresh.ts +32 -1
- package/src/modules/customers/ai-tools/deals-pack.ts +62 -0
- package/src/modules/customers/api/deals/aggregate/route.ts +14 -4
- package/src/modules/customers/api/deals/route.ts +50 -1
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +5 -14
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +109 -27
- package/src/modules/customers/backend/customers/deals/pipeline/components/toneClasses.ts +21 -0
- package/src/modules/customers/commands/deals.ts +34 -59
- package/src/modules/customers/i18n/de.json +3 -0
- package/src/modules/customers/i18n/en.json +3 -0
- package/src/modules/customers/i18n/es.json +3 -0
- package/src/modules/customers/i18n/ko.json +3 -0
- package/src/modules/customers/i18n/pl.json +3 -0
- package/src/modules/customers/lib/closureStage.ts +76 -0
- package/src/modules/customers/lib/dealStatus.ts +42 -0
- package/src/modules/query_index/lib/search-entity-policy.ts +46 -0
|
@@ -45,6 +45,13 @@ import type { CrudIndexerConfig, CrudEventsConfig } from '@open-mercato/shared/l
|
|
|
45
45
|
import { E } from '#generated/entities.ids.generated'
|
|
46
46
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
47
47
|
import { isMissingDealStageTransitionTable, warnMissingDealStageTransitionTable } from '../lib/dealStageTransitionTable'
|
|
48
|
+
import {
|
|
49
|
+
dealClosureOutcomeFromStatus,
|
|
50
|
+
loadClosurePipelineStageSnapshot,
|
|
51
|
+
type DealClosureOutcome,
|
|
52
|
+
type PipelineStageSnapshot,
|
|
53
|
+
} from '../lib/closureStage'
|
|
54
|
+
import { canonicalDealStatus, isClosedDealStatus } from '../lib/dealStatus'
|
|
48
55
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
49
56
|
|
|
50
57
|
const logger = createLogger('customers')
|
|
@@ -65,20 +72,6 @@ const dealCrudEvents: CrudEventsConfig = {
|
|
|
65
72
|
}),
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
type PipelineStageSnapshot = {
|
|
69
|
-
id: string
|
|
70
|
-
pipelineId: string
|
|
71
|
-
label: string
|
|
72
|
-
order: number
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
type DealClosureOutcome = 'won' | 'lost'
|
|
76
|
-
|
|
77
|
-
const TERMINAL_PIPELINE_STAGE_LABELS: Record<DealClosureOutcome, ReadonlySet<string>> = {
|
|
78
|
-
won: new Set(['won', 'win', 'closed won', 'closed win']),
|
|
79
|
-
lost: new Set(['lost', 'loose', 'closed lost', 'closed loose']),
|
|
80
|
-
}
|
|
81
|
-
|
|
82
75
|
type DealStageTransitionSnapshot = {
|
|
83
76
|
id: string
|
|
84
77
|
pipelineId: string
|
|
@@ -123,54 +116,14 @@ async function loadPipelineStageSnapshot(
|
|
|
123
116
|
}
|
|
124
117
|
}
|
|
125
118
|
|
|
126
|
-
function normalizePipelineStageLabel(value: string): string {
|
|
127
|
-
return value
|
|
128
|
-
.trim()
|
|
129
|
-
.toLowerCase()
|
|
130
|
-
.replace(/[^a-z0-9]+/g, ' ')
|
|
131
|
-
.trim()
|
|
132
|
-
}
|
|
133
|
-
|
|
134
119
|
function resolveRequestedClosureOutcome(input: DealUpdateInput): DealClosureOutcome | null {
|
|
135
120
|
if (input.closureOutcome === 'won' || input.closureOutcome === 'lost') {
|
|
136
121
|
return input.closureOutcome
|
|
137
122
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
async function loadClosurePipelineStageSnapshot(
|
|
144
|
-
em: EntityManager,
|
|
145
|
-
input: {
|
|
146
|
-
pipelineId: string | null
|
|
147
|
-
closureOutcome: DealClosureOutcome
|
|
148
|
-
tenantId: string
|
|
149
|
-
organizationId: string
|
|
150
|
-
},
|
|
151
|
-
): Promise<PipelineStageSnapshot | null> {
|
|
152
|
-
if (!input.pipelineId) return null
|
|
153
|
-
|
|
154
|
-
const stages = await findWithDecryption(
|
|
155
|
-
em,
|
|
156
|
-
CustomerPipelineStage,
|
|
157
|
-
{
|
|
158
|
-
pipelineId: input.pipelineId,
|
|
159
|
-
tenantId: input.tenantId,
|
|
160
|
-
organizationId: input.organizationId,
|
|
161
|
-
},
|
|
162
|
-
{ orderBy: { order: 'ASC' } },
|
|
163
|
-
{ tenantId: input.tenantId, organizationId: input.organizationId },
|
|
164
|
-
)
|
|
165
|
-
const aliases = TERMINAL_PIPELINE_STAGE_LABELS[input.closureOutcome]
|
|
166
|
-
const stage = stages.find((candidate) => aliases.has(normalizePipelineStageLabel(candidate.label)))
|
|
167
|
-
if (!stage) return null
|
|
168
|
-
return {
|
|
169
|
-
id: stage.id,
|
|
170
|
-
pipelineId: stage.pipelineId,
|
|
171
|
-
label: stage.label,
|
|
172
|
-
order: stage.order,
|
|
173
|
-
}
|
|
123
|
+
// `win` / `loose` are what the UI closure flows persist; `won` / `lost` are the
|
|
124
|
+
// spellings the AI stage tool persists. Both must derive the same closure outcome so
|
|
125
|
+
// every closed deal lands in the pipeline's terminal stage (#5107).
|
|
126
|
+
return dealClosureOutcomeFromStatus(input.status)
|
|
174
127
|
}
|
|
175
128
|
|
|
176
129
|
async function resolvePipelineStageValue(
|
|
@@ -739,6 +692,7 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
739
692
|
let nextPipelineStageLabel: string | null = null
|
|
740
693
|
let resolvedCurrentPipelineStageLabel: string | null = null
|
|
741
694
|
let pipelineStageAssignmentChanged = false
|
|
695
|
+
let requestedClosureOutcome: DealClosureOutcome | null = null
|
|
742
696
|
|
|
743
697
|
await runCrudCommandWrite({
|
|
744
698
|
ctx,
|
|
@@ -759,7 +713,7 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
759
713
|
}),
|
|
760
714
|
phases: [
|
|
761
715
|
async () => {
|
|
762
|
-
|
|
716
|
+
requestedClosureOutcome = resolveRequestedClosureOutcome(parsed)
|
|
763
717
|
const requestedPipelineId =
|
|
764
718
|
parsed.pipelineId !== undefined ? parsed.pipelineId ?? null : record.pipelineId ?? null
|
|
765
719
|
const closureStageSnapshot =
|
|
@@ -828,6 +782,27 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
828
782
|
if (parsed.ownerUserId !== undefined) record.ownerUserId = parsed.ownerUserId ?? null
|
|
829
783
|
if (parsed.source !== undefined) record.source = parsed.source ?? null
|
|
830
784
|
if (parsed.closureOutcome !== undefined) record.closureOutcome = parsed.closureOutcome ?? null
|
|
785
|
+
// Derive the outcome only when the status spelling alone drives the closure —
|
|
786
|
+
// the same condition that triggers the terminal-stage move below — so an
|
|
787
|
+
// explicit non-terminal pipelineStageId never produces a half-closed state.
|
|
788
|
+
else if (requestedClosureOutcome && parsed.pipelineStageId === undefined) {
|
|
789
|
+
record.closureOutcome = requestedClosureOutcome
|
|
790
|
+
} else if (
|
|
791
|
+
parsed.status !== undefined &&
|
|
792
|
+
!requestedClosureOutcome &&
|
|
793
|
+
// `closed` is a seeded dictionary status: saving it is not a reopen, so only
|
|
794
|
+
// a genuinely non-closed status clears stored closure state — and only when
|
|
795
|
+
// the deal actually carries any (a no-op status echo must not wipe loss data).
|
|
796
|
+
// Canonicalize first: `dealClosureOutcomeFromStatus` above already matches
|
|
797
|
+
// case-insensitively, so matching `closed` exactly would let `Closed` through
|
|
798
|
+
// this guard and null the operator's loss notes.
|
|
799
|
+
!isClosedDealStatus(canonicalDealStatus(parsed.status)) &&
|
|
800
|
+
record.closureOutcome !== null
|
|
801
|
+
) {
|
|
802
|
+
record.closureOutcome = null
|
|
803
|
+
if (parsed.lossReasonId === undefined) record.lossReasonId = null
|
|
804
|
+
if (parsed.lossNotes === undefined) record.lossNotes = null
|
|
805
|
+
}
|
|
831
806
|
if (parsed.lossReasonId !== undefined) record.lossReasonId = parsed.lossReasonId ?? null
|
|
832
807
|
if (parsed.lossNotes !== undefined) record.lossNotes = parsed.lossNotes ?? null
|
|
833
808
|
},
|
|
@@ -1323,6 +1323,9 @@
|
|
|
1323
1323
|
"customers.deals.kanban.filter.searchPlaceholder": "Search…",
|
|
1324
1324
|
"customers.deals.kanban.filter.selected": "selected",
|
|
1325
1325
|
"customers.deals.kanban.filter.status": "Status",
|
|
1326
|
+
"customers.deals.kanban.filter.status.closed": "Geschlossen",
|
|
1327
|
+
"customers.deals.kanban.filter.status.inProgress": "In Bearbeitung",
|
|
1328
|
+
"customers.deals.kanban.filter.status.loading": "Status werden geladen…",
|
|
1326
1329
|
"customers.deals.kanban.filter.status.lost": "Lost",
|
|
1327
1330
|
"customers.deals.kanban.filter.status.open": "Open",
|
|
1328
1331
|
"customers.deals.kanban.filter.status.title": "Filter · Status",
|
|
@@ -1323,6 +1323,9 @@
|
|
|
1323
1323
|
"customers.deals.kanban.filter.searchPlaceholder": "Search…",
|
|
1324
1324
|
"customers.deals.kanban.filter.selected": "selected",
|
|
1325
1325
|
"customers.deals.kanban.filter.status": "Status",
|
|
1326
|
+
"customers.deals.kanban.filter.status.closed": "Closed",
|
|
1327
|
+
"customers.deals.kanban.filter.status.inProgress": "In progress",
|
|
1328
|
+
"customers.deals.kanban.filter.status.loading": "Loading statuses…",
|
|
1326
1329
|
"customers.deals.kanban.filter.status.lost": "Lost",
|
|
1327
1330
|
"customers.deals.kanban.filter.status.open": "Open",
|
|
1328
1331
|
"customers.deals.kanban.filter.status.title": "Filter · Status",
|
|
@@ -1323,6 +1323,9 @@
|
|
|
1323
1323
|
"customers.deals.kanban.filter.searchPlaceholder": "Search…",
|
|
1324
1324
|
"customers.deals.kanban.filter.selected": "selected",
|
|
1325
1325
|
"customers.deals.kanban.filter.status": "Status",
|
|
1326
|
+
"customers.deals.kanban.filter.status.closed": "Cerrado",
|
|
1327
|
+
"customers.deals.kanban.filter.status.inProgress": "En curso",
|
|
1328
|
+
"customers.deals.kanban.filter.status.loading": "Cargando estados…",
|
|
1326
1329
|
"customers.deals.kanban.filter.status.lost": "Lost",
|
|
1327
1330
|
"customers.deals.kanban.filter.status.open": "Open",
|
|
1328
1331
|
"customers.deals.kanban.filter.status.title": "Filter · Status",
|
|
@@ -1323,6 +1323,9 @@
|
|
|
1323
1323
|
"customers.deals.kanban.filter.searchPlaceholder": "검색…",
|
|
1324
1324
|
"customers.deals.kanban.filter.selected": "선택됨",
|
|
1325
1325
|
"customers.deals.kanban.filter.status": "상태",
|
|
1326
|
+
"customers.deals.kanban.filter.status.closed": "마감",
|
|
1327
|
+
"customers.deals.kanban.filter.status.inProgress": "진행 중",
|
|
1328
|
+
"customers.deals.kanban.filter.status.loading": "상태 로드 중…",
|
|
1326
1329
|
"customers.deals.kanban.filter.status.lost": "실패",
|
|
1327
1330
|
"customers.deals.kanban.filter.status.open": "열림",
|
|
1328
1331
|
"customers.deals.kanban.filter.status.title": "필터 · 상태",
|
|
@@ -1323,6 +1323,9 @@
|
|
|
1323
1323
|
"customers.deals.kanban.filter.searchPlaceholder": "Szukaj…",
|
|
1324
1324
|
"customers.deals.kanban.filter.selected": "zaznaczono",
|
|
1325
1325
|
"customers.deals.kanban.filter.status": "Status",
|
|
1326
|
+
"customers.deals.kanban.filter.status.closed": "Zamknięta",
|
|
1327
|
+
"customers.deals.kanban.filter.status.inProgress": "W toku",
|
|
1328
|
+
"customers.deals.kanban.filter.status.loading": "Ładowanie statusów…",
|
|
1326
1329
|
"customers.deals.kanban.filter.status.lost": "Przegrana",
|
|
1327
1330
|
"customers.deals.kanban.filter.status.open": "Otwarta",
|
|
1328
1331
|
"customers.deals.kanban.filter.status.title": "Filtr · Status",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { CustomerPipelineStage } from '../data/entities'
|
|
4
|
+
|
|
5
|
+
export type DealClosureOutcome = 'won' | 'lost'
|
|
6
|
+
|
|
7
|
+
export type PipelineStageSnapshot = {
|
|
8
|
+
id: string
|
|
9
|
+
pipelineId: string
|
|
10
|
+
label: string
|
|
11
|
+
order: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Stage labels that mark a pipeline as terminal for a given closure outcome. Matching is
|
|
15
|
+
// label-based because stages are tenant-configurable rows; see #5107.
|
|
16
|
+
export const TERMINAL_PIPELINE_STAGE_LABELS: Record<DealClosureOutcome, ReadonlySet<string>> = {
|
|
17
|
+
won: new Set(['won', 'win', 'closed won', 'closed win']),
|
|
18
|
+
lost: new Set(['lost', 'loose', 'closed lost', 'closed loose']),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Map a deal status spelling (UI `win`/`loose` or AI `won`/`lost`) to its closure
|
|
23
|
+
* outcome. Case-insensitive because callers such as the AI tool pass free-form model
|
|
24
|
+
* text through, while every persisted status is lower-case.
|
|
25
|
+
*/
|
|
26
|
+
export function dealClosureOutcomeFromStatus(status: string | null | undefined): DealClosureOutcome | null {
|
|
27
|
+
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : status
|
|
28
|
+
if (normalized === 'win' || normalized === 'won') return 'won'
|
|
29
|
+
if (normalized === 'loose' || normalized === 'lost') return 'lost'
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizePipelineStageLabel(value: string): string {
|
|
34
|
+
return value
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
38
|
+
.trim()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the terminal stage of the deal's pipeline for a closure outcome, so every
|
|
43
|
+
* writer (command, AI tool preview) agrees on where a closed deal lands (#5107).
|
|
44
|
+
*/
|
|
45
|
+
export async function loadClosurePipelineStageSnapshot(
|
|
46
|
+
em: EntityManager,
|
|
47
|
+
input: {
|
|
48
|
+
pipelineId: string | null
|
|
49
|
+
closureOutcome: DealClosureOutcome
|
|
50
|
+
tenantId: string
|
|
51
|
+
organizationId: string
|
|
52
|
+
},
|
|
53
|
+
): Promise<PipelineStageSnapshot | null> {
|
|
54
|
+
if (!input.pipelineId) return null
|
|
55
|
+
|
|
56
|
+
const stages = await findWithDecryption(
|
|
57
|
+
em,
|
|
58
|
+
CustomerPipelineStage,
|
|
59
|
+
{
|
|
60
|
+
pipelineId: input.pipelineId,
|
|
61
|
+
tenantId: input.tenantId,
|
|
62
|
+
organizationId: input.organizationId,
|
|
63
|
+
},
|
|
64
|
+
{ orderBy: { order: 'ASC' } },
|
|
65
|
+
{ tenantId: input.tenantId, organizationId: input.organizationId },
|
|
66
|
+
)
|
|
67
|
+
const aliases = TERMINAL_PIPELINE_STAGE_LABELS[input.closureOutcome]
|
|
68
|
+
const stage = stages.find((candidate) => aliases.has(normalizePipelineStageLabel(candidate.label)))
|
|
69
|
+
if (!stage) return null
|
|
70
|
+
return {
|
|
71
|
+
id: stage.id,
|
|
72
|
+
pipelineId: stage.pipelineId,
|
|
73
|
+
label: stage.label,
|
|
74
|
+
order: stage.order,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -46,3 +46,45 @@ export function isClosedDealStatus(value: string | null | undefined): boolean {
|
|
|
46
46
|
export function isOpenDealStatus(value: string | null | undefined): boolean {
|
|
47
47
|
return !isClosedDealStatus(value)
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
export function canonicalDealStatus(value: string): string {
|
|
51
|
+
const lower = value.toLowerCase()
|
|
52
|
+
if (lower === 'won') return DEAL_STATUS_WIN
|
|
53
|
+
if (lower === 'lost') return DEAL_STATUS_LOSE
|
|
54
|
+
// Canonical spellings normalize to lower-case too, so an upper-case selection still
|
|
55
|
+
// matches the lower-case persisted values and dictionary options.
|
|
56
|
+
if (lower === 'win' || lower === 'loose' || lower === 'open' || lower === 'closed') return lower
|
|
57
|
+
return value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Expand operator-selected status filter values into the canonical vocabulary so a
|
|
62
|
+
* single selection matches every spelling the supported writers persist (#5107).
|
|
63
|
+
*
|
|
64
|
+
* Alias groups (`win`/`won`, `loose`/`lost`, `closed`) are matched case-insensitively,
|
|
65
|
+
* expanded to their canonical spellings, and also keep the caller's original token so
|
|
66
|
+
* the result is always a strict superset of a verbatim match. Unknown values emit both
|
|
67
|
+
* the trimmed original and its lower-case form — mixed-case tenant dictionary statuses
|
|
68
|
+
* match exactly while `?status=Open` still hits lower-case stored rows.
|
|
69
|
+
*/
|
|
70
|
+
export function expandDealStatusAliases(values: string[]): string[] {
|
|
71
|
+
const out = new Set<string>()
|
|
72
|
+
for (const value of values) {
|
|
73
|
+
const trimmed = value.trim()
|
|
74
|
+
const lower = trimmed.toLowerCase()
|
|
75
|
+
if (lower === 'win' || lower === 'won') {
|
|
76
|
+
WON_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))
|
|
77
|
+
out.add(trimmed)
|
|
78
|
+
} else if (lower === 'loose' || lower === 'lost') {
|
|
79
|
+
LOST_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))
|
|
80
|
+
out.add(trimmed)
|
|
81
|
+
} else if (lower === 'closed') {
|
|
82
|
+
CLOSED_DEAL_STATUS_LIST.forEach((entry) => out.add(entry))
|
|
83
|
+
out.add(trimmed)
|
|
84
|
+
} else {
|
|
85
|
+
out.add(trimmed)
|
|
86
|
+
out.add(lower)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return Array.from(out)
|
|
90
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The canonical customer record. Every customer also has a person or company profile indexed under
|
|
5
|
+
* its own entity type, and it is the profile — not the base row — that carries the detail-page
|
|
6
|
+
* navigation.
|
|
7
|
+
*/
|
|
8
|
+
export const CUSTOMERS_BASE_ENTITY_TYPE = 'customers:customer_entity'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether base customer rows are offered as search results.
|
|
12
|
+
*
|
|
13
|
+
* Why the knob exists: `search_tokens` is written by the query index for **every** entity a CRUD
|
|
14
|
+
* route indexes, independently of any module's `search.ts` config. `customers:customer_entity` is
|
|
15
|
+
* indexed but no module registers it for search, so its rows only ever surface a second,
|
|
16
|
+
* navigation-less copy of a customer who is already represented by their person or company
|
|
17
|
+
* profile — the duplicate reported in #5046. Worse, the duplicate was visible to superadmins only:
|
|
18
|
+
* `resolveReadableEntityTypes` narrows every other caller to entity types a module registered for
|
|
19
|
+
* search, so the same query returned different shapes for different users.
|
|
20
|
+
*
|
|
21
|
+
* Default `false`: the token strategy never returns base customer rows, so a customer appears
|
|
22
|
+
* exactly once, as their profile. `OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=true` restores the
|
|
23
|
+
* previous behavior, where both rows are returned and the presenter enricher's linked-result merge
|
|
24
|
+
* collapses the pair into the canonical entity result.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately a **read-side** filter. `search_tokens` is not only the token-search index: the
|
|
27
|
+
* People and Companies list routes resolve their search box through
|
|
28
|
+
* `findEntityIdsBySearchTokens` on this exact entity type, and both query engines rewrite
|
|
29
|
+
* `like`/`ilike` on encrypted customer columns into the same table. Dropping the rows would
|
|
30
|
+
* silently break list search on `display_name`, `primary_email`, `description` and friends, so the
|
|
31
|
+
* rows stay and only the search strategy stops offering them.
|
|
32
|
+
*/
|
|
33
|
+
export function indexesCustomerBaseEntity(): boolean {
|
|
34
|
+
return parseBooleanWithDefault(process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY, false)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Entity types the token search strategy must not return.
|
|
39
|
+
*
|
|
40
|
+
* Exposed as a list so the strategy can express the exclusion as a single `NOT IN` predicate
|
|
41
|
+
* instead of post-filtering rows it already paid to fetch — a post-filter would silently shrink
|
|
42
|
+
* the caller's `limit`.
|
|
43
|
+
*/
|
|
44
|
+
export function listSearchTokenExcludedEntityTypes(): string[] {
|
|
45
|
+
return indexesCustomerBaseEntity() ? [] : [CUSTOMERS_BASE_ENTITY_TYPE]
|
|
46
|
+
}
|