@open-mercato/core 0.6.6-develop.6257.1.6d0af84d26 → 0.6.6-develop.6286.1.d985c5c374

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 (208) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/carrier_shipment_idempotency_key/index.js +19 -0
  3. package/dist/generated/entities/carrier_shipment_idempotency_key/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +2 -1
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +10 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/lib/productResolution.js +45 -0
  9. package/dist/modules/catalog/lib/productResolution.js.map +7 -0
  10. package/dist/modules/communication_channels/workers/channel-import-history.js +1 -1
  11. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  12. package/dist/modules/currencies/api/fetch-configs/route.js +34 -0
  13. package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
  14. package/dist/modules/currencies/components/CurrencyFetchingConfig.js +17 -15
  15. package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
  16. package/dist/modules/customers/api/companies/[id]/route.js +176 -161
  17. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  18. package/dist/modules/customers/api/people/[id]/route.js +75 -59
  19. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  20. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +17 -10
  21. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
  22. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +6 -4
  23. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +2 -2
  24. package/dist/modules/customers/components/detail/AiActionChips.js +5 -2
  25. package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
  26. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +19 -21
  27. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  28. package/dist/modules/data_sync/api/runs/[id]/cancel.js +1 -1
  29. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  30. package/dist/modules/data_sync/backend/data-sync/page.js +9 -45
  31. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  32. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +11 -17
  33. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  34. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +12 -10
  35. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  36. package/dist/modules/data_sync/lib/id-mapping.js +46 -1
  37. package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-engine.js +2 -2
  39. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  40. package/dist/modules/data_sync/lib/syncRunStatus.js +30 -0
  41. package/dist/modules/data_sync/lib/syncRunStatus.js.map +7 -0
  42. package/dist/modules/entities/api/records.js +6 -0
  43. package/dist/modules/entities/api/records.js.map +2 -2
  44. package/dist/modules/feature_toggles/api/global/route.js +17 -15
  45. package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
  46. package/dist/modules/feature_toggles/api/openapi.js +2 -2
  47. package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
  48. package/dist/modules/feature_toggles/commands/global.js +14 -0
  49. package/dist/modules/feature_toggles/commands/global.js.map +2 -2
  50. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +6 -0
  51. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  52. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js +3 -1
  53. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js.map +2 -2
  54. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +12 -5
  55. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
  56. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js +10 -1
  57. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js.map +2 -2
  58. package/dist/modules/payment_gateways/api/cancel/route.js +5 -0
  59. package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
  60. package/dist/modules/payment_gateways/api/capture/route.js +5 -0
  61. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  62. package/dist/modules/payment_gateways/api/refund/route.js +5 -0
  63. package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
  64. package/dist/modules/payment_gateways/lib/gateway-service.js +52 -25
  65. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  66. package/dist/modules/payment_gateways/lib/status-machine.js +13 -0
  67. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  68. package/dist/modules/perspectives/api/[tableId]/route.js +8 -2
  69. package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
  70. package/dist/modules/perspectives/data/entities.js +33 -3
  71. package/dist/modules/perspectives/data/entities.js.map +2 -2
  72. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js +63 -0
  73. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js.map +7 -0
  74. package/dist/modules/perspectives/services/perspectiveService.js +17 -0
  75. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  76. package/dist/modules/planner/commands/availability-weekly.js +33 -1
  77. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  78. package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -2
  79. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  80. package/dist/modules/progress/api/active/route.js +1 -1
  81. package/dist/modules/progress/api/active/route.js.map +2 -2
  82. package/dist/modules/progress/lib/progressService.js.map +1 -1
  83. package/dist/modules/progress/lib/progressServiceImpl.js +33 -8
  84. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  85. package/dist/modules/query_index/api/status.js +19 -24
  86. package/dist/modules/query_index/api/status.js.map +3 -3
  87. package/dist/modules/query_index/lib/coverage.js +35 -0
  88. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  89. package/dist/modules/query_index/lib/engine.js +11 -6
  90. package/dist/modules/query_index/lib/engine.js.map +2 -2
  91. package/dist/modules/resources/api/resource-types.js +5 -37
  92. package/dist/modules/resources/api/resource-types.js.map +2 -2
  93. package/dist/modules/resources/api/resourceTypeCounts.js +35 -0
  94. package/dist/modules/resources/api/resourceTypeCounts.js.map +7 -0
  95. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
  96. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +1 -1
  97. package/dist/modules/resources/backend/resources/resource-types/page.js +2 -1
  98. package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
  99. package/dist/modules/sales/components/documents/ReturnDialog.js +4 -1
  100. package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
  101. package/dist/modules/sales/components/documents/ShipmentDialog.js +17 -2
  102. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  103. package/dist/modules/sales/di.js +4 -0
  104. package/dist/modules/sales/di.js.map +2 -2
  105. package/dist/modules/sales/services/salesOrderService.js +26 -0
  106. package/dist/modules/sales/services/salesOrderService.js.map +7 -0
  107. package/dist/modules/shipping_carriers/api/shipments/route.js +8 -0
  108. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  109. package/dist/modules/shipping_carriers/data/entities.js +38 -0
  110. package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
  111. package/dist/modules/shipping_carriers/data/validators.js +2 -1
  112. package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
  113. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js +76 -0
  114. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js.map +7 -0
  115. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js +2 -1
  116. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js.map +2 -2
  117. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +20 -2
  118. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
  119. package/dist/modules/shipping_carriers/lib/shipping-service.js +82 -43
  120. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  121. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js +14 -0
  122. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js.map +7 -0
  123. package/dist/modules/staff/lib/timesheets-ui/ListView.js +15 -6
  124. package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
  125. package/dist/modules/translations/components/TranslationManager.js +9 -18
  126. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  127. package/generated/entities/carrier_shipment_idempotency_key/index.ts +8 -0
  128. package/generated/entities.ids.generated.ts +2 -1
  129. package/generated/entity-fields-registry.ts +10 -0
  130. package/package.json +9 -8
  131. package/src/modules/catalog/lib/productResolution.ts +75 -0
  132. package/src/modules/communication_channels/workers/channel-import-history.ts +1 -1
  133. package/src/modules/currencies/api/fetch-configs/route.ts +36 -0
  134. package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +20 -17
  135. package/src/modules/currencies/i18n/de.json +2 -1
  136. package/src/modules/currencies/i18n/en.json +2 -1
  137. package/src/modules/currencies/i18n/es.json +2 -1
  138. package/src/modules/currencies/i18n/pl.json +2 -1
  139. package/src/modules/customers/api/companies/[id]/route.ts +214 -189
  140. package/src/modules/customers/api/people/[id]/route.ts +106 -80
  141. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +19 -12
  142. package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +8 -6
  143. package/src/modules/customers/components/detail/AiActionChips.tsx +6 -3
  144. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +16 -18
  145. package/src/modules/data_sync/api/runs/[id]/cancel.ts +1 -1
  146. package/src/modules/data_sync/backend/data-sync/page.tsx +11 -58
  147. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +13 -20
  148. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +10 -8
  149. package/src/modules/data_sync/lib/id-mapping.ts +58 -1
  150. package/src/modules/data_sync/lib/sync-engine.ts +2 -2
  151. package/src/modules/data_sync/lib/syncRunStatus.ts +39 -0
  152. package/src/modules/entities/api/records.ts +14 -0
  153. package/src/modules/feature_toggles/api/global/route.ts +16 -14
  154. package/src/modules/feature_toggles/api/openapi.ts +2 -2
  155. package/src/modules/feature_toggles/commands/global.ts +15 -0
  156. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +7 -0
  157. package/src/modules/messages/components/message-detail/hooks/useMessageDetails.ts +2 -0
  158. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +14 -4
  159. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.ts +15 -1
  160. package/src/modules/payment_gateways/api/cancel/route.ts +5 -0
  161. package/src/modules/payment_gateways/api/capture/route.ts +5 -0
  162. package/src/modules/payment_gateways/api/refund/route.ts +5 -0
  163. package/src/modules/payment_gateways/lib/gateway-service.ts +58 -25
  164. package/src/modules/payment_gateways/lib/status-machine.ts +16 -0
  165. package/src/modules/perspectives/api/[tableId]/route.ts +8 -2
  166. package/src/modules/perspectives/data/entities.ts +43 -3
  167. package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +69 -25
  168. package/src/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.ts +65 -0
  169. package/src/modules/perspectives/services/perspectiveService.ts +17 -0
  170. package/src/modules/planner/commands/availability-weekly.ts +48 -1
  171. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +31 -10
  172. package/src/modules/progress/api/active/route.ts +1 -1
  173. package/src/modules/progress/lib/progressService.ts +2 -2
  174. package/src/modules/progress/lib/progressServiceImpl.ts +33 -8
  175. package/src/modules/query_index/api/status.ts +28 -32
  176. package/src/modules/query_index/lib/coverage.ts +63 -0
  177. package/src/modules/query_index/lib/engine.ts +37 -24
  178. package/src/modules/resources/api/resource-types.ts +4 -44
  179. package/src/modules/resources/api/resourceTypeCounts.ts +74 -0
  180. package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
  181. package/src/modules/resources/backend/resources/resource-types/page.tsx +1 -0
  182. package/src/modules/sales/components/documents/ReturnDialog.tsx +4 -1
  183. package/src/modules/sales/components/documents/ShipmentDialog.tsx +20 -2
  184. package/src/modules/sales/di.ts +6 -0
  185. package/src/modules/sales/services/salesOrderService.ts +38 -0
  186. package/src/modules/shipping_carriers/api/shipments/route.ts +8 -0
  187. package/src/modules/shipping_carriers/data/entities.ts +33 -0
  188. package/src/modules/shipping_carriers/data/validators.ts +1 -0
  189. package/src/modules/shipping_carriers/i18n/de.json +1 -0
  190. package/src/modules/shipping_carriers/i18n/en.json +1 -0
  191. package/src/modules/shipping_carriers/i18n/es.json +1 -0
  192. package/src/modules/shipping_carriers/i18n/pl.json +1 -0
  193. package/src/modules/shipping_carriers/lib/shipment-idempotency.ts +119 -0
  194. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.ts +3 -1
  195. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +24 -2
  196. package/src/modules/shipping_carriers/lib/shipping-service.ts +87 -43
  197. package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +197 -0
  198. package/src/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.ts +14 -0
  199. package/src/modules/staff/i18n/de.json +1 -0
  200. package/src/modules/staff/i18n/en.json +1 -0
  201. package/src/modules/staff/i18n/es.json +1 -0
  202. package/src/modules/staff/i18n/pl.json +1 -0
  203. package/src/modules/staff/lib/timesheets-ui/ListView.tsx +15 -7
  204. package/src/modules/translations/components/TranslationManager.tsx +17 -24
  205. package/src/modules/translations/i18n/de.json +1 -0
  206. package/src/modules/translations/i18n/en.json +1 -0
  207. package/src/modules/translations/i18n/es.json +1 -0
  208. package/src/modules/translations/i18n/pl.json +1 -0
@@ -14,10 +14,34 @@ import {
14
14
  import type { CredentialsService } from '../../integrations/lib/credentials-service'
15
15
  import type { IntegrationStateService } from '../../integrations/lib/state-service'
16
16
  import type { IntegrationLogService } from '../../integrations/lib/log-service'
17
+ import { conflict } from '@open-mercato/shared/lib/crud/errors'
17
18
  import { GatewayTransaction } from '../data/entities'
18
- import { isValidTransition } from './status-machine'
19
+ import { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'
19
20
  import { emitPaymentGatewayEvent } from '../events'
20
21
 
22
+ function assertManualActionAllowed(action: ManualGatewayAction, transaction: GatewayTransaction): void {
23
+ const current = transaction.unifiedStatus as UnifiedPaymentStatus
24
+ if (!canApplyManualAction(action, current)) {
25
+ throw conflict(`Cannot ${action} a payment in status "${current}"`)
26
+ }
27
+ }
28
+
29
+ function applyAdapterResultStatus(
30
+ action: ManualGatewayAction,
31
+ transaction: GatewayTransaction,
32
+ resultStatus: UnifiedPaymentStatus,
33
+ ): boolean {
34
+ const current = transaction.unifiedStatus as UnifiedPaymentStatus
35
+ if (resultStatus === current) return false
36
+ if (!isValidTransition(current, resultStatus)) {
37
+ throw conflict(
38
+ `Gateway returned status "${resultStatus}" which is not a valid transition from "${current}" for ${action}`,
39
+ )
40
+ }
41
+ transaction.unifiedStatus = resultStatus
42
+ return true
43
+ }
44
+
21
45
  export interface PaymentGatewayServiceDeps {
22
46
  em: EntityManager
23
47
  integrationCredentialsService: CredentialsService
@@ -197,6 +221,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
197
221
 
198
222
  async capturePayment(transactionId: string, amount: number | undefined, scope: { organizationId: string; tenantId: string }): Promise<CaptureResult> {
199
223
  const transaction = await findTransactionOrThrow(transactionId, scope)
224
+ assertManualActionAllowed('capture', transaction)
200
225
  const { adapter, credentials } = await resolveAdapterAndCredentials(
201
226
  transaction.providerKey,
202
227
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -208,16 +233,18 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
208
233
  credentials,
209
234
  })
210
235
 
211
- transaction.unifiedStatus = result.status
236
+ const statusChanged = applyAdapterResultStatus('capture', transaction, result.status)
212
237
  transaction.gatewayMetadata = { ...transaction.gatewayMetadata, captureResult: result.providerData }
213
238
  await em.flush()
214
- await emitStatusEvent(result.status, {
215
- transactionId: transaction.id,
216
- paymentId: transaction.paymentId,
217
- providerKey: transaction.providerKey,
218
- organizationId: transaction.organizationId,
219
- tenantId: transaction.tenantId,
220
- })
239
+ if (statusChanged) {
240
+ await emitStatusEvent(result.status, {
241
+ transactionId: transaction.id,
242
+ paymentId: transaction.paymentId,
243
+ providerKey: transaction.providerKey,
244
+ organizationId: transaction.organizationId,
245
+ tenantId: transaction.tenantId,
246
+ })
247
+ }
221
248
  await writeTransactionLog(
222
249
  transaction.providerKey,
223
250
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -241,6 +268,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
241
268
  scope: { organizationId: string; tenantId: string },
242
269
  ): Promise<RefundResult> {
243
270
  const transaction = await findTransactionOrThrow(transactionId, scope)
271
+ assertManualActionAllowed('refund', transaction)
244
272
  const { adapter, credentials } = await resolveAdapterAndCredentials(
245
273
  transaction.providerKey,
246
274
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -253,17 +281,19 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
253
281
  credentials,
254
282
  })
255
283
 
256
- transaction.unifiedStatus = result.status
284
+ const statusChanged = applyAdapterResultStatus('refund', transaction, result.status)
257
285
  transaction.gatewayRefundId = result.refundId
258
286
  transaction.gatewayMetadata = { ...transaction.gatewayMetadata, refundResult: result.providerData }
259
287
  await em.flush()
260
- await emitStatusEvent(result.status, {
261
- transactionId: transaction.id,
262
- paymentId: transaction.paymentId,
263
- providerKey: transaction.providerKey,
264
- organizationId: transaction.organizationId,
265
- tenantId: transaction.tenantId,
266
- })
288
+ if (statusChanged) {
289
+ await emitStatusEvent(result.status, {
290
+ transactionId: transaction.id,
291
+ paymentId: transaction.paymentId,
292
+ providerKey: transaction.providerKey,
293
+ organizationId: transaction.organizationId,
294
+ tenantId: transaction.tenantId,
295
+ })
296
+ }
267
297
  await writeTransactionLog(
268
298
  transaction.providerKey,
269
299
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -287,6 +317,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
287
317
  scope: { organizationId: string; tenantId: string },
288
318
  ): Promise<CancelResult> {
289
319
  const transaction = await findTransactionOrThrow(transactionId, scope)
320
+ assertManualActionAllowed('cancel', transaction)
290
321
  const { adapter, credentials } = await resolveAdapterAndCredentials(
291
322
  transaction.providerKey,
292
323
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -298,15 +329,17 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
298
329
  credentials,
299
330
  })
300
331
 
301
- transaction.unifiedStatus = result.status
332
+ const statusChanged = applyAdapterResultStatus('cancel', transaction, result.status)
302
333
  await em.flush()
303
- await emitStatusEvent(result.status, {
304
- transactionId: transaction.id,
305
- paymentId: transaction.paymentId,
306
- providerKey: transaction.providerKey,
307
- organizationId: transaction.organizationId,
308
- tenantId: transaction.tenantId,
309
- })
334
+ if (statusChanged) {
335
+ await emitStatusEvent(result.status, {
336
+ transactionId: transaction.id,
337
+ paymentId: transaction.paymentId,
338
+ providerKey: transaction.providerKey,
339
+ organizationId: transaction.organizationId,
340
+ tenantId: transaction.tenantId,
341
+ })
342
+ }
310
343
  await writeTransactionLog(
311
344
  transaction.providerKey,
312
345
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -26,3 +26,19 @@ export function isValidTransition(from: UnifiedPaymentStatus, to: UnifiedPayment
26
26
  export function isTerminalStatus(status: UnifiedPaymentStatus): boolean {
27
27
  return TERMINAL_STATUSES.has(status)
28
28
  }
29
+
30
+ export type ManualGatewayAction = 'capture' | 'refund' | 'cancel'
31
+
32
+ const MANUAL_ACTION_TARGET_STATUSES: Record<ManualGatewayAction, UnifiedPaymentStatus[]> = {
33
+ capture: ['captured', 'partially_captured'],
34
+ refund: ['refunded', 'partially_refunded'],
35
+ cancel: ['cancelled'],
36
+ }
37
+
38
+ export function canApplyManualAction(action: ManualGatewayAction, from: UnifiedPaymentStatus): boolean {
39
+ if (isTerminalStatus(from)) return false
40
+ const targets = MANUAL_ACTION_TARGET_STATUSES[action]
41
+ if (!targets) return false
42
+ if (targets.includes(from)) return true
43
+ return targets.some((target) => isValidTransition(from, target))
44
+ }
@@ -3,7 +3,7 @@ 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 { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
6
- import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
+ import { isCrudHttpError, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
7
7
  import {
8
8
  runCrudMutationGuardAfterSuccess,
9
9
  validateCrudMutationGuard,
@@ -308,6 +308,12 @@ export async function POST(req: Request, ctx: { params: { tableId: string } }) {
308
308
  if (isCrudHttpError(err)) {
309
309
  return NextResponse.json(err.body, { status: err.status })
310
310
  }
311
+ if (isUniqueViolation(err)) {
312
+ return NextResponse.json({
313
+ error: 'A view with this name already exists.',
314
+ code: 'duplicate_name',
315
+ }, { status: 409 })
316
+ }
311
317
  throw err
312
318
  }
313
319
 
@@ -380,7 +386,7 @@ const perspectivesPostDoc: OpenApiMethodDoc = {
380
386
  { status: 400, description: 'Validation failed or invalid roles provided', schema: perspectivesErrorSchema },
381
387
  { status: 401, description: 'Authentication required', schema: perspectivesErrorSchema },
382
388
  { status: 403, description: 'Missing perspectives.role_defaults feature for role updates', schema: perspectivesErrorSchema },
383
- { status: 409, description: 'Optimistic lock conflict', schema: perspectivesErrorSchema },
389
+ { status: 409, description: 'Optimistic lock conflict or perspective name already exists', schema: perspectivesErrorSchema },
384
390
  ],
385
391
  }
386
392
 
@@ -1,8 +1,28 @@
1
- import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
1
+ import { Entity, Index, PrimaryKey, Property } from '@mikro-orm/decorators/legacy'
2
2
 
3
3
  @Entity({ tableName: 'perspectives' })
4
4
  @Index({ name: 'perspectives_user_scope_idx', properties: ['userId', 'tenantId', 'organizationId', 'tableId'] })
5
- @Unique({ properties: ['userId', 'tenantId', 'organizationId', 'tableId', 'name'] })
5
+ // Live-row uniqueness is owned by partial indexes; ordinary unique decorators cannot express these scope rules.
6
+ @Index({
7
+ name: 'perspectives_live_user_org_uq',
8
+ expression:
9
+ 'create unique index "perspectives_live_user_org_uq" on "perspectives" ("user_id", "tenant_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is not null',
10
+ })
11
+ @Index({
12
+ name: 'perspectives_live_user_tenant_uq',
13
+ expression:
14
+ 'create unique index "perspectives_live_user_tenant_uq" on "perspectives" ("user_id", "tenant_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is null',
15
+ })
16
+ @Index({
17
+ name: 'perspectives_live_user_org_only_uq',
18
+ expression:
19
+ 'create unique index "perspectives_live_user_org_only_uq" on "perspectives" ("user_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is not null',
20
+ })
21
+ @Index({
22
+ name: 'perspectives_live_user_global_uq',
23
+ expression:
24
+ 'create unique index "perspectives_live_user_global_uq" on "perspectives" ("user_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is null',
25
+ })
6
26
  export class Perspective {
7
27
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
8
28
  id!: string
@@ -40,7 +60,27 @@ export class Perspective {
40
60
 
41
61
  @Entity({ tableName: 'role_perspectives' })
42
62
  @Index({ name: 'role_perspectives_role_scope_idx', properties: ['roleId', 'tenantId', 'organizationId', 'tableId'] })
43
- @Unique({ properties: ['roleId', 'tenantId', 'organizationId', 'tableId', 'name'] })
63
+ // Live-row uniqueness is owned by partial indexes; ordinary unique decorators cannot express these scope rules.
64
+ @Index({
65
+ name: 'role_perspectives_live_role_org_uq',
66
+ expression:
67
+ 'create unique index "role_perspectives_live_role_org_uq" on "role_perspectives" ("role_id", "tenant_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is not null',
68
+ })
69
+ @Index({
70
+ name: 'role_perspectives_live_role_tenant_uq',
71
+ expression:
72
+ 'create unique index "role_perspectives_live_role_tenant_uq" on "role_perspectives" ("role_id", "tenant_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is null',
73
+ })
74
+ @Index({
75
+ name: 'role_perspectives_live_role_org_only_uq',
76
+ expression:
77
+ 'create unique index "role_perspectives_live_role_org_only_uq" on "role_perspectives" ("role_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is not null',
78
+ })
79
+ @Index({
80
+ name: 'role_perspectives_live_role_global_uq',
81
+ expression:
82
+ 'create unique index "role_perspectives_live_role_global_uq" on "role_perspectives" ("role_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is null',
83
+ })
44
84
  export class RolePerspective {
45
85
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
46
86
  id!: string
@@ -175,7 +175,7 @@
175
175
  "autoincrement": false,
176
176
  "primary": false,
177
177
  "nullable": false,
178
- "unique": true,
178
+ "unique": false,
179
179
  "length": null,
180
180
  "precision": null,
181
181
  "scale": null,
@@ -197,18 +197,40 @@
197
197
  "unique": true
198
198
  },
199
199
  {
200
- "columnNames": [
201
- "user_id",
202
- "tenant_id",
203
- "organization_id",
204
- "table_id",
205
- "name"
206
- ],
207
- "composite": true,
208
- "constraint": true,
209
- "keyName": "perspectives_user_id_tenant_id_organization_id_ta_0c702_unique",
200
+ "columnNames": [],
201
+ "composite": false,
202
+ "constraint": false,
203
+ "keyName": "perspectives_live_user_global_uq",
210
204
  "primary": false,
211
- "unique": true
205
+ "unique": false,
206
+ "expression": "create unique index \"perspectives_live_user_global_uq\" on \"perspectives\" (\"user_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is null and \"organization_id\" is null"
207
+ },
208
+ {
209
+ "columnNames": [],
210
+ "composite": false,
211
+ "constraint": false,
212
+ "keyName": "perspectives_live_user_org_only_uq",
213
+ "primary": false,
214
+ "unique": false,
215
+ "expression": "create unique index \"perspectives_live_user_org_only_uq\" on \"perspectives\" (\"user_id\", \"organization_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is null and \"organization_id\" is not null"
216
+ },
217
+ {
218
+ "columnNames": [],
219
+ "composite": false,
220
+ "constraint": false,
221
+ "keyName": "perspectives_live_user_org_uq",
222
+ "primary": false,
223
+ "unique": false,
224
+ "expression": "create unique index \"perspectives_live_user_org_uq\" on \"perspectives\" (\"user_id\", \"tenant_id\", \"organization_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is not null and \"organization_id\" is not null"
225
+ },
226
+ {
227
+ "columnNames": [],
228
+ "composite": false,
229
+ "constraint": false,
230
+ "keyName": "perspectives_live_user_tenant_uq",
231
+ "primary": false,
232
+ "unique": false,
233
+ "expression": "create unique index \"perspectives_live_user_tenant_uq\" on \"perspectives\" (\"user_id\", \"tenant_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is not null and \"organization_id\" is null"
212
234
  },
213
235
  {
214
236
  "columnNames": [
@@ -335,7 +357,7 @@
335
357
  "autoincrement": false,
336
358
  "primary": false,
337
359
  "nullable": false,
338
- "unique": true,
360
+ "unique": false,
339
361
  "length": null,
340
362
  "precision": null,
341
363
  "scale": null,
@@ -421,18 +443,40 @@
421
443
  "unique": true
422
444
  },
423
445
  {
424
- "columnNames": [
425
- "role_id",
426
- "tenant_id",
427
- "organization_id",
428
- "table_id",
429
- "name"
430
- ],
431
- "composite": true,
432
- "constraint": true,
433
- "keyName": "role_perspectives_role_id_tenant_id_organization__f0fc6_unique",
446
+ "columnNames": [],
447
+ "composite": false,
448
+ "constraint": false,
449
+ "keyName": "role_perspectives_live_role_global_uq",
434
450
  "primary": false,
435
- "unique": true
451
+ "unique": false,
452
+ "expression": "create unique index \"role_perspectives_live_role_global_uq\" on \"role_perspectives\" (\"role_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is null and \"organization_id\" is null"
453
+ },
454
+ {
455
+ "columnNames": [],
456
+ "composite": false,
457
+ "constraint": false,
458
+ "keyName": "role_perspectives_live_role_org_only_uq",
459
+ "primary": false,
460
+ "unique": false,
461
+ "expression": "create unique index \"role_perspectives_live_role_org_only_uq\" on \"role_perspectives\" (\"role_id\", \"organization_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is null and \"organization_id\" is not null"
462
+ },
463
+ {
464
+ "columnNames": [],
465
+ "composite": false,
466
+ "constraint": false,
467
+ "keyName": "role_perspectives_live_role_org_uq",
468
+ "primary": false,
469
+ "unique": false,
470
+ "expression": "create unique index \"role_perspectives_live_role_org_uq\" on \"role_perspectives\" (\"role_id\", \"tenant_id\", \"organization_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is not null and \"organization_id\" is not null"
471
+ },
472
+ {
473
+ "columnNames": [],
474
+ "composite": false,
475
+ "constraint": false,
476
+ "keyName": "role_perspectives_live_role_tenant_uq",
477
+ "primary": false,
478
+ "unique": false,
479
+ "expression": "create unique index \"role_perspectives_live_role_tenant_uq\" on \"role_perspectives\" (\"role_id\", \"tenant_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is not null and \"organization_id\" is null"
436
480
  },
437
481
  {
438
482
  "columnNames": [
@@ -455,4 +499,4 @@
455
499
  ],
456
500
  "views": [],
457
501
  "nativeEnums": {}
458
- }
502
+ }
@@ -0,0 +1,65 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260619120000_perspectives_live_row_uniqueness extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`
7
+ with ranked as (
8
+ select id,
9
+ row_number() over (
10
+ partition by user_id, tenant_id, organization_id, table_id, name
11
+ order by coalesce(updated_at, created_at) desc, created_at desc, id desc
12
+ ) as rn
13
+ from perspectives
14
+ where deleted_at is null
15
+ )
16
+ update perspectives
17
+ set deleted_at = now(), is_default = false
18
+ from ranked
19
+ where perspectives.id = ranked.id and ranked.rn > 1;
20
+ `);
21
+ this.addSql(`alter table "perspectives" drop constraint if exists "perspectives_user_id_tenant_id_organization_id_ta_2d725_unique";`);
22
+ this.addSql(`alter table "perspectives" drop constraint if exists "perspectives_user_id_tenant_id_organization_id_ta_0c702_unique";`);
23
+ this.addSql(`create unique index if not exists "perspectives_live_user_org_uq" on "perspectives" ("user_id", "tenant_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is not null;`);
24
+ this.addSql(`create unique index if not exists "perspectives_live_user_tenant_uq" on "perspectives" ("user_id", "tenant_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is null;`);
25
+ this.addSql(`create unique index if not exists "perspectives_live_user_org_only_uq" on "perspectives" ("user_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is not null;`);
26
+ this.addSql(`create unique index if not exists "perspectives_live_user_global_uq" on "perspectives" ("user_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is null;`);
27
+
28
+ this.addSql(`
29
+ with ranked as (
30
+ select id,
31
+ row_number() over (
32
+ partition by role_id, tenant_id, organization_id, table_id, name
33
+ order by coalesce(updated_at, created_at) desc, created_at desc, id desc
34
+ ) as rn
35
+ from role_perspectives
36
+ where deleted_at is null
37
+ )
38
+ update role_perspectives
39
+ set deleted_at = now(), is_default = false
40
+ from ranked
41
+ where role_perspectives.id = ranked.id and ranked.rn > 1;
42
+ `);
43
+ this.addSql(`alter table "role_perspectives" drop constraint if exists "role_perspectives_role_id_tenant_id_organization__c5467_unique";`);
44
+ this.addSql(`alter table "role_perspectives" drop constraint if exists "role_perspectives_role_id_tenant_id_organization__f0fc6_unique";`);
45
+ this.addSql(`create unique index if not exists "role_perspectives_live_role_org_uq" on "role_perspectives" ("role_id", "tenant_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is not null;`);
46
+ this.addSql(`create unique index if not exists "role_perspectives_live_role_tenant_uq" on "role_perspectives" ("role_id", "tenant_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is null;`);
47
+ this.addSql(`create unique index if not exists "role_perspectives_live_role_org_only_uq" on "role_perspectives" ("role_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is not null;`);
48
+ this.addSql(`create unique index if not exists "role_perspectives_live_role_global_uq" on "role_perspectives" ("role_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is null;`);
49
+ }
50
+
51
+ override async down(): Promise<void> {
52
+ this.addSql(`drop index if exists "perspectives_live_user_org_uq";`);
53
+ this.addSql(`drop index if exists "perspectives_live_user_tenant_uq";`);
54
+ this.addSql(`drop index if exists "perspectives_live_user_org_only_uq";`);
55
+ this.addSql(`drop index if exists "perspectives_live_user_global_uq";`);
56
+ this.addSql(`alter table "perspectives" add constraint "perspectives_user_id_tenant_id_organization_id_ta_2d725_unique" unique ("user_id", "tenant_id", "organization_id", "table_id", "name");`);
57
+
58
+ this.addSql(`drop index if exists "role_perspectives_live_role_org_uq";`);
59
+ this.addSql(`drop index if exists "role_perspectives_live_role_tenant_uq";`);
60
+ this.addSql(`drop index if exists "role_perspectives_live_role_org_only_uq";`);
61
+ this.addSql(`drop index if exists "role_perspectives_live_role_global_uq";`);
62
+ this.addSql(`alter table "role_perspectives" add constraint "role_perspectives_role_id_tenant_id_organization__c5467_unique" unique ("role_id", "tenant_id", "organization_id", "table_id", "name");`);
63
+ }
64
+
65
+ }
@@ -296,6 +296,23 @@ export async function saveUserPerspective(
296
296
  current: entity.updatedAt ?? null,
297
297
  request: options.request ?? null,
298
298
  })
299
+ if (entity.name !== input.name) {
300
+ const duplicate = await em.findOne(Perspective, {
301
+ userId: scope.userId,
302
+ tenantId,
303
+ organizationId,
304
+ tableId,
305
+ name: input.name,
306
+ id: { $ne: entity.id } as any,
307
+ deletedAt: null,
308
+ })
309
+ if (duplicate) {
310
+ throw new CrudHttpError(409, {
311
+ error: 'A view with this name already exists.',
312
+ code: 'duplicate_name',
313
+ })
314
+ }
315
+ }
299
316
  } else {
300
317
  entity = await em.findOne(Perspective, {
301
318
  userId: scope.userId,
@@ -2,17 +2,27 @@ import type { CommandHandler } from '@open-mercato/shared/lib/commands'
2
2
  import { registerCommand } from '@open-mercato/shared/lib/commands'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'
5
- import { PlannerAvailabilityRule } from '../data/entities'
5
+ import { PlannerAvailabilityRule, PlannerAvailabilityRuleSet } from '../data/entities'
6
6
  import {
7
7
  plannerAvailabilityWeeklyReplaceSchema,
8
8
  type PlannerAvailabilityWeeklyReplaceInput,
9
9
  } from '../data/validators'
10
10
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
11
  import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
12
+ import {
13
+ enforceCommandOptimisticLock,
14
+ enforceRecordGoneIsConflict,
15
+ } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
12
16
  import { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'
13
17
 
14
18
  const AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'
15
19
 
20
+ // Canonical resource kind for the parent rule set, matching the tag the CRUD
21
+ // factory derives for `planner.availability-rule-sets.*` commands. Weekly
22
+ // replace mutates the rule set's child `availability_rules`, so the parent is
23
+ // the optimistic-lock consistency boundary (document-aggregate pattern).
24
+ const AVAILABILITY_RULE_SET_RESOURCE_KIND = 'planner.availability.rule.set'
25
+
16
26
  const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
17
27
 
18
28
  type AvailabilityRuleSnapshot = {
@@ -164,6 +174,38 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
164
174
  const now = new Date()
165
175
 
166
176
  await em.transactional(async (trx) => {
177
+ // The weekly rules of a rule set are a sub-resource of that rule set: the
178
+ // parent is the optimistic-lock consistency boundary. Guard the parent's
179
+ // version (so a stale weekly save loses to a concurrent rule-set
180
+ // change/delete) and bump its `updated_at` after the replace (so a
181
+ // concurrent rule-set delete/update with a stale token conflicts). See #2927.
182
+ let ruleSet: PlannerAvailabilityRuleSet | null = null
183
+ if (parsed.subjectType === 'ruleset') {
184
+ ruleSet = await trx.findOne(PlannerAvailabilityRuleSet, {
185
+ id: parsed.subjectId,
186
+ tenantId: parsed.tenantId,
187
+ organizationId: parsed.organizationId,
188
+ deletedAt: null,
189
+ })
190
+ if (ruleSet) {
191
+ enforceCommandOptimisticLock({
192
+ resourceKind: AVAILABILITY_RULE_SET_RESOURCE_KIND,
193
+ resourceId: ruleSet.id,
194
+ current: ruleSet.updatedAt,
195
+ request: ctx.request ?? null,
196
+ })
197
+ } else {
198
+ // The rule set was deleted concurrently. When the client opted into
199
+ // optimistic locking, surface the unified conflict instead of
200
+ // silently writing orphan rules; otherwise preserve legacy behavior.
201
+ enforceRecordGoneIsConflict({
202
+ resourceKind: AVAILABILITY_RULE_SET_RESOURCE_KIND,
203
+ resourceId: parsed.subjectId,
204
+ request: ctx.request ?? null,
205
+ })
206
+ }
207
+ }
208
+
167
209
  const existing = await trx.find(PlannerAvailabilityRule, {
168
210
  tenantId: parsed.tenantId,
169
211
  organizationId: parsed.organizationId,
@@ -207,6 +249,11 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
207
249
  trx.persist(record)
208
250
  })
209
251
 
252
+ if (ruleSet) {
253
+ ruleSet.updatedAt = now
254
+ trx.persist(ruleSet)
255
+ }
256
+
210
257
  await trx.flush()
211
258
  })
212
259
 
@@ -857,21 +857,31 @@ export function AvailabilityRulesEditor({
857
857
  if (!subjectIdForRules) return
858
858
  if (weeklyHasErrors) return
859
859
 
860
+ // Weekly rules of a rule set are a sub-resource of that rule set. Send the
861
+ // parent's expected version so a concurrent rule-set change/delete is
862
+ // detected, and refresh it afterwards because the server bumps the rule
863
+ // set's `updated_at` on a successful replace (#2927).
864
+ const parentRuleSet = subjectForRules === 'ruleset'
865
+ ? (ruleSets.find((entry) => entry.id === subjectIdForRules) ?? null)
866
+ : null
867
+
860
868
  const shouldSkipRefresh = Boolean(options?.skipRefresh)
861
869
  setIsWeeklyAutoSaving(options?.silentSuccess === true)
862
870
  try {
863
871
  const windows = buildWeeklyPayload(normalizeWeeklyWindows(weeklyWindowsRef.current))
864
872
  await runMutation({
865
- operation: () => apiCallOrThrow('/api/planner/availability-weekly', {
866
- method: 'POST',
867
- headers: { 'Content-Type': 'application/json' },
868
- body: JSON.stringify({
869
- subjectType: subjectForRules,
870
- subjectId: subjectIdForRules,
871
- timezone,
872
- windows,
873
- }),
874
- }, { errorMessage: listLabels.saveWeeklyError }),
873
+ operation: () => withOptimisticLockForRuleSet(parentRuleSet, () => (
874
+ apiCallOrThrow('/api/planner/availability-weekly', {
875
+ method: 'POST',
876
+ headers: { 'Content-Type': 'application/json' },
877
+ body: JSON.stringify({
878
+ subjectType: subjectForRules,
879
+ subjectId: subjectIdForRules,
880
+ timezone,
881
+ windows,
882
+ }),
883
+ }, { errorMessage: listLabels.saveWeeklyError })
884
+ )),
875
885
  context: mutationContext,
876
886
  mutationPayload: { action: 'save-weekly', subjectType: subjectForRules, subjectId: subjectIdForRules },
877
887
  })
@@ -884,7 +894,13 @@ export function AvailabilityRulesEditor({
884
894
  await refreshAvailability()
885
895
  await refreshRuleSetRules()
886
896
  }
897
+ if (parentRuleSet) {
898
+ await refreshRuleSets()
899
+ }
887
900
  } catch (error) {
901
+ if (surfaceRecordConflict(error, t)) {
902
+ return
903
+ }
888
904
  const message = error instanceof Error ? error.message : listLabels.saveWeeklyError
889
905
  flash(message, 'error')
890
906
  } finally {
@@ -895,9 +911,12 @@ export function AvailabilityRulesEditor({
895
911
  listLabels.saveWeeklySuccess,
896
912
  refreshAvailability,
897
913
  refreshRuleSetRules,
914
+ refreshRuleSets,
915
+ ruleSets,
898
916
  effectiveRulesetId,
899
917
  subjectId,
900
918
  subjectType,
919
+ t,
901
920
  timezone,
902
921
  usingRuleSet,
903
922
  weeklyHasErrors,
@@ -1161,6 +1180,7 @@ export function AvailabilityRulesEditor({
1161
1180
  refreshRuleSets,
1162
1181
  onSuccess: () => flash(listLabels.ruleSetDeleteSuccess, 'success'),
1163
1182
  onError: (error) => {
1183
+ if (surfaceRecordConflict(error, t)) return
1164
1184
  console.error('planner.availability-rule-sets.delete', error)
1165
1185
  const normalized = normalizeCrudServerError(error)
1166
1186
  flash(normalized.message ?? listLabels.ruleSetDeleteError, 'error')
@@ -1178,6 +1198,7 @@ export function AvailabilityRulesEditor({
1178
1198
  refreshRuleSets,
1179
1199
  ruleSets,
1180
1200
  rulesetId,
1201
+ t,
1181
1202
  mutationContext,
1182
1203
  runMutation,
1183
1204
  ])
@@ -20,7 +20,7 @@ export async function GET(req: Request) {
20
20
 
21
21
  const ctx = { tenantId: auth.tenantId, organizationId: auth.orgId }
22
22
 
23
- await progressService.markStaleJobsFailed(auth.tenantId)
23
+ await progressService.markStaleJobsFailed(auth.tenantId, undefined, auth.orgId)
24
24
 
25
25
  const [jobs, recentlyCompleted] = await Promise.all([
26
26
  progressService.getActiveJobs(ctx),