@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/carrier_shipment_idempotency_key/index.js +19 -0
- package/dist/generated/entities/carrier_shipment_idempotency_key/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +10 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/catalog/lib/productResolution.js +45 -0
- package/dist/modules/catalog/lib/productResolution.js.map +7 -0
- package/dist/modules/communication_channels/workers/channel-import-history.js +1 -1
- package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
- package/dist/modules/currencies/api/fetch-configs/route.js +34 -0
- package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js +17 -15
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +176 -161
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +75 -59
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +17 -10
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +6 -4
- package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +2 -2
- package/dist/modules/customers/components/detail/AiActionChips.js +5 -2
- package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +19 -21
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/cancel.js +1 -1
- package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/page.js +9 -45
- package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +11 -17
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js +12 -10
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
- package/dist/modules/data_sync/lib/id-mapping.js +46 -1
- package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/syncRunStatus.js +30 -0
- package/dist/modules/data_sync/lib/syncRunStatus.js.map +7 -0
- package/dist/modules/entities/api/records.js +6 -0
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/feature_toggles/api/global/route.js +17 -15
- package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
- package/dist/modules/feature_toggles/api/openapi.js +2 -2
- package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
- package/dist/modules/feature_toggles/commands/global.js +14 -0
- package/dist/modules/feature_toggles/commands/global.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js +6 -0
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js +3 -1
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +12 -5
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js +10 -1
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js.map +2 -2
- package/dist/modules/payment_gateways/api/cancel/route.js +5 -0
- package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +5 -0
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/refund/route.js +5 -0
- package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +52 -25
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +13 -0
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/perspectives/api/[tableId]/route.js +8 -2
- package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
- package/dist/modules/perspectives/data/entities.js +33 -3
- package/dist/modules/perspectives/data/entities.js.map +2 -2
- package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js +63 -0
- package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js.map +7 -0
- package/dist/modules/perspectives/services/perspectiveService.js +17 -0
- package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
- package/dist/modules/planner/commands/availability-weekly.js +33 -1
- package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
- package/dist/modules/progress/api/active/route.js +1 -1
- package/dist/modules/progress/api/active/route.js.map +2 -2
- package/dist/modules/progress/lib/progressService.js.map +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js +33 -8
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/query_index/api/status.js +19 -24
- package/dist/modules/query_index/api/status.js.map +3 -3
- package/dist/modules/query_index/lib/coverage.js +35 -0
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +11 -6
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/resources/api/resource-types.js +5 -37
- package/dist/modules/resources/api/resource-types.js.map +2 -2
- package/dist/modules/resources/api/resourceTypeCounts.js +35 -0
- package/dist/modules/resources/api/resourceTypeCounts.js.map +7 -0
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +1 -1
- package/dist/modules/resources/backend/resources/resource-types/page.js +2 -1
- package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnDialog.js +4 -1
- package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js +17 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
- package/dist/modules/sales/di.js +4 -0
- package/dist/modules/sales/di.js.map +2 -2
- package/dist/modules/sales/services/salesOrderService.js +26 -0
- package/dist/modules/sales/services/salesOrderService.js.map +7 -0
- package/dist/modules/shipping_carriers/api/shipments/route.js +8 -0
- package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
- package/dist/modules/shipping_carriers/data/entities.js +38 -0
- package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
- package/dist/modules/shipping_carriers/data/validators.js +2 -1
- package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipment-idempotency.js +76 -0
- package/dist/modules/shipping_carriers/lib/shipment-idempotency.js.map +7 -0
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js +2 -1
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +20 -2
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipping-service.js +82 -43
- package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
- package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js +14 -0
- package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-ui/ListView.js +15 -6
- package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +9 -18
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/generated/entities/carrier_shipment_idempotency_key/index.ts +8 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +10 -0
- package/package.json +9 -8
- package/src/modules/catalog/lib/productResolution.ts +75 -0
- package/src/modules/communication_channels/workers/channel-import-history.ts +1 -1
- package/src/modules/currencies/api/fetch-configs/route.ts +36 -0
- package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +20 -17
- package/src/modules/currencies/i18n/de.json +2 -1
- package/src/modules/currencies/i18n/en.json +2 -1
- package/src/modules/currencies/i18n/es.json +2 -1
- package/src/modules/currencies/i18n/pl.json +2 -1
- package/src/modules/customers/api/companies/[id]/route.ts +214 -189
- package/src/modules/customers/api/people/[id]/route.ts +106 -80
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +19 -12
- package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +8 -6
- package/src/modules/customers/components/detail/AiActionChips.tsx +6 -3
- package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +16 -18
- package/src/modules/data_sync/api/runs/[id]/cancel.ts +1 -1
- package/src/modules/data_sync/backend/data-sync/page.tsx +11 -58
- package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +13 -20
- package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +10 -8
- package/src/modules/data_sync/lib/id-mapping.ts +58 -1
- package/src/modules/data_sync/lib/sync-engine.ts +2 -2
- package/src/modules/data_sync/lib/syncRunStatus.ts +39 -0
- package/src/modules/entities/api/records.ts +14 -0
- package/src/modules/feature_toggles/api/global/route.ts +16 -14
- package/src/modules/feature_toggles/api/openapi.ts +2 -2
- package/src/modules/feature_toggles/commands/global.ts +15 -0
- package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +7 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetails.ts +2 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +14 -4
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.ts +15 -1
- package/src/modules/payment_gateways/api/cancel/route.ts +5 -0
- package/src/modules/payment_gateways/api/capture/route.ts +5 -0
- package/src/modules/payment_gateways/api/refund/route.ts +5 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +58 -25
- package/src/modules/payment_gateways/lib/status-machine.ts +16 -0
- package/src/modules/perspectives/api/[tableId]/route.ts +8 -2
- package/src/modules/perspectives/data/entities.ts +43 -3
- package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +69 -25
- package/src/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.ts +65 -0
- package/src/modules/perspectives/services/perspectiveService.ts +17 -0
- package/src/modules/planner/commands/availability-weekly.ts +48 -1
- package/src/modules/planner/components/AvailabilityRulesEditor.tsx +31 -10
- package/src/modules/progress/api/active/route.ts +1 -1
- package/src/modules/progress/lib/progressService.ts +2 -2
- package/src/modules/progress/lib/progressServiceImpl.ts +33 -8
- package/src/modules/query_index/api/status.ts +28 -32
- package/src/modules/query_index/lib/coverage.ts +63 -0
- package/src/modules/query_index/lib/engine.ts +37 -24
- package/src/modules/resources/api/resource-types.ts +4 -44
- package/src/modules/resources/api/resourceTypeCounts.ts +74 -0
- package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
- package/src/modules/resources/backend/resources/resource-types/page.tsx +1 -0
- package/src/modules/sales/components/documents/ReturnDialog.tsx +4 -1
- package/src/modules/sales/components/documents/ShipmentDialog.tsx +20 -2
- package/src/modules/sales/di.ts +6 -0
- package/src/modules/sales/services/salesOrderService.ts +38 -0
- package/src/modules/shipping_carriers/api/shipments/route.ts +8 -0
- package/src/modules/shipping_carriers/data/entities.ts +33 -0
- package/src/modules/shipping_carriers/data/validators.ts +1 -0
- package/src/modules/shipping_carriers/i18n/de.json +1 -0
- package/src/modules/shipping_carriers/i18n/en.json +1 -0
- package/src/modules/shipping_carriers/i18n/es.json +1 -0
- package/src/modules/shipping_carriers/i18n/pl.json +1 -0
- package/src/modules/shipping_carriers/lib/shipment-idempotency.ts +119 -0
- package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.ts +3 -1
- package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +24 -2
- package/src/modules/shipping_carriers/lib/shipping-service.ts +87 -43
- package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +197 -0
- package/src/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.ts +14 -0
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/timesheets-ui/ListView.tsx +15 -7
- package/src/modules/translations/components/TranslationManager.tsx +17 -24
- package/src/modules/translations/i18n/de.json +1 -0
- package/src/modules/translations/i18n/en.json +1 -0
- package/src/modules/translations/i18n/es.json +1 -0
- 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
|
-
|
|
236
|
+
const statusChanged = applyAdapterResultStatus('capture', transaction, result.status)
|
|
212
237
|
transaction.gatewayMetadata = { ...transaction.gatewayMetadata, captureResult: result.providerData }
|
|
213
238
|
await em.flush()
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
332
|
+
const statusChanged = applyAdapterResultStatus('cancel', transaction, result.status)
|
|
302
333
|
await em.flush()
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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":
|
|
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: () =>
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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),
|