@open-mercato/core 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6241.1.47c01ad4ed

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 (125) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/commands/users.js +1 -0
  3. package/dist/modules/auth/commands/users.js.map +2 -2
  4. package/dist/modules/communication_channels/data/enrichers.js +9 -1
  5. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  6. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js +3 -5
  7. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js.map +2 -2
  8. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.js.map +2 -2
  9. package/dist/modules/customers/api/pipeline-stages/reorder/route.js +39 -1
  10. package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +2 -2
  11. package/dist/modules/customers/api/pipeline-stages/route.js +107 -12
  12. package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
  13. package/dist/modules/customers/api/pipelines/route.js +107 -12
  14. package/dist/modules/customers/api/pipelines/route.js.map +2 -2
  15. package/dist/modules/customers/api/settings/address-format/route.js +33 -1
  16. package/dist/modules/customers/api/settings/address-format/route.js.map +2 -2
  17. package/dist/modules/customers/api/tags/assign/route.js +37 -0
  18. package/dist/modules/customers/api/tags/assign/route.js.map +2 -2
  19. package/dist/modules/customers/api/tags/unassign/route.js +37 -0
  20. package/dist/modules/customers/api/tags/unassign/route.js.map +2 -2
  21. package/dist/modules/entities/api/encryption.js +109 -38
  22. package/dist/modules/entities/api/encryption.js.map +2 -2
  23. package/dist/modules/entities/components/EncryptionManager.js +22 -9
  24. package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
  25. package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
  26. package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
  27. package/dist/modules/integrations/api/[id]/route.js +10 -3
  28. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  29. package/dist/modules/integrations/api/[id]/state/route.js +20 -5
  30. package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
  31. package/dist/modules/integrations/api/[id]/version/route.js +17 -1
  32. package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
  33. package/dist/modules/integrations/api/route.js +2 -0
  34. package/dist/modules/integrations/api/route.js.map +2 -2
  35. package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
  36. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  37. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
  38. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  39. package/dist/modules/integrations/backend/integrations/page.js +4 -3
  40. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  41. package/dist/modules/integrations/data/entities.js +2 -2
  42. package/dist/modules/integrations/data/entities.js.map +2 -2
  43. package/dist/modules/integrations/lib/credentials-service.js +32 -0
  44. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  45. package/dist/modules/integrations/lib/state-service.js +4 -2
  46. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  47. package/dist/modules/notifications/lib/notificationService.js +42 -10
  48. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  49. package/dist/modules/payment_gateways/api/status/route.js +91 -6
  50. package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
  51. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
  52. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
  53. package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
  54. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  55. package/dist/modules/sales/backend/sales/channels/page.js +25 -7
  56. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  57. package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
  58. package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
  59. package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
  60. package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
  61. package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
  62. package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
  63. package/dist/modules/sales/components/StatusSettings.js +66 -31
  64. package/dist/modules/sales/components/StatusSettings.js.map +2 -2
  65. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
  66. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
  67. package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
  68. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  69. package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
  70. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  71. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +7 -8
  72. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  73. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
  74. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
  75. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +4 -3
  76. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  77. package/dist/modules/workflows/backend/definitions/page.js +19 -10
  78. package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
  79. package/package.json +7 -7
  80. package/src/modules/auth/commands/users.ts +1 -0
  81. package/src/modules/communication_channels/data/enrichers.ts +25 -0
  82. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
  83. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
  84. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
  85. package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
  86. package/src/modules/customers/api/pipelines/route.ts +112 -13
  87. package/src/modules/customers/api/settings/address-format/route.ts +36 -1
  88. package/src/modules/customers/api/tags/assign/route.ts +39 -0
  89. package/src/modules/customers/api/tags/unassign/route.ts +39 -0
  90. package/src/modules/customers/i18n/de.json +1 -0
  91. package/src/modules/customers/i18n/en.json +1 -0
  92. package/src/modules/customers/i18n/es.json +1 -0
  93. package/src/modules/customers/i18n/pl.json +1 -0
  94. package/src/modules/entities/api/encryption.ts +122 -39
  95. package/src/modules/entities/components/EncryptionManager.tsx +28 -9
  96. package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
  97. package/src/modules/integrations/api/[id]/route.ts +8 -1
  98. package/src/modules/integrations/api/[id]/state/route.ts +20 -4
  99. package/src/modules/integrations/api/[id]/version/route.ts +18 -1
  100. package/src/modules/integrations/api/route.ts +3 -0
  101. package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
  102. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
  103. package/src/modules/integrations/backend/integrations/page.tsx +8 -5
  104. package/src/modules/integrations/data/entities.ts +2 -2
  105. package/src/modules/integrations/lib/credentials-service.ts +35 -0
  106. package/src/modules/integrations/lib/state-service.ts +3 -0
  107. package/src/modules/notifications/lib/notificationService.ts +74 -11
  108. package/src/modules/payment_gateways/api/status/route.ts +97 -5
  109. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
  110. package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
  111. package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
  112. package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
  113. package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
  114. package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
  115. package/src/modules/sales/components/StatusSettings.tsx +74 -33
  116. package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
  117. package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
  118. package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
  119. package/src/modules/staff/i18n/de.json +5 -5
  120. package/src/modules/staff/i18n/es.json +5 -5
  121. package/src/modules/staff/i18n/pl.json +5 -5
  122. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +7 -8
  123. package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
  124. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +4 -3
  125. package/src/modules/workflows/backend/definitions/page.tsx +19 -10
@@ -9,8 +9,14 @@ import { tagAssignmentSchema, type TagAssignmentInput } from '../../../data/vali
9
9
  import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
10
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
11
  import { withScopedPayload } from '../../utils'
12
+ import {
13
+ runCrudMutationGuardAfterSuccess,
14
+ validateCrudMutationGuard,
15
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
12
16
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
13
17
 
18
+ const TAG_ASSIGNMENT_RESOURCE_KIND = 'customers.tagAssignment'
19
+
14
20
  export const metadata = {
15
21
  POST: { requireAuth: true, requireFeatures: ['customers.activities.manage'] },
16
22
  }
@@ -37,15 +43,48 @@ async function buildContext(
37
43
  export async function POST(req: Request) {
38
44
  try {
39
45
  const { ctx, auth, translate } = await buildContext(req)
46
+ const tenantId = auth!.tenantId
47
+ const organizationId = ctx.selectedOrganizationId
48
+ if (!tenantId || !organizationId) {
49
+ return NextResponse.json({ error: translate('customers.errors.context_required', 'Organization and tenant context required') }, { status: 400 })
50
+ }
40
51
  const body = await req.json().catch(() => ({}))
41
52
  const scoped = withScopedPayload(body, ctx, translate)
42
53
  const input = tagAssignmentSchema.parse(scoped)
43
54
 
55
+ const guardResult = await validateCrudMutationGuard(ctx.container, {
56
+ tenantId,
57
+ organizationId,
58
+ userId: auth!.sub,
59
+ resourceKind: TAG_ASSIGNMENT_RESOURCE_KIND,
60
+ resourceId: input.entityId,
61
+ operation: 'custom',
62
+ requestMethod: req.method,
63
+ requestHeaders: req.headers,
64
+ mutationPayload: input,
65
+ })
66
+ if (guardResult && !guardResult.ok) {
67
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
68
+ }
69
+
44
70
  const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
45
71
  const { result, logEntry } = await commandBus.execute<TagAssignmentInput, { assignmentId: string | null }>(
46
72
  'customers.tags.unassign',
47
73
  { input, ctx },
48
74
  )
75
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
76
+ await runCrudMutationGuardAfterSuccess(ctx.container, {
77
+ tenantId,
78
+ organizationId,
79
+ userId: auth!.sub,
80
+ resourceKind: TAG_ASSIGNMENT_RESOURCE_KIND,
81
+ resourceId: input.entityId,
82
+ operation: 'custom',
83
+ requestMethod: req.method,
84
+ requestHeaders: req.headers,
85
+ metadata: guardResult.metadata ?? null,
86
+ })
87
+ }
49
88
  const response = NextResponse.json({ id: result?.assignmentId ?? null })
50
89
  if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
51
90
  response.headers.set(
@@ -1505,6 +1505,7 @@
1505
1505
  "customers.errors.company_not_found": "Unternehmen nicht gefunden",
1506
1506
  "customers.errors.company_people_load_failed": "Verknüpfte Personen konnten nicht geladen werden",
1507
1507
  "customers.errors.company_required": "Unternehmens-ID ist erforderlich",
1508
+ "customers.errors.context_required": "Organisations- und Mandantenkontext erforderlich",
1508
1509
  "customers.errors.customer_label_tables_missing": "Kundenlabel-Tabellen fehlen. Führen Sie yarn db:migrate aus.",
1509
1510
  "customers.errors.customer_not_found": "Kunde nicht gefunden",
1510
1511
  "customers.errors.deal_companies_load_failed": "Verknüpfte Unternehmen konnten nicht geladen werden",
@@ -1505,6 +1505,7 @@
1505
1505
  "customers.errors.company_not_found": "Company not found",
1506
1506
  "customers.errors.company_people_load_failed": "Failed to load linked people",
1507
1507
  "customers.errors.company_required": "Company id is required",
1508
+ "customers.errors.context_required": "Organization and tenant context required",
1508
1509
  "customers.errors.customer_label_tables_missing": "Customer label tables are missing. Run yarn db:migrate.",
1509
1510
  "customers.errors.customer_not_found": "Customer not found",
1510
1511
  "customers.errors.deal_companies_load_failed": "Failed to load linked companies",
@@ -1505,6 +1505,7 @@
1505
1505
  "customers.errors.company_not_found": "Empresa no encontrada",
1506
1506
  "customers.errors.company_people_load_failed": "No se pudieron cargar las personas vinculadas",
1507
1507
  "customers.errors.company_required": "Se requiere el ID de la empresa",
1508
+ "customers.errors.context_required": "Se requiere contexto de organización e inquilino",
1508
1509
  "customers.errors.customer_label_tables_missing": "Faltan las tablas de etiquetas de cliente. Ejecute yarn db:migrate.",
1509
1510
  "customers.errors.customer_not_found": "Cliente no encontrado",
1510
1511
  "customers.errors.deal_companies_load_failed": "No se pudieron cargar las empresas vinculadas",
@@ -1505,6 +1505,7 @@
1505
1505
  "customers.errors.company_not_found": "Nie znaleziono firmy",
1506
1506
  "customers.errors.company_people_load_failed": "Nie udało się załadować powiązanych osób",
1507
1507
  "customers.errors.company_required": "Wymagany identyfikator firmy",
1508
+ "customers.errors.context_required": "Wymagany jest kontekst organizacji i najemcy",
1508
1509
  "customers.errors.customer_label_tables_missing": "Brakuje tabel etykiet klientów. Uruchom yarn db:migrate.",
1509
1510
  "customers.errors.customer_not_found": "Nie znaleziono klienta",
1510
1511
  "customers.errors.deal_companies_load_failed": "Nie udało się załadować powiązanych firm",
@@ -4,8 +4,16 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { EncryptionMap } from '@open-mercato/core/modules/entities/data/entities'
6
6
  import { upsertEncryptionMapSchema } from '@open-mercato/core/modules/entities/data/validators'
7
+ import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
9
+ import {
10
+ runCrudMutationGuardAfterSuccess,
11
+ validateCrudMutationGuard,
12
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
7
13
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
8
14
 
15
+ const ENCRYPTION_MAP_RESOURCE_KIND = 'entities.encryption_map'
16
+
9
17
  export const metadata = {
10
18
  GET: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
11
19
  POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
@@ -18,6 +26,16 @@ function resolveScope(auth: { tenantId?: string | null; orgId?: string | null })
18
26
  }
19
27
  }
20
28
 
29
+ function toIsoOrNull(value: Date | string | null | undefined): string | null {
30
+ if (value == null) return null
31
+ if (value instanceof Date) {
32
+ const ms = value.getTime()
33
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null
34
+ }
35
+ const trimmed = String(value).trim()
36
+ return trimmed.length ? trimmed : null
37
+ }
38
+
21
39
  export async function GET(req: Request) {
22
40
  const url = new URL(req.url)
23
41
  const entityId = url.searchParams.get('entityId') || ''
@@ -26,8 +44,8 @@ export async function GET(req: Request) {
26
44
  if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
27
45
  const { tenantId, organizationId } = resolveScope(auth)
28
46
 
29
- const { resolve } = await createRequestContainer()
30
- const em = resolve('em') as any
47
+ const container = await createRequestContainer()
48
+ const em = container.resolve('em') as any
31
49
  const repo = em.getRepository(EncryptionMap)
32
50
  // Prefer tenant+org, then tenant-global, then global
33
51
  const candidates = [
@@ -51,52 +69,114 @@ export async function GET(req: Request) {
51
69
  organizationId,
52
70
  fields: record?.fieldsJson ?? [],
53
71
  isActive: record?.isActive ?? true,
72
+ updatedAt: toIsoOrNull(record?.updatedAt),
54
73
  })
55
74
  }
56
75
 
57
76
  export async function POST(req: Request) {
58
- const body = await req.json().catch(() => ({}))
59
- const parsed = upsertEncryptionMapSchema.safeParse(body)
60
- if (!parsed.success) {
61
- return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })
62
- }
63
- const auth = await getAuthFromRequest(req)
64
- if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
65
- const scope = resolveScope(auth)
66
- const payload = parsed.data
67
- const tenantId = scope.tenantId
68
- const organizationId = scope.organizationId
77
+ try {
78
+ const body = await req.json().catch(() => ({}))
79
+ const parsed = upsertEncryptionMapSchema.safeParse(body)
80
+ if (!parsed.success) {
81
+ return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })
82
+ }
83
+ const auth = await getAuthFromRequest(req)
84
+ if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
85
+ const scope = resolveScope(auth)
86
+ const payload = parsed.data
87
+ const tenantId: string = auth.tenantId
88
+ const organizationId = scope.organizationId
69
89
 
70
- const { resolve } = await createRequestContainer()
71
- const em = resolve('em') as any
72
- const repo = em.getRepository(EncryptionMap)
73
- const existing = await repo.findOne({ entityId: payload.entityId, tenantId, organizationId, deletedAt: null })
74
- if (existing) {
75
- existing.fieldsJson = payload.fields
76
- existing.isActive = payload.isActive ?? true
77
- existing.updatedAt = new Date()
78
- await em.persist(existing).flush()
79
- } else {
80
- const map = repo.create({
81
- entityId: payload.entityId,
90
+ const container = await createRequestContainer()
91
+ const em = container.resolve('em') as any
92
+ const repo = em.getRepository(EncryptionMap)
93
+ const existing = await repo.findOne({ entityId: payload.entityId, tenantId, organizationId, deletedAt: null })
94
+
95
+ // Reject stale writes: a save started from an older tab must not silently
96
+ // overwrite a newer encryption configuration. No-op when the client did not
97
+ // send the expected-version header (strictly additive).
98
+ if (existing) {
99
+ enforceCommandOptimisticLock({
100
+ resourceKind: ENCRYPTION_MAP_RESOURCE_KIND,
101
+ resourceId: existing.id,
102
+ current: existing.updatedAt,
103
+ request: req,
104
+ })
105
+ }
106
+
107
+ // Mutation-guard contract for custom write routes. The resource is the
108
+ // encryption map for this entity scoped to the tenant/organization.
109
+ const guardResult = await validateCrudMutationGuard(container, {
82
110
  tenantId,
83
111
  organizationId,
84
- fieldsJson: payload.fields,
85
- isActive: payload.isActive ?? true,
112
+ userId: auth.sub,
113
+ resourceKind: ENCRYPTION_MAP_RESOURCE_KIND,
114
+ resourceId: existing?.id ?? payload.entityId,
115
+ operation: existing ? 'update' : 'create',
116
+ requestMethod: req.method,
117
+ requestHeaders: req.headers,
118
+ mutationPayload: payload,
86
119
  })
87
- await em.persist(map).flush()
88
- }
120
+ if (guardResult && !guardResult.ok) {
121
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
122
+ }
89
123
 
90
- try {
91
- const svc = resolve('tenantEncryptionService') as { invalidateMap?: (e: string, t: string | null, o: string | null) => Promise<void> }
92
- await svc?.invalidateMap?.(payload.entityId, tenantId, organizationId)
93
- } catch {
94
- // best-effort cache bust
95
- }
124
+ let saved: any
125
+ if (existing) {
126
+ existing.fieldsJson = payload.fields
127
+ existing.isActive = payload.isActive ?? true
128
+ existing.updatedAt = new Date()
129
+ await em.persist(existing).flush()
130
+ saved = existing
131
+ } else {
132
+ const map = repo.create({
133
+ entityId: payload.entityId,
134
+ tenantId,
135
+ organizationId,
136
+ fieldsJson: payload.fields,
137
+ isActive: payload.isActive ?? true,
138
+ })
139
+ await em.persist(map).flush()
140
+ saved = map
141
+ }
142
+
143
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
144
+ await runCrudMutationGuardAfterSuccess(container, {
145
+ tenantId,
146
+ organizationId,
147
+ userId: auth.sub,
148
+ resourceKind: ENCRYPTION_MAP_RESOURCE_KIND,
149
+ resourceId: saved?.id ?? payload.entityId,
150
+ operation: existing ? 'update' : 'create',
151
+ requestMethod: req.method,
152
+ requestHeaders: req.headers,
153
+ metadata: guardResult.metadata ?? null,
154
+ })
155
+ }
156
+
157
+ try {
158
+ const svc = container.resolve('tenantEncryptionService') as { invalidateMap?: (e: string, t: string | null, o: string | null) => Promise<void> }
159
+ await svc?.invalidateMap?.(payload.entityId, tenantId, organizationId)
160
+ } catch {
161
+ // best-effort cache bust
162
+ }
96
163
 
97
- return NextResponse.json({ ok: true })
164
+ return NextResponse.json({ ok: true, updatedAt: toIsoOrNull(saved?.updatedAt) })
165
+ } catch (err) {
166
+ if (isCrudHttpError(err)) {
167
+ return NextResponse.json(err.body, { status: err.status })
168
+ }
169
+ throw err
170
+ }
98
171
  }
99
172
 
173
+ const conflictResponseSchema = z.object({
174
+ error: z.string(),
175
+ code: z.string(),
176
+ currentUpdatedAt: z.string(),
177
+ expectedUpdatedAt: z.string(),
178
+ })
179
+
100
180
  export const openApi: OpenApiRouteDoc = {
101
181
  tag: 'Entities',
102
182
  summary: 'Manage encryption maps',
@@ -105,13 +185,16 @@ export const openApi: OpenApiRouteDoc = {
105
185
  summary: 'Fetch encryption map',
106
186
  description: 'Returns the encrypted field map for the current tenant/organization scope.',
107
187
  query: z.object({ entityId: z.string() }),
108
- responses: [{ status: 200, description: 'Map', schema: z.object({ entityId: z.string(), fields: z.array(z.object({ field: z.string(), hashField: z.string().nullable().optional() })), isActive: z.boolean().optional() }) }],
188
+ responses: [{ status: 200, description: 'Map', schema: z.object({ entityId: z.string(), fields: z.array(z.object({ field: z.string(), hashField: z.string().nullable().optional() })), isActive: z.boolean().optional(), updatedAt: z.string().nullable().optional() }) }],
109
189
  },
110
190
  POST: {
111
191
  summary: 'Upsert encryption map',
112
- description: 'Creates or updates the encryption map for the current tenant/organization scope.',
192
+ description: 'Creates or updates the encryption map for the current tenant/organization scope. Enforces optimistic locking when the caller sends the expected version header.',
113
193
  requestBody: { contentType: 'application/json', schema: upsertEncryptionMapSchema },
114
- responses: [{ status: 200, description: 'Saved', schema: z.object({ ok: z.boolean() }) }],
194
+ responses: [
195
+ { status: 200, description: 'Saved', schema: z.object({ ok: z.boolean(), updatedAt: z.string().nullable().optional() }) },
196
+ { status: 409, description: 'Optimistic-lock conflict (stale write)', schema: conflictResponseSchema },
197
+ ],
115
198
  },
116
199
  },
117
200
  }
@@ -13,8 +13,10 @@ import {
13
13
  } from '@open-mercato/ui/primitives/select'
14
14
  import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
15
15
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
- import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
16
+ import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
17
17
  import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
18
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
19
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
18
20
  import { useCustomFieldDefs, type CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
19
21
  import { Plus, Save, Trash2 } from 'lucide-react'
20
22
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
@@ -34,6 +36,7 @@ type EncryptionMapResponse = {
34
36
  entityId: string
35
37
  fields?: Array<{ field: string; hashField?: string | null }>
36
38
  isActive?: boolean
39
+ updatedAt?: string | null
37
40
  }
38
41
 
39
42
  type CanonicalOption = { value: string; label?: string }
@@ -225,6 +228,8 @@ export function EncryptionManager() {
225
228
  lastMapSignatureRef.current = signature
226
229
  }, [mapSignature, map, canonicalOptions, hasUserEdited])
227
230
 
231
+ const { runMutation } = useGuardedMutation({ contextId: 'entities.encryption-map' })
232
+
228
233
  const mutation = useMutation({
229
234
  mutationFn: async () => {
230
235
  const trimmed = fields
@@ -243,15 +248,29 @@ export function EncryptionManager() {
243
248
  if (!trimmed.length) {
244
249
  throw new Error(t('entities.encryption.errors.noFields', 'Add at least one field to encrypt'))
245
250
  }
246
- const res = await apiCall('/api/entities/encryption', {
247
- method: 'POST',
248
- headers: { 'content-type': 'application/json' },
249
- body: JSON.stringify({ entityId: selectedEntityId, fields: trimmed, isActive }),
251
+ const payload = { entityId: selectedEntityId, fields: trimmed, isActive }
252
+ // Route the write through the guarded mutation helper so global injection
253
+ // modules (mutation guard, record-lock conflict handling) can run, and send
254
+ // the loaded map's version so a stale tab cannot silently overwrite a newer
255
+ // encryption configuration (the server rejects mismatches with a 409).
256
+ return runMutation({
257
+ operation: async () => {
258
+ const res = await withScopedApiRequestHeaders(
259
+ buildOptimisticLockHeader(map?.updatedAt ?? null),
260
+ () => apiCall('/api/entities/encryption', {
261
+ method: 'POST',
262
+ headers: { 'content-type': 'application/json' },
263
+ body: JSON.stringify(payload),
264
+ }),
265
+ )
266
+ if (!res.ok) {
267
+ await raiseCrudError(res.response, t('entities.encryption.errors.save', 'Failed to save encryption map'))
268
+ }
269
+ return true
270
+ },
271
+ context: {},
272
+ mutationPayload: payload,
250
273
  })
251
- if (!res.ok) {
252
- await raiseCrudError(res.response, t('entities.encryption.errors.save', 'Failed to save encryption map'))
253
- }
254
- return true
255
274
  },
256
275
  onSuccess: () => {
257
276
  flash(t('entities.encryption.flash.saved', 'Encryption map saved'), 'success')
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { getIntegration } from '@open-mercato/shared/modules/integrations/types'
6
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
7
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
8
  import { emitIntegrationsEvent } from '../../../events'
7
9
  import { saveCredentialsSchema } from '../../../data/validators'
8
10
  import {
@@ -58,8 +60,10 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
58
60
  const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
59
61
 
60
62
  let values: Record<string, unknown> | null
63
+ let updatedAt: Date | null
61
64
  try {
62
65
  values = await credentialsService.resolve(integration.id, scope)
66
+ updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)
63
67
  } catch (error) {
64
68
  if (isCredentialsEncryptionUnavailableError(error)) {
65
69
  return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })
@@ -71,6 +75,7 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
71
75
  integrationId: integration.id,
72
76
  schema: credentialsService.getSchema(integration.id),
73
77
  credentials: values ?? {},
78
+ updatedAt: updatedAt?.toISOString() ?? null,
74
79
  })
75
80
  }
76
81
 
@@ -130,6 +135,24 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
130
135
  const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService
131
136
  const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
132
137
 
138
+ try {
139
+ const currentUpdatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)
140
+ enforceCommandOptimisticLock({
141
+ resourceKind: 'integrations.integration',
142
+ resourceId: integration.id,
143
+ current: currentUpdatedAt,
144
+ request: req,
145
+ })
146
+ } catch (error) {
147
+ if (isCrudHttpError(error)) {
148
+ return NextResponse.json(error.body, { status: error.status })
149
+ }
150
+ if (isCredentialsEncryptionUnavailableError(error)) {
151
+ return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })
152
+ }
153
+ throw error
154
+ }
155
+
133
156
  const credentialFieldErrors = collectCredentialUrlValidationErrors(
134
157
  credentialsService.getSchema(integration.id),
135
158
  payloadData.credentials,
@@ -59,11 +59,15 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
59
59
  const logService = container.resolve('integrationLogService') as IntegrationLogService
60
60
  const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
61
61
 
62
- const [credentials, state, analyticsMap] = await Promise.all([
62
+ const [credentials, credentialsUpdatedAt, state, analyticsMap] = await Promise.all([
63
63
  credentialsService.resolve(integration.id, scope).catch((err) => {
64
64
  if (err instanceof CredentialsEncryptionUnavailableError) return null
65
65
  throw err
66
66
  }),
67
+ credentialsService.resolveUpdatedAt(integration.id, scope).catch((err) => {
68
+ if (err instanceof CredentialsEncryptionUnavailableError) return null
69
+ throw err
70
+ }),
67
71
  stateService.resolveState(integration.id, scope),
68
72
  logService.aggregateAnalytics([integration.id], scope, 30),
69
73
  ])
@@ -102,6 +106,7 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
102
106
  lastHealthCheckedAt: resolvedState.lastHealthCheckedAt?.toISOString() ?? null,
103
107
  lastHealthLatencyMs: resolvedState.lastHealthLatencyMs,
104
108
  enabledAt: resolvedState.enabledAt?.toISOString() ?? null,
109
+ updatedAt: resolvedState.updatedAt?.toISOString() ?? null,
105
110
  },
106
111
  }
107
112
  }),
@@ -132,8 +137,10 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
132
137
  lastHealthCheckedAt: state.lastHealthCheckedAt?.toISOString() ?? null,
133
138
  lastHealthLatencyMs: state.lastHealthLatencyMs,
134
139
  enabledAt: state.enabledAt?.toISOString() ?? null,
140
+ updatedAt: state.updatedAt?.toISOString() ?? null,
135
141
  },
136
142
  hasCredentials,
143
+ credentialsUpdatedAt: credentialsUpdatedAt?.toISOString() ?? null,
137
144
  healthStatus,
138
145
  analytics,
139
146
  },
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { getIntegration } from '@open-mercato/shared/modules/integrations/types'
6
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
7
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
8
  import { emitIntegrationsEvent } from '../../../events'
7
9
  import { updateStateSchema } from '../../../data/validators'
8
10
  import type { IntegrationStateService } from '../../../lib/state-service'
@@ -80,6 +82,22 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
80
82
  }
81
83
 
82
84
  const stateService = container.resolve('integrationStateService') as IntegrationStateService
85
+ const stateScope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
86
+
87
+ try {
88
+ const current = await stateService.resolveState(integration.id, stateScope)
89
+ enforceCommandOptimisticLock({
90
+ resourceKind: 'integrations.integration',
91
+ resourceId: integration.id,
92
+ current: current.updatedAt,
93
+ request: req,
94
+ })
95
+ } catch (error) {
96
+ if (isCrudHttpError(error)) {
97
+ return NextResponse.json(error.body, { status: error.status })
98
+ }
99
+ throw error
100
+ }
83
101
 
84
102
  const state = await stateService.upsert(
85
103
  integration.id,
@@ -87,10 +105,7 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
87
105
  isEnabled: payloadData.isEnabled,
88
106
  reauthRequired: payloadData.reauthRequired,
89
107
  },
90
- {
91
- organizationId: auth.orgId as string,
92
- tenantId: auth.tenantId,
93
- },
108
+ stateScope,
94
109
  )
95
110
 
96
111
  await emitIntegrationsEvent('integrations.state.updated', {
@@ -117,5 +132,6 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
117
132
  isEnabled: state.isEnabled,
118
133
  reauthRequired: state.reauthRequired,
119
134
  apiVersion: state.apiVersion ?? null,
135
+ updatedAt: state.updatedAt?.toISOString() ?? null,
120
136
  })
121
137
  }
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { getIntegration } from '@open-mercato/shared/modules/integrations/types'
6
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
7
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
8
  import { emitIntegrationsEvent } from '../../../events'
7
9
  import { updateVersionSchema } from '../../../data/validators'
8
10
  import type { IntegrationStateService } from '../../../lib/state-service'
@@ -102,7 +104,22 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
102
104
  const stateService = container.resolve('integrationStateService') as IntegrationStateService
103
105
  const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
104
106
 
105
- const before = await stateService.resolveApiVersion(integration.id, scope)
107
+ const currentState = await stateService.resolveState(integration.id, scope)
108
+ try {
109
+ enforceCommandOptimisticLock({
110
+ resourceKind: 'integrations.integration',
111
+ resourceId: integration.id,
112
+ current: currentState.updatedAt,
113
+ request: req,
114
+ })
115
+ } catch (error) {
116
+ if (isCrudHttpError(error)) {
117
+ return NextResponse.json(error.body, { status: error.status })
118
+ }
119
+ throw error
120
+ }
121
+
122
+ const before = currentState.apiVersion
106
123
  await stateService.upsert(integration.id, { apiVersion: payloadData.apiVersion }, scope)
107
124
 
108
125
  await emitIntegrationsEvent('integrations.version.changed', {
@@ -93,6 +93,7 @@ export async function GET(req: Request) {
93
93
  lastHealthCheckedAt: string | null
94
94
  lastHealthLatencyMs: number | null
95
95
  enabledAt: string | null
96
+ stateUpdatedAt: string | null
96
97
  sortEnabledAtMs: number
97
98
  sortTitle: string
98
99
  sortCategory: string
@@ -140,6 +141,7 @@ export async function GET(req: Request) {
140
141
  lastHealthCheckedAt: state.lastHealthCheckedAt?.toISOString() ?? null,
141
142
  lastHealthLatencyMs: state.lastHealthLatencyMs,
142
143
  enabledAt: state.enabledAt?.toISOString() ?? null,
144
+ stateUpdatedAt: state.updatedAt?.toISOString() ?? null,
143
145
  sortEnabledAtMs: enabledAtMs,
144
146
  sortTitle: integration.title.toLowerCase(),
145
147
  sortCategory: (integration.category ?? '').toLowerCase(),
@@ -220,6 +222,7 @@ export async function GET(req: Request) {
220
222
  lastHealthCheckedAt: row.lastHealthCheckedAt,
221
223
  lastHealthLatencyMs: row.lastHealthLatencyMs,
222
224
  enabledAt: row.enabledAt,
225
+ stateUpdatedAt: row.stateUpdatedAt,
223
226
  analytics: analytics ?? {
224
227
  lastActivityAt: null,
225
228
  totalCount: 0,