@open-mercato/core 0.6.3-develop.3635.1.e4a05c16e8 → 0.6.3-develop.3653.1.b8558737ae

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_settings/index.js +2 -0
  3. package/dist/generated/entities/customer_settings/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
  7. package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
  8. package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
  9. package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
  10. package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
  11. package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
  12. package/dist/modules/customers/api/deals/route.js +255 -122
  13. package/dist/modules/customers/api/deals/route.js.map +2 -2
  14. package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
  15. package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
  16. package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
  17. package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
  18. package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
  19. package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
  21. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/page.js +2 -0
  23. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
  26. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
  27. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
  28. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
  29. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
  30. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
  31. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
  34. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
  35. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
  36. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
  37. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
  38. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
  39. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
  40. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
  41. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
  43. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
  44. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
  45. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
  46. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
  47. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
  48. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
  49. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
  50. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
  51. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
  52. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
  53. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
  54. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
  55. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
  56. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
  57. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
  58. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
  59. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
  60. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
  61. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
  62. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
  63. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
  64. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
  65. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
  66. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
  67. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
  68. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
  69. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
  70. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
  71. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
  72. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
  73. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
  74. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
  75. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
  76. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
  77. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
  78. package/dist/modules/customers/commands/deals.js +6 -0
  79. package/dist/modules/customers/commands/deals.js.map +2 -2
  80. package/dist/modules/customers/commands/pipeline-stages.js +48 -13
  81. package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
  82. package/dist/modules/customers/commands/settings.js +45 -5
  83. package/dist/modules/customers/commands/settings.js.map +2 -2
  84. package/dist/modules/customers/data/enrichers.js +130 -1
  85. package/dist/modules/customers/data/enrichers.js.map +2 -2
  86. package/dist/modules/customers/data/entities.js +4 -0
  87. package/dist/modules/customers/data/entities.js.map +2 -2
  88. package/dist/modules/customers/data/validators.js +29 -2
  89. package/dist/modules/customers/data/validators.js.map +2 -2
  90. package/dist/modules/customers/lib/bulkDeals.js +150 -0
  91. package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
  92. package/dist/modules/customers/lib/kysely.js +12 -0
  93. package/dist/modules/customers/lib/kysely.js.map +7 -0
  94. package/dist/modules/customers/lib/stuckDeals.js +32 -0
  95. package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
  96. package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
  97. package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
  98. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
  99. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
  100. package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
  101. package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
  102. package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
  103. package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
  104. package/generated/entities/customer_settings/index.ts +1 -0
  105. package/generated/entity-fields-registry.ts +1 -0
  106. package/package.json +7 -7
  107. package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
  108. package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
  109. package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
  110. package/src/modules/customers/api/deals/route.ts +330 -135
  111. package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
  112. package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
  113. package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
  114. package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
  115. package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
  116. package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
  117. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
  118. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
  119. package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
  120. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
  121. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
  122. package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
  123. package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
  124. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
  125. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
  126. package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
  127. package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
  128. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
  129. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
  130. package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
  131. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
  132. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
  133. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
  134. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
  135. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
  136. package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
  137. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
  138. package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
  139. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
  140. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
  141. package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
  142. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
  143. package/src/modules/customers/commands/deals.ts +19 -0
  144. package/src/modules/customers/commands/pipeline-stages.ts +68 -13
  145. package/src/modules/customers/commands/settings.ts +64 -5
  146. package/src/modules/customers/data/enrichers.ts +209 -2
  147. package/src/modules/customers/data/entities.ts +4 -1
  148. package/src/modules/customers/data/validators.ts +36 -2
  149. package/src/modules/customers/i18n/de.json +244 -0
  150. package/src/modules/customers/i18n/en.json +244 -0
  151. package/src/modules/customers/i18n/es.json +244 -0
  152. package/src/modules/customers/i18n/pl.json +244 -0
  153. package/src/modules/customers/lib/bulkDeals.ts +231 -0
  154. package/src/modules/customers/lib/kysely.ts +60 -0
  155. package/src/modules/customers/lib/stuckDeals.ts +88 -0
  156. package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
  157. package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
  158. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
  159. package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
  160. package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
@@ -0,0 +1,216 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
+ import type { TranslateWithFallbackFn } from '@open-mercato/shared/lib/i18n/translate'
7
+ import {
8
+ runCrudMutationGuardAfterSuccess,
9
+ validateCrudMutationGuard,
10
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
11
+ import type { ProgressService } from '../../../../progress/lib/progressService'
12
+ import {
13
+ CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,
14
+ getCustomersQueue,
15
+ } from '../../../lib/bulkDeals'
16
+ import {
17
+ dealsBulkUpdateOwnerSchema as requestSchema,
18
+ dealsBulkUpdateResponseSchema as responseSchema,
19
+ } from '../../../data/validators'
20
+
21
+ export const metadata = {
22
+ POST: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },
23
+ }
24
+
25
+ export const openApi = {
26
+ tags: ['Customers'],
27
+ summary: 'Bulk reassign deal owner',
28
+ description:
29
+ 'Queues a background job that reassigns the listed deals to a new owner (or clears the owner when null).',
30
+ }
31
+
32
+ async function postImpl(req: Request, translate: TranslateWithFallbackFn): Promise<NextResponse> {
33
+ const auth = await getAuthFromRequest(req)
34
+ if (!auth?.tenantId || !auth.orgId) {
35
+ return NextResponse.json(
36
+ responseSchema.parse({
37
+ ok: false,
38
+ progressJobId: null,
39
+ message: translate('customers.errors.unauthorized', 'Unauthorized'),
40
+ }),
41
+ { status: 401 },
42
+ )
43
+ }
44
+
45
+ const parsed = requestSchema.safeParse(await readJsonSafe(req))
46
+ if (!parsed.success) {
47
+ return NextResponse.json(
48
+ responseSchema.parse({
49
+ ok: false,
50
+ progressJobId: null,
51
+ message: translate('customers.errors.invalid_payload', 'Invalid payload'),
52
+ }),
53
+ { status: 400 },
54
+ )
55
+ }
56
+
57
+ const ids = Array.from(new Set(parsed.data.ids))
58
+ const container = await createRequestContainer()
59
+
60
+ // Mutation-guard contract for custom write routes — see comment on bulk-update-stage
61
+ // route for full rationale. Per-record locks are enforced inside the worker (per id).
62
+ // The guard at the bulk-entry point is best-effort: failures (e.g. a guard that
63
+ // expects a single-record id, or transient lock-table errors) must NOT take the whole
64
+ // bulk enqueue down — swallow so the bulk job still gets queued.
65
+ let guardResult: Awaited<ReturnType<typeof validateCrudMutationGuard>> = null
66
+ try {
67
+ guardResult = await validateCrudMutationGuard(container, {
68
+ tenantId: auth.tenantId,
69
+ organizationId: auth.orgId,
70
+ userId: auth.sub,
71
+ resourceKind: 'customers.deal',
72
+ resourceId: ids.join(','),
73
+ operation: 'custom',
74
+ requestMethod: req.method,
75
+ requestHeaders: req.headers,
76
+ mutationPayload: { ids, ownerUserId: parsed.data.ownerUserId },
77
+ })
78
+ } catch (guardError) {
79
+ console.warn('[customers.deals.bulk-update-owner] mutation-guard skipped', guardError)
80
+ guardResult = null
81
+ }
82
+ if (guardResult && !guardResult.ok) {
83
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
84
+ }
85
+
86
+ let progressService: ProgressService
87
+ try {
88
+ progressService = container.resolve('progressService') as ProgressService
89
+ } catch (resolveError) {
90
+ console.error('[customers.deals.bulk-update-owner] progressService resolve failed', resolveError)
91
+ throw new Error(
92
+ `progressService resolve failed: ${resolveError instanceof Error ? resolveError.message : String(resolveError)}`,
93
+ )
94
+ }
95
+
96
+ let progressJob: Awaited<ReturnType<ProgressService['createJob']>>
97
+ try {
98
+ progressJob = await progressService.createJob(
99
+ {
100
+ jobType: 'customers.deals.bulk_update_owner',
101
+ name: translate(
102
+ 'customers.deals.kanban.bulk.changeOwner.progress.name',
103
+ 'Reassign selected deals to a new owner',
104
+ ),
105
+ description: translate(
106
+ 'customers.deals.kanban.bulk.changeOwner.progress.description',
107
+ '{count} deals queued for owner reassignment',
108
+ { count: ids.length },
109
+ ),
110
+ totalCount: ids.length,
111
+ cancellable: false,
112
+ meta: {
113
+ source: 'customers.deals.bulk-update-owner',
114
+ ownerUserId: parsed.data.ownerUserId,
115
+ },
116
+ },
117
+ {
118
+ tenantId: auth.tenantId,
119
+ organizationId: auth.orgId,
120
+ userId: auth.sub,
121
+ },
122
+ )
123
+ } catch (createError) {
124
+ console.error('[customers.deals.bulk-update-owner] progressService.createJob failed', createError)
125
+ throw new Error(
126
+ `progressService.createJob failed: ${createError instanceof Error ? createError.message : String(createError)}`,
127
+ )
128
+ }
129
+
130
+ const queue = getCustomersQueue(CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE)
131
+ try {
132
+ await queue.enqueue({
133
+ progressJobId: progressJob.id,
134
+ ids,
135
+ ownerUserId: parsed.data.ownerUserId,
136
+ scope: {
137
+ organizationId: auth.orgId,
138
+ tenantId: auth.tenantId,
139
+ userId: auth.sub,
140
+ },
141
+ })
142
+ } catch (error) {
143
+ // See bulk-update-stage route for rationale: without this guard the progress
144
+ // job hangs in `pending` because the worker will never pick it up after a
145
+ // failed enqueue, and the top-bar polls indefinitely.
146
+ await progressService
147
+ .failJob(
148
+ progressJob.id,
149
+ {
150
+ errorMessage:
151
+ error instanceof Error
152
+ ? error.message
153
+ : translate('customers.errors.bulk_enqueue_failed', 'Failed to enqueue bulk job'),
154
+ },
155
+ {
156
+ tenantId: auth.tenantId,
157
+ organizationId: auth.orgId,
158
+ userId: auth.sub,
159
+ },
160
+ )
161
+ .catch((failErr) => {
162
+ console.warn('[customers.deals.bulk-update-owner] failed to mark progress job as failed', failErr)
163
+ })
164
+ throw error
165
+ }
166
+
167
+ // After-success half of the mutation-guard contract — see bulk-update-stage route.
168
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
169
+ await runCrudMutationGuardAfterSuccess(container, {
170
+ tenantId: auth.tenantId,
171
+ organizationId: auth.orgId,
172
+ userId: auth.sub,
173
+ resourceKind: 'customers.deal',
174
+ resourceId: ids.join(','),
175
+ operation: 'custom',
176
+ requestMethod: req.method,
177
+ requestHeaders: req.headers,
178
+ metadata: guardResult.metadata ?? null,
179
+ })
180
+ }
181
+
182
+ return NextResponse.json(
183
+ responseSchema.parse({
184
+ ok: true,
185
+ progressJobId: progressJob.id,
186
+ message: translate(
187
+ 'customers.deals.kanban.bulk.changeOwner.queued',
188
+ 'Bulk owner update started ({count} deals).',
189
+ { count: ids.length },
190
+ ),
191
+ }),
192
+ { status: 202 },
193
+ )
194
+ }
195
+
196
+ // Outer wrapper: every uncaught error becomes a controlled JSON 500 instead of
197
+ // Next.js's default HTML error page. See bulk-update-stage for the same rationale.
198
+ export async function POST(req: Request) {
199
+ const { translate } = await resolveTranslations()
200
+ try {
201
+ return await postImpl(req, translate)
202
+ } catch (error) {
203
+ console.error('[customers.deals.bulk-update-owner] route failed', error)
204
+ return NextResponse.json(
205
+ responseSchema.parse({
206
+ ok: false,
207
+ progressJobId: null,
208
+ message:
209
+ error instanceof Error
210
+ ? error.message
211
+ : translate('customers.errors.bulk_enqueue_failed', 'Failed to enqueue bulk job'),
212
+ }),
213
+ { status: 500 },
214
+ )
215
+ }
216
+ }
@@ -0,0 +1,228 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
+ import type { TranslateWithFallbackFn } from '@open-mercato/shared/lib/i18n/translate'
7
+ import {
8
+ runCrudMutationGuardAfterSuccess,
9
+ validateCrudMutationGuard,
10
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
11
+ import type { ProgressService } from '../../../../progress/lib/progressService'
12
+ import {
13
+ CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE,
14
+ getCustomersQueue,
15
+ } from '../../../lib/bulkDeals'
16
+ import {
17
+ dealsBulkUpdateStageSchema as requestSchema,
18
+ dealsBulkUpdateResponseSchema as responseSchema,
19
+ } from '../../../data/validators'
20
+
21
+ export const metadata = {
22
+ POST: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },
23
+ }
24
+
25
+ export const openApi = {
26
+ tags: ['Customers'],
27
+ summary: 'Bulk update deal pipeline stage',
28
+ description:
29
+ 'Queues a background job that moves the listed deals to the same pipeline stage. Returns a progress job id to poll for completion.',
30
+ }
31
+
32
+ async function postImpl(req: Request, translate: TranslateWithFallbackFn): Promise<NextResponse> {
33
+ const auth = await getAuthFromRequest(req)
34
+ if (!auth?.tenantId || !auth.orgId) {
35
+ return NextResponse.json(
36
+ responseSchema.parse({
37
+ ok: false,
38
+ progressJobId: null,
39
+ message: translate('customers.errors.unauthorized', 'Unauthorized'),
40
+ }),
41
+ { status: 401 },
42
+ )
43
+ }
44
+
45
+ const parsed = requestSchema.safeParse(await readJsonSafe(req))
46
+ if (!parsed.success) {
47
+ return NextResponse.json(
48
+ responseSchema.parse({
49
+ ok: false,
50
+ progressJobId: null,
51
+ message: translate('customers.errors.invalid_payload', 'Invalid payload'),
52
+ }),
53
+ { status: 400 },
54
+ )
55
+ }
56
+
57
+ const ids = Array.from(new Set(parsed.data.ids))
58
+ const container = await createRequestContainer()
59
+
60
+ // Mutation-guard contract for custom write routes — lets injection modules (record-lock
61
+ // conflict handling, scoped headers, undo-history reconciliation) run `onBeforeSave` /
62
+ // `onAfterSave` for non-`makeCrudRoute` writes. Per-record locks are enforced by the
63
+ // worker (the worker calls `customers.deals.update` per id, which itself goes through
64
+ // the single-record route + guard). The guard at the bulk-entry point is best-effort:
65
+ // it gives lock-aware modules a chance to register a bulk activity log, but failures
66
+ // (e.g. a guard that expects a single-record id, or transient lock-table errors) must
67
+ // NOT take the whole bulk enqueue down. Swallow the error so the bulk job still gets
68
+ // queued and per-record enforcement happens downstream.
69
+ let guardResult: Awaited<ReturnType<typeof validateCrudMutationGuard>> = null
70
+ try {
71
+ guardResult = await validateCrudMutationGuard(container, {
72
+ tenantId: auth.tenantId,
73
+ organizationId: auth.orgId,
74
+ userId: auth.sub,
75
+ resourceKind: 'customers.deal',
76
+ resourceId: ids.join(','),
77
+ operation: 'custom',
78
+ requestMethod: req.method,
79
+ requestHeaders: req.headers,
80
+ mutationPayload: { ids, pipelineStageId: parsed.data.pipelineStageId },
81
+ })
82
+ } catch (guardError) {
83
+ console.warn('[customers.deals.bulk-update-stage] mutation-guard skipped', guardError)
84
+ guardResult = null
85
+ }
86
+ if (guardResult && !guardResult.ok) {
87
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
88
+ }
89
+
90
+ let progressService: ProgressService
91
+ try {
92
+ progressService = container.resolve('progressService') as ProgressService
93
+ } catch (resolveError) {
94
+ console.error('[customers.deals.bulk-update-stage] progressService resolve failed', resolveError)
95
+ throw new Error(
96
+ `progressService resolve failed: ${resolveError instanceof Error ? resolveError.message : String(resolveError)}`,
97
+ )
98
+ }
99
+
100
+ let progressJob: Awaited<ReturnType<ProgressService['createJob']>>
101
+ try {
102
+ progressJob = await progressService.createJob(
103
+ {
104
+ jobType: 'customers.deals.bulk_update_stage',
105
+ name: translate(
106
+ 'customers.deals.kanban.bulk.changeStage.progress.name',
107
+ 'Move selected deals to a new stage',
108
+ ),
109
+ description: translate(
110
+ 'customers.deals.kanban.bulk.changeStage.progress.description',
111
+ '{count} deals queued for stage update',
112
+ { count: ids.length },
113
+ ),
114
+ totalCount: ids.length,
115
+ cancellable: false,
116
+ meta: {
117
+ source: 'customers.deals.bulk-update-stage',
118
+ pipelineStageId: parsed.data.pipelineStageId,
119
+ },
120
+ },
121
+ {
122
+ tenantId: auth.tenantId,
123
+ organizationId: auth.orgId,
124
+ userId: auth.sub,
125
+ },
126
+ )
127
+ } catch (createError) {
128
+ console.error('[customers.deals.bulk-update-stage] progressService.createJob failed', createError)
129
+ throw new Error(
130
+ `progressService.createJob failed: ${createError instanceof Error ? createError.message : String(createError)}`,
131
+ )
132
+ }
133
+
134
+ const queue = getCustomersQueue(CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE)
135
+ try {
136
+ await queue.enqueue({
137
+ progressJobId: progressJob.id,
138
+ ids,
139
+ pipelineStageId: parsed.data.pipelineStageId,
140
+ scope: {
141
+ organizationId: auth.orgId,
142
+ tenantId: auth.tenantId,
143
+ userId: auth.sub,
144
+ },
145
+ })
146
+ } catch (error) {
147
+ // Without this guard the progress row sits in `pending` forever — the worker
148
+ // never picks the job up, and the top-bar polls a job that will never finish.
149
+ // Mark it failed so the operator gets a deterministic UX (a failure toast +
150
+ // ability to retry) instead of an invisible stuck job.
151
+ await progressService
152
+ .failJob(
153
+ progressJob.id,
154
+ {
155
+ errorMessage:
156
+ error instanceof Error
157
+ ? error.message
158
+ : translate('customers.errors.bulk_enqueue_failed', 'Failed to enqueue bulk job'),
159
+ },
160
+ {
161
+ tenantId: auth.tenantId,
162
+ organizationId: auth.orgId,
163
+ userId: auth.sub,
164
+ },
165
+ )
166
+ .catch((failErr) => {
167
+ console.warn('[customers.deals.bulk-update-stage] failed to mark progress job as failed', failErr)
168
+ })
169
+ throw error
170
+ }
171
+
172
+ // After-success half of the mutation-guard contract — fires `onAfterSave` injection
173
+ // hooks. We invoke it here because enqueue succeeded; the worker takes over from here.
174
+ // If an injection-side after-handler needs the bulk job's actual completion (rather than
175
+ // enqueue), it should subscribe to the progress event stream — beyond the scope of the
176
+ // synchronous guard contract.
177
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
178
+ await runCrudMutationGuardAfterSuccess(container, {
179
+ tenantId: auth.tenantId,
180
+ organizationId: auth.orgId,
181
+ userId: auth.sub,
182
+ resourceKind: 'customers.deal',
183
+ resourceId: ids.join(','),
184
+ operation: 'custom',
185
+ requestMethod: req.method,
186
+ requestHeaders: req.headers,
187
+ metadata: guardResult.metadata ?? null,
188
+ })
189
+ }
190
+
191
+ return NextResponse.json(
192
+ responseSchema.parse({
193
+ ok: true,
194
+ progressJobId: progressJob.id,
195
+ message: translate(
196
+ 'customers.deals.kanban.bulk.changeStage.queued',
197
+ 'Bulk stage update started ({count} deals).',
198
+ { count: ids.length },
199
+ ),
200
+ }),
201
+ { status: 202 },
202
+ )
203
+ }
204
+
205
+ // Outer wrapper: every uncaught error from `postImpl` becomes a controlled 500 with
206
+ // diagnostic JSON instead of Next.js's default HTML error page. Routes that throw
207
+ // produced opaque 500s in CI integration tests (no body, no message), making the
208
+ // root cause invisible. With this wrapper the operator sees the actual error string
209
+ // in the response body and the server log, and the test framework can surface it.
210
+ export async function POST(req: Request) {
211
+ const { translate } = await resolveTranslations()
212
+ try {
213
+ return await postImpl(req, translate)
214
+ } catch (error) {
215
+ console.error('[customers.deals.bulk-update-stage] route failed', error)
216
+ return NextResponse.json(
217
+ responseSchema.parse({
218
+ ok: false,
219
+ progressJobId: null,
220
+ message:
221
+ error instanceof Error
222
+ ? error.message
223
+ : translate('customers.errors.bulk_enqueue_failed', 'Failed to enqueue bulk job'),
224
+ }),
225
+ { status: 500 },
226
+ )
227
+ }
228
+ }