@open-mercato/core 0.6.6-develop.6204.1.30b1f58642 → 0.6.6-develop.6227.1.2695efff30

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 (201) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/currencies/backend/currencies/[id]/page.js +33 -8
  3. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  4. package/dist/modules/currencies/backend/currencies/page.js +63 -27
  5. package/dist/modules/currencies/backend/currencies/page.js.map +2 -2
  6. package/dist/modules/currencies/backend/exchange-rates/page.js +35 -14
  7. package/dist/modules/currencies/backend/exchange-rates/page.js.map +2 -2
  8. package/dist/modules/currencies/components/CurrencyFetchingConfig.js +104 -41
  9. package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
  10. package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js +57 -8
  11. package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js.map +2 -2
  12. package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js +5 -2
  13. package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js.map +2 -2
  14. package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js +30 -14
  15. package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js.map +2 -2
  16. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +29 -19
  17. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
  18. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +166 -64
  19. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
  20. package/dist/modules/customers/components/AddressFormatSettings.js +31 -16
  21. package/dist/modules/customers/components/AddressFormatSettings.js.map +2 -2
  22. package/dist/modules/customers/components/DictionarySettings.js +57 -31
  23. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  24. package/dist/modules/customers/components/PipelineSettings.js +156 -76
  25. package/dist/modules/customers/components/PipelineSettings.js.map +2 -2
  26. package/dist/modules/customers/components/calendar/AgendaList.js +10 -4
  27. package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
  28. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +18 -2
  29. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  30. package/dist/modules/customers/components/calendar/MonthGrid.js +5 -2
  31. package/dist/modules/customers/components/calendar/MonthGrid.js.map +2 -2
  32. package/dist/modules/customers/components/calendar/editor/PeopleField.js +6 -0
  33. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  34. package/dist/modules/customers/components/calendar/editor/RelatedToField.js +2 -0
  35. package/dist/modules/customers/components/calendar/editor/RelatedToField.js.map +2 -2
  36. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +4 -1
  37. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  38. package/dist/modules/customers/lib/calendar/labels.js +33 -0
  39. package/dist/modules/customers/lib/calendar/labels.js.map +7 -0
  40. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +32 -11
  41. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
  42. package/dist/modules/data_sync/api/mappings/[id]/route.js +59 -0
  43. package/dist/modules/data_sync/api/mappings/[id]/route.js.map +2 -2
  44. package/dist/modules/data_sync/api/mappings/route.js +44 -0
  45. package/dist/modules/data_sync/api/mappings/route.js.map +2 -2
  46. package/dist/modules/data_sync/api/run.js +31 -0
  47. package/dist/modules/data_sync/api/run.js.map +2 -2
  48. package/dist/modules/data_sync/api/runs/[id]/cancel.js +31 -0
  49. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  50. package/dist/modules/data_sync/api/runs/[id]/retry.js +31 -0
  51. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
  52. package/dist/modules/data_sync/api/schedules/[id]/route.js +59 -3
  53. package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
  54. package/dist/modules/data_sync/api/schedules/route.js +33 -4
  55. package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
  56. package/dist/modules/dictionaries/components/DictionariesManager.js +7 -4
  57. package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
  58. package/dist/modules/directory/api/organization-branding/route.js +54 -2
  59. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  60. package/dist/modules/directory/backend/directory/branding/page.js +15 -9
  61. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  62. package/dist/modules/directory/backend/directory/organizations/page.js +28 -9
  63. package/dist/modules/directory/backend/directory/organizations/page.js.map +2 -2
  64. package/dist/modules/directory/backend/directory/tenants/page.js +29 -13
  65. package/dist/modules/directory/backend/directory/tenants/page.js.map +2 -2
  66. package/dist/modules/entities/api/definitions.batch.js +15 -0
  67. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  68. package/dist/modules/entities/api/definitions.js +26 -0
  69. package/dist/modules/entities/api/definitions.js.map +2 -2
  70. package/dist/modules/entities/api/definitions.mutation-guard.js +57 -0
  71. package/dist/modules/entities/api/definitions.mutation-guard.js.map +7 -0
  72. package/dist/modules/entities/api/definitions.restore.js +15 -0
  73. package/dist/modules/entities/api/definitions.restore.js.map +2 -2
  74. package/dist/modules/entities/api/entities.js +31 -3
  75. package/dist/modules/entities/api/entities.js.map +2 -2
  76. package/dist/modules/entities/api/records.js +18 -0
  77. package/dist/modules/entities/api/records.js.map +2 -2
  78. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +28 -10
  79. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  80. package/dist/modules/feature_toggles/api/overrides/route.js +38 -1
  81. package/dist/modules/feature_toggles/api/overrides/route.js.map +2 -2
  82. package/dist/modules/inbox_ops/api/emails/[id]/route.js +30 -0
  83. package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
  84. package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js +30 -0
  85. package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js.map +2 -2
  86. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +31 -0
  87. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  88. package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js +31 -0
  89. package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js.map +2 -2
  90. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +68 -39
  91. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
  92. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +14 -4
  93. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
  94. package/dist/modules/payment_gateways/api/cancel/route.js +37 -0
  95. package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
  96. package/dist/modules/payment_gateways/api/capture/route.js +37 -0
  97. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  98. package/dist/modules/payment_gateways/api/guards.js +31 -0
  99. package/dist/modules/payment_gateways/api/guards.js.map +7 -0
  100. package/dist/modules/payment_gateways/api/refund/route.js +37 -0
  101. package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
  102. package/dist/modules/payment_gateways/api/sessions/route.js +37 -0
  103. package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
  104. package/dist/modules/planner/api/availability-date-specific.js +11 -1
  105. package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
  106. package/dist/modules/planner/backend/planner/availability-rulesets/page.js +20 -3
  107. package/dist/modules/planner/backend/planner/availability-rulesets/page.js.map +2 -2
  108. package/dist/modules/planner/commands/availability-date-specific.js +16 -0
  109. package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
  110. package/dist/modules/planner/components/AvailabilityRulesEditor.js +109 -40
  111. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  112. package/dist/modules/query_index/api/purge.js +35 -3
  113. package/dist/modules/query_index/api/purge.js.map +2 -2
  114. package/dist/modules/query_index/api/reindex.js +41 -3
  115. package/dist/modules/query_index/api/reindex.js.map +2 -2
  116. package/dist/modules/query_index/components/QueryIndexesTable.js +57 -24
  117. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  118. package/dist/modules/shipping_carriers/api/shipments/route.js +31 -0
  119. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  120. package/dist/modules/shipping_carriers/api/tracking/refresh/route.js +55 -0
  121. package/dist/modules/shipping_carriers/api/tracking/refresh/route.js.map +7 -0
  122. package/dist/modules/shipping_carriers/data/validators.js +6 -1
  123. package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
  124. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +28 -8
  125. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
  126. package/dist/modules/shipping_carriers/lib/shipping-service.js +37 -7
  127. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  128. package/dist/modules/shipping_carriers/workers/status-poller.js +1 -1
  129. package/dist/modules/shipping_carriers/workers/status-poller.js.map +2 -2
  130. package/dist/modules/translations/components/TranslationManager.js +49 -20
  131. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  132. package/package.json +7 -7
  133. package/src/modules/currencies/backend/currencies/[id]/page.tsx +40 -10
  134. package/src/modules/currencies/backend/currencies/page.tsx +68 -29
  135. package/src/modules/currencies/backend/exchange-rates/page.tsx +40 -15
  136. package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +110 -41
  137. package/src/modules/customer_accounts/api/admin/roles/[id]/acl.ts +69 -7
  138. package/src/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.tsx +16 -8
  139. package/src/modules/customer_accounts/backend/customer_accounts/roles/page.tsx +32 -14
  140. package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +32 -20
  141. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +171 -64
  142. package/src/modules/customers/components/AddressFormatSettings.tsx +39 -19
  143. package/src/modules/customers/components/DictionarySettings.tsx +63 -29
  144. package/src/modules/customers/components/PipelineSettings.tsx +165 -80
  145. package/src/modules/customers/components/calendar/AgendaList.tsx +13 -8
  146. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +16 -2
  147. package/src/modules/customers/components/calendar/MonthGrid.tsx +5 -2
  148. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +6 -0
  149. package/src/modules/customers/components/calendar/editor/RelatedToField.tsx +2 -0
  150. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +7 -0
  151. package/src/modules/customers/i18n/de.json +11 -7
  152. package/src/modules/customers/i18n/en.json +6 -2
  153. package/src/modules/customers/i18n/es.json +11 -7
  154. package/src/modules/customers/i18n/pl.json +11 -7
  155. package/src/modules/customers/lib/calendar/labels.ts +42 -0
  156. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +39 -11
  157. package/src/modules/data_sync/api/mappings/[id]/route.ts +63 -0
  158. package/src/modules/data_sync/api/mappings/route.ts +48 -0
  159. package/src/modules/data_sync/api/run.ts +33 -0
  160. package/src/modules/data_sync/api/runs/[id]/cancel.ts +34 -0
  161. package/src/modules/data_sync/api/runs/[id]/retry.ts +33 -0
  162. package/src/modules/data_sync/api/schedules/[id]/route.ts +64 -2
  163. package/src/modules/data_sync/api/schedules/route.ts +36 -4
  164. package/src/modules/dictionaries/components/DictionariesManager.tsx +7 -4
  165. package/src/modules/directory/api/organization-branding/route.ts +61 -0
  166. package/src/modules/directory/backend/directory/branding/page.tsx +16 -10
  167. package/src/modules/directory/backend/directory/organizations/page.tsx +35 -8
  168. package/src/modules/directory/backend/directory/tenants/page.tsx +37 -13
  169. package/src/modules/entities/api/definitions.batch.ts +17 -0
  170. package/src/modules/entities/api/definitions.mutation-guard.ts +80 -0
  171. package/src/modules/entities/api/definitions.restore.ts +17 -0
  172. package/src/modules/entities/api/definitions.ts +30 -0
  173. package/src/modules/entities/api/entities.ts +35 -3
  174. package/src/modules/entities/api/records.ts +20 -0
  175. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +33 -10
  176. package/src/modules/feature_toggles/api/overrides/route.ts +44 -1
  177. package/src/modules/inbox_ops/api/emails/[id]/route.ts +32 -0
  178. package/src/modules/inbox_ops/api/proposals/[id]/accept-all/route.ts +32 -0
  179. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +33 -0
  180. package/src/modules/inbox_ops/api/proposals/[id]/categorize/route.ts +33 -0
  181. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +80 -42
  182. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +21 -4
  183. package/src/modules/payment_gateways/api/cancel/route.ts +39 -0
  184. package/src/modules/payment_gateways/api/capture/route.ts +39 -0
  185. package/src/modules/payment_gateways/api/guards.ts +59 -0
  186. package/src/modules/payment_gateways/api/refund/route.ts +39 -0
  187. package/src/modules/payment_gateways/api/sessions/route.ts +40 -0
  188. package/src/modules/planner/api/availability-date-specific.ts +10 -0
  189. package/src/modules/planner/backend/planner/availability-rulesets/page.tsx +26 -5
  190. package/src/modules/planner/commands/availability-date-specific.ts +24 -0
  191. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +122 -41
  192. package/src/modules/query_index/api/purge.ts +37 -3
  193. package/src/modules/query_index/api/reindex.ts +43 -3
  194. package/src/modules/query_index/components/QueryIndexesTable.tsx +66 -24
  195. package/src/modules/shipping_carriers/api/shipments/route.ts +31 -0
  196. package/src/modules/shipping_carriers/api/tracking/refresh/route.ts +53 -0
  197. package/src/modules/shipping_carriers/data/validators.ts +5 -0
  198. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +29 -8
  199. package/src/modules/shipping_carriers/lib/shipping-service.ts +43 -7
  200. package/src/modules/shipping_carriers/workers/status-poller.ts +1 -1
  201. package/src/modules/translations/components/TranslationManager.tsx +65 -21
@@ -7,6 +7,10 @@ import type { SyncScheduleService } from '../../lib/sync-schedule-service'
7
7
  import { serializeSchedule } from './serialize'
8
8
  import { readOptimisticLockExpected } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
9
9
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import {
11
+ runCrudMutationGuardAfterSuccess,
12
+ validateCrudMutationGuard,
13
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
10
14
 
11
15
  export const metadata = {
12
16
  GET: { requireAuth: true, requireFeatures: ['data_sync.configure'] },
@@ -65,15 +69,43 @@ export async function POST(req: Request) {
65
69
 
66
70
  const container = await createRequestContainer()
67
71
  const scheduleService = container.resolve('dataSyncScheduleService') as SyncScheduleService
72
+ const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
73
+
74
+ const guardResult = await validateCrudMutationGuard(container, {
75
+ tenantId: auth.tenantId,
76
+ organizationId: scope.organizationId,
77
+ userId: auth.sub,
78
+ resourceKind: 'data_sync.schedule',
79
+ resourceId: scope.organizationId,
80
+ operation: 'create',
81
+ requestMethod: req.method,
82
+ requestHeaders: req.headers,
83
+ mutationPayload: parsed.data,
84
+ })
85
+ if (guardResult && !guardResult.ok) {
86
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
87
+ }
68
88
 
69
89
  try {
70
90
  const schedule = await scheduleService.saveSchedule({
71
91
  ...parsed.data,
72
92
  expectedUpdatedAt: readOptimisticLockExpected(req),
73
- }, {
74
- organizationId: auth.orgId as string,
75
- tenantId: auth.tenantId,
76
- })
93
+ }, scope)
94
+
95
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
96
+ await runCrudMutationGuardAfterSuccess(container, {
97
+ tenantId: auth.tenantId,
98
+ organizationId: scope.organizationId,
99
+ userId: auth.sub,
100
+ resourceKind: 'data_sync.schedule',
101
+ resourceId: schedule.id,
102
+ operation: 'create',
103
+ requestMethod: req.method,
104
+ requestHeaders: req.headers,
105
+ metadata: guardResult.metadata ?? null,
106
+ })
107
+ }
108
+
77
109
  return NextResponse.json(serializeSchedule(schedule), { status: 201 })
78
110
  } catch (error) {
79
111
  if (isCrudHttpError(error)) {
@@ -131,16 +131,19 @@ export function DictionariesManager() {
131
131
  : []
132
132
  const filtered = list.filter((dictionary: DictionarySummary) => dictionary.managerVisibility !== 'hidden')
133
133
  setItems(filtered)
134
- if (!filtered.find((dict: DictionarySummary) => dict.id === selectedId)) {
135
- setSelectedId(filtered.length ? filtered[0].id : null)
136
- }
134
+ setSelectedId((current) => {
135
+ if (current && filtered.some((dict: DictionarySummary) => dict.id === current)) {
136
+ return current
137
+ }
138
+ return filtered.length ? filtered[0].id : null
139
+ })
137
140
  } catch (err) {
138
141
  console.error('Failed to load dictionaries', err)
139
142
  flash(t('dictionaries.config.error.load', 'Failed to load dictionaries.'), 'error')
140
143
  } finally {
141
144
  setLoading(false)
142
145
  }
143
- }, [selectedId, t])
146
+ }, [t])
144
147
 
145
148
  React.useEffect(() => {
146
149
  loadDictionaries().catch(() => {})
@@ -7,6 +7,11 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
7
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
8
8
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
9
9
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import {
11
+ runCrudMutationGuardAfterSuccess,
12
+ validateCrudMutationGuard,
13
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
14
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
10
15
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
16
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
12
17
  import { Organization } from '@open-mercato/core/modules/directory/data/entities'
@@ -24,6 +29,7 @@ const brandingResponseSchema = z.object({
24
29
  organizationName: z.string(),
25
30
  tenantId: z.string().uuid(),
26
31
  logoUrl: z.string().nullable(),
32
+ updatedAt: z.string().nullable(),
27
33
  })
28
34
 
29
35
  const brandingUpdateSchema = z.object({
@@ -105,12 +111,25 @@ async function resolveCurrentOrganization(req: Request) {
105
111
  return { auth, container, organization, organizationId, tenantId, translate }
106
112
  }
107
113
 
114
+ function toIsoOrNull(value: Date | string | null | undefined): string | null {
115
+ if (value == null) return null
116
+ if (value instanceof Date) {
117
+ const ms = value.getTime()
118
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null
119
+ }
120
+ const trimmed = String(value).trim()
121
+ if (!trimmed) return null
122
+ const parsed = new Date(trimmed)
123
+ return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null
124
+ }
125
+
108
126
  function toResponsePayload(organization: Organization, tenantId: string) {
109
127
  return {
110
128
  organizationId: String(organization.id),
111
129
  organizationName: organization.name,
112
130
  tenantId,
113
131
  logoUrl: organization.logoUrl ?? null,
132
+ updatedAt: toIsoOrNull(organization.updatedAt),
114
133
  }
115
134
  }
116
135
 
@@ -169,6 +188,32 @@ export async function PUT(req: Request) {
169
188
  }
170
189
 
171
190
  try {
191
+ // Refuse a stale overwrite when two tabs edit the same organization branding.
192
+ // Strictly additive — a no-op when the client omits the expected-version header.
193
+ enforceCommandOptimisticLock({
194
+ resourceKind: 'directory.organization',
195
+ resourceId: resolved.organizationId,
196
+ current: resolved.organization.updatedAt ?? null,
197
+ request: req,
198
+ })
199
+
200
+ // Mutation-guard contract for custom write routes: validate before the write,
201
+ // then run the after-success hook once the command persists.
202
+ const guardResult = await validateCrudMutationGuard(resolved.container, {
203
+ tenantId: resolved.tenantId,
204
+ organizationId: resolved.organizationId,
205
+ userId: resolved.auth.sub,
206
+ resourceKind: 'directory.organization',
207
+ resourceId: resolved.organizationId,
208
+ operation: 'update',
209
+ requestMethod: req.method,
210
+ requestHeaders: req.headers,
211
+ mutationPayload: { logoUrl: parsed.data.logoUrl ?? null },
212
+ })
213
+ if (guardResult && !guardResult.ok) {
214
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
215
+ }
216
+
172
217
  const commandBus = resolved.container.resolve('commandBus') as CommandBus
173
218
  const ctx = buildCommandContext(
174
219
  resolved.container,
@@ -188,6 +233,21 @@ export async function PUT(req: Request) {
188
233
  ctx,
189
234
  },
190
235
  )
236
+
237
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
238
+ await runCrudMutationGuardAfterSuccess(resolved.container, {
239
+ tenantId: resolved.tenantId,
240
+ organizationId: resolved.organizationId,
241
+ userId: resolved.auth.sub,
242
+ resourceKind: 'directory.organization',
243
+ resourceId: resolved.organizationId,
244
+ operation: 'update',
245
+ requestMethod: req.method,
246
+ requestHeaders: req.headers,
247
+ metadata: guardResult.metadata ?? null,
248
+ })
249
+ }
250
+
191
251
  await invalidateSidebarBrandingCache(resolved.container, resolved.organizationId, resolved.tenantId)
192
252
  return NextResponse.json(toResponsePayload(result, resolved.tenantId))
193
253
  } catch (err) {
@@ -231,6 +291,7 @@ export const openApi: OpenApiRouteDoc = {
231
291
  errors: [
232
292
  { status: 400, description: 'Save failed', schema: errorSchema },
233
293
  { status: 401, description: 'Unauthorized', schema: errorSchema },
294
+ { status: 409, description: 'Organization branding changed since it was loaded', schema: errorSchema },
234
295
  { status: 422, description: 'Invalid logo URL', schema: errorSchema },
235
296
  ],
236
297
  },
@@ -7,7 +7,9 @@ import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'
7
7
  import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
8
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
9
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
10
- import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
10
+ import { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
11
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
12
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
11
13
  import { Button } from '@open-mercato/ui/primitives/button'
12
14
  import { Input } from '@open-mercato/ui/primitives/input'
13
15
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -17,6 +19,7 @@ type BrandingPayload = {
17
19
  organizationName: string
18
20
  tenantId: string
19
21
  logoUrl: string | null
22
+ updatedAt: string | null
20
23
  }
21
24
 
22
25
  type UploadPayload = {
@@ -98,15 +101,17 @@ export default function OrganizationBrandingPage() {
98
101
  operation: async () => {
99
102
  const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null
100
103
  const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()
101
- // optimistic-lock-exempt: selected organization branding uses a scoped command endpoint without an exposed updatedAt token.
102
- const response = await apiCallOrThrow<BrandingPayload>(
103
- BRANDING_API,
104
- {
105
- method: 'PUT',
106
- headers: { 'content-type': 'application/json' },
107
- body: JSON.stringify({ logoUrl: resolvedLogoUrl || null }),
108
- },
109
- { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },
104
+ const response = await withScopedApiRequestHeaders(
105
+ buildOptimisticLockHeader(data.updatedAt),
106
+ () => apiCallOrThrow<BrandingPayload>(
107
+ BRANDING_API,
108
+ {
109
+ method: 'PUT',
110
+ headers: { 'content-type': 'application/json' },
111
+ body: JSON.stringify({ logoUrl: resolvedLogoUrl || null }),
112
+ },
113
+ { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },
114
+ ),
110
115
  )
111
116
  return response.result
112
117
  },
@@ -127,6 +132,7 @@ export default function OrganizationBrandingPage() {
127
132
  if (fileInputRef.current) fileInputRef.current.value = ''
128
133
  flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')
129
134
  } catch (err: unknown) {
135
+ if (surfaceRecordConflict(err, t)) return
130
136
  const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')
131
137
  const message = err instanceof Error ? err.message : fallback
132
138
  flash(message, 'error')
@@ -13,6 +13,8 @@ import { Button } from '@open-mercato/ui/primitives/button'
13
13
  import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
14
14
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
15
15
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
17
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
16
18
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
17
19
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
18
20
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -177,6 +179,17 @@ export default function DirectoryOrganizationsPage() {
177
179
  const total = data?.total ?? 0
178
180
  const totalPages = data?.totalPages ?? 0
179
181
 
182
+ const deleteMutationContextId = 'directory-organizations-list:single-delete'
183
+ const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation<{
184
+ formId: string
185
+ resourceKind: string
186
+ resourceId: string
187
+ retryLastMutation: () => Promise<boolean>
188
+ }>({
189
+ contextId: deleteMutationContextId,
190
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
191
+ })
192
+
180
193
  const handleDelete = React.useCallback(async (org: OrganizationRow) => {
181
194
  const confirmLabel = t('directory.organizations.list.confirmDelete', 'Archive organization "{{name}}"?', { name: org.name })
182
195
  const confirmed = await confirm({
@@ -187,22 +200,36 @@ export default function DirectoryOrganizationsPage() {
187
200
  if (!confirmed) return
188
201
 
189
202
  try {
190
- await withScopedApiRequestHeaders(
191
- buildOptimisticLockHeader(org.updatedAt),
192
- () => apiCallOrThrow(
193
- `/api/directory/organizations?id=${encodeURIComponent(org.id)}`,
194
- { method: 'DELETE' },
195
- { errorMessage: t('directory.organizations.list.error.delete', 'Failed to delete organization') },
203
+ await runDeleteMutation({
204
+ operation: () => withScopedApiRequestHeaders(
205
+ buildOptimisticLockHeader(org.updatedAt),
206
+ () => apiCallOrThrow(
207
+ `/api/directory/organizations?id=${encodeURIComponent(org.id)}`,
208
+ { method: 'DELETE' },
209
+ { errorMessage: t('directory.organizations.list.error.delete', 'Failed to delete organization') },
210
+ ),
196
211
  ),
197
- )
212
+ context: {
213
+ formId: deleteMutationContextId,
214
+ resourceKind: 'directory.organization',
215
+ resourceId: org.id,
216
+ retryLastMutation: retryDeleteMutation,
217
+ },
218
+ })
198
219
  await queryClient.invalidateQueries({ queryKey: ['directory-organizations'] })
199
220
  flash(t('directory.organizations.flash.deleted', 'Organization deleted'), 'success')
200
221
  } catch (err: unknown) {
222
+ // A stale delete surfaces the unified conflict bar (via the guarded
223
+ // mutation) — skip the generic error flash to avoid a double message.
224
+ if (surfaceRecordConflict(err, t)) {
225
+ await queryClient.invalidateQueries({ queryKey: ['directory-organizations'] })
226
+ return
227
+ }
201
228
  const fallback = t('directory.organizations.list.error.delete', 'Failed to delete organization')
202
229
  const message = err instanceof Error ? err.message : fallback
203
230
  flash(message, 'error')
204
231
  }
205
- }, [confirm, queryClient, t])
232
+ }, [confirm, deleteMutationContextId, queryClient, retryDeleteMutation, runDeleteMutation, t])
206
233
 
207
234
  return (
208
235
  <Page>
@@ -10,10 +10,11 @@ import { RowActions } from '@open-mercato/ui/backend/RowActions'
10
10
  import { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'
11
11
  import type { FilterValues } from '@open-mercato/ui/backend/FilterBar'
12
12
  import { Button } from '@open-mercato/ui/primitives/button'
13
- import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
13
+ import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
14
14
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
15
15
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
- import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
16
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
17
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
17
18
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
18
19
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
19
20
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -117,6 +118,17 @@ export default function DirectoryTenantsPage() {
117
118
  const total = data?.total ?? 0
118
119
  const totalPages = data?.totalPages ?? 0
119
120
 
121
+ const deleteMutationContextId = 'directory-tenants-list:single-delete'
122
+ const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation<{
123
+ formId: string
124
+ resourceKind: string
125
+ resourceId: string
126
+ retryLastMutation: () => Promise<boolean>
127
+ }>({
128
+ contextId: deleteMutationContextId,
129
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
130
+ })
131
+
120
132
  const handleDelete = React.useCallback(async (tenant: TenantRow) => {
121
133
  const confirmMessage = t('directory.tenants.list.confirmDelete', 'Delete tenant "{{name}}"? This will archive it.').replace('{{name}}', tenant.name)
122
134
  const confirmed = await confirm({
@@ -127,23 +139,35 @@ export default function DirectoryTenantsPage() {
127
139
  if (!confirmed) return
128
140
 
129
141
  try {
130
- const call = await withScopedApiRequestHeaders(
131
- buildOptimisticLockHeader(tenant.updatedAt),
132
- () => apiCall(
133
- `/api/directory/tenants?id=${encodeURIComponent(tenant.id)}`,
134
- { method: 'DELETE' },
142
+ await runDeleteMutation({
143
+ operation: () => withScopedApiRequestHeaders(
144
+ buildOptimisticLockHeader(tenant.updatedAt),
145
+ () => apiCallOrThrow(
146
+ `/api/directory/tenants?id=${encodeURIComponent(tenant.id)}`,
147
+ { method: 'DELETE' },
148
+ { errorMessage: t('directory.tenants.list.error.delete', 'Failed to delete tenant') },
149
+ ),
135
150
  ),
136
- )
137
- if (!call.ok) {
138
- await raiseCrudError(call.response, t('directory.tenants.list.error.delete', 'Failed to delete tenant'))
139
- }
151
+ context: {
152
+ formId: deleteMutationContextId,
153
+ resourceKind: 'directory.tenant',
154
+ resourceId: tenant.id,
155
+ retryLastMutation: retryDeleteMutation,
156
+ },
157
+ })
140
158
  await queryClient.invalidateQueries({ queryKey: ['directory-tenants'] })
141
159
  flash(t('directory.tenants.list.success.delete', 'Tenant deleted'), 'success')
142
- } catch (err: any) {
160
+ } catch (err: unknown) {
161
+ // A stale delete surfaces the unified conflict bar (via the guarded
162
+ // mutation) — skip the generic error flash to avoid a double message.
163
+ if (surfaceRecordConflict(err, t)) {
164
+ await queryClient.invalidateQueries({ queryKey: ['directory-tenants'] })
165
+ return
166
+ }
143
167
  const message = err instanceof Error ? err.message : t('directory.tenants.list.error.delete', 'Failed to delete tenant')
144
168
  flash(message, 'error')
145
169
  }
146
- }, [confirm, queryClient, t])
170
+ }, [confirm, deleteMutationContextId, queryClient, retryDeleteMutation, runDeleteMutation, t])
147
171
 
148
172
  return (
149
173
  <Page>
@@ -8,6 +8,10 @@ import { z } from 'zod'
8
8
  import { invalidateDefinitionsCache } from './definitions.cache'
9
9
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
10
  import { mergeEntityFieldsetConfig, normalizeEntityFieldsetConfig } from '../lib/fieldsets'
11
+ import {
12
+ beginEntitiesMutationGuard,
13
+ FIELD_DEFINITION_RESOURCE_KIND,
14
+ } from './definitions.mutation-guard'
11
15
 
12
16
  export const metadata = {
13
17
  POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
@@ -67,6 +71,17 @@ export async function POST(req: Request) {
67
71
  cache = resolve('cache') as CacheStrategy
68
72
  } catch {}
69
73
 
74
+ const guard = await beginEntitiesMutationGuard({
75
+ container,
76
+ auth,
77
+ req,
78
+ resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
79
+ resourceId: entityId,
80
+ operation: 'custom',
81
+ mutationPayload: { entityId, definitionCount: definitions.length },
82
+ })
83
+ if (guard.blockedResponse) return guard.blockedResponse
84
+
70
85
  await em.begin()
71
86
  try {
72
87
  // Prefetch every existing definition for this entity in a single query, then index
@@ -134,6 +149,8 @@ export async function POST(req: Request) {
134
149
  return NextResponse.json({ error: 'Failed to save definitions batch' }, { status: 500 })
135
150
  }
136
151
 
152
+ await guard.runAfterSuccess()
153
+
137
154
  await invalidateDefinitionsCache(cache, {
138
155
  tenantId: auth.tenantId ?? null,
139
156
  organizationId: auth.orgId ?? null,
@@ -0,0 +1,80 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
4
+ import {
5
+ runCrudMutationGuardAfterSuccess,
6
+ validateCrudMutationGuard,
7
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
8
+
9
+ export const ENTITY_DEFINITION_RESOURCE_KIND = 'entities.entity'
10
+ export const FIELD_DEFINITION_RESOURCE_KIND = 'entities.field_definition'
11
+
12
+ type AuthenticatedContext = NonNullable<AuthContext>
13
+
14
+ type GuardOperation = 'create' | 'update' | 'delete' | 'custom'
15
+
16
+ type EntitiesMutationGuardContext = {
17
+ container: AwilixContainer
18
+ auth: AuthenticatedContext
19
+ req: Request
20
+ resourceKind: string
21
+ resourceId: string
22
+ operation: GuardOperation
23
+ mutationPayload?: Record<string, unknown> | null
24
+ }
25
+
26
+ type EntitiesMutationGuardHandle = {
27
+ blockedResponse: NextResponse | null
28
+ runAfterSuccess: () => Promise<void>
29
+ }
30
+
31
+ function resolveActorUserId(auth: AuthenticatedContext): string {
32
+ return auth.userId ?? auth.sub
33
+ }
34
+
35
+ export async function beginEntitiesMutationGuard(
36
+ ctx: EntitiesMutationGuardContext,
37
+ ): Promise<EntitiesMutationGuardHandle> {
38
+ const tenantId = ctx.auth.tenantId
39
+ const userId = resolveActorUserId(ctx.auth)
40
+ if (!tenantId) {
41
+ return { blockedResponse: null, runAfterSuccess: async () => undefined }
42
+ }
43
+
44
+ const guardResult = await validateCrudMutationGuard(ctx.container, {
45
+ tenantId,
46
+ organizationId: ctx.auth.orgId ?? null,
47
+ userId,
48
+ resourceKind: ctx.resourceKind,
49
+ resourceId: ctx.resourceId,
50
+ operation: ctx.operation,
51
+ requestMethod: ctx.req.method,
52
+ requestHeaders: ctx.req.headers,
53
+ mutationPayload: ctx.mutationPayload ?? null,
54
+ })
55
+
56
+ if (guardResult && !guardResult.ok) {
57
+ return {
58
+ blockedResponse: NextResponse.json(guardResult.body, { status: guardResult.status }),
59
+ runAfterSuccess: async () => undefined,
60
+ }
61
+ }
62
+
63
+ return {
64
+ blockedResponse: null,
65
+ runAfterSuccess: async () => {
66
+ if (!guardResult?.ok || !guardResult.shouldRunAfterSuccess) return
67
+ await runCrudMutationGuardAfterSuccess(ctx.container, {
68
+ tenantId,
69
+ organizationId: ctx.auth.orgId ?? null,
70
+ userId,
71
+ resourceKind: ctx.resourceKind,
72
+ resourceId: ctx.resourceId,
73
+ operation: ctx.operation,
74
+ requestMethod: ctx.req.method,
75
+ requestHeaders: ctx.req.headers,
76
+ metadata: guardResult.metadata ?? null,
77
+ })
78
+ },
79
+ }
80
+ }
@@ -6,6 +6,10 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
6
  import { CustomFieldDef } from '@open-mercato/core/modules/entities/data/entities'
7
7
  import { invalidateDefinitionsCache } from './definitions.cache'
8
8
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
+ import {
10
+ beginEntitiesMutationGuard,
11
+ FIELD_DEFINITION_RESOURCE_KIND,
12
+ } from './definitions.mutation-guard'
9
13
 
10
14
  export const metadata = {
11
15
  POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
@@ -30,11 +34,24 @@ export async function POST(req: Request) {
30
34
  const where: any = { entityId, key, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }
31
35
  const def = await em.findOne(CustomFieldDef, where)
32
36
  if (!def) return NextResponse.json({ error: 'Not found' }, { status: 404 })
37
+
38
+ const guard = await beginEntitiesMutationGuard({
39
+ container,
40
+ auth,
41
+ req,
42
+ resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
43
+ resourceId: def.id,
44
+ operation: 'custom',
45
+ mutationPayload: { entityId, key },
46
+ })
47
+ if (guard.blockedResponse) return guard.blockedResponse
48
+
33
49
  ;(def as any).deletedAt = null
34
50
  ;(def as any).isActive = true
35
51
  ;(def as any).updatedAt = new Date()
36
52
  em.persist(def)
37
53
  await em.flush()
54
+ await guard.runAfterSuccess()
38
55
  await invalidateDefinitionsCache(cache, {
39
56
  tenantId: auth.tenantId ?? null,
40
57
  organizationId: auth.orgId ?? null,
@@ -20,6 +20,10 @@ import { installCustomEntitiesFromModules } from '../lib/install-from-ce'
20
20
  import { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'
21
21
  import { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'
22
22
  import type { EntityManager } from '@mikro-orm/postgresql'
23
+ import {
24
+ beginEntitiesMutationGuard,
25
+ FIELD_DEFINITION_RESOURCE_KIND,
26
+ } from './definitions.mutation-guard'
23
27
 
24
28
  /**
25
29
  * Validate defaultValue against the field kind. Returns an error message string
@@ -473,6 +477,18 @@ export async function POST(req: Request) {
473
477
 
474
478
  const where: any = { entityId: input.entityId, key: input.key, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }
475
479
  let def = await em.findOne(CustomFieldDef, where)
480
+
481
+ const guard = await beginEntitiesMutationGuard({
482
+ container,
483
+ auth,
484
+ req,
485
+ resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
486
+ resourceId: def ? def.id : `${input.entityId}:${input.key}`,
487
+ operation: def ? 'update' : 'create',
488
+ mutationPayload: input as unknown as Record<string, unknown>,
489
+ })
490
+ if (guard.blockedResponse) return guard.blockedResponse
491
+
476
492
  if (!def) def = em.create(CustomFieldDef, { ...where, createdAt: new Date() })
477
493
  def.kind = input.kind
478
494
  const inCfg = (input as any).configJson ?? {}
@@ -508,6 +524,7 @@ export async function POST(req: Request) {
508
524
  def.updatedAt = new Date()
509
525
  em.persist(def)
510
526
  await em.flush()
527
+ await guard.runAfterSuccess()
511
528
  await invalidateDefinitionsCache(cache, {
512
529
  tenantId: auth.tenantId ?? null,
513
530
  organizationId: auth.orgId ?? null,
@@ -535,11 +552,24 @@ export async function DELETE(req: Request) {
535
552
  const where: any = { entityId, key, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }
536
553
  const def = await em.findOne(CustomFieldDef, where)
537
554
  if (!def) return NextResponse.json({ error: 'Not found' }, { status: 404 })
555
+
556
+ const guard = await beginEntitiesMutationGuard({
557
+ container,
558
+ auth,
559
+ req,
560
+ resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
561
+ resourceId: def.id,
562
+ operation: 'delete',
563
+ mutationPayload: { entityId, key },
564
+ })
565
+ if (guard.blockedResponse) return guard.blockedResponse
566
+
538
567
  def.isActive = false
539
568
  def.updatedAt = new Date()
540
569
  def.deletedAt = def.deletedAt ?? new Date()
541
570
  em.persist(def)
542
571
  await em.flush()
572
+ await guard.runAfterSuccess()
543
573
  await invalidateDefinitionsCache(cache, {
544
574
  tenantId: auth.tenantId ?? null,
545
575
  organizationId: auth.orgId ?? null,