@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3653.1.b8558737ae

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 (160) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_settings/index.js +2 -0
  3. package/dist/generated/entities/customer_settings/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
  7. package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
  8. package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
  9. package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
  10. package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
  11. package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
  12. package/dist/modules/customers/api/deals/route.js +255 -122
  13. package/dist/modules/customers/api/deals/route.js.map +2 -2
  14. package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
  15. package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
  16. package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
  17. package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
  18. package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
  19. package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
  21. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/page.js +2 -0
  23. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
  26. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
  27. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
  28. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
  29. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
  30. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
  31. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
  34. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
  35. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
  36. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
  37. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
  38. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
  39. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
  40. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
  41. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
  43. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
  44. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
  45. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
  46. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
  47. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
  48. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
  49. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
  50. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
  51. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
  52. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
  53. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
  54. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
  55. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
  56. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
  57. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
  58. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
  59. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
  60. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
  61. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
  62. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
  63. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
  64. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
  65. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
  66. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
  67. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
  68. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
  69. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
  70. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
  71. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
  72. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
  73. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
  74. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
  75. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
  76. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
  77. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
  78. package/dist/modules/customers/commands/deals.js +6 -0
  79. package/dist/modules/customers/commands/deals.js.map +2 -2
  80. package/dist/modules/customers/commands/pipeline-stages.js +48 -13
  81. package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
  82. package/dist/modules/customers/commands/settings.js +45 -5
  83. package/dist/modules/customers/commands/settings.js.map +2 -2
  84. package/dist/modules/customers/data/enrichers.js +130 -1
  85. package/dist/modules/customers/data/enrichers.js.map +2 -2
  86. package/dist/modules/customers/data/entities.js +4 -0
  87. package/dist/modules/customers/data/entities.js.map +2 -2
  88. package/dist/modules/customers/data/validators.js +29 -2
  89. package/dist/modules/customers/data/validators.js.map +2 -2
  90. package/dist/modules/customers/lib/bulkDeals.js +150 -0
  91. package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
  92. package/dist/modules/customers/lib/kysely.js +12 -0
  93. package/dist/modules/customers/lib/kysely.js.map +7 -0
  94. package/dist/modules/customers/lib/stuckDeals.js +32 -0
  95. package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
  96. package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
  97. package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
  98. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
  99. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
  100. package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
  101. package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
  102. package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
  103. package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
  104. package/generated/entities/customer_settings/index.ts +1 -0
  105. package/generated/entity-fields-registry.ts +1 -0
  106. package/package.json +7 -7
  107. package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
  108. package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
  109. package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
  110. package/src/modules/customers/api/deals/route.ts +330 -135
  111. package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
  112. package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
  113. package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
  114. package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
  115. package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
  116. package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
  117. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
  118. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
  119. package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
  120. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
  121. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
  122. package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
  123. package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
  124. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
  125. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
  126. package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
  127. package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
  128. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
  129. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
  130. package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
  131. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
  132. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
  133. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
  134. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
  135. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
  136. package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
  137. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
  138. package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
  139. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
  140. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
  141. package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
  142. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
  143. package/src/modules/customers/commands/deals.ts +19 -0
  144. package/src/modules/customers/commands/pipeline-stages.ts +68 -13
  145. package/src/modules/customers/commands/settings.ts +64 -5
  146. package/src/modules/customers/data/enrichers.ts +209 -2
  147. package/src/modules/customers/data/entities.ts +4 -1
  148. package/src/modules/customers/data/validators.ts +36 -2
  149. package/src/modules/customers/i18n/de.json +244 -0
  150. package/src/modules/customers/i18n/en.json +244 -0
  151. package/src/modules/customers/i18n/es.json +244 -0
  152. package/src/modules/customers/i18n/pl.json +244 -0
  153. package/src/modules/customers/lib/bulkDeals.ts +231 -0
  154. package/src/modules/customers/lib/kysely.ts +60 -0
  155. package/src/modules/customers/lib/stuckDeals.ts +88 -0
  156. package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
  157. package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
  158. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
  159. package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
  160. package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
@@ -5,6 +5,7 @@ import { CustomerDeal, CustomerDealPersonLink, CustomerDealCompanyLink } from '.
5
5
  import { dealCreateSchema, dealUpdateSchema } from '../../data/validators'
6
6
  import { E } from '#generated/entities.ids.generated'
7
7
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
8
+ import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
8
9
  import {
9
10
  applyEntityIdRestriction,
10
11
  findMatchingEntityIdsWithQueryEngine,
@@ -18,24 +19,48 @@ import {
18
19
  defaultOkResponseSchema,
19
20
  } from '../openapi'
20
21
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
22
+ import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
21
23
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
22
24
  import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
25
+ import { fetchStuckDealIds } from '../../lib/stuckDeals'
23
26
 
24
27
  const rawBodySchema = z.object({}).passthrough()
25
28
 
26
- const listSchema = z
29
+ const stringOrStringArray = z.union([z.string(), z.array(z.string())])
30
+ const booleanQueryParam = z.preprocess((value) => {
31
+ const parsed = parseBooleanFromUnknown(value)
32
+ return parsed === null ? value : parsed
33
+ }, z.boolean()).optional()
34
+
35
+ export const dealListQuerySchema = z
27
36
  .object({
28
37
  page: z.coerce.number().min(1).default(1),
29
38
  pageSize: z.coerce.number().min(1).max(100).default(50),
30
39
  search: z.string().optional(),
31
- status: z.string().optional(),
40
+ status: stringOrStringArray.optional(),
32
41
  pipelineStage: z.string().optional(),
33
- pipelineId: z.string().uuid().optional(),
34
- pipelineStageId: z.string().uuid().optional(),
42
+ pipelineId: stringOrStringArray.optional(),
43
+ pipelineStageId: z.union([z.string().uuid(), z.literal('__unassigned')]).optional(),
44
+ ownerUserId: stringOrStringArray.optional(),
45
+ expectedCloseAtFrom: z.string().optional(),
46
+ expectedCloseAtTo: z.string().optional(),
47
+ isStuck: booleanQueryParam,
48
+ isOverdue: booleanQueryParam,
49
+ valueCurrency: stringOrStringArray.optional(),
35
50
  sortField: z.string().optional(),
36
51
  sortDir: z.enum(['asc', 'desc']).optional(),
37
- personEntityId: z.string().uuid().optional(),
38
- companyEntityId: z.string().uuid().optional(),
52
+ /**
53
+ * @deprecated Use `personId` instead. The `personEntityId` alias is kept for backward
54
+ * compatibility with older clients and will be removed in a future minor release.
55
+ */
56
+ personEntityId: z.string().uuid().optional().describe('Deprecated; use personId'),
57
+ /**
58
+ * @deprecated Use `companyId` instead. The `companyEntityId` alias is kept for backward
59
+ * compatibility with older clients and will be removed in a future minor release.
60
+ */
61
+ companyEntityId: z.string().uuid().optional().describe('Deprecated; use companyId'),
62
+ personId: stringOrStringArray.optional(),
63
+ companyId: stringOrStringArray.optional(),
39
64
  })
40
65
  .passthrough()
41
66
 
@@ -48,7 +73,7 @@ const routeMetadata = {
48
73
 
49
74
  export const metadata = routeMetadata
50
75
 
51
- type DealListQuery = z.infer<typeof listSchema>
76
+ export type DealListQuery = z.infer<typeof dealListQuerySchema>
52
77
 
53
78
  function parseUuid(value: unknown): string | null {
54
79
  if (typeof value !== 'string') return null
@@ -58,6 +83,97 @@ function parseUuid(value: unknown): string | null {
58
83
  return result.success ? trimmed : null
59
84
  }
60
85
 
86
+ function normalizeStringList(value: unknown): string[] {
87
+ const set = new Set<string>()
88
+ const visit = (entry: unknown) => {
89
+ if (entry == null) return
90
+ if (Array.isArray(entry)) {
91
+ entry.forEach(visit)
92
+ return
93
+ }
94
+ if (typeof entry !== 'string') return
95
+ entry
96
+ .split(',')
97
+ .map((token) => token.trim())
98
+ .filter((token) => token.length > 0)
99
+ .forEach((token) => set.add(token))
100
+ }
101
+ visit(value)
102
+ return Array.from(set)
103
+ }
104
+
105
+ function parseDateInput(value: unknown): Date | null {
106
+ if (!(typeof value === 'string') || value.trim().length === 0) return null
107
+ const date = new Date(value)
108
+ return Number.isNaN(date.getTime()) ? null : date
109
+ }
110
+
111
+ /**
112
+ * Pre-pagination ID narrowing for person/company filters.
113
+ *
114
+ * Mirrors the SQL the aggregate route uses (`EXISTS ... IN (...)`) so the list endpoint
115
+ * and the lane-header aggregate return a consistent set of deals for the same filters.
116
+ * Semantics: OR within a category (any selected person/company matches), AND across
117
+ * categories (deal must match at least one person AND at least one company when both
118
+ * filter lists are provided).
119
+ *
120
+ * Returns the matched deal IDs (UUIDs). An empty array means "no deals match" — callers
121
+ * must intersect with `restrictedIds` (which will collapse the list to zero).
122
+ */
123
+ async function fetchDealIdsMatchingAssociations(
124
+ em: EntityManager,
125
+ organizationId: string,
126
+ tenantId: string,
127
+ personIds: string[],
128
+ companyIds: string[],
129
+ ): Promise<string[]> {
130
+ if (!personIds.length && !companyIds.length) return []
131
+ const where: string[] = [
132
+ 'organization_id = ?',
133
+ 'tenant_id = ?',
134
+ 'deleted_at IS NULL',
135
+ ]
136
+ const values: Array<string | number> = [organizationId, tenantId]
137
+ if (personIds.length) {
138
+ const placeholders = personIds.map(() => '?').join(',')
139
+ where.push(
140
+ `EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`,
141
+ )
142
+ values.push(...personIds)
143
+ }
144
+ if (companyIds.length) {
145
+ const placeholders = companyIds.map(() => '?').join(',')
146
+ where.push(
147
+ `EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`,
148
+ )
149
+ values.push(...companyIds)
150
+ }
151
+ const rows = await em.getConnection().execute<Array<{ id: string }>>(
152
+ `SELECT id FROM customer_deals WHERE ${where.join(' AND ')}`,
153
+ values,
154
+ )
155
+ return rows.map((row) => row.id)
156
+ }
157
+
158
+ function normalizeCurrencyList(value: unknown): string[] {
159
+ const set = new Set<string>()
160
+ const visit = (entry: unknown) => {
161
+ if (entry == null) return
162
+ if (Array.isArray(entry)) {
163
+ entry.forEach(visit)
164
+ return
165
+ }
166
+ if (typeof entry !== 'string') return
167
+ entry
168
+ .split(',')
169
+ .map((token) => token.trim().toUpperCase())
170
+ .filter((token) => /^[A-Z]{3}$/.test(token))
171
+ .forEach((token) => set.add(token))
172
+ }
173
+ visit(value)
174
+ return Array.from(set)
175
+ }
176
+
61
177
  function normalizeUuidList(values: Array<unknown>): string[] {
62
178
  const set = new Set<string>()
63
179
  values.forEach((candidate) => {
@@ -84,6 +200,186 @@ function normalizeUuidList(values: Array<unknown>): string[] {
84
200
  return Array.from(set)
85
201
  }
86
202
 
203
+ export async function buildDealListFilters(query: DealListQuery, ctx?: import('@open-mercato/shared/lib/crud/factory').CrudCtx) {
204
+ const advancedFilterTree = consumeAdvancedFilterState(query)
205
+ const filters: Record<string, unknown> = {}
206
+ let restrictedIds: string[] | null = null
207
+
208
+ const intersectIds = (ids: string[]) => {
209
+ if (restrictedIds === null) {
210
+ restrictedIds = ids
211
+ return
212
+ }
213
+ const lookup = new Set(ids)
214
+ restrictedIds = restrictedIds.filter((id) => lookup.has(id))
215
+ }
216
+
217
+ if (query.search) {
218
+ const matchingIds = ctx
219
+ ? await findMatchingEntityIdsBySearchTokensAcrossSources({
220
+ ctx,
221
+ query: query.search,
222
+ sources: [
223
+ {
224
+ entityType: E.customers.customer_deal,
225
+ fields: [
226
+ 'title',
227
+ 'description',
228
+ 'status',
229
+ 'pipeline_stage',
230
+ 'source',
231
+ 'value_amount',
232
+ 'value_currency',
233
+ 'cf:competitive_risk',
234
+ 'cf:implementation_complexity',
235
+ ],
236
+ },
237
+ ],
238
+ })
239
+ : null
240
+ if (matchingIds !== null && matchingIds.length > 0) {
241
+ intersectIds(matchingIds)
242
+ } else if (isTenantDataEncryptionEnabled()) {
243
+ // `customers:customer_deal.title` and `.description` are encrypted at rest
244
+ // (see `encryption.ts`). The `$ilike` fallback below would silently match
245
+ // nothing on the ciphertext and produce empty pages without disclosing why —
246
+ // collapse the result to "no matches" instead so the kanban + list views
247
+ // agree, and lane aggregates stay consistent with the list endpoint.
248
+ intersectIds([])
249
+ } else {
250
+ const searchPattern = `%${escapeLikePattern(query.search)}%`
251
+ filters.$or = [
252
+ { title: { $ilike: searchPattern } },
253
+ { description: { $ilike: searchPattern } },
254
+ ]
255
+ }
256
+ }
257
+
258
+ const statusList = query.status ? normalizeStringList(query.status) : []
259
+ if (statusList.length > 0) {
260
+ filters.status = statusList.length === 1 ? { $eq: statusList[0] } : { $in: statusList }
261
+ }
262
+
263
+ if (query.pipelineStage) {
264
+ filters.pipeline_stage = { $eq: query.pipelineStage }
265
+ }
266
+
267
+ const pipelineIds = query.pipelineId ? normalizeUuidList([query.pipelineId]) : []
268
+ if (pipelineIds.length > 0) {
269
+ filters.pipeline_id = pipelineIds.length === 1 ? { $eq: pipelineIds[0] } : { $in: pipelineIds }
270
+ }
271
+
272
+ if (query.pipelineStageId === '__unassigned') {
273
+ filters.pipeline_stage_id = { $eq: null }
274
+ } else if (query.pipelineStageId) {
275
+ filters.pipeline_stage_id = { $eq: query.pipelineStageId }
276
+ }
277
+
278
+ const ownerUserIds = query.ownerUserId ? normalizeUuidList([query.ownerUserId]) : []
279
+ if (ownerUserIds.length > 0) {
280
+ filters.owner_user_id =
281
+ ownerUserIds.length === 1 ? { $eq: ownerUserIds[0] } : { $in: ownerUserIds }
282
+ }
283
+
284
+ // Currency filter (additive; sourced by the kanban Currency popover). Codes are
285
+ // normalised to upper-case so `usd` / `USD` / mixed-case query params all hit the same
286
+ // stored value (the deals API persists `value_currency` upper-case already).
287
+ const currencyCodes = query.valueCurrency ? normalizeCurrencyList(query.valueCurrency) : []
288
+ if (currencyCodes.length > 0) {
289
+ filters.value_currency =
290
+ currencyCodes.length === 1 ? { $eq: currencyCodes[0] } : { $in: currencyCodes }
291
+ }
292
+
293
+ const expectedCloseFrom = parseDateInput(query.expectedCloseAtFrom)
294
+ const expectedCloseTo = parseDateInput(query.expectedCloseAtTo)
295
+ if (expectedCloseFrom || expectedCloseTo) {
296
+ const range: Record<string, Date> = {}
297
+ if (expectedCloseFrom) range.$gte = expectedCloseFrom
298
+ if (expectedCloseTo) range.$lte = expectedCloseTo
299
+ filters.expected_close_at = range
300
+ }
301
+
302
+ if (query.isOverdue) {
303
+ const today = new Date()
304
+ today.setHours(0, 0, 0, 0)
305
+ if (statusList.length === 0) {
306
+ filters.status = { $eq: 'open' }
307
+ }
308
+ const existingRange =
309
+ filters.expected_close_at && typeof filters.expected_close_at === 'object'
310
+ ? (filters.expected_close_at as Record<string, Date>)
311
+ : {}
312
+ existingRange.$lt = today
313
+ filters.expected_close_at = existingRange
314
+ }
315
+
316
+ if (query.isStuck && ctx) {
317
+ const tenantId = ctx.auth?.tenantId
318
+ // CrudCtx.auth carries `orgId` (not `organizationId`). The previous code referenced
319
+ // `organizationId` which is always `undefined`, so the typeof check below silently
320
+ // skipped the entire isStuck branch — `?isStuck=true` was a no-op on this endpoint.
321
+ const organizationId = ctx.auth?.orgId
322
+ if (typeof tenantId === 'string' && typeof organizationId === 'string') {
323
+ const em = ctx.container.resolve<EntityManager>('em')
324
+ const stuckIds = await fetchStuckDealIds(em, organizationId, tenantId)
325
+ intersectIds(stuckIds)
326
+ }
327
+ }
328
+
329
+ // Pre-pagination association filter. Must run on the FULL dataset (before pagination),
330
+ // otherwise matching deals on later pages disappear and `total` would be wrong. Read the
331
+ // raw URL too so legacy `?personEntityId=` / `?companyEntityId=` keep working alongside the
332
+ // canonical `?personId=` / `?companyId=`.
333
+ const url = ctx?.request ? new URL(ctx.request.url) : null
334
+ const personCandidates: unknown[] = [query.personId, query.personEntityId]
335
+ const companyCandidates: unknown[] = [query.companyId, query.companyEntityId]
336
+ if (url) {
337
+ personCandidates.push(url.searchParams.getAll('personId'))
338
+ personCandidates.push(url.searchParams.getAll('personEntityId'))
339
+ companyCandidates.push(url.searchParams.getAll('companyId'))
340
+ companyCandidates.push(url.searchParams.getAll('companyEntityId'))
341
+ }
342
+ const selectedPersonIds = normalizeUuidList(personCandidates)
343
+ const selectedCompanyIds = normalizeUuidList(companyCandidates)
344
+ if ((selectedPersonIds.length > 0 || selectedCompanyIds.length > 0) && ctx) {
345
+ const tenantId = ctx.auth?.tenantId
346
+ // `ctx.auth` exposes `orgId` (see AuthContext in @open-mercato/shared/lib/auth/server).
347
+ // Read it under the correct key — the previous code's `organizationId` would always be
348
+ // `undefined`, silently disabling association filtering on the deals list endpoint.
349
+ const organizationId = ctx.auth?.orgId
350
+ if (typeof tenantId === 'string' && typeof organizationId === 'string') {
351
+ const em = ctx.container.resolve<EntityManager>('em')
352
+ const matchedIds = await fetchDealIdsMatchingAssociations(
353
+ em,
354
+ organizationId,
355
+ tenantId,
356
+ selectedPersonIds,
357
+ selectedCompanyIds,
358
+ )
359
+ // intersectIds with empty array → no rows; collapses the page to zero, total stays correct.
360
+ intersectIds(matchedIds)
361
+ }
362
+ }
363
+
364
+ if (ctx && advancedFilterTree) {
365
+ const advancedFilters = mergeAdvancedFilterTree({ ...filters }, advancedFilterTree)
366
+ const matchedIds = await findMatchingEntityIdsWithQueryEngine({
367
+ ctx,
368
+ entityId: E.customers.customer_deal,
369
+ filters: advancedFilters,
370
+ })
371
+ if (matchedIds !== null) {
372
+ intersectIds(matchedIds)
373
+ }
374
+ }
375
+
376
+ if (restrictedIds !== null) {
377
+ applyEntityIdRestriction(filters, restrictedIds)
378
+ }
379
+
380
+ return filters
381
+ }
382
+
87
383
  const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
88
384
  metadata: routeMetadata,
89
385
  orm: {
@@ -96,8 +392,9 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
96
392
  indexer: {
97
393
  entityType: E.customers.customer_deal,
98
394
  },
395
+ enrichers: { entityId: 'customers.deal' },
99
396
  list: {
100
- schema: listSchema,
397
+ schema: dealListQuerySchema,
101
398
  entityId: E.customers.customer_deal,
102
399
  fields: [
103
400
  'id',
@@ -129,68 +426,10 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
129
426
  updatedAt: 'updated_at',
130
427
  title: 'title',
131
428
  value: 'value_amount',
429
+ probability: 'probability',
430
+ expectedCloseAt: 'expected_close_at',
132
431
  },
133
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
- buildFilters: async (query: any, ctx) => {
135
- const advancedFilterTree = consumeAdvancedFilterState(query)
136
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
- const filters: Record<string, any> = {}
138
- if (query.search) {
139
- const matchingIds = ctx
140
- ? await findMatchingEntityIdsBySearchTokensAcrossSources({
141
- ctx,
142
- query: query.search,
143
- sources: [
144
- {
145
- entityType: E.customers.customer_deal,
146
- fields: [
147
- 'title',
148
- 'description',
149
- 'status',
150
- 'pipeline_stage',
151
- 'source',
152
- 'value_amount',
153
- 'value_currency',
154
- 'cf:competitive_risk',
155
- 'cf:implementation_complexity',
156
- ],
157
- },
158
- ],
159
- })
160
- : null
161
- if (matchingIds !== null && matchingIds.length > 0) {
162
- applyEntityIdRestriction(filters, matchingIds)
163
- } else {
164
- const searchPattern = `%${escapeLikePattern(query.search)}%`
165
- filters.$or = [
166
- { title: { $ilike: searchPattern } },
167
- { description: { $ilike: searchPattern } },
168
- ]
169
- }
170
- }
171
- if (query.status) {
172
- filters.status = { $eq: query.status }
173
- }
174
- if (query.pipelineStage) {
175
- filters.pipeline_stage = { $eq: query.pipelineStage }
176
- }
177
- if (query.pipelineId) {
178
- filters.pipeline_id = { $eq: query.pipelineId }
179
- }
180
- if (query.pipelineStageId) {
181
- filters.pipeline_stage_id = { $eq: query.pipelineStageId }
182
- }
183
- if (ctx && advancedFilterTree) {
184
- const advancedFilters = mergeAdvancedFilterTree({ ...filters }, advancedFilterTree)
185
- const matchedIds = await findMatchingEntityIdsWithQueryEngine({
186
- ctx,
187
- entityId: E.customers.customer_deal,
188
- filters: advancedFilters,
189
- })
190
- applyEntityIdRestriction(filters, matchedIds)
191
- }
192
- return filters
193
- },
432
+ buildFilters: buildDealListFilters,
194
433
  },
195
434
  actions: {
196
435
  create: {
@@ -229,50 +468,18 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
229
468
  },
230
469
  },
231
470
  hooks: {
232
- beforeList: (query, ctx) => {
233
- const url = ctx.request ? new URL(ctx.request.url) : null
234
- const legacyPersonId = query.personEntityId ?? null
235
- const legacyCompanyId = query.companyEntityId ?? null
236
- const allPersonCandidates: unknown[] = []
237
- const allCompanyCandidates: unknown[] = []
238
- if (legacyPersonId) allPersonCandidates.push(legacyPersonId)
239
- if (legacyCompanyId) allCompanyCandidates.push(legacyCompanyId)
240
- if (url) {
241
- const personParams = url.searchParams.getAll('personId')
242
- const companyParams = url.searchParams.getAll('companyId')
243
- if (personParams.length) allPersonCandidates.push(...personParams)
244
- if (companyParams.length) allCompanyCandidates.push(...companyParams)
245
- const legacyRepeatPerson = url.searchParams.getAll('personEntityId')
246
- const legacyRepeatCompany = url.searchParams.getAll('companyEntityId')
247
- if (legacyRepeatPerson.length) allPersonCandidates.push(...legacyRepeatPerson)
248
- if (legacyRepeatCompany.length) allCompanyCandidates.push(...legacyRepeatCompany)
249
- }
250
- const personIds = normalizeUuidList(allPersonCandidates)
251
- const companyIds = normalizeUuidList(allCompanyCandidates)
252
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
253
- ;(ctx as any).__dealsFilters = {
254
- personIds,
255
- companyIds,
256
- }
257
- },
471
+ // afterList only DECORATES results with `people`/`companies` arrays — it must not filter,
472
+ // because filtering after pagination would drop deals on later pages and rewrite `total`
473
+ // to a misleading value. Association filtering happens pre-pagination in `buildFilters`
474
+ // via `fetchDealIdsMatchingAssociations`.
258
475
  afterList: async (payload, ctx) => {
259
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
- const filters = ((ctx as any).__dealsFilters || {}) as {
261
- personIds?: string[]
262
- companyIds?: string[]
263
- }
264
- const selectedPersonIds = Array.isArray(filters.personIds) ? filters.personIds : []
265
- const selectedCompanyIds = Array.isArray(filters.companyIds) ? filters.companyIds : []
266
-
267
476
  const items = Array.isArray(payload.items) ? payload.items : []
268
477
  if (!items.length) return
269
478
  const scopeSource = (items[0] ?? {}) as Record<string, unknown>
270
479
  const tenantIdRaw = scopeSource.tenantId ?? scopeSource.tenant_id
271
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
272
- const fallbackTenantId = (typeof tenantIdRaw === 'string' && tenantIdRaw.trim().length ? tenantIdRaw : null) ?? (ctx as any)?.auth?.tenantId ?? null
480
+ const fallbackTenantId = (typeof tenantIdRaw === 'string' && tenantIdRaw.trim().length ? tenantIdRaw : null) ?? ctx.auth?.tenantId ?? null
273
481
  const orgIdRaw = scopeSource.organizationId ?? scopeSource.organization_id
274
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
275
- const fallbackOrganizationId = (typeof orgIdRaw === 'string' && orgIdRaw.trim().length ? orgIdRaw : null) ?? (ctx as any)?.auth?.orgId ?? null
482
+ const fallbackOrganizationId = (typeof orgIdRaw === 'string' && orgIdRaw.trim().length ? orgIdRaw : null) ?? ctx.auth?.orgId ?? null
276
483
  const ids = items
277
484
  .map((item: unknown) => {
278
485
  if (!item || typeof item !== 'object') return null
@@ -305,7 +512,6 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
305
512
  ])
306
513
 
307
514
  const personAssignments = new Map<string, { id: string; label: string }[]>()
308
- const personMemberships = new Map<string, Set<string>>()
309
515
  allPersonLinks.forEach((link) => {
310
516
  const deal = link.deal
311
517
  const dealRecord = deal && typeof deal === 'object' ? deal as unknown as Record<string, unknown> : null
@@ -327,13 +533,9 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
327
533
  bucket.push({ id: personId, label })
328
534
  personAssignments.set(dealId, bucket)
329
535
  }
330
- const membership = personMemberships.get(dealId) ?? new Set<string>()
331
- membership.add(personId)
332
- personMemberships.set(dealId, membership)
333
536
  })
334
537
 
335
538
  const companyAssignments = new Map<string, { id: string; label: string }[]>()
336
- const companyMemberships = new Map<string, Set<string>>()
337
539
  allCompanyLinks.forEach((link) => {
338
540
  const deal = link.deal
339
541
  const dealRecord = deal && typeof deal === 'object' ? deal as unknown as Record<string, unknown> : null
@@ -355,20 +557,8 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
355
557
  bucket.push({ id: companyId, label })
356
558
  companyAssignments.set(dealId, bucket)
357
559
  }
358
- const membership = companyMemberships.get(dealId) ?? new Set<string>()
359
- membership.add(companyId)
360
- companyMemberships.set(dealId, membership)
361
560
  })
362
561
 
363
- const hasPersonFilter = selectedPersonIds.length > 0
364
- const hasCompanyFilter = selectedCompanyIds.length > 0
365
- const matchesAll = (selected: string[], memberships: Map<string, Set<string>>, dealId: string) => {
366
- if (!selected.length) return true
367
- const membership = memberships.get(dealId)
368
- if (!membership || membership.size === 0) return false
369
- return selected.every((id) => membership.has(id))
370
- }
371
-
372
562
  const enhancedItems = items
373
563
  .map((item: unknown) => {
374
564
  if (!item || typeof item !== 'object') return null
@@ -377,9 +567,6 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
377
567
  if (!candidate || !candidate.trim().length) return null
378
568
  const people = personAssignments.get(candidate) ?? []
379
569
  const companies = companyAssignments.get(candidate) ?? []
380
- const matchesPerson = matchesAll(selectedPersonIds, personMemberships, candidate)
381
- const matchesCompany = matchesAll(selectedCompanyIds, companyMemberships, candidate)
382
- if (!matchesPerson || !matchesCompany) return null
383
570
  const tenantIdRaw =
384
571
  typeof data.tenantId === 'string'
385
572
  ? data.tenantId
@@ -409,14 +596,22 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
409
596
  )
410
597
 
411
598
  payload.items = enhancedItems
412
- if (hasPersonFilter || hasCompanyFilter) {
413
- payload.total = enhancedItems.length
414
- payload.totalPages = 1
415
- payload.page = 1
416
- }
417
599
  } catch (err) {
418
- console.warn('[customers.deals] failed to filter by person/company link', err)
419
- // fall back to unfiltered list to avoid breaking the endpoint
600
+ // We swallow rather than fail the request because the kanban is still useful without
601
+ // people/companies labels (cards just lose their company pill). Tag every item with
602
+ // `_associations: { ok: false }` so a future surface can render a degraded-state hint
603
+ // instead of silently showing cards without company badges.
604
+ console.warn('[customers.deals] failed to decorate items with person/company links', err)
605
+ payload.items = items.map((item: unknown) => {
606
+ if (!item || typeof item !== 'object') return item
607
+ return {
608
+ ...(item as Record<string, unknown>),
609
+ _associations: {
610
+ ok: false,
611
+ reason: err instanceof Error ? err.message : 'unknown',
612
+ },
613
+ }
614
+ })
420
615
  }
421
616
  },
422
617
  },
@@ -466,7 +661,7 @@ const dealCreateResponseSchema = z.object({
466
661
 
467
662
  export const openApi = createCustomersCrudOpenApi({
468
663
  resourceName: 'Deal',
469
- querySchema: listSchema,
664
+ querySchema: dealListQuerySchema,
470
665
  listResponseSchema: createPagedListResponseSchema(dealListItemSchema),
471
666
  create: {
472
667
  schema: dealCreateSchema,