@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.
Files changed (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/login.js +4 -1
  3. package/dist/modules/auth/api/login.js.map +2 -2
  4. package/dist/modules/auth/api/session/refresh.js +25 -1
  5. package/dist/modules/auth/api/session/refresh.js.map +2 -2
  6. package/dist/modules/customers/ai-tools/deals-pack.js +48 -5
  7. package/dist/modules/customers/ai-tools/deals-pack.js.map +2 -2
  8. package/dist/modules/customers/api/deals/aggregate/route.js +11 -4
  9. package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
  10. package/dist/modules/customers/api/deals/route.js +38 -1
  11. package/dist/modules/customers/api/deals/route.js.map +2 -2
  12. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +4 -13
  13. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +85 -14
  15. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +2 -2
  16. package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js +18 -0
  17. package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js.map +7 -0
  18. package/dist/modules/customers/commands/deals.js +21 -34
  19. package/dist/modules/customers/commands/deals.js.map +2 -2
  20. package/dist/modules/customers/lib/closureStage.js +45 -0
  21. package/dist/modules/customers/lib/closureStage.js.map +7 -0
  22. package/dist/modules/customers/lib/dealStatus.js +30 -0
  23. package/dist/modules/customers/lib/dealStatus.js.map +2 -2
  24. package/dist/modules/query_index/lib/search-entity-policy.js +14 -0
  25. package/dist/modules/query_index/lib/search-entity-policy.js.map +7 -0
  26. package/package.json +7 -7
  27. package/src/modules/auth/api/login.ts +9 -1
  28. package/src/modules/auth/api/session/refresh.ts +32 -1
  29. package/src/modules/customers/ai-tools/deals-pack.ts +62 -0
  30. package/src/modules/customers/api/deals/aggregate/route.ts +14 -4
  31. package/src/modules/customers/api/deals/route.ts +50 -1
  32. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +5 -14
  33. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +109 -27
  34. package/src/modules/customers/backend/customers/deals/pipeline/components/toneClasses.ts +21 -0
  35. package/src/modules/customers/commands/deals.ts +34 -59
  36. package/src/modules/customers/i18n/de.json +3 -0
  37. package/src/modules/customers/i18n/en.json +3 -0
  38. package/src/modules/customers/i18n/es.json +3 -0
  39. package/src/modules/customers/i18n/ko.json +3 -0
  40. package/src/modules/customers/i18n/pl.json +3 -0
  41. package/src/modules/customers/lib/closureStage.ts +76 -0
  42. package/src/modules/customers/lib/dealStatus.ts +42 -0
  43. 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
- if (input.status === 'win') return 'won'
139
- if (input.status === 'loose') return 'lost'
140
- return null
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
- const requestedClosureOutcome = resolveRequestedClosureOutcome(parsed)
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
+ }