@open-mercato/core 0.6.6-develop.6330.1.a261878aa8 → 0.6.6-develop.6331.1.a33b8e99b7
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/dist/modules/attachments/components/AttachmentPartitionSettings.js +3 -0
- package/dist/modules/attachments/components/AttachmentPartitionSettings.js.map +2 -2
- package/dist/modules/auth/api/roles/acl/route.js +2 -2
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +2 -2
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/backend/roles/[id]/edit/page.js +12 -0
- package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/backend/users/[id]/edit/page.js +12 -0
- package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/data/entities.js +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/business_rules/api/rules/route.js +3 -3
- package/dist/modules/business_rules/api/rules/route.js.map +2 -2
- package/dist/modules/business_rules/api/sets/route.js +3 -3
- package/dist/modules/business_rules/api/sets/route.js.map +2 -2
- package/dist/modules/business_rules/backend/rules/[id]/page.js +12 -1
- package/dist/modules/business_rules/backend/rules/[id]/page.js.map +2 -2
- package/dist/modules/business_rules/backend/sets/[id]/page.js +12 -1
- package/dist/modules/business_rules/backend/sets/[id]/page.js.map +2 -2
- package/dist/modules/catalog/api/product-media/route.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js +12 -0
- package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js +15 -0
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +12 -1
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
- package/dist/modules/catalog/components/PriceKindSettings.js +13 -3
- package/dist/modules/catalog/components/PriceKindSettings.js.map +2 -2
- package/dist/modules/catalog/lib/bulkDelete.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/[id]/page.js +15 -3
- package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/page.js +2 -1
- package/dist/modules/currencies/backend/currencies/page.js.map +2 -2
- package/dist/modules/currencies/backend/exchange-rates/[id]/page.js +12 -1
- package/dist/modules/currencies/backend/exchange-rates/[id]/page.js.map +2 -2
- package/dist/modules/currencies/backend/exchange-rates/page.js +3 -2
- package/dist/modules/currencies/backend/exchange-rates/page.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/roles/[id].js +3 -3
- package/dist/modules/customer_accounts/api/admin/roles/[id].js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users/[id].js +3 -3
- package/dist/modules/customer_accounts/api/admin/users/[id].js.map +2 -2
- package/dist/modules/customers/api/addresses/route.js +4 -2
- package/dist/modules/customers/api/addresses/route.js.map +2 -2
- package/dist/modules/customers/api/tags/route.js +8 -3
- package/dist/modules/customers/api/tags/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +13 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +24 -9
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +53 -23
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.js +13 -3
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +19 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +13 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/commands/interactions.js +5 -5
- package/dist/modules/customers/commands/interactions.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +24 -2
- package/dist/modules/customers/commands/pipeline-stages.js.map +2 -2
- package/dist/modules/customers/commands/pipelines.js +24 -2
- package/dist/modules/customers/commands/pipelines.js.map +2 -2
- package/dist/modules/customers/components/detail/AddressesSection.js +46 -29
- package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +5 -1
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/notesAdapter.js +25 -18
- package/dist/modules/customers/components/detail/notesAdapter.js.map +2 -2
- package/dist/modules/data_sync/api/schedules/[id]/route.js +38 -19
- package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
- package/dist/modules/data_sync/api/schedules/route.js +1 -1
- package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/page.js +32 -15
- package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-schedule-service.js +19 -5
- package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js +3 -3
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/route.js +3 -3
- package/dist/modules/dictionaries/api/[dictionaryId]/route.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionariesManager.js +12 -1
- package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
- package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js +12 -0
- package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js.map +2 -2
- package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js +12 -0
- package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js.map +2 -2
- package/dist/modules/entities/api/entities.js +2 -2
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +7 -5
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/feature_toggles/api/overrides/route.js +2 -2
- package/dist/modules/feature_toggles/api/overrides/route.js.map +2 -2
- package/dist/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.js.map +2 -2
- package/dist/modules/feature_toggles/data/entities.js +1 -1
- package/dist/modules/feature_toggles/data/entities.js.map +2 -2
- package/dist/modules/feature_toggles/data/validators.js +2 -1
- package/dist/modules/feature_toggles/data/validators.js.map +2 -2
- package/dist/modules/feature_toggles/lib/queries.js +2 -1
- package/dist/modules/feature_toggles/lib/queries.js.map +2 -2
- package/dist/modules/inbox_ops/api/settings/route.js +2 -2
- package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +12 -1
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js +12 -1
- package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
- package/dist/modules/resources/components/detail/notesAdapter.js +25 -18
- package/dist/modules/resources/components/detail/notesAdapter.js.map +2 -2
- package/dist/modules/sales/backend/sales/documents/[id]/page.js +14 -1
- package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +11 -10
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/payments.js +6 -1
- package/dist/modules/sales/commands/payments.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +3 -3
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/commands/shared.js +3 -3
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/commands/shipments.js +6 -1
- package/dist/modules/sales/commands/shipments.js.map +2 -2
- package/dist/modules/sales/components/documents/PaymentDialog.js +6 -3
- package/dist/modules/sales/components/documents/PaymentDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/PaymentsSection.js +7 -3
- package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js +6 -3
- package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentsSection.js +7 -3
- package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
- package/dist/modules/sales/di.js +39 -3
- package/dist/modules/sales/di.js.map +2 -2
- package/dist/modules/sales/setup.js +2 -0
- package/dist/modules/sales/setup.js.map +2 -2
- package/dist/modules/staff/backend/staff/leave-requests/[id]/page.js +12 -1
- package/dist/modules/staff/backend/staff/leave-requests/[id]/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/team-members/[id]/page.js +12 -1
- package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/team-roles/[id]/edit/page.js +12 -1
- package/dist/modules/staff/backend/staff/team-roles/[id]/edit/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js +12 -1
- package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js.map +2 -2
- package/dist/modules/staff/commands/job-histories.js +3 -3
- package/dist/modules/staff/commands/job-histories.js.map +2 -2
- package/dist/modules/staff/components/detail/addressesAdapter.js +33 -24
- package/dist/modules/staff/components/detail/addressesAdapter.js.map +2 -2
- package/dist/modules/staff/components/detail/notesAdapter.js +25 -18
- package/dist/modules/staff/components/detail/notesAdapter.js.map +2 -2
- package/dist/modules/translations/api/[entityType]/[entityId]/route.js +37 -0
- package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +7 -1
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/dist/modules/workflows/api/definitions/[id]/route.js +3 -3
- package/dist/modules/workflows/api/definitions/[id]/route.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/[id]/page.js +12 -1
- package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +11 -1
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/components/AttachmentPartitionSettings.tsx +3 -0
- package/src/modules/auth/api/roles/acl/route.ts +2 -2
- package/src/modules/auth/api/users/acl/route.ts +2 -2
- package/src/modules/auth/backend/roles/[id]/edit/page.tsx +17 -0
- package/src/modules/auth/backend/users/[id]/edit/page.tsx +17 -0
- package/src/modules/auth/data/entities.ts +2 -2
- package/src/modules/business_rules/api/rules/route.ts +3 -3
- package/src/modules/business_rules/api/sets/route.ts +3 -3
- package/src/modules/business_rules/backend/rules/[id]/page.tsx +17 -1
- package/src/modules/business_rules/backend/sets/[id]/page.tsx +18 -1
- package/src/modules/catalog/api/product-media/route.ts +4 -0
- package/src/modules/catalog/backend/catalog/categories/[id]/edit/page.tsx +17 -0
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +21 -0
- package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +16 -1
- package/src/modules/catalog/components/PriceKindSettings.tsx +15 -7
- package/src/modules/catalog/lib/bulkDelete.ts +6 -0
- package/src/modules/currencies/backend/currencies/[id]/page.tsx +20 -3
- package/src/modules/currencies/backend/currencies/page.tsx +2 -1
- package/src/modules/currencies/backend/exchange-rates/[id]/page.tsx +17 -1
- package/src/modules/currencies/backend/exchange-rates/page.tsx +3 -2
- package/src/modules/customer_accounts/api/admin/roles/[id].ts +3 -3
- package/src/modules/customer_accounts/api/admin/users/[id].ts +3 -3
- package/src/modules/customers/api/addresses/route.ts +2 -0
- package/src/modules/customers/api/tags/route.ts +6 -1
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +19 -2
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +29 -8
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +47 -20
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealPipeline.ts +13 -3
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +21 -1
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -2
- package/src/modules/customers/commands/interactions.ts +5 -5
- package/src/modules/customers/commands/pipeline-stages.ts +26 -2
- package/src/modules/customers/commands/pipelines.ts +26 -2
- package/src/modules/customers/components/detail/AddressesSection.tsx +50 -28
- package/src/modules/customers/components/detail/DealForm.tsx +12 -0
- package/src/modules/customers/components/detail/notesAdapter.ts +29 -18
- package/src/modules/customers/i18n/de.json +2 -0
- package/src/modules/customers/i18n/en.json +2 -0
- package/src/modules/customers/i18n/es.json +2 -0
- package/src/modules/customers/i18n/pl.json +2 -0
- package/src/modules/data_sync/api/schedules/[id]/route.ts +38 -20
- package/src/modules/data_sync/api/schedules/route.ts +1 -1
- package/src/modules/data_sync/backend/data-sync/page.tsx +32 -15
- package/src/modules/data_sync/lib/sync-schedule-service.ts +30 -5
- package/src/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.ts +3 -3
- package/src/modules/dictionaries/api/[dictionaryId]/route.ts +3 -3
- package/src/modules/dictionaries/components/DictionariesManager.tsx +21 -1
- package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +2 -0
- package/src/modules/directory/backend/directory/organizations/[id]/edit/page.tsx +17 -0
- package/src/modules/directory/backend/directory/tenants/[id]/edit/page.tsx +17 -0
- package/src/modules/entities/api/entities.ts +2 -2
- package/src/modules/entities/api/records.ts +7 -5
- package/src/modules/feature_toggles/api/overrides/route.ts +2 -2
- package/src/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.tsx +12 -0
- package/src/modules/feature_toggles/data/entities.ts +1 -1
- package/src/modules/feature_toggles/data/validators.ts +1 -0
- package/src/modules/feature_toggles/lib/queries.ts +1 -0
- package/src/modules/inbox_ops/api/settings/route.ts +2 -2
- package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +17 -1
- package/src/modules/resources/backend/resources/resources/[id]/page.tsx +17 -1
- package/src/modules/resources/components/detail/notesAdapter.ts +29 -18
- package/src/modules/sales/backend/sales/documents/[id]/page.tsx +17 -1
- package/src/modules/sales/commands/documents.ts +11 -10
- package/src/modules/sales/commands/payments.ts +11 -0
- package/src/modules/sales/commands/returns.ts +3 -3
- package/src/modules/sales/commands/shared.ts +10 -4
- package/src/modules/sales/commands/shipments.ts +14 -0
- package/src/modules/sales/components/documents/PaymentDialog.tsx +7 -3
- package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -3
- package/src/modules/sales/components/documents/ShipmentDialog.tsx +7 -3
- package/src/modules/sales/components/documents/ShipmentsSection.tsx +8 -3
- package/src/modules/sales/di.ts +68 -9
- package/src/modules/sales/setup.ts +2 -0
- package/src/modules/staff/backend/staff/leave-requests/[id]/page.tsx +17 -1
- package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +17 -1
- package/src/modules/staff/backend/staff/team-roles/[id]/edit/page.tsx +17 -1
- package/src/modules/staff/backend/staff/teams/[id]/edit/page.tsx +17 -1
- package/src/modules/staff/commands/job-histories.ts +3 -3
- package/src/modules/staff/components/detail/addressesAdapter.ts +40 -23
- package/src/modules/staff/components/detail/notesAdapter.ts +28 -18
- package/src/modules/translations/api/[entityType]/[entityId]/route.ts +74 -0
- package/src/modules/translations/components/TranslationManager.tsx +14 -1
- package/src/modules/workflows/api/definitions/[id]/route.ts +3 -3
- package/src/modules/workflows/backend/definitions/[id]/page.tsx +18 -1
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +19 -1
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
import { ensureOrganizationScope, ensureTenantScope, ensureDictionaryEntry } from './shared'
|
|
17
17
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
18
18
|
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
19
|
+
import {
|
|
20
|
+
enforceCommandOptimisticLockWithGuards,
|
|
21
|
+
enforceRecordGoneIsConflict,
|
|
22
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
19
23
|
|
|
20
24
|
const createPipelineStageCommand: CommandHandler<PipelineStageCreateInput, { stageId: string }> = {
|
|
21
25
|
id: 'customers.pipeline-stages.create',
|
|
@@ -106,11 +110,21 @@ const updatePipelineStageCommand: CommandHandler<PipelineStageUpdateInput, void>
|
|
|
106
110
|
...(callerTenantId ? { tenantId: callerTenantId } : {}),
|
|
107
111
|
...(callerOrganizationId ? { organizationId: callerOrganizationId } : {}),
|
|
108
112
|
})
|
|
109
|
-
if (!stage)
|
|
113
|
+
if (!stage) {
|
|
114
|
+
enforceRecordGoneIsConflict({ resourceKind: 'customers.pipelineStage', resourceId: parsed.id, request: ctx.request ?? null })
|
|
115
|
+
throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
|
|
116
|
+
}
|
|
110
117
|
|
|
111
118
|
ensureTenantScope(ctx, stage.tenantId)
|
|
112
119
|
ensureOrganizationScope(ctx, stage.organizationId)
|
|
113
120
|
|
|
121
|
+
await enforceCommandOptimisticLockWithGuards(ctx.container, {
|
|
122
|
+
resourceKind: 'customers.pipelineStage',
|
|
123
|
+
resourceId: stage.id,
|
|
124
|
+
current: stage.updatedAt,
|
|
125
|
+
request: ctx.request ?? null,
|
|
126
|
+
})
|
|
127
|
+
|
|
114
128
|
await withAtomicFlush(em, [
|
|
115
129
|
() => {
|
|
116
130
|
if (parsed.label !== undefined) stage.label = parsed.label
|
|
@@ -148,11 +162,21 @@ const deletePipelineStageCommand: CommandHandler<PipelineStageDeleteInput, void>
|
|
|
148
162
|
...(callerTenantId ? { tenantId: callerTenantId } : {}),
|
|
149
163
|
...(callerOrganizationId ? { organizationId: callerOrganizationId } : {}),
|
|
150
164
|
})
|
|
151
|
-
if (!stage)
|
|
165
|
+
if (!stage) {
|
|
166
|
+
enforceRecordGoneIsConflict({ resourceKind: 'customers.pipelineStage', resourceId: parsed.id, request: ctx.request ?? null })
|
|
167
|
+
throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
|
|
168
|
+
}
|
|
152
169
|
|
|
153
170
|
ensureTenantScope(ctx, stage.tenantId)
|
|
154
171
|
ensureOrganizationScope(ctx, stage.organizationId)
|
|
155
172
|
|
|
173
|
+
await enforceCommandOptimisticLockWithGuards(ctx.container, {
|
|
174
|
+
resourceKind: 'customers.pipelineStage',
|
|
175
|
+
resourceId: stage.id,
|
|
176
|
+
current: stage.updatedAt,
|
|
177
|
+
request: ctx.request ?? null,
|
|
178
|
+
})
|
|
179
|
+
|
|
156
180
|
const activeDealsCount = await em.count(CustomerDeal, {
|
|
157
181
|
pipelineStageId: parsed.id,
|
|
158
182
|
deletedAt: null,
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
} from '../data/validators'
|
|
13
13
|
import { ensureOrganizationScope, ensureTenantScope } from './shared'
|
|
14
14
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
15
|
+
import {
|
|
16
|
+
enforceCommandOptimisticLockWithGuards,
|
|
17
|
+
enforceRecordGoneIsConflict,
|
|
18
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
15
19
|
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
16
20
|
|
|
17
21
|
const createPipelineCommand: CommandHandler<PipelineCreateInput, { pipelineId: string }> = {
|
|
@@ -56,11 +60,21 @@ const updatePipelineCommand: CommandHandler<PipelineUpdateInput, void> = {
|
|
|
56
60
|
|
|
57
61
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
58
62
|
const pipeline = await em.findOne(CustomerPipeline, { id: parsed.id })
|
|
59
|
-
if (!pipeline)
|
|
63
|
+
if (!pipeline) {
|
|
64
|
+
enforceRecordGoneIsConflict({ resourceKind: 'customers.pipeline', resourceId: parsed.id, request: ctx.request ?? null })
|
|
65
|
+
throw new CrudHttpError(404, { error: 'Pipeline not found' })
|
|
66
|
+
}
|
|
60
67
|
|
|
61
68
|
ensureTenantScope(ctx, pipeline.tenantId)
|
|
62
69
|
ensureOrganizationScope(ctx, pipeline.organizationId)
|
|
63
70
|
|
|
71
|
+
await enforceCommandOptimisticLockWithGuards(ctx.container, {
|
|
72
|
+
resourceKind: 'customers.pipeline',
|
|
73
|
+
resourceId: pipeline.id,
|
|
74
|
+
current: pipeline.updatedAt,
|
|
75
|
+
request: ctx.request ?? null,
|
|
76
|
+
})
|
|
77
|
+
|
|
64
78
|
await withAtomicFlush(em, [
|
|
65
79
|
async () => {
|
|
66
80
|
if (parsed.isDefault && !pipeline.isDefault) {
|
|
@@ -86,11 +100,21 @@ const deletePipelineCommand: CommandHandler<PipelineDeleteInput, void> = {
|
|
|
86
100
|
|
|
87
101
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
88
102
|
const pipeline = await em.findOne(CustomerPipeline, { id: parsed.id })
|
|
89
|
-
if (!pipeline)
|
|
103
|
+
if (!pipeline) {
|
|
104
|
+
enforceRecordGoneIsConflict({ resourceKind: 'customers.pipeline', resourceId: parsed.id, request: ctx.request ?? null })
|
|
105
|
+
throw new CrudHttpError(404, { error: 'Pipeline not found' })
|
|
106
|
+
}
|
|
90
107
|
|
|
91
108
|
ensureTenantScope(ctx, pipeline.tenantId)
|
|
92
109
|
ensureOrganizationScope(ctx, pipeline.organizationId)
|
|
93
110
|
|
|
111
|
+
await enforceCommandOptimisticLockWithGuards(ctx.container, {
|
|
112
|
+
resourceKind: 'customers.pipeline',
|
|
113
|
+
resourceId: pipeline.id,
|
|
114
|
+
current: pipeline.updatedAt,
|
|
115
|
+
request: ctx.request ?? null,
|
|
116
|
+
})
|
|
117
|
+
|
|
94
118
|
const activeDealsCount = await em.count(CustomerDeal, {
|
|
95
119
|
pipelineId: parsed.id,
|
|
96
120
|
deletedAt: null,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
4
|
+
import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
5
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
6
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
5
7
|
import { AddressesSection as SharedAddressesSection } from '@open-mercato/ui/backend/detail'
|
|
6
8
|
import type { AddressDataAdapter, AddressTypesAdapter, AddressFormatStrategy, SectionAction } from '@open-mercato/ui/backend/detail'
|
|
7
9
|
import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
@@ -99,6 +101,12 @@ export function AddressesSection({
|
|
|
99
101
|
: typeof record.isPrimary === 'boolean'
|
|
100
102
|
? record.isPrimary
|
|
101
103
|
: false,
|
|
104
|
+
updatedAt:
|
|
105
|
+
typeof record.updated_at === 'string'
|
|
106
|
+
? record.updated_at
|
|
107
|
+
: typeof record.updatedAt === 'string'
|
|
108
|
+
? record.updatedAt
|
|
109
|
+
: null,
|
|
102
110
|
}
|
|
103
111
|
})
|
|
104
112
|
.filter((value): value is NonNullable<typeof value> => value !== null)
|
|
@@ -107,7 +115,7 @@ export function AddressesSection({
|
|
|
107
115
|
const response = await apiCallOrThrow<Record<string, unknown>>(
|
|
108
116
|
'/api/customers/addresses',
|
|
109
117
|
{
|
|
110
|
-
// optimistic-lock-exempt: address
|
|
118
|
+
// optimistic-lock-exempt: address create-only (no prior version to compare)
|
|
111
119
|
method: 'POST',
|
|
112
120
|
headers: { 'content-type': 'application/json' },
|
|
113
121
|
body: JSON.stringify({
|
|
@@ -120,33 +128,47 @@ export function AddressesSection({
|
|
|
120
128
|
)
|
|
121
129
|
return response.result ?? {}
|
|
122
130
|
},
|
|
123
|
-
update: async ({ id, payload }) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
update: async ({ id, payload, updatedAt }) => {
|
|
132
|
+
try {
|
|
133
|
+
await withScopedApiRequestHeaders(
|
|
134
|
+
buildOptimisticLockHeader(updatedAt ?? null),
|
|
135
|
+
() => apiCallOrThrow(
|
|
136
|
+
'/api/customers/addresses',
|
|
137
|
+
{
|
|
138
|
+
method: 'PUT',
|
|
139
|
+
headers: { 'content-type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
id,
|
|
142
|
+
...payload,
|
|
143
|
+
country: payload.country ? payload.country.toUpperCase() : undefined,
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
{ errorMessage: t('customers.people.detail.addresses.error') },
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
surfaceRecordConflict(err, t)
|
|
151
|
+
throw err
|
|
152
|
+
}
|
|
138
153
|
},
|
|
139
|
-
delete: async ({ id }) => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
delete: async ({ id, updatedAt }) => {
|
|
155
|
+
try {
|
|
156
|
+
await withScopedApiRequestHeaders(
|
|
157
|
+
buildOptimisticLockHeader(updatedAt ?? null),
|
|
158
|
+
() => apiCallOrThrow(
|
|
159
|
+
'/api/customers/addresses',
|
|
160
|
+
{
|
|
161
|
+
method: 'DELETE',
|
|
162
|
+
headers: { 'content-type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({ id }),
|
|
164
|
+
},
|
|
165
|
+
{ errorMessage: t('customers.people.detail.addresses.error') },
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
} catch (err) {
|
|
169
|
+
surfaceRecordConflict(err, t)
|
|
170
|
+
throw err
|
|
171
|
+
}
|
|
150
172
|
},
|
|
151
173
|
}), [t])
|
|
152
174
|
|
|
@@ -69,6 +69,14 @@ export type DealFormProps = {
|
|
|
69
69
|
showCancelAction?: boolean
|
|
70
70
|
initialPipelineOptions?: PipelineOption[]
|
|
71
71
|
initialPipelineStageOptions?: PipelineStageOption[]
|
|
72
|
+
/**
|
|
73
|
+
* Injection spot id for the form-scoped record_locks widget (e.g.
|
|
74
|
+
* `customers.deal`). Mirrors how people-v2/companies-v2 mount their save-time
|
|
75
|
+
* `crud-form:*` widget so a deal save conflict surfaces the merge dialog.
|
|
76
|
+
*/
|
|
77
|
+
injectionSpotId?: string
|
|
78
|
+
/** Optimistic-lock version (`deal.updatedAt`) for the embedded CrudForm. */
|
|
79
|
+
optimisticLockUpdatedAt?: string | null
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
type EntityOption = {
|
|
@@ -732,6 +740,8 @@ export function DealForm({
|
|
|
732
740
|
showCancelAction = true,
|
|
733
741
|
initialPipelineOptions,
|
|
734
742
|
initialPipelineStageOptions,
|
|
743
|
+
injectionSpotId,
|
|
744
|
+
optimisticLockUpdatedAt,
|
|
735
745
|
}: DealFormProps) {
|
|
736
746
|
const t = useT()
|
|
737
747
|
const [pending, setPending] = React.useState(false)
|
|
@@ -1168,6 +1178,8 @@ export function DealForm({
|
|
|
1168
1178
|
backHref={backHref}
|
|
1169
1179
|
hideFooterActions={hideFooterActions}
|
|
1170
1180
|
onDirtyChange={onDirtyChange}
|
|
1181
|
+
injectionSpotId={injectionSpotId}
|
|
1182
|
+
optimisticLockUpdatedAt={optimisticLockUpdatedAt}
|
|
1171
1183
|
collapsibleGroups={collapsibleGroups}
|
|
1172
1184
|
sortableGroups={sortableGroups}
|
|
1173
1185
|
versionHistory={showVersionHistory && mode === 'edit' && initialValues?.id
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
3
|
+
import { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
4
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
4
5
|
import { mapCommentSummary, type NotesDataAdapter } from '@open-mercato/ui/backend/detail/NotesSection'
|
|
5
6
|
|
|
6
7
|
type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
|
|
@@ -92,33 +93,43 @@ export function createCustomerNotesAdapter(
|
|
|
92
93
|
)
|
|
93
94
|
return response.result ?? {}
|
|
94
95
|
},
|
|
95
|
-
update: async ({ id, patch }) => {
|
|
96
|
+
update: async ({ id, patch, updatedAt }) => {
|
|
96
97
|
const payload: Record<string, unknown> = { id }
|
|
97
98
|
if (patch.body !== undefined) payload.body = patch.body
|
|
98
99
|
if (patch.appearanceIcon !== undefined) payload.appearanceIcon = patch.appearanceIcon
|
|
99
100
|
if (patch.appearanceColor !== undefined) payload.appearanceColor = patch.appearanceColor
|
|
101
|
+
// Send the optimistic-lock header (note's loaded updatedAt) so a stale edit —
|
|
102
|
+
// or an edit after the note was deleted in another tab — surfaces the unified
|
|
103
|
+
// conflict bar (409) instead of silently overwriting. The guarded runner
|
|
104
|
+
// (useGuardedMutation) routes the 409 through surfaceRecordConflict.
|
|
100
105
|
await runWrite(
|
|
101
|
-
() =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
() => withScopedApiRequestHeaders(
|
|
107
|
+
buildOptimisticLockHeader(updatedAt ?? null),
|
|
108
|
+
() => apiCallOrThrow(
|
|
109
|
+
'/api/customers/comments',
|
|
110
|
+
{
|
|
111
|
+
method: 'PUT',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(payload),
|
|
114
|
+
},
|
|
115
|
+
{ errorMessage: translator('customers.people.detail.notes.updateError') },
|
|
116
|
+
),
|
|
109
117
|
),
|
|
110
118
|
{ operation: 'updateNote', id },
|
|
111
119
|
)
|
|
112
120
|
},
|
|
113
|
-
delete: async ({ id }) => {
|
|
121
|
+
delete: async ({ id, updatedAt }) => {
|
|
114
122
|
await runWrite(
|
|
115
|
-
() =>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
() => withScopedApiRequestHeaders(
|
|
124
|
+
buildOptimisticLockHeader(updatedAt ?? null),
|
|
125
|
+
() => apiCallOrThrow(
|
|
126
|
+
`/api/customers/comments?id=${encodeURIComponent(id)}`,
|
|
127
|
+
{
|
|
128
|
+
method: 'DELETE',
|
|
129
|
+
headers: { 'content-type': 'application/json' },
|
|
130
|
+
},
|
|
131
|
+
{ errorMessage: translator('customers.people.detail.notes.deleteError', 'Failed to delete note') },
|
|
132
|
+
),
|
|
122
133
|
),
|
|
123
134
|
{ operation: 'deleteNote', id },
|
|
124
135
|
)
|
|
@@ -923,6 +923,8 @@
|
|
|
923
923
|
"customers.deals.detail.badge.deal": "Deal",
|
|
924
924
|
"customers.deals.detail.badge.lost": "Lost",
|
|
925
925
|
"customers.deals.detail.badge.won": "Won",
|
|
926
|
+
"customers.deals.detail.closeLostError": "Deal konnte nicht als verloren markiert werden.",
|
|
927
|
+
"customers.deals.detail.closeWonError": "Deal konnte nicht als gewonnen markiert werden.",
|
|
926
928
|
"customers.deals.detail.closure.lost": "Lost",
|
|
927
929
|
"customers.deals.detail.closure.prompt": "Close deal — choose outcome",
|
|
928
930
|
"customers.deals.detail.closure.won": "Won",
|
|
@@ -923,6 +923,8 @@
|
|
|
923
923
|
"customers.deals.detail.badge.deal": "Deal",
|
|
924
924
|
"customers.deals.detail.badge.lost": "Lost",
|
|
925
925
|
"customers.deals.detail.badge.won": "Won",
|
|
926
|
+
"customers.deals.detail.closeLostError": "Failed to mark deal as lost.",
|
|
927
|
+
"customers.deals.detail.closeWonError": "Failed to mark deal as won.",
|
|
926
928
|
"customers.deals.detail.closure.lost": "Lost",
|
|
927
929
|
"customers.deals.detail.closure.prompt": "Close deal — choose outcome",
|
|
928
930
|
"customers.deals.detail.closure.won": "Won",
|
|
@@ -923,6 +923,8 @@
|
|
|
923
923
|
"customers.deals.detail.badge.deal": "Deal",
|
|
924
924
|
"customers.deals.detail.badge.lost": "Lost",
|
|
925
925
|
"customers.deals.detail.badge.won": "Won",
|
|
926
|
+
"customers.deals.detail.closeLostError": "No se pudo marcar el deal como perdido.",
|
|
927
|
+
"customers.deals.detail.closeWonError": "No se pudo marcar el deal como ganado.",
|
|
926
928
|
"customers.deals.detail.closure.lost": "Lost",
|
|
927
929
|
"customers.deals.detail.closure.prompt": "Close deal — choose outcome",
|
|
928
930
|
"customers.deals.detail.closure.won": "Won",
|
|
@@ -923,6 +923,8 @@
|
|
|
923
923
|
"customers.deals.detail.badge.deal": "Deal",
|
|
924
924
|
"customers.deals.detail.badge.lost": "Lost",
|
|
925
925
|
"customers.deals.detail.badge.won": "Won",
|
|
926
|
+
"customers.deals.detail.closeLostError": "Nie udało się oznaczyć deala jako przegranego.",
|
|
927
|
+
"customers.deals.detail.closeWonError": "Nie udało się oznaczyć deala jako wygranego.",
|
|
926
928
|
"customers.deals.detail.closure.lost": "Lost",
|
|
927
929
|
"customers.deals.detail.closure.prompt": "Close deal — choose outcome",
|
|
928
930
|
"customers.deals.detail.closure.won": "Won",
|
|
@@ -3,6 +3,8 @@ import { z } from 'zod'
|
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
6
|
+
import { readOptimisticLockExpected } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
7
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
8
|
import { updateSyncScheduleSchema } from '../../../data/validators'
|
|
7
9
|
import type { SyncScheduleService } from '../../../lib/sync-schedule-service'
|
|
8
10
|
import { serializeSchedule } from '../serialize'
|
|
@@ -111,7 +113,8 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
111
113
|
timezone: parsed.data.timezone ?? current.timezone,
|
|
112
114
|
fullSync: parsed.data.fullSync ?? current.fullSync,
|
|
113
115
|
isEnabled: parsed.data.isEnabled ?? current.isEnabled,
|
|
114
|
-
|
|
116
|
+
expectedUpdatedAt: readOptimisticLockExpected(req),
|
|
117
|
+
}, scope, container)
|
|
115
118
|
|
|
116
119
|
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
117
120
|
await runCrudMutationGuardAfterSuccess(container, {
|
|
@@ -126,9 +129,11 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
126
129
|
metadata: guardResult.metadata ?? null,
|
|
127
130
|
})
|
|
128
131
|
}
|
|
129
|
-
|
|
130
132
|
return NextResponse.json(serializeSchedule(schedule))
|
|
131
133
|
} catch (error) {
|
|
134
|
+
if (isCrudHttpError(error)) {
|
|
135
|
+
return NextResponse.json(error.body, { status: error.status })
|
|
136
|
+
}
|
|
132
137
|
const message = error instanceof Error ? error.message : 'Failed to update sync schedule'
|
|
133
138
|
return NextResponse.json({ error: message }, { status: 422 })
|
|
134
139
|
}
|
|
@@ -168,25 +173,38 @@ export async function DELETE(req: Request, ctx: { params?: Promise<{ id?: string
|
|
|
168
173
|
return NextResponse.json(guardResult.body, { status: guardResult.status })
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
|
|
176
|
+
try {
|
|
177
|
+
const deleted = await scheduleService.deleteSchedule(
|
|
178
|
+
parsedParams.data.id,
|
|
179
|
+
scope,
|
|
180
|
+
container,
|
|
181
|
+
readOptimisticLockExpected(req),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if (!deleted) {
|
|
185
|
+
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
|
|
186
|
+
}
|
|
172
187
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
189
|
+
await runCrudMutationGuardAfterSuccess(container, {
|
|
190
|
+
tenantId: auth.tenantId,
|
|
191
|
+
organizationId: scope.organizationId,
|
|
192
|
+
userId: auth.sub,
|
|
193
|
+
resourceKind: 'data_sync.schedule',
|
|
194
|
+
resourceId: parsedParams.data.id,
|
|
195
|
+
operation: 'delete',
|
|
196
|
+
requestMethod: req.method,
|
|
197
|
+
requestHeaders: req.headers,
|
|
198
|
+
metadata: guardResult.metadata ?? null,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
176
201
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
operation: 'delete',
|
|
185
|
-
requestMethod: req.method,
|
|
186
|
-
requestHeaders: req.headers,
|
|
187
|
-
metadata: guardResult.metadata ?? null,
|
|
188
|
-
})
|
|
202
|
+
return NextResponse.json({ deleted: true })
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (isCrudHttpError(error)) {
|
|
205
|
+
return NextResponse.json(error.body, { status: error.status })
|
|
206
|
+
}
|
|
207
|
+
const message = error instanceof Error ? error.message : 'Failed to delete sync schedule'
|
|
208
|
+
return NextResponse.json({ error: message }, { status: 422 })
|
|
189
209
|
}
|
|
190
|
-
|
|
191
|
-
return NextResponse.json({ deleted: true })
|
|
192
210
|
}
|
|
@@ -90,7 +90,7 @@ export async function POST(req: Request) {
|
|
|
90
90
|
const schedule = await scheduleService.saveSchedule({
|
|
91
91
|
...parsed.data,
|
|
92
92
|
expectedUpdatedAt: readOptimisticLockExpected(req),
|
|
93
|
-
}, scope)
|
|
93
|
+
}, scope, container)
|
|
94
94
|
|
|
95
95
|
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
96
96
|
await runCrudMutationGuardAfterSuccess(container, {
|
|
@@ -26,6 +26,7 @@ import { Switch } from '@open-mercato/ui/primitives/switch'
|
|
|
26
26
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
27
27
|
import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
28
28
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
29
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
29
30
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
30
31
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
31
32
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -402,21 +403,27 @@ export default function SyncRunsDashboardPage() {
|
|
|
402
403
|
setIsSavingSchedule(true)
|
|
403
404
|
try {
|
|
404
405
|
const call = await runMutation({
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
406
|
+
// Keyed upsert (POST). When the editor holds an existing schedule's
|
|
407
|
+
// `updatedAt`, the lock header version-checks the resolved row on the
|
|
408
|
+
// server; a brand-new schedule has a null `updatedAt`, so the header is
|
|
409
|
+
// empty and the create path stays unaffected.
|
|
410
|
+
operation: () => withScopedApiRequestHeaders(
|
|
411
|
+
buildOptimisticLockHeader(scheduleEditor.updatedAt),
|
|
412
|
+
() => apiCall<SyncScheduleRecord>('/api/data_sync/schedules', {
|
|
413
|
+
method: 'POST',
|
|
414
|
+
headers: { 'Content-Type': 'application/json' },
|
|
415
|
+
body: JSON.stringify({
|
|
416
|
+
integrationId: selectedIntegration.integrationId,
|
|
417
|
+
entityType: selectedEntityType,
|
|
418
|
+
direction: selectedDirection,
|
|
419
|
+
scheduleType: scheduleEditor.scheduleType,
|
|
420
|
+
scheduleValue: scheduleEditor.scheduleValue.trim(),
|
|
421
|
+
timezone: scheduleEditor.timezone.trim() || DEFAULT_TIMEZONE,
|
|
422
|
+
fullSync: scheduleEditor.fullSync,
|
|
423
|
+
isEnabled: scheduleEditor.isEnabled,
|
|
424
|
+
}),
|
|
425
|
+
}, { fallback: null }),
|
|
426
|
+
),
|
|
420
427
|
mutationPayload: {
|
|
421
428
|
integrationId: selectedIntegration.integrationId,
|
|
422
429
|
entityType: selectedEntityType,
|
|
@@ -435,6 +442,16 @@ export default function SyncRunsDashboardPage() {
|
|
|
435
442
|
})
|
|
436
443
|
|
|
437
444
|
if (!call.ok || !call.result) {
|
|
445
|
+
const conflictError = Object.assign(
|
|
446
|
+
new Error((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.schedule.error', 'Failed to save recurring schedule')),
|
|
447
|
+
{
|
|
448
|
+
status: call.status,
|
|
449
|
+
...(call.result && typeof call.result === 'object' ? call.result : {}),
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
if (surfaceRecordConflict(conflictError, t)) {
|
|
453
|
+
return
|
|
454
|
+
}
|
|
438
455
|
flash((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.schedule.error', 'Failed to save recurring schedule'), 'error')
|
|
439
456
|
return
|
|
440
457
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
3
|
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
3
4
|
import { findAndCountWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
-
import {
|
|
5
|
+
import { enforceCommandOptimisticLockWithGuards, enforceRecordGoneIsConflict } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
5
6
|
import { SyncSchedule } from '../data/entities'
|
|
6
7
|
|
|
7
8
|
type SyncScope = {
|
|
@@ -131,18 +132,28 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
131
132
|
fullSync: boolean
|
|
132
133
|
isEnabled: boolean
|
|
133
134
|
expectedUpdatedAt?: string | null
|
|
134
|
-
}, scope: SyncScope): Promise<SyncSchedule> {
|
|
135
|
+
}, scope: SyncScope, container?: AwilixContainer): Promise<SyncSchedule> {
|
|
135
136
|
const existing = input.id
|
|
136
137
|
? await getById(input.id, scope)
|
|
137
138
|
: await getByKey(input.integrationId, input.entityType, input.direction, scope)
|
|
138
139
|
|
|
139
|
-
if (existing) {
|
|
140
|
-
|
|
140
|
+
if (existing && container) {
|
|
141
|
+
await enforceCommandOptimisticLockWithGuards(container, {
|
|
141
142
|
resourceKind: 'data_sync.schedule',
|
|
142
143
|
resourceId: existing.id,
|
|
143
144
|
current: existing.updatedAt ?? null,
|
|
144
145
|
expected: input.expectedUpdatedAt ?? null,
|
|
145
146
|
})
|
|
147
|
+
} else if (!existing && input.expectedUpdatedAt) {
|
|
148
|
+
// Concurrent delete-then-edit race: the client edited a schedule that was
|
|
149
|
+
// removed before this keyed upsert ran. Surface the unified conflict
|
|
150
|
+
// instead of silently re-creating it. No-op when no expected version was
|
|
151
|
+
// sent (a genuine create) or when OM_OPTIMISTIC_LOCK is off.
|
|
152
|
+
enforceRecordGoneIsConflict({
|
|
153
|
+
resourceKind: 'data_sync.schedule',
|
|
154
|
+
resourceId: input.id ?? `${input.integrationId}:${input.entityType}:${input.direction}`,
|
|
155
|
+
expected: input.expectedUpdatedAt,
|
|
156
|
+
})
|
|
146
157
|
}
|
|
147
158
|
|
|
148
159
|
const row = existing ?? em.create(SyncSchedule, {
|
|
@@ -200,10 +211,24 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
200
211
|
return row
|
|
201
212
|
},
|
|
202
213
|
|
|
203
|
-
async deleteSchedule(
|
|
214
|
+
async deleteSchedule(
|
|
215
|
+
id: string,
|
|
216
|
+
scope: SyncScope,
|
|
217
|
+
container?: AwilixContainer,
|
|
218
|
+
expectedUpdatedAt?: string | null,
|
|
219
|
+
): Promise<boolean> {
|
|
204
220
|
const row = await getById(id, scope)
|
|
205
221
|
if (!row) return false
|
|
206
222
|
|
|
223
|
+
if (container) {
|
|
224
|
+
await enforceCommandOptimisticLockWithGuards(container, {
|
|
225
|
+
resourceKind: 'data_sync.schedule',
|
|
226
|
+
resourceId: row.id,
|
|
227
|
+
current: row.updatedAt ?? null,
|
|
228
|
+
expected: expectedUpdatedAt ?? null,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
207
232
|
const scheduledJobId = row.scheduledJobId ?? row.id
|
|
208
233
|
await requireScheduler().unregister(scheduledJobId)
|
|
209
234
|
|
|
@@ -4,7 +4,7 @@ import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictiona
|
|
|
4
4
|
import { resolveDictionariesRouteContext, resolveDictionaryActorId } from '@open-mercato/core/modules/dictionaries/api/context'
|
|
5
5
|
import { updateDictionaryEntrySchema } from '@open-mercato/core/modules/dictionaries/data/validators'
|
|
6
6
|
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
7
|
-
import {
|
|
7
|
+
import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
8
8
|
import {
|
|
9
9
|
runCrudMutationGuardAfterSuccess,
|
|
10
10
|
validateCrudMutationGuard,
|
|
@@ -76,7 +76,7 @@ export async function PATCH(req: Request, ctx: { params?: { dictionaryId?: strin
|
|
|
76
76
|
})
|
|
77
77
|
const dictionary = await loadDictionary(context, dictionaryId)
|
|
78
78
|
const entry = await loadEntry(context, dictionary, entryId)
|
|
79
|
-
|
|
79
|
+
await enforceCommandOptimisticLockWithGuards(context.container, {
|
|
80
80
|
resourceKind: 'dictionaries.entry',
|
|
81
81
|
resourceId: entry.id,
|
|
82
82
|
current: entry.updatedAt ?? null,
|
|
@@ -178,7 +178,7 @@ export async function DELETE(req: Request, ctx: { params?: { dictionaryId?: stri
|
|
|
178
178
|
})
|
|
179
179
|
const dictionary = await loadDictionary(context, dictionaryId)
|
|
180
180
|
const entry = await loadEntry(context, dictionary, entryId)
|
|
181
|
-
|
|
181
|
+
await enforceCommandOptimisticLockWithGuards(context.container, {
|
|
182
182
|
resourceKind: 'dictionaries.entry',
|
|
183
183
|
resourceId: entry.id,
|
|
184
184
|
current: entry.updatedAt ?? null,
|