@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4

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
@@ -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
- const existingCount = await em.count(CustomerPipelineStage, {
28
- organizationId: parsed.organizationId,
29
- tenantId: parsed.tenantId,
30
- pipelineId: parsed.pipelineId,
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: parsed.order ?? existingCount,
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.findOne(CustomerPipelineStage, { id: parsed.id })
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.findOne(CustomerPipelineStage, { id: parsed.id })
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 em.find(CustomerPipelineStage, {
127
- id: { $in: ids },
128
- organizationId: parsed.organizationId,
129
- tenantId: parsed.tenantId,
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 { customerSettingsUpsertSchema, type CustomerSettingsUpsertInput } from '../data/validators'
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 em.findOne(CustomerSettings, {
13
- tenantId: params.tenantId,
14
- organizationId: params.organizationId,
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
- export const enrichers: ResponseEnricher[] = []
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
- ownerUserId: uuid().optional(),
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(/^#([0-9a-fA-F]{6})$/, 'Color must be a valid six-digit hex code like #3366ff')
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>