@open-mercato/core 0.4.5-develop-0f0e676c72 → 0.4.5-develop-e694581d9f

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 (113) hide show
  1. package/dist/generated/entities/customer_deal/index.js +4 -0
  2. package/dist/generated/entities/customer_deal/index.js.map +2 -2
  3. package/dist/generated/entities/customer_pipeline/index.js +17 -0
  4. package/dist/generated/entities/customer_pipeline/index.js.map +7 -0
  5. package/dist/generated/entities/customer_pipeline_stage/index.js +19 -0
  6. package/dist/generated/entities/customer_pipeline_stage/index.js.map +7 -0
  7. package/dist/generated/entities.ids.generated.js +2 -0
  8. package/dist/generated/entities.ids.generated.js.map +2 -2
  9. package/dist/generated/entity-fields-registry.js +4 -0
  10. package/dist/generated/entity-fields-registry.js.map +2 -2
  11. package/dist/modules/customers/acl.js +2 -0
  12. package/dist/modules/customers/acl.js.map +2 -2
  13. package/dist/modules/customers/api/deals/[id]/route.js +4 -0
  14. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  15. package/dist/modules/customers/api/deals/route.js +12 -0
  16. package/dist/modules/customers/api/deals/route.js.map +2 -2
  17. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -1
  18. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  19. package/dist/modules/customers/api/pipeline-stages/reorder/route.js +69 -0
  20. package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +7 -0
  21. package/dist/modules/customers/api/pipeline-stages/route.js +275 -0
  22. package/dist/modules/customers/api/pipeline-stages/route.js.map +7 -0
  23. package/dist/modules/customers/api/pipelines/route.js +245 -0
  24. package/dist/modules/customers/api/pipelines/route.js.map +7 -0
  25. package/dist/modules/customers/backend/config/customers/page.js +2 -0
  26. package/dist/modules/customers/backend/config/customers/page.js.map +2 -2
  27. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +439 -0
  28. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +7 -0
  29. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +17 -0
  30. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +7 -0
  31. package/dist/modules/customers/backend/customers/deals/[id]/page.js +19 -1
  32. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  33. package/dist/modules/customers/backend/customers/deals/page.js +35 -1
  34. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  35. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +102 -74
  36. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  37. package/dist/modules/customers/cli.js +28 -2
  38. package/dist/modules/customers/cli.js.map +2 -2
  39. package/dist/modules/customers/commands/deals.js +34 -2
  40. package/dist/modules/customers/commands/deals.js.map +2 -2
  41. package/dist/modules/customers/commands/index.js +2 -0
  42. package/dist/modules/customers/commands/index.js.map +2 -2
  43. package/dist/modules/customers/commands/pipeline-stages.js +126 -0
  44. package/dist/modules/customers/commands/pipeline-stages.js.map +7 -0
  45. package/dist/modules/customers/commands/pipelines.js +87 -0
  46. package/dist/modules/customers/commands/pipelines.js.map +7 -0
  47. package/dist/modules/customers/components/DictionarySettings.js +0 -5
  48. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  49. package/dist/modules/customers/components/PipelineSettings.js +474 -0
  50. package/dist/modules/customers/components/PipelineSettings.js.map +7 -0
  51. package/dist/modules/customers/components/detail/DealForm.js +84 -12
  52. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  53. package/dist/modules/customers/data/entities.js +78 -0
  54. package/dist/modules/customers/data/entities.js.map +2 -2
  55. package/dist/modules/customers/data/validators.js +44 -0
  56. package/dist/modules/customers/data/validators.js.map +2 -2
  57. package/dist/modules/customers/migrations/Migration20260218191730.js +77 -0
  58. package/dist/modules/customers/migrations/Migration20260218191730.js.map +7 -0
  59. package/dist/modules/customers/setup.js +7 -3
  60. package/dist/modules/customers/setup.js.map +2 -2
  61. package/dist/modules/translations/api/[entityType]/[entityId]/route.js +46 -44
  62. package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
  63. package/dist/modules/translations/api/context.js +10 -1
  64. package/dist/modules/translations/api/context.js.map +2 -2
  65. package/dist/modules/translations/commands/index.js +2 -0
  66. package/dist/modules/translations/commands/index.js.map +7 -0
  67. package/dist/modules/translations/commands/translations.js +160 -0
  68. package/dist/modules/translations/commands/translations.js.map +7 -0
  69. package/dist/modules/translations/index.js +1 -0
  70. package/dist/modules/translations/index.js.map +2 -2
  71. package/dist/modules/workflows/migrations/Migration20260222205305.js +14 -0
  72. package/dist/modules/workflows/migrations/Migration20260222205305.js.map +7 -0
  73. package/generated/entities/customer_deal/index.ts +2 -0
  74. package/generated/entities/customer_pipeline/index.ts +7 -0
  75. package/generated/entities/customer_pipeline_stage/index.ts +8 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +4 -0
  78. package/package.json +2 -2
  79. package/src/modules/customers/acl.ts +2 -0
  80. package/src/modules/customers/api/deals/[id]/route.ts +4 -0
  81. package/src/modules/customers/api/deals/route.ts +12 -0
  82. package/src/modules/customers/api/dictionaries/[kind]/route.ts +21 -1
  83. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +71 -0
  84. package/src/modules/customers/api/pipeline-stages/route.ts +296 -0
  85. package/src/modules/customers/api/pipelines/route.ts +261 -0
  86. package/src/modules/customers/backend/config/customers/page.tsx +2 -0
  87. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +13 -0
  88. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +512 -0
  89. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +21 -1
  90. package/src/modules/customers/backend/customers/deals/page.tsx +33 -1
  91. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +119 -79
  92. package/src/modules/customers/cli.ts +29 -1
  93. package/src/modules/customers/commands/deals.ts +44 -1
  94. package/src/modules/customers/commands/index.ts +2 -0
  95. package/src/modules/customers/commands/pipeline-stages.ts +156 -0
  96. package/src/modules/customers/commands/pipelines.ts +105 -0
  97. package/src/modules/customers/components/DictionarySettings.tsx +0 -5
  98. package/src/modules/customers/components/PipelineSettings.tsx +570 -0
  99. package/src/modules/customers/components/detail/DealForm.tsx +89 -11
  100. package/src/modules/customers/data/entities.ts +64 -0
  101. package/src/modules/customers/data/validators.ts +57 -0
  102. package/src/modules/customers/i18n/de.json +4 -0
  103. package/src/modules/customers/i18n/en.json +4 -0
  104. package/src/modules/customers/i18n/es.json +4 -0
  105. package/src/modules/customers/i18n/pl.json +5 -1
  106. package/src/modules/customers/migrations/Migration20260218191730.ts +84 -0
  107. package/src/modules/customers/setup.ts +5 -1
  108. package/src/modules/translations/api/[entityType]/[entityId]/route.ts +65 -60
  109. package/src/modules/translations/api/context.ts +12 -0
  110. package/src/modules/translations/commands/index.ts +1 -0
  111. package/src/modules/translations/commands/translations.ts +253 -0
  112. package/src/modules/translations/index.ts +1 -0
  113. package/src/modules/workflows/migrations/Migration20260222205305.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.4.5-develop-0f0e676c72",
3
+ "version": "0.4.5-develop-e694581d9f",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -207,7 +207,7 @@
207
207
  }
208
208
  },
209
209
  "dependencies": {
210
- "@open-mercato/shared": "0.4.5-develop-0f0e676c72",
210
+ "@open-mercato/shared": "0.4.5-develop-e694581d9f",
211
211
  "@types/semver": "^7.5.8",
212
212
  "@xyflow/react": "^12.6.0",
213
213
  "ai": "^6.0.0",
@@ -8,6 +8,8 @@ export const features = [
8
8
  { id: 'customers.activities.view', title: 'View activities', module: 'customers' },
9
9
  { id: 'customers.activities.manage', title: 'Manage activities', module: 'customers' },
10
10
  { id: 'customers.settings.manage', title: 'Manage customer settings', module: 'customers' },
11
+ { id: 'customers.pipelines.view', title: 'View pipelines', module: 'customers' },
12
+ { id: 'customers.pipelines.manage', title: 'Manage pipelines', module: 'customers' },
11
13
  { id: 'customers.widgets.todos', title: 'Use customer todos widget', module: 'customers' },
12
14
  { id: 'customers.widgets.next-interactions', title: 'Use customer next interactions widget', module: 'customers' },
13
15
  { id: 'customers.widgets.new-customers', title: 'Use customer new customers widget', module: 'customers' },
@@ -212,6 +212,8 @@ export async function GET(request: Request, context: { params?: Record<string, u
212
212
  description: deal.description ?? null,
213
213
  status: deal.status ?? null,
214
214
  pipelineStage: deal.pipelineStage ?? null,
215
+ pipelineId: deal.pipelineId ?? null,
216
+ pipelineStageId: deal.pipelineStageId ?? null,
215
217
  valueAmount: deal.valueAmount ?? null,
216
218
  valueCurrency: deal.valueCurrency ?? null,
217
219
  probability: deal.probability ?? null,
@@ -241,6 +243,8 @@ const dealDetailResponseSchema = z.object({
241
243
  description: z.string().nullable().optional(),
242
244
  status: z.string().nullable().optional(),
243
245
  pipelineStage: z.string().nullable().optional(),
246
+ pipelineId: z.string().uuid().nullable().optional(),
247
+ pipelineStageId: z.string().uuid().nullable().optional(),
244
248
  valueAmount: z.number().nullable().optional(),
245
249
  valueCurrency: z.string().nullable().optional(),
246
250
  probability: z.number().nullable().optional(),
@@ -25,6 +25,8 @@ const listSchema = z
25
25
  search: z.string().optional(),
26
26
  status: z.string().optional(),
27
27
  pipelineStage: z.string().optional(),
28
+ pipelineId: z.string().uuid().optional(),
29
+ pipelineStageId: z.string().uuid().optional(),
28
30
  sortField: z.string().optional(),
29
31
  sortDir: z.enum(['asc', 'desc']).optional(),
30
32
  personEntityId: z.string().uuid().optional(),
@@ -98,6 +100,8 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
98
100
  'description',
99
101
  'status',
100
102
  'pipeline_stage',
103
+ 'pipeline_id',
104
+ 'pipeline_stage_id',
101
105
  'value_amount',
102
106
  'value_currency',
103
107
  'probability',
@@ -129,6 +133,12 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
129
133
  if (query.pipelineStage) {
130
134
  filters.pipeline_stage = { $eq: query.pipelineStage }
131
135
  }
136
+ if (query.pipelineId) {
137
+ filters.pipeline_id = { $eq: query.pipelineId }
138
+ }
139
+ if (query.pipelineStageId) {
140
+ filters.pipeline_stage_id = { $eq: query.pipelineStageId }
141
+ }
132
142
  return filters
133
143
  },
134
144
  },
@@ -394,6 +404,8 @@ const dealListItemSchema = z
394
404
  description: z.string().nullable().optional(),
395
405
  status: z.string().nullable().optional(),
396
406
  pipeline_stage: z.string().nullable().optional(),
407
+ pipeline_id: z.string().uuid().nullable().optional(),
408
+ pipeline_stage_id: z.string().uuid().nullable().optional(),
397
409
  value_amount: z.number().nullable().optional(),
398
410
  value_currency: z.string().nullable().optional(),
399
411
  probability: z.number().nullable().optional(),
@@ -4,7 +4,8 @@ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
4
  import type { CommandBus } from '@open-mercato/shared/lib/commands'
5
5
  import type { CommandExecuteResult } from '@open-mercato/shared/lib/commands/types'
6
6
  import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
7
- import { CustomerDictionaryEntry } from '../../../data/entities'
7
+ import { CustomerDictionaryEntry, CustomerPipelineStage } from '../../../data/entities'
8
+ import { ensureDictionaryEntry } from '../../../commands/shared'
8
9
  import { mapDictionaryKind, resolveDictionaryRouteContext } from '../context'
9
10
  import { createDictionaryCacheKey, createDictionaryCacheTags, invalidateDictionaryCache, DICTIONARY_CACHE_TTL_MS } from '../cache'
10
11
  import { z } from 'zod'
@@ -48,6 +49,25 @@ export async function GET(req: Request, ctx: { params?: { kind?: string } }) {
48
49
  { orderBy: { label: 'asc' } }
49
50
  )
50
51
 
52
+ if (mappedKind === 'pipeline_stage' && organizationId) {
53
+ const existingNormalized = new Set(entries.map((e) => e.normalizedValue))
54
+ const pipelineStages = await em.find(CustomerPipelineStage, { organizationId, tenantId })
55
+ for (const stage of pipelineStages) {
56
+ if (!existingNormalized.has(stage.label.trim().toLowerCase())) {
57
+ const created = await ensureDictionaryEntry(em, {
58
+ tenantId,
59
+ organizationId,
60
+ kind: 'pipeline_stage',
61
+ value: stage.label,
62
+ })
63
+ if (created) {
64
+ entries.push(created)
65
+ existingNormalized.add(created.normalizedValue)
66
+ }
67
+ }
68
+ }
69
+ }
70
+
51
71
  const byValue = new Map<string, { entry: CustomerDictionaryEntry; isInherited: boolean; order: number }>()
52
72
  for (const entry of entries) {
53
73
  const normalized = entry.normalizedValue
@@ -0,0 +1,71 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
+ import type { CommandRuntimeContext, CommandBus } from '@open-mercato/shared/lib/commands'
7
+ import { pipelineStageReorderSchema, type PipelineStageReorderInput } from '../../../data/validators'
8
+ import { withScopedPayload } from '../../utils'
9
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
12
+
13
+ export const metadata = {
14
+ POST: { requireAuth: true, requireFeatures: ['customers.pipelines.manage'] },
15
+ }
16
+
17
+ export async function POST(req: Request) {
18
+ try {
19
+ const container = await createRequestContainer()
20
+ const auth = await getAuthFromRequest(req)
21
+ const { translate } = await resolveTranslations()
22
+ if (!auth) throw new CrudHttpError(401, { error: translate('customers.errors.unauthorized', 'Unauthorized') })
23
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
24
+ const ctx: CommandRuntimeContext = {
25
+ container,
26
+ auth,
27
+ organizationScope: scope,
28
+ selectedOrganizationId: scope?.selectedId ?? auth.orgId ?? null,
29
+ organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),
30
+ request: req,
31
+ }
32
+
33
+ const body = await req.json().catch(() => ({}))
34
+ const scoped = withScopedPayload(body, ctx, translate)
35
+
36
+ const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
37
+ await commandBus.execute<PipelineStageReorderInput, void>(
38
+ 'customers.pipeline-stages.reorder',
39
+ { input: pipelineStageReorderSchema.parse(scoped), ctx },
40
+ )
41
+ return NextResponse.json({ ok: true })
42
+ } catch (err) {
43
+ if (err instanceof CrudHttpError) {
44
+ return NextResponse.json(err.body, { status: err.status })
45
+ }
46
+ console.error('customers.pipeline-stages.reorder failed', err)
47
+ return NextResponse.json({ error: 'Failed to reorder pipeline stages' }, { status: 400 })
48
+ }
49
+ }
50
+
51
+ const reorderOkResponseSchema = z.object({ ok: z.boolean() })
52
+ const reorderErrorSchema = z.object({ error: z.string() })
53
+
54
+ export const openApi: OpenApiRouteDoc = {
55
+ tag: 'Customers',
56
+ summary: 'Reorder pipeline stages',
57
+ methods: {
58
+ POST: {
59
+ summary: 'Reorder pipeline stages',
60
+ description: 'Updates the order of pipeline stages in bulk.',
61
+ requestBody: { contentType: 'application/json', schema: pipelineStageReorderSchema },
62
+ responses: [
63
+ { status: 200, description: 'Stages reordered', schema: reorderOkResponseSchema },
64
+ ],
65
+ errors: [
66
+ { status: 400, description: 'Validation failed', schema: reorderErrorSchema },
67
+ { status: 401, description: 'Unauthorized', schema: reorderErrorSchema },
68
+ ],
69
+ },
70
+ },
71
+ }
@@ -0,0 +1,296 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
+ import type { CommandRuntimeContext, CommandBus } from '@open-mercato/shared/lib/commands'
8
+ import { CustomerPipelineStage, CustomerDictionaryEntry } from '../../data/entities'
9
+ import {
10
+ pipelineStageCreateSchema,
11
+ pipelineStageUpdateSchema,
12
+ pipelineStageDeleteSchema,
13
+ type PipelineStageCreateInput,
14
+ type PipelineStageUpdateInput,
15
+ type PipelineStageDeleteInput,
16
+ } from '../../data/validators'
17
+ import { withScopedPayload } from '../utils'
18
+ import { ensureDictionaryEntry } from '../../commands/shared'
19
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
20
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
21
+ import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
22
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
23
+
24
+ export const metadata = {
25
+ GET: { requireAuth: true, requireFeatures: ['customers.pipelines.view'] },
26
+ POST: { requireAuth: true, requireFeatures: ['customers.pipelines.manage'] },
27
+ PUT: { requireAuth: true, requireFeatures: ['customers.pipelines.manage'] },
28
+ DELETE: { requireAuth: true, requireFeatures: ['customers.pipelines.manage'] },
29
+ }
30
+
31
+ async function buildContext(
32
+ req: Request
33
+ ): Promise<{ ctx: CommandRuntimeContext; organizationId: string | null; tenantId: string | null }> {
34
+ const container = await createRequestContainer()
35
+ const auth = await getAuthFromRequest(req)
36
+ const { translate } = await resolveTranslations()
37
+ if (!auth) throw new CrudHttpError(401, { error: translate('customers.errors.unauthorized', 'Unauthorized') })
38
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
39
+ const ctx: CommandRuntimeContext = {
40
+ container,
41
+ auth,
42
+ organizationScope: scope,
43
+ selectedOrganizationId: scope?.selectedId ?? auth.orgId ?? null,
44
+ organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),
45
+ request: req,
46
+ }
47
+ const organizationId = scope?.selectedId ?? auth.orgId ?? null
48
+ const tenantId = auth.tenantId ?? null
49
+ return { ctx, organizationId, tenantId }
50
+ }
51
+
52
+ export async function GET(req: Request) {
53
+ try {
54
+ const { ctx, organizationId, tenantId } = await buildContext(req)
55
+ if (!organizationId || !tenantId) {
56
+ return NextResponse.json({ error: 'Organization and tenant context required' }, { status: 400 })
57
+ }
58
+ const url = new URL(req.url)
59
+ const pipelineId = url.searchParams.get('pipelineId')
60
+
61
+ const em = (ctx.container.resolve('em') as EntityManager)
62
+ const where: Record<string, unknown> = { organizationId, tenantId }
63
+ if (pipelineId) where.pipelineId = pipelineId
64
+
65
+ const stages = await em.find(CustomerPipelineStage, where, { orderBy: { order: 'ASC' } })
66
+
67
+ const stageLabels = stages.map((s) => s.label.trim().toLowerCase())
68
+ const dictEntries = stageLabels.length
69
+ ? await em.find(CustomerDictionaryEntry, {
70
+ organizationId,
71
+ tenantId,
72
+ kind: 'pipeline_stage',
73
+ normalizedValue: { $in: stageLabels },
74
+ })
75
+ : []
76
+ const dictByNormalized = new Map<string, CustomerDictionaryEntry>()
77
+ dictEntries.forEach((entry) => dictByNormalized.set(entry.normalizedValue, entry))
78
+
79
+ const missingStages = stages.filter((s) => !dictByNormalized.has(s.label.trim().toLowerCase()))
80
+ if (missingStages.length) {
81
+ for (const stage of missingStages) {
82
+ const created = await ensureDictionaryEntry(em, {
83
+ tenantId,
84
+ organizationId,
85
+ kind: 'pipeline_stage',
86
+ value: stage.label,
87
+ })
88
+ if (created) dictByNormalized.set(created.normalizedValue, created)
89
+ }
90
+ }
91
+
92
+ const items = stages.map((stage) => {
93
+ const dictEntry = dictByNormalized.get(stage.label.trim().toLowerCase())
94
+ return {
95
+ id: stage.id,
96
+ pipelineId: stage.pipelineId,
97
+ label: stage.label,
98
+ order: stage.order,
99
+ color: dictEntry?.color ?? null,
100
+ icon: dictEntry?.icon ?? null,
101
+ organizationId: stage.organizationId,
102
+ tenantId: stage.tenantId,
103
+ createdAt: stage.createdAt,
104
+ updatedAt: stage.updatedAt,
105
+ }
106
+ })
107
+ return NextResponse.json({ items, total: items.length })
108
+ } catch (err) {
109
+ if (err instanceof CrudHttpError) {
110
+ return NextResponse.json(err.body, { status: err.status })
111
+ }
112
+ console.error('customers.pipeline-stages GET failed', err)
113
+ return NextResponse.json({ error: 'Failed to load pipeline stages' }, { status: 500 })
114
+ }
115
+ }
116
+
117
+ export async function POST(req: Request) {
118
+ try {
119
+ const { ctx } = await buildContext(req)
120
+ const body = await req.json().catch(() => ({}))
121
+ const { translate } = await resolveTranslations()
122
+ const scoped = withScopedPayload(body, ctx, translate)
123
+
124
+ const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
125
+ const { result, logEntry } = await commandBus.execute<PipelineStageCreateInput, { stageId: string }>(
126
+ 'customers.pipeline-stages.create',
127
+ { input: pipelineStageCreateSchema.parse(scoped), ctx },
128
+ )
129
+ const response = NextResponse.json({ id: result?.stageId ?? null }, { status: 201 })
130
+ if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
131
+ response.headers.set(
132
+ 'x-om-operation',
133
+ serializeOperationMetadata({
134
+ id: logEntry.id,
135
+ undoToken: logEntry.undoToken,
136
+ commandId: logEntry.commandId,
137
+ actionLabel: logEntry.actionLabel ?? null,
138
+ resourceKind: logEntry.resourceKind ?? 'customers.pipelineStage',
139
+ resourceId: logEntry.resourceId ?? result?.stageId ?? null,
140
+ executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString() : undefined,
141
+ })
142
+ )
143
+ }
144
+ return response
145
+ } catch (err) {
146
+ if (err instanceof CrudHttpError) {
147
+ return NextResponse.json(err.body, { status: err.status })
148
+ }
149
+ console.error('customers.pipeline-stages POST failed', err)
150
+ return NextResponse.json({ error: 'Failed to create pipeline stage' }, { status: 400 })
151
+ }
152
+ }
153
+
154
+ export async function PUT(req: Request) {
155
+ try {
156
+ const { ctx } = await buildContext(req)
157
+ const body = await req.json().catch(() => ({}))
158
+ const { translate } = await resolveTranslations()
159
+ const scoped = withScopedPayload(body, ctx, translate)
160
+
161
+ const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
162
+ const { logEntry } = await commandBus.execute<PipelineStageUpdateInput, void>(
163
+ 'customers.pipeline-stages.update',
164
+ { input: pipelineStageUpdateSchema.parse(scoped), ctx },
165
+ )
166
+ const response = NextResponse.json({ ok: true })
167
+ if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
168
+ response.headers.set(
169
+ 'x-om-operation',
170
+ serializeOperationMetadata({
171
+ id: logEntry.id,
172
+ undoToken: logEntry.undoToken,
173
+ commandId: logEntry.commandId,
174
+ actionLabel: logEntry.actionLabel ?? null,
175
+ resourceKind: logEntry.resourceKind ?? 'customers.pipelineStage',
176
+ resourceId: logEntry.resourceId ?? null,
177
+ executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString() : undefined,
178
+ })
179
+ )
180
+ }
181
+ return response
182
+ } catch (err) {
183
+ if (err instanceof CrudHttpError) {
184
+ return NextResponse.json(err.body, { status: err.status })
185
+ }
186
+ console.error('customers.pipeline-stages PUT failed', err)
187
+ return NextResponse.json({ error: 'Failed to update pipeline stage' }, { status: 400 })
188
+ }
189
+ }
190
+
191
+ export async function DELETE(req: Request) {
192
+ try {
193
+ const { ctx } = await buildContext(req)
194
+ const body = await req.json().catch(() => ({}))
195
+ const { translate } = await resolveTranslations()
196
+ const scoped = withScopedPayload(body, ctx, translate)
197
+
198
+ const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
199
+ await commandBus.execute<PipelineStageDeleteInput, void>(
200
+ 'customers.pipeline-stages.delete',
201
+ { input: pipelineStageDeleteSchema.parse(scoped), ctx },
202
+ )
203
+ return NextResponse.json({ ok: true })
204
+ } catch (err) {
205
+ if (err instanceof CrudHttpError) {
206
+ return NextResponse.json(err.body, { status: err.status })
207
+ }
208
+ console.error('customers.pipeline-stages DELETE failed', err)
209
+ return NextResponse.json({ error: 'Failed to delete pipeline stage' }, { status: 400 })
210
+ }
211
+ }
212
+
213
+ const stageItemSchema = z.object({
214
+ id: z.string().uuid(),
215
+ pipelineId: z.string().uuid(),
216
+ label: z.string(),
217
+ order: z.number(),
218
+ color: z.string().nullable(),
219
+ icon: z.string().nullable(),
220
+ organizationId: z.string().uuid(),
221
+ tenantId: z.string().uuid(),
222
+ createdAt: z.date(),
223
+ updatedAt: z.date(),
224
+ })
225
+
226
+ const stageListResponseSchema = z.object({
227
+ items: z.array(stageItemSchema),
228
+ total: z.number(),
229
+ })
230
+
231
+ const stageCreateResponseSchema = z.object({
232
+ id: z.string().uuid().nullable(),
233
+ })
234
+
235
+ const stageOkResponseSchema = z.object({
236
+ ok: z.boolean(),
237
+ })
238
+
239
+ const stageErrorSchema = z.object({
240
+ error: z.string(),
241
+ })
242
+
243
+ export const openApi: OpenApiRouteDoc = {
244
+ tag: 'Customers',
245
+ summary: 'Manage pipeline stages',
246
+ methods: {
247
+ GET: {
248
+ summary: 'List pipeline stages',
249
+ description: 'Returns pipeline stages for the authenticated organization, optionally filtered by pipelineId.',
250
+ query: z.object({ pipelineId: z.string().uuid().optional() }),
251
+ responses: [
252
+ { status: 200, description: 'Stage list', schema: stageListResponseSchema },
253
+ ],
254
+ errors: [
255
+ { status: 401, description: 'Unauthorized', schema: stageErrorSchema },
256
+ { status: 400, description: 'Invalid request', schema: stageErrorSchema },
257
+ ],
258
+ },
259
+ POST: {
260
+ summary: 'Create pipeline stage',
261
+ description: 'Creates a new pipeline stage.',
262
+ requestBody: { contentType: 'application/json', schema: pipelineStageCreateSchema },
263
+ responses: [
264
+ { status: 201, description: 'Stage created', schema: stageCreateResponseSchema },
265
+ ],
266
+ errors: [
267
+ { status: 400, description: 'Validation failed', schema: stageErrorSchema },
268
+ { status: 401, description: 'Unauthorized', schema: stageErrorSchema },
269
+ ],
270
+ },
271
+ PUT: {
272
+ summary: 'Update pipeline stage',
273
+ description: 'Updates an existing pipeline stage.',
274
+ requestBody: { contentType: 'application/json', schema: pipelineStageUpdateSchema },
275
+ responses: [
276
+ { status: 200, description: 'Stage updated', schema: stageOkResponseSchema },
277
+ ],
278
+ errors: [
279
+ { status: 400, description: 'Validation failed', schema: stageErrorSchema },
280
+ { status: 404, description: 'Stage not found', schema: stageErrorSchema },
281
+ ],
282
+ },
283
+ DELETE: {
284
+ summary: 'Delete pipeline stage',
285
+ description: 'Deletes a pipeline stage. Returns 409 if active deals use this stage.',
286
+ requestBody: { contentType: 'application/json', schema: pipelineStageDeleteSchema },
287
+ responses: [
288
+ { status: 200, description: 'Stage deleted', schema: stageOkResponseSchema },
289
+ ],
290
+ errors: [
291
+ { status: 409, description: 'Stage has active deals', schema: stageErrorSchema },
292
+ { status: 404, description: 'Stage not found', schema: stageErrorSchema },
293
+ ],
294
+ },
295
+ },
296
+ }