@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.
- 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
|
@@ -554,6 +554,17 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
554
554
|
if (parsed.lossReasonId !== undefined) record.lossReasonId = parsed.lossReasonId ?? null
|
|
555
555
|
if (parsed.lossNotes !== undefined) record.lossNotes = parsed.lossNotes ?? null
|
|
556
556
|
},
|
|
557
|
+
async () => {
|
|
558
|
+
// CRITICAL: persist the scalar mutations above before any further `em.findOne` / sync
|
|
559
|
+
// helpers run inside this transaction. MikroORM v7's identity-map silently discards
|
|
560
|
+
// pending scalar changes on `record` if a query (such as the stage-transition lookup
|
|
561
|
+
// inside `upsertDealStageTransition`, or the linked-entity finds inside
|
|
562
|
+
// `syncDealPeople` / `syncDealCompanies`) executes on the same `EntityManager`
|
|
563
|
+
// before we explicitly flush. Without this flush, the entire kanban drag-and-drop
|
|
564
|
+
// returns 200 OK but never actually updates `customer_deals` rows — the card
|
|
565
|
+
// snaps back to its source lane on the next refetch (see SPEC-018).
|
|
566
|
+
await em.flush()
|
|
567
|
+
},
|
|
557
568
|
async () => {
|
|
558
569
|
const snapshot = nextStageSnapshot
|
|
559
570
|
if (!snapshot) return
|
|
@@ -715,6 +726,14 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
715
726
|
deal.lossReasonId = before.deal.lossReasonId
|
|
716
727
|
deal.lossNotes = before.deal.lossNotes
|
|
717
728
|
},
|
|
729
|
+
async () => {
|
|
730
|
+
// Mirror of the fix applied to the forward `execute` path: persist the scalar
|
|
731
|
+
// mutations on `deal` before any further `em.findOne` (the transition lookup
|
|
732
|
+
// inside `upsertDealStageTransition`) or sync-helper queries run on the same EM.
|
|
733
|
+
// MikroORM v7 silently discards the pending scalar changes if we don't flush here
|
|
734
|
+
// (see SPEC-018), which would make an undo of a kanban stage move silently no-op.
|
|
735
|
+
await em.flush()
|
|
736
|
+
},
|
|
718
737
|
async () => {
|
|
719
738
|
if (!shouldRecordRevertTransition || !before.deal.pipelineStageId) return
|
|
720
739
|
const pipelineId = revertedStageSnapshot?.pipelineId ?? before.deal.pipelineId ?? existingTransition?.pipelineId
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
2
2
|
import type { CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
5
|
import { CustomerPipelineStage, CustomerDeal } from '../data/entities'
|
|
5
6
|
import {
|
|
6
7
|
pipelineStageCreateSchema,
|
|
@@ -24,18 +25,49 @@ const createPipelineStageCommand: CommandHandler<PipelineStageCreateInput, { sta
|
|
|
24
25
|
|
|
25
26
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// Load the full ordered list once. We need it both to know where "end" is and to
|
|
29
|
+
// shift any stages that occupy positions at or after the chosen insert point.
|
|
30
|
+
const existingStages = await findWithDecryption(
|
|
31
|
+
em,
|
|
32
|
+
CustomerPipelineStage,
|
|
33
|
+
{
|
|
34
|
+
organizationId: parsed.organizationId,
|
|
35
|
+
tenantId: parsed.tenantId,
|
|
36
|
+
pipelineId: parsed.pipelineId,
|
|
37
|
+
},
|
|
38
|
+
{ orderBy: { order: 'ASC' } },
|
|
39
|
+
{ tenantId: parsed.tenantId, organizationId: parsed.organizationId },
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Clamp the requested insert position into the legal range [0, length]. Anything
|
|
43
|
+
// outside that range (negative, way past the end) collapses to "append" so we never
|
|
44
|
+
// create stages that hop the visible board boundary.
|
|
45
|
+
const requestedOrder = parsed.order
|
|
46
|
+
const insertOrder =
|
|
47
|
+
requestedOrder === undefined
|
|
48
|
+
? existingStages.length
|
|
49
|
+
: Math.max(0, Math.min(requestedOrder, existingStages.length))
|
|
50
|
+
|
|
51
|
+
// Shift the order of every stage at or after the insert position. We use the
|
|
52
|
+
// already-forked EM so the shifts and the new INSERT land in a single `flush()`
|
|
53
|
+
// (one transaction by default with MikroORM's `forceTransactions` config). Skipping
|
|
54
|
+
// this step would either duplicate `order` values (silently corrupting kanban
|
|
55
|
+
// ordering) or push the new stage to the wrong spot when re-sorting.
|
|
56
|
+
if (requestedOrder !== undefined) {
|
|
57
|
+
for (const stage of existingStages) {
|
|
58
|
+
if (stage.order >= insertOrder) {
|
|
59
|
+
stage.order += 1
|
|
60
|
+
stage.updatedAt = new Date()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
32
64
|
|
|
33
65
|
const stage = em.create(CustomerPipelineStage, {
|
|
34
66
|
organizationId: parsed.organizationId,
|
|
35
67
|
tenantId: parsed.tenantId,
|
|
36
68
|
pipelineId: parsed.pipelineId,
|
|
37
69
|
label: parsed.label,
|
|
38
|
-
order:
|
|
70
|
+
order: insertOrder,
|
|
39
71
|
createdAt: new Date(),
|
|
40
72
|
updatedAt: new Date(),
|
|
41
73
|
})
|
|
@@ -61,8 +93,17 @@ const updatePipelineStageCommand: CommandHandler<PipelineStageUpdateInput, void>
|
|
|
61
93
|
async execute(rawInput, ctx) {
|
|
62
94
|
const parsed = pipelineStageUpdateSchema.parse(rawInput)
|
|
63
95
|
|
|
96
|
+
// Restrict the lookup to the caller's tenant/organization scope so a
|
|
97
|
+
// wrong-tenant id returns 404 (same as a missing row), not 403 — avoids
|
|
98
|
+
// leaking existence of stages outside the caller's scope.
|
|
99
|
+
const callerTenantId = ctx.auth?.tenantId ?? null
|
|
100
|
+
const callerOrganizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
64
101
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
65
|
-
const stage = await em
|
|
102
|
+
const stage = await findOneWithDecryption(em, CustomerPipelineStage, {
|
|
103
|
+
id: parsed.id,
|
|
104
|
+
...(callerTenantId ? { tenantId: callerTenantId } : {}),
|
|
105
|
+
...(callerOrganizationId ? { organizationId: callerOrganizationId } : {}),
|
|
106
|
+
})
|
|
66
107
|
if (!stage) throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
|
|
67
108
|
|
|
68
109
|
ensureTenantScope(ctx, stage.tenantId)
|
|
@@ -93,8 +134,16 @@ const deletePipelineStageCommand: CommandHandler<PipelineStageDeleteInput, void>
|
|
|
93
134
|
async execute(rawInput, ctx) {
|
|
94
135
|
const parsed = pipelineStageDeleteSchema.parse(rawInput)
|
|
95
136
|
|
|
137
|
+
// See update command above — scope the lookup to the caller's tenant/org so a
|
|
138
|
+
// cross-tenant id returns 404, not 403.
|
|
139
|
+
const callerTenantId = ctx.auth?.tenantId ?? null
|
|
140
|
+
const callerOrganizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
96
141
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
97
|
-
const stage = await em
|
|
142
|
+
const stage = await findOneWithDecryption(em, CustomerPipelineStage, {
|
|
143
|
+
id: parsed.id,
|
|
144
|
+
...(callerTenantId ? { tenantId: callerTenantId } : {}),
|
|
145
|
+
...(callerOrganizationId ? { organizationId: callerOrganizationId } : {}),
|
|
146
|
+
})
|
|
98
147
|
if (!stage) throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
|
|
99
148
|
|
|
100
149
|
ensureTenantScope(ctx, stage.tenantId)
|
|
@@ -123,11 +172,17 @@ const reorderPipelineStagesCommand: CommandHandler<PipelineStageReorderInput, vo
|
|
|
123
172
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
124
173
|
|
|
125
174
|
const ids = parsed.stages.map((s) => s.id)
|
|
126
|
-
const stages = await
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
175
|
+
const stages = await findWithDecryption(
|
|
176
|
+
em,
|
|
177
|
+
CustomerPipelineStage,
|
|
178
|
+
{
|
|
179
|
+
id: { $in: ids },
|
|
180
|
+
organizationId: parsed.organizationId,
|
|
181
|
+
tenantId: parsed.tenantId,
|
|
182
|
+
},
|
|
183
|
+
undefined,
|
|
184
|
+
{ tenantId: parsed.tenantId, organizationId: parsed.organizationId },
|
|
185
|
+
)
|
|
131
186
|
|
|
132
187
|
const stageMap = new Map<string, CustomerPipelineStage>()
|
|
133
188
|
stages.forEach((stage) => stageMap.set(stage.id, stage))
|
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
2
2
|
import type { CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
5
|
import { CustomerSettings, type CustomerAddressFormat } from '../data/entities'
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
customerSettingsUpsertSchema,
|
|
8
|
+
customerStuckThresholdUpsertSchema,
|
|
9
|
+
type CustomerSettingsUpsertInput,
|
|
10
|
+
type CustomerStuckThresholdUpsertInput,
|
|
11
|
+
} from '../data/validators'
|
|
6
12
|
import { ensureOrganizationScope, ensureTenantScope } from './shared'
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Tenant-scoped settings lookup. `CustomerSettings` carries no encrypted columns today
|
|
16
|
+
* (just enum + int scalars + scope/timestamps), so `findOneWithDecryption` is effectively
|
|
17
|
+
* a passthrough. We use it anyway per the production-code rule in `packages/core/AGENTS.md`
|
|
18
|
+
* § Encryption — the wrapper is mandatory regardless of current encrypted-field count so
|
|
19
|
+
* future GDPR additions don't need a route-level migration. Scope hints are required so the
|
|
20
|
+
* decryption helper can resolve the tenant DEK if any encrypted field is added later.
|
|
21
|
+
*/
|
|
8
22
|
export async function loadCustomerSettings(
|
|
9
23
|
em: EntityManager,
|
|
10
24
|
params: { tenantId: string; organizationId: string }
|
|
11
25
|
): Promise<CustomerSettings | null> {
|
|
12
|
-
return
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
return findOneWithDecryption(
|
|
27
|
+
em,
|
|
28
|
+
CustomerSettings,
|
|
29
|
+
{
|
|
30
|
+
tenantId: params.tenantId,
|
|
31
|
+
organizationId: params.organizationId,
|
|
32
|
+
},
|
|
33
|
+
undefined,
|
|
34
|
+
{ tenantId: params.tenantId, organizationId: params.organizationId },
|
|
35
|
+
)
|
|
16
36
|
}
|
|
17
37
|
|
|
18
38
|
const saveCustomerSettingsCommand: CommandHandler<
|
|
@@ -53,3 +73,42 @@ const saveCustomerSettingsCommand: CommandHandler<
|
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
registerCommand(saveCustomerSettingsCommand)
|
|
76
|
+
|
|
77
|
+
const saveStuckThresholdCommand: CommandHandler<
|
|
78
|
+
CustomerStuckThresholdUpsertInput,
|
|
79
|
+
{ settingsId: string; stuckThresholdDays: number }
|
|
80
|
+
> = {
|
|
81
|
+
id: 'customers.settings.save_stuck_threshold',
|
|
82
|
+
async execute(rawInput, ctx) {
|
|
83
|
+
const input = customerStuckThresholdUpsertSchema.parse(rawInput)
|
|
84
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
85
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
86
|
+
|
|
87
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
88
|
+
let settings = await loadCustomerSettings(em, {
|
|
89
|
+
tenantId: input.tenantId,
|
|
90
|
+
organizationId: input.organizationId,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (!settings) {
|
|
94
|
+
settings = em.create(CustomerSettings, {
|
|
95
|
+
tenantId: input.tenantId,
|
|
96
|
+
organizationId: input.organizationId,
|
|
97
|
+
stuckThresholdDays: input.stuckThresholdDays,
|
|
98
|
+
})
|
|
99
|
+
em.persist(settings)
|
|
100
|
+
} else if (settings.stuckThresholdDays !== input.stuckThresholdDays) {
|
|
101
|
+
settings.stuckThresholdDays = input.stuckThresholdDays
|
|
102
|
+
settings.updatedAt = new Date()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await em.flush()
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
settingsId: settings.id,
|
|
109
|
+
stuckThresholdDays: settings.stuckThresholdDays,
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
registerCommand(saveStuckThresholdCommand)
|
|
@@ -1,3 +1,210 @@
|
|
|
1
|
-
import type { ResponseEnricher } from '@open-mercato/shared/lib/crud/response-enricher'
|
|
1
|
+
import type { ResponseEnricher, EnricherContext } from '@open-mercato/shared/lib/crud/response-enricher'
|
|
2
|
+
import type { CustomerKysely } from '../lib/kysely'
|
|
3
|
+
import { resolveKyselyClient } from '../lib/kysely'
|
|
4
|
+
import { fetchStuckThresholdDays } from '../lib/stuckDeals'
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
type DealRecord = Record<string, unknown> & {
|
|
7
|
+
id: string
|
|
8
|
+
status?: string | null
|
|
9
|
+
expected_close_at?: string | null
|
|
10
|
+
created_at?: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type PipelineState = {
|
|
14
|
+
openActivitiesCount: number
|
|
15
|
+
daysInCurrentStage: number
|
|
16
|
+
isStuck: boolean
|
|
17
|
+
isOverdue: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ENRICHER_TIMEOUT_MS = 2000
|
|
21
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
22
|
+
// Mirror the canonical statuses defined in validators.ts (`interactionStatusValues`). The
|
|
23
|
+
// canonical spelling is `canceled` (US, single L), not `cancelled` (UK). Misspelling this
|
|
24
|
+
// here let canceled interactions slip into the open-activities count badge on the card.
|
|
25
|
+
// We accept `completed` defensively too, even though `done` is the canonical "completed"
|
|
26
|
+
// value in the validator, in case any legacy rows exist with the longer spelling.
|
|
27
|
+
const TERMINAL_INTERACTION_STATUSES = ['done', 'canceled', 'completed'] as const
|
|
28
|
+
|
|
29
|
+
function parseDate(value: unknown): Date | null {
|
|
30
|
+
if (!value) return null
|
|
31
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
|
32
|
+
if (typeof value === 'string') {
|
|
33
|
+
const date = new Date(value)
|
|
34
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function diffDays(from: Date, to: Date): number {
|
|
40
|
+
return Math.floor((to.getTime() - from.getTime()) / DAY_MS)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchOpenInteractionCounts(
|
|
44
|
+
db: CustomerKysely,
|
|
45
|
+
dealIds: Set<string>,
|
|
46
|
+
organizationId: string,
|
|
47
|
+
tenantId: string,
|
|
48
|
+
): Promise<Map<string, number>> {
|
|
49
|
+
const map = new Map<string, number>()
|
|
50
|
+
if (dealIds.size === 0) return map
|
|
51
|
+
const rows = await db
|
|
52
|
+
.selectFrom('customer_interactions')
|
|
53
|
+
.select(['deal_id'])
|
|
54
|
+
.select((eb) => eb.fn.countAll().as('count'))
|
|
55
|
+
.where('deal_id', 'in', [...dealIds])
|
|
56
|
+
.where('organization_id', '=', organizationId)
|
|
57
|
+
.where('tenant_id', '=', tenantId)
|
|
58
|
+
.where('deleted_at', 'is', null)
|
|
59
|
+
.where('status', 'not in', [...TERMINAL_INTERACTION_STATUSES])
|
|
60
|
+
.groupBy('deal_id')
|
|
61
|
+
.execute()
|
|
62
|
+
for (const row of rows) {
|
|
63
|
+
if (row.deal_id == null) continue
|
|
64
|
+
const count = typeof row.count === 'number' ? row.count : Number(row.count)
|
|
65
|
+
if (Number.isFinite(count)) map.set(row.deal_id, count)
|
|
66
|
+
}
|
|
67
|
+
return map
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fetchLatestStageTransitions(
|
|
71
|
+
db: CustomerKysely,
|
|
72
|
+
dealIds: Set<string>,
|
|
73
|
+
organizationId: string,
|
|
74
|
+
tenantId: string,
|
|
75
|
+
): Promise<Map<string, Date>> {
|
|
76
|
+
const map = new Map<string, Date>()
|
|
77
|
+
if (dealIds.size === 0) return map
|
|
78
|
+
const rows = await db
|
|
79
|
+
.selectFrom('customer_deal_stage_transitions')
|
|
80
|
+
.select(['deal_id'])
|
|
81
|
+
.select((eb) => eb.fn.max('transitioned_at').as('last_transitioned_at'))
|
|
82
|
+
.where('deal_id', 'in', [...dealIds])
|
|
83
|
+
.where('organization_id', '=', organizationId)
|
|
84
|
+
.where('tenant_id', '=', tenantId)
|
|
85
|
+
.where('deleted_at', 'is', null)
|
|
86
|
+
.groupBy('deal_id')
|
|
87
|
+
.execute()
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
const parsed = parseDate(row.last_transitioned_at)
|
|
90
|
+
if (parsed) map.set(row.deal_id, parsed)
|
|
91
|
+
}
|
|
92
|
+
return map
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildPipelineState(
|
|
96
|
+
record: DealRecord,
|
|
97
|
+
openInteractionCounts: Map<string, number>,
|
|
98
|
+
latestTransitions: Map<string, Date>,
|
|
99
|
+
threshold: number,
|
|
100
|
+
now: Date,
|
|
101
|
+
today: Date,
|
|
102
|
+
): PipelineState {
|
|
103
|
+
const openActivitiesCount = openInteractionCounts.get(record.id) ?? 0
|
|
104
|
+
const transitionAt =
|
|
105
|
+
latestTransitions.get(record.id) ?? parseDate(record.created_at) ?? now
|
|
106
|
+
const daysInCurrentStage = Math.max(0, diffDays(transitionAt, now))
|
|
107
|
+
const expectedClose = parseDate(record.expected_close_at)
|
|
108
|
+
const status = typeof record.status === 'string' ? record.status : null
|
|
109
|
+
const isOverdue = status === 'open' && !!expectedClose && expectedClose < today
|
|
110
|
+
const isStuck = daysInCurrentStage > threshold
|
|
111
|
+
return { openActivitiesCount, daysInCurrentStage, isStuck, isOverdue }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const dealPipelineEnricher: ResponseEnricher<DealRecord> = {
|
|
115
|
+
id: 'customers.deal-pipeline-state',
|
|
116
|
+
targetEntity: 'customers.deal',
|
|
117
|
+
// No `features` gate: the deals list route already enforces `customers.deals.view`
|
|
118
|
+
// at the route-metadata level (see api/deals/route.ts). Declaring `features` here
|
|
119
|
+
// would silently disable the enricher in environments where `rbacService` resolves
|
|
120
|
+
// to undefined or `getGrantedFeatures` throws — `hasRequiredFeatures` treats a
|
|
121
|
+
// missing `userFeatures` as "no access", which made `_pipeline` disappear from
|
|
122
|
+
// CI responses (TC-CRM-066) while keeping the local kanban working.
|
|
123
|
+
priority: 10,
|
|
124
|
+
timeout: ENRICHER_TIMEOUT_MS,
|
|
125
|
+
critical: false,
|
|
126
|
+
fallback: {
|
|
127
|
+
_pipeline: {
|
|
128
|
+
openActivitiesCount: 0,
|
|
129
|
+
daysInCurrentStage: 0,
|
|
130
|
+
isStuck: false,
|
|
131
|
+
isOverdue: false,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async enrichOne(record, context) {
|
|
136
|
+
const enriched = await this.enrichMany!([record], context)
|
|
137
|
+
return enriched[0]
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async enrichMany(records, context: EnricherContext) {
|
|
141
|
+
if (records.length === 0) return records
|
|
142
|
+
|
|
143
|
+
const now = new Date()
|
|
144
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
145
|
+
|
|
146
|
+
// `buildPipelineState` is pure on top of (record + maps + threshold). The record alone
|
|
147
|
+
// is enough to compute `isOverdue` (status + expected_close_at) and a conservative
|
|
148
|
+
// `daysInCurrentStage` (created_at fallback). When Kysely isn't available on this
|
|
149
|
+
// EntityManager — test stubs, unusual driver wrappers, or a transient DB issue —
|
|
150
|
+
// we still ship the correct overdue flag and a zeroed activities count rather than
|
|
151
|
+
// silently dropping `_pipeline`, which previously made the kanban think every deal
|
|
152
|
+
// had no stuck/overdue state.
|
|
153
|
+
const emptyCounts: Map<string, number> = new Map()
|
|
154
|
+
const emptyTransitions: Map<string, Date> = new Map()
|
|
155
|
+
const FALLBACK_THRESHOLD = 14
|
|
156
|
+
|
|
157
|
+
const db = resolveKyselyClient(context.em)
|
|
158
|
+
if (!db) {
|
|
159
|
+
return records.map((record) => ({
|
|
160
|
+
...record,
|
|
161
|
+
_pipeline: buildPipelineState(
|
|
162
|
+
record,
|
|
163
|
+
emptyCounts,
|
|
164
|
+
emptyTransitions,
|
|
165
|
+
FALLBACK_THRESHOLD,
|
|
166
|
+
now,
|
|
167
|
+
today,
|
|
168
|
+
),
|
|
169
|
+
}))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const dealIds = new Set<string>()
|
|
173
|
+
for (const record of records) {
|
|
174
|
+
if (typeof record.id === 'string') dealIds.add(record.id)
|
|
175
|
+
}
|
|
176
|
+
if (dealIds.size === 0) {
|
|
177
|
+
return records.map((record) => ({
|
|
178
|
+
...record,
|
|
179
|
+
_pipeline: buildPipelineState(
|
|
180
|
+
record,
|
|
181
|
+
emptyCounts,
|
|
182
|
+
emptyTransitions,
|
|
183
|
+
FALLBACK_THRESHOLD,
|
|
184
|
+
now,
|
|
185
|
+
today,
|
|
186
|
+
),
|
|
187
|
+
}))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const [openInteractionCounts, latestTransitions, threshold] = await Promise.all([
|
|
191
|
+
fetchOpenInteractionCounts(db, dealIds, context.organizationId, context.tenantId),
|
|
192
|
+
fetchLatestStageTransitions(db, dealIds, context.organizationId, context.tenantId),
|
|
193
|
+
fetchStuckThresholdDays(db, context.organizationId, context.tenantId),
|
|
194
|
+
])
|
|
195
|
+
|
|
196
|
+
return records.map((record) => ({
|
|
197
|
+
...record,
|
|
198
|
+
_pipeline: buildPipelineState(
|
|
199
|
+
record,
|
|
200
|
+
openInteractionCounts,
|
|
201
|
+
latestTransitions,
|
|
202
|
+
threshold,
|
|
203
|
+
now,
|
|
204
|
+
today,
|
|
205
|
+
),
|
|
206
|
+
}))
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const enrichers: ResponseEnricher[] = [dealPipelineEnricher]
|
|
@@ -750,7 +750,7 @@ export class CustomerAddress {
|
|
|
750
750
|
@Entity({ tableName: 'customer_settings' })
|
|
751
751
|
@Unique({ name: 'customer_settings_scope_unique', properties: ['organizationId', 'tenantId'] })
|
|
752
752
|
export class CustomerSettings {
|
|
753
|
-
[OptionalProps]?: 'createdAt' | 'updatedAt'
|
|
753
|
+
[OptionalProps]?: 'createdAt' | 'updatedAt' | 'addressFormat' | 'stuckThresholdDays'
|
|
754
754
|
|
|
755
755
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
756
756
|
id!: string
|
|
@@ -764,6 +764,9 @@ export class CustomerSettings {
|
|
|
764
764
|
@Property({ name: 'address_format', type: 'text', default: 'line_first' })
|
|
765
765
|
addressFormat: CustomerAddressFormat = 'line_first'
|
|
766
766
|
|
|
767
|
+
@Property({ name: 'stuck_threshold_days', type: 'int', default: 14 })
|
|
768
|
+
stuckThresholdDays: number = 14
|
|
769
|
+
|
|
767
770
|
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
768
771
|
createdAt: Date = new Date()
|
|
769
772
|
|
|
@@ -126,7 +126,10 @@ export const dealCreateSchema = scopedSchema.extend({
|
|
|
126
126
|
valueCurrency: z.string().min(3).max(3).optional(),
|
|
127
127
|
probability: z.number().min(0).max(100).optional(),
|
|
128
128
|
expectedCloseAt: z.coerce.date().optional(),
|
|
129
|
-
|
|
129
|
+
// Nullable: the bulk owner-update worker passes `null` to clear ownership.
|
|
130
|
+
// Without `.nullable()`, dealUpdateSchema.parse({ ownerUserId: null }) throws
|
|
131
|
+
// ZodError "expected string, received null" inside the queue worker (TC-CRM-069).
|
|
132
|
+
ownerUserId: uuid().optional().nullable(),
|
|
130
133
|
source: z.string().max(150).optional(),
|
|
131
134
|
closureOutcome: z.enum(['won', 'lost']).optional(),
|
|
132
135
|
lossReasonId: uuid().optional(),
|
|
@@ -141,6 +144,24 @@ export const dealUpdateSchema = z
|
|
|
141
144
|
})
|
|
142
145
|
.merge(dealCreateSchema.partial())
|
|
143
146
|
|
|
147
|
+
// Bulk update schemas — used by `api/deals/bulk-update-{owner,stage}/route.ts`. Kept here
|
|
148
|
+
// so all deal-write contracts live next to `dealCreateSchema` / `dealUpdateSchema`.
|
|
149
|
+
export const dealsBulkUpdateOwnerSchema = z.object({
|
|
150
|
+
ids: z.array(uuid()).min(1).max(10000),
|
|
151
|
+
ownerUserId: uuid().nullable(),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
export const dealsBulkUpdateStageSchema = z.object({
|
|
155
|
+
ids: z.array(uuid()).min(1).max(10000),
|
|
156
|
+
pipelineStageId: uuid(),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
export const dealsBulkUpdateResponseSchema = z.object({
|
|
160
|
+
ok: z.boolean(),
|
|
161
|
+
progressJobId: uuid().nullable(),
|
|
162
|
+
message: z.string(),
|
|
163
|
+
})
|
|
164
|
+
|
|
144
165
|
export const activityCreateSchema = scopedSchema.extend({
|
|
145
166
|
entityId: uuid(),
|
|
146
167
|
activityType: z.string().min(1).max(100),
|
|
@@ -252,10 +273,18 @@ const dictionaryKindEnum = z.string().trim().refine(
|
|
|
252
273
|
|
|
253
274
|
const dictionaryValueSchema = z.string().trim().min(1).max(150)
|
|
254
275
|
const dictionaryLabelSchema = z.string().trim().max(150)
|
|
276
|
+
// Pipeline-stage rows migrated to semantic tone identifiers in
|
|
277
|
+
// Migration20260519120000_pipeline_stage_color_tones; AddStageDialog now writes those
|
|
278
|
+
// directly. Other dictionary kinds still store hex. Accept either format so round-tripping
|
|
279
|
+
// a migrated pipeline-stage entry through the dictionary edit UI doesn't fail validation.
|
|
280
|
+
const DICTIONARY_COLOR_TONES = ['success', 'warning', 'info', 'error', 'neutral', 'brand', 'pink'] as const
|
|
255
281
|
const dictionaryColorSchema = z
|
|
256
282
|
.string()
|
|
257
283
|
.trim()
|
|
258
|
-
.regex(
|
|
284
|
+
.regex(
|
|
285
|
+
new RegExp(`^(#[0-9a-fA-F]{6}|${DICTIONARY_COLOR_TONES.join('|')})$`),
|
|
286
|
+
'Color must be a six-digit hex code (e.g. #3366ff) or a semantic tone identifier',
|
|
287
|
+
)
|
|
259
288
|
const dictionaryIconSchema = z.string().trim().max(48)
|
|
260
289
|
|
|
261
290
|
export const customerDictionaryEntryCreateSchema = scopedSchema.extend({
|
|
@@ -497,6 +526,10 @@ export const customerSettingsUpsertSchema = scopedSchema.extend({
|
|
|
497
526
|
addressFormat: customerAddressFormatSchema,
|
|
498
527
|
})
|
|
499
528
|
|
|
529
|
+
export const customerStuckThresholdUpsertSchema = scopedSchema.extend({
|
|
530
|
+
stuckThresholdDays: z.number().int().min(1).max(365),
|
|
531
|
+
})
|
|
532
|
+
|
|
500
533
|
export type PersonCreateInput = z.infer<typeof personCreateSchema>
|
|
501
534
|
export type PersonUpdateInput = z.infer<typeof personUpdateSchema>
|
|
502
535
|
export type CompanyCreateInput = z.infer<typeof companyCreateSchema>
|
|
@@ -515,6 +548,7 @@ export type TagAssignmentInput = z.infer<typeof tagAssignmentSchema>
|
|
|
515
548
|
export type TodoLinkCreateInput = z.infer<typeof todoLinkCreateSchema>
|
|
516
549
|
export type TodoLinkWithTodoCreateInput = z.infer<typeof todoLinkWithTodoCreateSchema>
|
|
517
550
|
export type CustomerSettingsUpsertInput = z.infer<typeof customerSettingsUpsertSchema>
|
|
551
|
+
export type CustomerStuckThresholdUpsertInput = z.infer<typeof customerStuckThresholdUpsertSchema>
|
|
518
552
|
export type CustomerAddressFormatInput = z.infer<typeof customerAddressFormatSchema>
|
|
519
553
|
export type InteractionCompleteInput = z.infer<typeof interactionCompleteSchema>
|
|
520
554
|
export type InteractionCancelInput = z.infer<typeof interactionCancelSchema>
|