@open-mercato/core 0.6.3-develop.3635.1.e4a05c16e8 → 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
@@ -0,0 +1,231 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'
3
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
4
+ import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
5
+ import { runWithCacheTenant } from '@open-mercato/cache'
6
+ import { createModuleQueue, type Queue } from '@open-mercato/queue'
7
+ import type { ProgressService, ProgressServiceContext } from '../../progress/lib/progressService'
8
+
9
+ export const CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE = 'customers-deals-bulk-update-stage'
10
+ export const CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE = 'customers-deals-bulk-update-owner'
11
+
12
+ const queues = new Map<string, Queue<Record<string, unknown>>>()
13
+
14
+ export function getCustomersQueue(queueName: string): Queue<Record<string, unknown>> {
15
+ const existing = queues.get(queueName)
16
+ if (existing) return existing
17
+ const concurrency = Math.max(
18
+ 1,
19
+ Number.parseInt(process.env.CUSTOMERS_QUEUE_CONCURRENCY ?? '3', 10) || 3,
20
+ )
21
+ const created = createModuleQueue<Record<string, unknown>>(queueName, { concurrency })
22
+ queues.set(queueName, created)
23
+ return created
24
+ }
25
+
26
+ export type CustomersDealsBulkScope = {
27
+ organizationId: string
28
+ tenantId: string
29
+ userId?: string | null
30
+ }
31
+
32
+ export type CustomersDealsBulkUpdateStageJobPayload = {
33
+ progressJobId: string
34
+ ids: string[]
35
+ pipelineStageId: string
36
+ scope: CustomersDealsBulkScope
37
+ }
38
+
39
+ export type CustomersDealsBulkUpdateOwnerJobPayload = {
40
+ progressJobId: string
41
+ ids: string[]
42
+ ownerUserId: string | null
43
+ scope: CustomersDealsBulkScope
44
+ }
45
+
46
+ export type CustomersDealsBulkFailedItem = {
47
+ id: string
48
+ message: string
49
+ }
50
+
51
+ export type CustomersDealsBulkSummary = {
52
+ affectedCount: number
53
+ failedCount: number
54
+ failedItems: CustomersDealsBulkFailedItem[]
55
+ }
56
+
57
+ export class BulkDealsPreflightError extends Error {
58
+ readonly code: string
59
+ constructor(code: string, message: string) {
60
+ super(message)
61
+ this.name = 'BulkDealsPreflightError'
62
+ this.code = code
63
+ }
64
+ }
65
+
66
+ async function verifyPipelineStageExists(
67
+ em: CoreEntityManager,
68
+ stageId: string,
69
+ scope: CustomersDealsBulkScope,
70
+ ): Promise<void> {
71
+ // The `pipelineStageId` posted from the kanban is a `customer_pipeline_stages.id`
72
+ // (the per-pipeline stage definition that `commands/deals.ts:loadPipelineStageSnapshot`
73
+ // also reads). It is NOT a `customer_dictionary_entries.id` — those entries are a
74
+ // per-tenant dictionary used to centralize stage colours/icons by normalized label.
75
+ const rows = await em.getConnection().execute<Array<{ id: string }>>(
76
+ `SELECT id FROM customer_pipeline_stages
77
+ WHERE id = ?
78
+ AND tenant_id = ?
79
+ AND organization_id = ?
80
+ LIMIT 1`,
81
+ [stageId, scope.tenantId, scope.organizationId],
82
+ )
83
+ if (rows.length === 0) {
84
+ throw new BulkDealsPreflightError(
85
+ 'pipeline_stage_not_found',
86
+ `Pipeline stage ${stageId} does not exist in this tenant`,
87
+ )
88
+ }
89
+ }
90
+
91
+ const BULK_CACHE_ALIASES = ['customers.deals']
92
+
93
+ function buildCommandContext(
94
+ scope: CustomersDealsBulkScope,
95
+ container: AwilixContainer,
96
+ ): CommandRuntimeContext {
97
+ return {
98
+ container,
99
+ auth: null,
100
+ organizationScope: {
101
+ selectedId: scope.organizationId,
102
+ filterIds: [scope.organizationId],
103
+ allowedIds: [scope.organizationId],
104
+ tenantId: scope.tenantId,
105
+ },
106
+ selectedOrganizationId: scope.organizationId,
107
+ organizationIds: [scope.organizationId],
108
+ }
109
+ }
110
+
111
+ async function runBulkDealUpdate(params: {
112
+ container: AwilixContainer
113
+ progressJobId: string
114
+ ids: string[]
115
+ scope: CustomersDealsBulkScope
116
+ cacheSource: string
117
+ buildBody: (id: string) => Record<string, unknown>
118
+ logTag: string
119
+ }): Promise<CustomersDealsBulkSummary> {
120
+ const { container, progressJobId, ids, scope, cacheSource, buildBody, logTag } = params
121
+ const commandBus = container.resolve('commandBus') as CommandBus
122
+ const progressService = container.resolve('progressService') as ProgressService
123
+ const progressContext: ProgressServiceContext = {
124
+ tenantId: scope.tenantId,
125
+ organizationId: scope.organizationId,
126
+ userId: scope.userId,
127
+ }
128
+
129
+ await progressService.startJob(progressJobId, progressContext)
130
+ await progressService.updateProgress(
131
+ progressJobId,
132
+ { totalCount: ids.length, processedCount: 0 },
133
+ progressContext,
134
+ )
135
+
136
+ const commandContext = buildCommandContext(scope, container)
137
+ const updatedIds = new Set<string>()
138
+ const failedItems: CustomersDealsBulkFailedItem[] = []
139
+ let affectedCount = 0
140
+
141
+ for (const [index, id] of ids.entries()) {
142
+ try {
143
+ // `customers.deals.update` parses its input with `parseWithCustomFields(dealUpdateSchema, rawInput)`
144
+ // and reads `parsed.id` at the top level (see commands/deals.ts). Wrapping the body in
145
+ // `{ body: ... }` made `id` undefined and every per-deal call ZodError'd in the bulk
146
+ // worker, so the job completed with affectedCount=0 in CI (TC-CRM-068/069).
147
+ await commandBus.execute<Record<string, unknown>, { dealId: string }>(
148
+ 'customers.deals.update',
149
+ {
150
+ input: buildBody(id),
151
+ ctx: commandContext,
152
+ skipCacheInvalidation: true,
153
+ },
154
+ )
155
+ affectedCount += 1
156
+ updatedIds.add(id)
157
+ } catch (error) {
158
+ const message = error instanceof Error ? error.message : String(error)
159
+ failedItems.push({ id, message })
160
+ console.warn(`[${logTag}] failed to update deal`, { jobId: progressJobId, id, error })
161
+ }
162
+
163
+ await progressService.updateProgress(
164
+ progressJobId,
165
+ { totalCount: ids.length, processedCount: index + 1 },
166
+ progressContext,
167
+ )
168
+ }
169
+
170
+ await runWithCacheTenant(scope.tenantId, async () => {
171
+ for (const id of updatedIds) {
172
+ await invalidateCrudCache(
173
+ container,
174
+ 'customers.deal',
175
+ { id, organizationId: scope.organizationId, tenantId: scope.tenantId },
176
+ scope.tenantId,
177
+ cacheSource,
178
+ BULK_CACHE_ALIASES,
179
+ )
180
+ }
181
+ })
182
+
183
+ const summary: CustomersDealsBulkSummary = {
184
+ affectedCount,
185
+ failedCount: failedItems.length,
186
+ failedItems,
187
+ }
188
+ await progressService.completeJob(progressJobId, { resultSummary: summary }, progressContext)
189
+ return summary
190
+ }
191
+
192
+ export async function bulkUpdateDealStageWithProgress(params: {
193
+ container: AwilixContainer
194
+ progressJobId: string
195
+ ids: string[]
196
+ pipelineStageId: string
197
+ scope: CustomersDealsBulkScope
198
+ }): Promise<CustomersDealsBulkSummary> {
199
+ // Pre-flight check: verify the target stage exists in the caller's tenant scope before
200
+ // doing N per-deal command calls. An invalid stage would otherwise produce N identical
201
+ // failures in `runBulkDealUpdate` and a noisy `failedItems` list.
202
+ const em = params.container.resolve('em') as CoreEntityManager
203
+ await verifyPipelineStageExists(em, params.pipelineStageId, params.scope)
204
+ return runBulkDealUpdate({
205
+ container: params.container,
206
+ progressJobId: params.progressJobId,
207
+ ids: params.ids,
208
+ scope: params.scope,
209
+ cacheSource: 'bulk-update-stage:customers.deals',
210
+ logTag: 'customers.deals.bulk-update-stage',
211
+ buildBody: (id) => ({ id, pipelineStageId: params.pipelineStageId }),
212
+ })
213
+ }
214
+
215
+ export async function bulkUpdateDealOwnerWithProgress(params: {
216
+ container: AwilixContainer
217
+ progressJobId: string
218
+ ids: string[]
219
+ ownerUserId: string | null
220
+ scope: CustomersDealsBulkScope
221
+ }): Promise<CustomersDealsBulkSummary> {
222
+ return runBulkDealUpdate({
223
+ container: params.container,
224
+ progressJobId: params.progressJobId,
225
+ ids: params.ids,
226
+ scope: params.scope,
227
+ cacheSource: 'bulk-update-owner:customers.deals',
228
+ logTag: 'customers.deals.bulk-update-owner',
229
+ buildBody: (id) => ({ id, ownerUserId: params.ownerUserId }),
230
+ })
231
+ }
@@ -0,0 +1,60 @@
1
+ import type { Kysely } from 'kysely'
2
+
3
+ /**
4
+ * Narrow Kysely schema for the customers-module tables used by the kanban helpers
5
+ * (`stuckDeals`, `enrichers`). Defining the schema locally keeps the queries strongly
6
+ * typed without forcing a project-wide DB schema declaration. Add columns/tables here
7
+ * when new customer-module queries need typed access.
8
+ */
9
+ export interface CustomerKyselyDb {
10
+ customer_settings: {
11
+ organization_id: string
12
+ tenant_id: string
13
+ stuck_threshold_days: number | string | null
14
+ }
15
+ customer_deals: {
16
+ id: string
17
+ organization_id: string
18
+ tenant_id: string
19
+ deleted_at: Date | string | null
20
+ created_at: Date | string | null
21
+ }
22
+ customer_deal_stage_transitions: {
23
+ deal_id: string
24
+ organization_id: string
25
+ tenant_id: string
26
+ deleted_at: Date | string | null
27
+ transitioned_at: Date | string | null
28
+ }
29
+ customer_interactions: {
30
+ deal_id: string | null
31
+ organization_id: string
32
+ tenant_id: string
33
+ deleted_at: Date | string | null
34
+ status: string | null
35
+ }
36
+ }
37
+
38
+ export type CustomerKysely = Kysely<CustomerKyselyDb>
39
+
40
+ /**
41
+ * Narrows an EntityManager-like value to a MikroORM v7 PG EntityManager and returns its
42
+ * Kysely client, or `null` when the value doesn't expose `getKysely`.
43
+ *
44
+ * MikroORM v7's `@mikro-orm/postgresql` EntityManager ships `getKysely<TDb>()`, but the
45
+ * shared `EnricherContext` types `em` as `unknown` to keep the contract DB-agnostic. The
46
+ * kanban helpers (`stuckDeals`, `enrichers`) both need the typed client — this helper
47
+ * centralizes the runtime check so neither call site has to repeat `(em as any).getKysely`.
48
+ *
49
+ * Returns `null` (rather than throwing) on purpose: callers fall back to a sensible default
50
+ * (empty stuck-id list, un-enriched records) when the env doesn't have Kysely available
51
+ * (e.g. unit tests with a stub EntityManager).
52
+ */
53
+ export function resolveKyselyClient<TDb = CustomerKyselyDb>(em: unknown): Kysely<TDb> | null {
54
+ if (em == null || typeof em !== 'object') return null
55
+ const candidate = (em as { getKysely?: unknown }).getKysely
56
+ if (typeof candidate !== 'function') return null
57
+ const db = (candidate as () => unknown).call(em)
58
+ if (db == null) return null
59
+ return db as Kysely<TDb>
60
+ }
@@ -0,0 +1,88 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CustomerKysely } from './kysely'
3
+ import { resolveKyselyClient } from './kysely'
4
+
5
+ /**
6
+ * Default number of days a deal can sit in a stage before it counts as "stuck".
7
+ * Tenants can override by writing a row into `customer_settings.stuck_threshold_days`.
8
+ */
9
+ export const STUCK_DEFAULT_THRESHOLD_DAYS = 14
10
+ const DAY_MS = 24 * 60 * 60 * 1000
11
+
12
+ /**
13
+ * Resolves the configured stuck-threshold for the tenant scope. Falls back to
14
+ * `STUCK_DEFAULT_THRESHOLD_DAYS` when no setting row is present or the value is invalid.
15
+ */
16
+ export async function fetchStuckThresholdDays(
17
+ db: CustomerKysely,
18
+ organizationId: string,
19
+ tenantId: string,
20
+ ): Promise<number> {
21
+ const row = await db
22
+ .selectFrom('customer_settings')
23
+ .select(['stuck_threshold_days'])
24
+ .where('organization_id', '=', organizationId)
25
+ .where('tenant_id', '=', tenantId)
26
+ .executeTakeFirst()
27
+ const raw = row?.stuck_threshold_days
28
+ const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : null
29
+ if (value !== null && Number.isFinite(value) && value > 0) return value
30
+ return STUCK_DEFAULT_THRESHOLD_DAYS
31
+ }
32
+
33
+ /**
34
+ * Returns the set of deal IDs (in scope) that are currently considered "stuck":
35
+ * - deals whose last stage transition was earlier than (now - threshold), OR
36
+ * - deals that never transitioned but were created earlier than (now - threshold).
37
+ *
38
+ * The list endpoint and the lane-aggregate endpoint MUST share this query so the kanban
39
+ * lane headers, lane counts, and the rendered cards agree on which deals count as stuck.
40
+ */
41
+ export async function fetchStuckDealIds(
42
+ em: EntityManager,
43
+ organizationId: string,
44
+ tenantId: string,
45
+ ): Promise<string[]> {
46
+ const db = resolveKyselyClient(em)
47
+ if (!db) return []
48
+
49
+ const threshold = await fetchStuckThresholdDays(db, organizationId, tenantId)
50
+ const cutoff = new Date(Date.now() - threshold * DAY_MS)
51
+
52
+ const oldTransitionRows = await db
53
+ .selectFrom('customer_deal_stage_transitions')
54
+ .select(['deal_id'])
55
+ .select((eb) => eb.fn.max('transitioned_at').as('last_transition'))
56
+ .where('organization_id', '=', organizationId)
57
+ .where('tenant_id', '=', tenantId)
58
+ .where('deleted_at', 'is', null)
59
+ .groupBy('deal_id')
60
+ .having((eb) => eb(eb.fn.max('transitioned_at'), '<', cutoff))
61
+ .execute()
62
+
63
+ const transitionedDealIds = await db
64
+ .selectFrom('customer_deal_stage_transitions')
65
+ .select(['deal_id'])
66
+ .distinct()
67
+ .where('organization_id', '=', organizationId)
68
+ .where('tenant_id', '=', tenantId)
69
+ .where('deleted_at', 'is', null)
70
+ .execute()
71
+ const transitionedSet = new Set(transitionedDealIds.map((r) => r.deal_id))
72
+
73
+ const oldUntransitionedDeals = await db
74
+ .selectFrom('customer_deals')
75
+ .select(['id'])
76
+ .where('organization_id', '=', organizationId)
77
+ .where('tenant_id', '=', tenantId)
78
+ .where('deleted_at', 'is', null)
79
+ .where('created_at', '<', cutoff)
80
+ .execute()
81
+
82
+ const result = new Set<string>()
83
+ oldTransitionRows.forEach((row) => result.add(row.deal_id))
84
+ oldUntransitionedDeals.forEach((row) => {
85
+ if (!transitionedSet.has(row.id)) result.add(row.id)
86
+ })
87
+ return Array.from(result)
88
+ }
@@ -5485,6 +5485,22 @@
5485
5485
  "enumItems": [],
5486
5486
  "mappedType": "uuid"
5487
5487
  },
5488
+ "stuck_threshold_days": {
5489
+ "name": "stuck_threshold_days",
5490
+ "type": "int",
5491
+ "unsigned": false,
5492
+ "autoincrement": false,
5493
+ "primary": false,
5494
+ "nullable": false,
5495
+ "unique": false,
5496
+ "length": null,
5497
+ "precision": null,
5498
+ "scale": null,
5499
+ "default": "14",
5500
+ "comment": null,
5501
+ "enumItems": [],
5502
+ "mappedType": "integer"
5503
+ },
5488
5504
  "tenant_id": {
5489
5505
  "name": "tenant_id",
5490
5506
  "type": "uuid",
@@ -0,0 +1,13 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260513203311_customers extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`alter table "customer_settings" add "stuck_threshold_days" int not null default 14;`);
7
+ }
8
+
9
+ override down(): void | Promise<void> {
10
+ this.addSql(`alter table "customer_settings" drop column "stuck_threshold_days";`);
11
+ }
12
+
13
+ }
@@ -0,0 +1,71 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ /**
4
+ * Migrate `customer_dictionary_entries.color` for `kind = 'pipeline_stage'` rows from the
5
+ * legacy hex chip values stored by AddStageDialog to canonical semantic tone identifiers
6
+ * matching Lane.tsx's `ACCENT_TONE_CLASS` map.
7
+ *
8
+ * Before this migration:
9
+ * AddStageDialog stored a hex value like '#16a34a' / '#f59e0b' / '#dc2626' / '#2563eb' /
10
+ * '#6b7280' / '#7c3aed', and Lane.tsx implicitly fell through to gray on every other hex
11
+ * because no explicit hex→tone map existed at render time.
12
+ *
13
+ * After this migration:
14
+ * `color` stores one of: 'success', 'warning', 'info', 'error', 'neutral', 'brand',
15
+ * 'pink', '', or NULL. Lane.tsx now reads the value directly as a tone identifier.
16
+ *
17
+ * Unknown / unmappable hex values collapse to 'neutral'. Forward-only — reverting tones to
18
+ * the original hex is lossy and not supported. See `.ai/specs/2026-05-19-customers-deals-kanban-ux-review-fixes.md`.
19
+ */
20
+ export class Migration20260519120000_pipeline_stage_color_tones extends Migration {
21
+ override up(): void | Promise<void> {
22
+ this.addSql(`
23
+ update "customer_dictionary_entries"
24
+ set "color" = case lower("color")
25
+ when '#16a34a' then 'success'
26
+ when '#22c55e' then 'success'
27
+ when '#15803d' then 'success'
28
+ when '#10b981' then 'success'
29
+ when '#059669' then 'success'
30
+ when '#f59e0b' then 'warning'
31
+ when '#eab308' then 'warning'
32
+ when '#d97706' then 'warning'
33
+ when '#f97316' then 'warning'
34
+ when '#ea580c' then 'warning'
35
+ when '#dc2626' then 'error'
36
+ when '#ef4444' then 'error'
37
+ when '#b91c1c' then 'error'
38
+ when '#991b1b' then 'error'
39
+ when '#2563eb' then 'info'
40
+ when '#3b82f6' then 'info'
41
+ when '#0ea5e9' then 'info'
42
+ when '#1d4ed8' then 'info'
43
+ when '#6366f1' then 'info'
44
+ when '#6b7280' then 'neutral'
45
+ when '#9ca3af' then 'neutral'
46
+ when '#4b5563' then 'neutral'
47
+ when '#374151' then 'neutral'
48
+ when '#1f2937' then 'neutral'
49
+ when '#7c3aed' then 'brand'
50
+ when '#8b5cf6' then 'brand'
51
+ when '#a855f7' then 'brand'
52
+ when '#9333ea' then 'brand'
53
+ when '#ec4899' then 'pink'
54
+ when '#f472b6' then 'pink'
55
+ when '#db2777' then 'pink'
56
+ when '#be185d' then 'pink'
57
+ when '' then ''
58
+ else 'neutral'
59
+ end
60
+ where "kind" = 'pipeline_stage'
61
+ and "color" is not null;
62
+ `);
63
+ }
64
+
65
+ override down(): void | Promise<void> {
66
+ // Forward-only migration. Reverting tone identifiers to their original hex values is
67
+ // lossy (we only kept the closest tone, not the source hex) and would not restore the
68
+ // pre-migration state. Operators wanting to change a stage color should reopen the
69
+ // AddStageDialog tone picker.
70
+ }
71
+ }
@@ -0,0 +1,46 @@
1
+ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import type { ProgressService } from '../../progress/lib/progressService'
4
+ import {
5
+ CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,
6
+ bulkUpdateDealOwnerWithProgress,
7
+ type CustomersDealsBulkUpdateOwnerJobPayload,
8
+ } from '../lib/bulkDeals'
9
+
10
+ export const metadata: WorkerMeta = {
11
+ queue: CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,
12
+ id: 'customers:deals-bulk-update-owner',
13
+ concurrency: 1,
14
+ }
15
+
16
+ export default async function handle(
17
+ job: QueuedJob<CustomersDealsBulkUpdateOwnerJobPayload>,
18
+ _ctx: JobContext,
19
+ ): Promise<void> {
20
+ const container = await createRequestContainer()
21
+
22
+ try {
23
+ await bulkUpdateDealOwnerWithProgress({
24
+ container,
25
+ progressJobId: job.payload.progressJobId,
26
+ ids: job.payload.ids,
27
+ ownerUserId: job.payload.ownerUserId,
28
+ scope: job.payload.scope,
29
+ })
30
+ } catch (error) {
31
+ const progressService = container.resolve('progressService') as ProgressService
32
+ await progressService.failJob(
33
+ job.payload.progressJobId,
34
+ {
35
+ errorMessage:
36
+ error instanceof Error ? error.message : 'Bulk deal owner update failed',
37
+ },
38
+ {
39
+ tenantId: job.payload.scope.tenantId,
40
+ organizationId: job.payload.scope.organizationId,
41
+ userId: job.payload.scope.userId,
42
+ },
43
+ )
44
+ throw error
45
+ }
46
+ }
@@ -0,0 +1,46 @@
1
+ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import type { ProgressService } from '../../progress/lib/progressService'
4
+ import {
5
+ CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE,
6
+ bulkUpdateDealStageWithProgress,
7
+ type CustomersDealsBulkUpdateStageJobPayload,
8
+ } from '../lib/bulkDeals'
9
+
10
+ export const metadata: WorkerMeta = {
11
+ queue: CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE,
12
+ id: 'customers:deals-bulk-update-stage',
13
+ concurrency: 1,
14
+ }
15
+
16
+ export default async function handle(
17
+ job: QueuedJob<CustomersDealsBulkUpdateStageJobPayload>,
18
+ _ctx: JobContext,
19
+ ): Promise<void> {
20
+ const container = await createRequestContainer()
21
+
22
+ try {
23
+ await bulkUpdateDealStageWithProgress({
24
+ container,
25
+ progressJobId: job.payload.progressJobId,
26
+ ids: job.payload.ids,
27
+ pipelineStageId: job.payload.pipelineStageId,
28
+ scope: job.payload.scope,
29
+ })
30
+ } catch (error) {
31
+ const progressService = container.resolve('progressService') as ProgressService
32
+ await progressService.failJob(
33
+ job.payload.progressJobId,
34
+ {
35
+ errorMessage:
36
+ error instanceof Error ? error.message : 'Bulk deal stage update failed',
37
+ },
38
+ {
39
+ tenantId: job.payload.scope.tenantId,
40
+ organizationId: job.payload.scope.organizationId,
41
+ userId: job.payload.scope.userId,
42
+ },
43
+ )
44
+ throw error
45
+ }
46
+ }