@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/customer_settings/index.js +2 -0
- package/dist/generated/entities/customer_settings/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
- package/dist/modules/customers/api/deals/route.js +255 -122
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
- package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +2 -0
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
- package/dist/modules/customers/commands/deals.js +6 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +48 -13
- package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
- package/dist/modules/customers/commands/settings.js +45 -5
- package/dist/modules/customers/commands/settings.js.map +2 -2
- package/dist/modules/customers/data/enrichers.js +130 -1
- package/dist/modules/customers/data/enrichers.js.map +2 -2
- package/dist/modules/customers/data/entities.js +4 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +29 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/bulkDeals.js +150 -0
- package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
- package/dist/modules/customers/lib/kysely.js +12 -0
- package/dist/modules/customers/lib/kysely.js.map +7 -0
- package/dist/modules/customers/lib/stuckDeals.js +32 -0
- package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
- package/generated/entities/customer_settings/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
- package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
- package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
- package/src/modules/customers/api/deals/route.ts +330 -135
- package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
- package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
- package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
- package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
- package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
- package/src/modules/customers/commands/deals.ts +19 -0
- package/src/modules/customers/commands/pipeline-stages.ts +68 -13
- package/src/modules/customers/commands/settings.ts +64 -5
- package/src/modules/customers/data/enrichers.ts +209 -2
- package/src/modules/customers/data/entities.ts +4 -1
- package/src/modules/customers/data/validators.ts +36 -2
- package/src/modules/customers/i18n/de.json +244 -0
- package/src/modules/customers/i18n/en.json +244 -0
- package/src/modules/customers/i18n/es.json +244 -0
- package/src/modules/customers/i18n/pl.json +244 -0
- package/src/modules/customers/lib/bulkDeals.ts +231 -0
- package/src/modules/customers/lib/kysely.ts +60 -0
- package/src/modules/customers/lib/stuckDeals.ts +88 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
- package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
- package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
- 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
|
+
}
|
package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts
ADDED
|
@@ -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
|
+
}
|