@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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
validateCrudMutationGuard,
|
|
8
8
|
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
9
9
|
import type { ShippingCarrierService } from '../../lib/shipping-service'
|
|
10
|
+
import { isShipmentIdempotencyConflictError } from '../../lib/shipment-idempotency'
|
|
10
11
|
import { createShipmentSchema } from '../../data/validators'
|
|
11
12
|
import { shippingCarriersTag } from '../openapi'
|
|
12
13
|
|
|
@@ -83,6 +84,12 @@ export async function POST(req: Request) {
|
|
|
83
84
|
labelUrl: shipment.labelUrl,
|
|
84
85
|
}, { status: 201 })
|
|
85
86
|
} catch (error: unknown) {
|
|
87
|
+
if (isShipmentIdempotencyConflictError(error)) {
|
|
88
|
+
return NextResponse.json(
|
|
89
|
+
{ error: 'Shipment idempotency conflict', code: 'idempotency_conflict' },
|
|
90
|
+
{ status: 409 },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
86
93
|
const message = error instanceof Error ? error.message : 'Failed to create shipment'
|
|
87
94
|
return NextResponse.json({ error: message }, { status: 502 })
|
|
88
95
|
}
|
|
@@ -97,6 +104,7 @@ export const openApi = {
|
|
|
97
104
|
tags: [shippingCarriersTag],
|
|
98
105
|
responses: [
|
|
99
106
|
{ status: 201, description: 'Shipment created' },
|
|
107
|
+
{ status: 409, description: 'Idempotency conflict: the idempotency key was reused with a different payload' },
|
|
100
108
|
{ status: 422, description: 'Validation failed' },
|
|
101
109
|
{ status: 502, description: 'Provider upstream error' },
|
|
102
110
|
],
|
|
@@ -89,3 +89,36 @@ export class CarrierWebhookProcessedEvent {
|
|
|
89
89
|
@Property({ name: 'processed_at', type: Date, onCreate: () => new Date() })
|
|
90
90
|
processedAt: Date = new Date()
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
@Entity({ tableName: 'carrier_shipment_idempotency_keys' })
|
|
94
|
+
@Unique({
|
|
95
|
+
name: 'carrier_shipment_idempotency_keys_unique',
|
|
96
|
+
properties: ['idempotencyKey', 'providerKey', 'organizationId', 'tenantId'],
|
|
97
|
+
})
|
|
98
|
+
export class CarrierShipmentIdempotencyKey {
|
|
99
|
+
[OptionalProps]?: 'shipmentId' | 'createdAt'
|
|
100
|
+
|
|
101
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
102
|
+
id!: string
|
|
103
|
+
|
|
104
|
+
@Property({ name: 'provider_key', type: 'text' })
|
|
105
|
+
providerKey!: string
|
|
106
|
+
|
|
107
|
+
@Property({ name: 'idempotency_key', type: 'text' })
|
|
108
|
+
idempotencyKey!: string
|
|
109
|
+
|
|
110
|
+
@Property({ name: 'request_hash', type: 'text' })
|
|
111
|
+
requestHash!: string
|
|
112
|
+
|
|
113
|
+
@Property({ name: 'shipment_id', type: 'uuid', nullable: true })
|
|
114
|
+
shipmentId?: string | null
|
|
115
|
+
|
|
116
|
+
@Property({ name: 'organization_id', type: 'uuid' })
|
|
117
|
+
organizationId!: string
|
|
118
|
+
|
|
119
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
120
|
+
tenantId!: string
|
|
121
|
+
|
|
122
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
123
|
+
createdAt: Date = new Date()
|
|
124
|
+
}
|
|
@@ -44,6 +44,7 @@ export const createShipmentSchema = z.object({
|
|
|
44
44
|
receiverEmail: emailField,
|
|
45
45
|
targetPoint: z.string().optional(),
|
|
46
46
|
c2cSendingMethod: z.enum(['parcel_locker', 'dispatch_order', 'pop', 'any_point']).optional(),
|
|
47
|
+
idempotencyKey: z.string().min(1).max(255).optional(),
|
|
47
48
|
})
|
|
48
49
|
|
|
49
50
|
export const trackingQuerySchema = z.object({
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"shipping_carriers.create.error.create": "Sendung konnte nicht erstellt werden.",
|
|
15
15
|
"shipping_carriers.create.error.fetchDropOffPoints": "Abgabepunkte konnten nicht abgerufen werden.",
|
|
16
16
|
"shipping_carriers.create.error.fetchRates": "Preise konnten nicht abgerufen werden.",
|
|
17
|
+
"shipping_carriers.create.error.idempotencyConflict": "Diese Sendung wurde bereits übermittelt. Aktualisieren Sie die Seite, bevor Sie es erneut versuchen.",
|
|
17
18
|
"shipping_carriers.create.error.invalidEmail": "Ungültige E-Mail-Adresse.",
|
|
18
19
|
"shipping_carriers.create.error.invalidPhone": "Ungültige Telefonnummer.",
|
|
19
20
|
"shipping_carriers.create.error.loadProviders": "Transporteure konnten nicht geladen werden.",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"shipping_carriers.create.error.create": "Failed to create shipment.",
|
|
15
15
|
"shipping_carriers.create.error.fetchDropOffPoints": "Failed to fetch drop-off points.",
|
|
16
16
|
"shipping_carriers.create.error.fetchRates": "Failed to fetch shipping rates.",
|
|
17
|
+
"shipping_carriers.create.error.idempotencyConflict": "This shipment was already submitted. Refresh the page before retrying.",
|
|
17
18
|
"shipping_carriers.create.error.invalidEmail": "Invalid email address.",
|
|
18
19
|
"shipping_carriers.create.error.invalidPhone": "Invalid phone number.",
|
|
19
20
|
"shipping_carriers.create.error.loadProviders": "Failed to load shipping providers.",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"shipping_carriers.create.error.create": "Error al crear el envio.",
|
|
15
15
|
"shipping_carriers.create.error.fetchDropOffPoints": "Error al obtener los puntos de entrega.",
|
|
16
16
|
"shipping_carriers.create.error.fetchRates": "Error al obtener las tarifas.",
|
|
17
|
+
"shipping_carriers.create.error.idempotencyConflict": "Este envío ya fue enviado. Actualiza la página antes de volver a intentarlo.",
|
|
17
18
|
"shipping_carriers.create.error.invalidEmail": "Dirección de correo electrónico no válida.",
|
|
18
19
|
"shipping_carriers.create.error.invalidPhone": "Número de teléfono no válido.",
|
|
19
20
|
"shipping_carriers.create.error.loadProviders": "Error al cargar los transportistas.",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"shipping_carriers.create.error.create": "Nie udało się utworzyć przesyłki.",
|
|
15
15
|
"shipping_carriers.create.error.fetchDropOffPoints": "Nie udało się pobrać punktów nadania.",
|
|
16
16
|
"shipping_carriers.create.error.fetchRates": "Nie udało się pobrać stawek.",
|
|
17
|
+
"shipping_carriers.create.error.idempotencyConflict": "Ta przesyłka została już wysłana. Odśwież stronę przed ponowną próbą.",
|
|
17
18
|
"shipping_carriers.create.error.invalidEmail": "Nieprawidłowy adres e-mail.",
|
|
18
19
|
"shipping_carriers.create.error.invalidPhone": "Nieprawidłowy numer telefonu.",
|
|
19
20
|
"shipping_carriers.create.error.loadProviders": "Nie udało się załadować przewoźników.",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { UniqueConstraintViolationException } from '@mikro-orm/core'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import { CarrierShipmentIdempotencyKey } from '../data/entities'
|
|
6
|
+
|
|
7
|
+
type Scope = { organizationId: string; tenantId: string }
|
|
8
|
+
|
|
9
|
+
// Use Symbol.for so the marker survives module duplication across bundle boundaries — an
|
|
10
|
+
// `instanceof` check returns false when the API route and the service load separate copies of
|
|
11
|
+
// this class in the production build, which would silently downgrade the 409 conflict to a 502.
|
|
12
|
+
const SHIPMENT_IDEMPOTENCY_CONFLICT_MARKER = Symbol.for('@open-mercato/ShipmentIdempotencyConflictError')
|
|
13
|
+
|
|
14
|
+
export class ShipmentIdempotencyConflictError extends Error {
|
|
15
|
+
readonly [SHIPMENT_IDEMPOTENCY_CONFLICT_MARKER] = true
|
|
16
|
+
readonly idempotencyKey: string
|
|
17
|
+
constructor(idempotencyKey: string) {
|
|
18
|
+
super(`[internal] Shipment idempotency conflict for key "${idempotencyKey}"`)
|
|
19
|
+
this.name = 'ShipmentIdempotencyConflictError'
|
|
20
|
+
this.idempotencyKey = idempotencyKey
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isShipmentIdempotencyConflictError(error: unknown): error is ShipmentIdempotencyConflictError {
|
|
25
|
+
return (
|
|
26
|
+
!!error &&
|
|
27
|
+
typeof error === 'object' &&
|
|
28
|
+
(error as Record<symbol, unknown>)[SHIPMENT_IDEMPOTENCY_CONFLICT_MARKER] === true
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stableSerialize(value: unknown): string {
|
|
33
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
|
|
34
|
+
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`
|
|
35
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
36
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
37
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
38
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerialize(entryValue)}`).join(',')}}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Deterministic fingerprint of a shipment-create request so that reusing the same
|
|
43
|
+
* idempotency key with a conflicting payload can be detected. Key order is
|
|
44
|
+
* normalized and `undefined` values are dropped so semantically equal requests
|
|
45
|
+
* hash identically.
|
|
46
|
+
*/
|
|
47
|
+
export function computeShipmentRequestHash(fingerprint: unknown): string {
|
|
48
|
+
return createHash('sha256').update(stableSerialize(fingerprint)).digest('hex')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function findShipmentIdempotencyClaim(
|
|
52
|
+
em: EntityManager,
|
|
53
|
+
idempotencyKey: string,
|
|
54
|
+
providerKey: string,
|
|
55
|
+
scope: Scope,
|
|
56
|
+
): Promise<CarrierShipmentIdempotencyKey | null> {
|
|
57
|
+
return findOneWithDecryption(
|
|
58
|
+
em,
|
|
59
|
+
CarrierShipmentIdempotencyKey,
|
|
60
|
+
{
|
|
61
|
+
idempotencyKey,
|
|
62
|
+
providerKey,
|
|
63
|
+
organizationId: scope.organizationId,
|
|
64
|
+
tenantId: scope.tenantId,
|
|
65
|
+
},
|
|
66
|
+
undefined,
|
|
67
|
+
scope,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Atomically claims an idempotency key by inserting the dedup row. Returns the
|
|
73
|
+
* managed claim on success, or `null` when a concurrent request already inserted
|
|
74
|
+
* the same scoped key (the unique constraint fires), mirroring the webhook-event
|
|
75
|
+
* claim idiom.
|
|
76
|
+
*/
|
|
77
|
+
export async function claimShipmentIdempotency(
|
|
78
|
+
em: EntityManager,
|
|
79
|
+
idempotencyKey: string,
|
|
80
|
+
providerKey: string,
|
|
81
|
+
requestHash: string,
|
|
82
|
+
scope: Scope,
|
|
83
|
+
): Promise<CarrierShipmentIdempotencyKey | null> {
|
|
84
|
+
const record = em.create(CarrierShipmentIdempotencyKey, {
|
|
85
|
+
idempotencyKey,
|
|
86
|
+
providerKey,
|
|
87
|
+
requestHash,
|
|
88
|
+
shipmentId: null,
|
|
89
|
+
organizationId: scope.organizationId,
|
|
90
|
+
tenantId: scope.tenantId,
|
|
91
|
+
})
|
|
92
|
+
try {
|
|
93
|
+
await em.persist(record).flush()
|
|
94
|
+
return record
|
|
95
|
+
} catch (error: unknown) {
|
|
96
|
+
if (error instanceof UniqueConstraintViolationException) return null
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function resolveShipmentIdempotency(
|
|
102
|
+
em: EntityManager,
|
|
103
|
+
claim: CarrierShipmentIdempotencyKey,
|
|
104
|
+
shipmentId: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
claim.shipmentId = shipmentId
|
|
107
|
+
await em.flush()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function releaseShipmentIdempotency(
|
|
111
|
+
em: EntityManager,
|
|
112
|
+
idempotencyKey: string,
|
|
113
|
+
providerKey: string,
|
|
114
|
+
scope: Scope,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const existing = await findShipmentIdempotencyClaim(em, idempotencyKey, providerKey, scope)
|
|
117
|
+
if (!existing) return
|
|
118
|
+
await em.remove(existing).flush()
|
|
119
|
+
}
|
|
@@ -37,11 +37,12 @@ export type CreateShipmentParams = {
|
|
|
37
37
|
receiverEmail?: string
|
|
38
38
|
targetPoint?: string
|
|
39
39
|
c2cSendingMethod?: string
|
|
40
|
+
idempotencyKey?: string
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export type CreateShipmentResult =
|
|
43
44
|
| { ok: true }
|
|
44
|
-
| { ok: false; error: string }
|
|
45
|
+
| { ok: false; error: string; code?: string }
|
|
45
46
|
|
|
46
47
|
export type FetchDropOffPointsParams = {
|
|
47
48
|
providerKey: string
|
|
@@ -126,6 +127,7 @@ export const createShipment = async (params: CreateShipmentParams): Promise<Crea
|
|
|
126
127
|
.otherwise(({ result }) => ({
|
|
127
128
|
ok: false as const,
|
|
128
129
|
error: (result as { error?: string } | null)?.error ?? 'Failed to create shipment.',
|
|
130
|
+
code: (result as { code?: string } | null)?.code,
|
|
129
131
|
}))
|
|
130
132
|
}
|
|
131
133
|
|
|
@@ -194,6 +194,9 @@ export const useShipmentWizard = (): ShipmentWizard => {
|
|
|
194
194
|
setIsFetchingRates(false)
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
const idempotencyKeyRef = React.useRef<string | null>(null)
|
|
198
|
+
const idempotencySignatureRef = React.useRef<string | null>(null)
|
|
199
|
+
|
|
197
200
|
const handleSubmit = async () => {
|
|
198
201
|
if (!selectedProvider || !selectedRate || !orderId) return
|
|
199
202
|
setIsSubmitting(true)
|
|
@@ -212,20 +215,39 @@ export const useShipmentWizard = (): ShipmentWizard => {
|
|
|
212
215
|
...(targetPoint ? { targetPoint } : {}),
|
|
213
216
|
...(c2cSendingMethod ? { c2cSendingMethod } : {}),
|
|
214
217
|
}
|
|
218
|
+
// Reuse one idempotency key per confirmed payload so a double-submit or a
|
|
219
|
+
// network-timeout retry de-duplicates server-side; regenerate it when the
|
|
220
|
+
// payload changes so an edited shipment is not rejected as a conflict.
|
|
221
|
+
const signature = JSON.stringify(payload)
|
|
222
|
+
if (idempotencyKeyRef.current === null || idempotencySignatureRef.current !== signature) {
|
|
223
|
+
idempotencyKeyRef.current = crypto.randomUUID()
|
|
224
|
+
idempotencySignatureRef.current = signature
|
|
225
|
+
}
|
|
226
|
+
const submitPayload: CreateShipmentParams = { ...payload, idempotencyKey: idempotencyKeyRef.current }
|
|
215
227
|
try {
|
|
216
228
|
const result = await runMutation({
|
|
217
|
-
operation: () => createShipment(
|
|
229
|
+
operation: () => createShipment(submitPayload),
|
|
218
230
|
context: {
|
|
219
231
|
operation: 'create',
|
|
220
232
|
resourceKind: 'shipping_carriers.shipment',
|
|
221
233
|
resourceId: orderId,
|
|
222
234
|
retryLastMutation,
|
|
223
235
|
},
|
|
224
|
-
mutationPayload:
|
|
236
|
+
mutationPayload: submitPayload as unknown as Record<string, unknown>,
|
|
225
237
|
})
|
|
226
238
|
if (result.ok) {
|
|
239
|
+
idempotencyKeyRef.current = null
|
|
240
|
+
idempotencySignatureRef.current = null
|
|
227
241
|
flash(t('shipping_carriers.create.success', 'Shipment created successfully.'), 'success')
|
|
228
242
|
router.push(backHref)
|
|
243
|
+
} else if (result.code === 'idempotency_conflict') {
|
|
244
|
+
flash(
|
|
245
|
+
t(
|
|
246
|
+
'shipping_carriers.create.error.idempotencyConflict',
|
|
247
|
+
'This shipment was already submitted. Refresh the page before retrying.',
|
|
248
|
+
),
|
|
249
|
+
'error',
|
|
250
|
+
)
|
|
229
251
|
} else {
|
|
230
252
|
flash(t('shipping_carriers.create.error.create', result.error), 'error')
|
|
231
253
|
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
2
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
3
|
import type { CredentialsService } from '../../integrations/lib/credentials-service'
|
|
4
|
-
import { CarrierShipment } from '../data/entities'
|
|
4
|
+
import { CarrierShipment, type CarrierShipmentIdempotencyKey } from '../data/entities'
|
|
5
5
|
import { emitShippingEvent } from '../events'
|
|
6
6
|
import { getShippingAdapter } from './adapter-registry'
|
|
7
7
|
import type { UnifiedShipmentStatus } from './adapter'
|
|
8
|
+
import {
|
|
9
|
+
claimShipmentIdempotency,
|
|
10
|
+
computeShipmentRequestHash,
|
|
11
|
+
findShipmentIdempotencyClaim,
|
|
12
|
+
isShipmentIdempotencyConflictError,
|
|
13
|
+
releaseShipmentIdempotency,
|
|
14
|
+
resolveShipmentIdempotency,
|
|
15
|
+
ShipmentIdempotencyConflictError,
|
|
16
|
+
} from './shipment-idempotency'
|
|
8
17
|
import {
|
|
9
18
|
getTerminalShippingEvent,
|
|
10
19
|
isValidShippingTransition,
|
|
@@ -90,52 +99,87 @@ export function createShippingCarrierService(deps: {
|
|
|
90
99
|
receiverEmail?: string
|
|
91
100
|
targetPoint?: string
|
|
92
101
|
c2cSendingMethod?: string
|
|
102
|
+
idempotencyKey?: string
|
|
93
103
|
organizationId: string
|
|
94
104
|
tenantId: string
|
|
95
105
|
}) {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
const scope = { organizationId: input.organizationId, tenantId: input.tenantId }
|
|
107
|
+
let claim: CarrierShipmentIdempotencyKey | null = null
|
|
108
|
+
if (input.idempotencyKey) {
|
|
109
|
+
const { idempotencyKey: _idempotencyKey, ...fingerprint } = input
|
|
110
|
+
const requestHash = computeShipmentRequestHash(fingerprint)
|
|
111
|
+
const existing = await findShipmentIdempotencyClaim(em, input.idempotencyKey, input.providerKey, scope)
|
|
112
|
+
if (existing) {
|
|
113
|
+
if (existing.requestHash !== requestHash) {
|
|
114
|
+
throw new ShipmentIdempotencyConflictError(input.idempotencyKey)
|
|
115
|
+
}
|
|
116
|
+
if (existing.shipmentId) {
|
|
117
|
+
return findShipmentOrThrow(existing.shipmentId, scope)
|
|
118
|
+
}
|
|
119
|
+
// Claim exists but the original request has not resolved yet (concurrent in-flight).
|
|
120
|
+
throw new ShipmentIdempotencyConflictError(input.idempotencyKey)
|
|
121
|
+
}
|
|
122
|
+
claim = await claimShipmentIdempotency(em, input.idempotencyKey, input.providerKey, requestHash, scope)
|
|
123
|
+
if (!claim) {
|
|
124
|
+
// Lost the claim race with a concurrent request creating the same shipment.
|
|
125
|
+
throw new ShipmentIdempotencyConflictError(input.idempotencyKey)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let carrierShipmentCreated = false
|
|
129
|
+
try {
|
|
130
|
+
const { adapter, credentials } = await resolveAdapter(input.providerKey, scope)
|
|
131
|
+
const mergedCredentials = {
|
|
132
|
+
...credentials,
|
|
133
|
+
...(input.senderPhone !== undefined ? { senderPhone: input.senderPhone } : {}),
|
|
134
|
+
...(input.senderEmail !== undefined ? { senderEmail: input.senderEmail } : {}),
|
|
135
|
+
...(input.receiverPhone !== undefined ? { receiverPhone: input.receiverPhone } : {}),
|
|
136
|
+
...(input.receiverEmail !== undefined ? { receiverEmail: input.receiverEmail } : {}),
|
|
137
|
+
...(input.targetPoint !== undefined ? { targetPoint: input.targetPoint } : {}),
|
|
138
|
+
...(input.c2cSendingMethod !== undefined ? { c2cSendingMethod: input.c2cSendingMethod } : {}),
|
|
139
|
+
}
|
|
140
|
+
const created = await adapter.createShipment({
|
|
141
|
+
orderId: input.orderId,
|
|
142
|
+
origin: input.origin,
|
|
143
|
+
destination: input.destination,
|
|
144
|
+
packages: input.packages,
|
|
145
|
+
serviceCode: input.serviceCode,
|
|
146
|
+
credentials: mergedCredentials,
|
|
147
|
+
labelFormat: input.labelFormat,
|
|
148
|
+
})
|
|
149
|
+
carrierShipmentCreated = true
|
|
150
|
+
const shipment = em.create(CarrierShipment, {
|
|
151
|
+
orderId: input.orderId,
|
|
152
|
+
providerKey: input.providerKey,
|
|
153
|
+
carrierShipmentId: created.shipmentId,
|
|
154
|
+
trackingNumber: created.trackingNumber,
|
|
155
|
+
unifiedStatus: 'label_created',
|
|
156
|
+
labelUrl: created.labelUrl ?? null,
|
|
157
|
+
labelData: created.labelData ?? null,
|
|
158
|
+
organizationId: input.organizationId,
|
|
159
|
+
tenantId: input.tenantId,
|
|
160
|
+
})
|
|
161
|
+
await em.persist(shipment).flush()
|
|
162
|
+
if (claim) {
|
|
163
|
+
await resolveShipmentIdempotency(em, claim, shipment.id)
|
|
164
|
+
}
|
|
165
|
+
await emitShippingEvent('shipping_carriers.shipment.created', {
|
|
166
|
+
shipmentId: shipment.id,
|
|
167
|
+
orderId: input.orderId,
|
|
168
|
+
providerKey: input.providerKey,
|
|
169
|
+
trackingNumber: created.trackingNumber,
|
|
170
|
+
organizationId: input.organizationId,
|
|
171
|
+
tenantId: input.tenantId,
|
|
172
|
+
})
|
|
173
|
+
return shipment
|
|
174
|
+
} catch (error: unknown) {
|
|
175
|
+
// Release the claim only when the carrier was NOT successfully called, so a retry can
|
|
176
|
+
// proceed. If the carrier already created the shipment, keep the claim so a retry does
|
|
177
|
+
// not produce a duplicate upstream shipment.
|
|
178
|
+
if (claim && !carrierShipmentCreated && input.idempotencyKey && !isShipmentIdempotencyConflictError(error)) {
|
|
179
|
+
await releaseShipmentIdempotency(em, input.idempotencyKey, input.providerKey, scope).catch(() => {})
|
|
180
|
+
}
|
|
181
|
+
throw error
|
|
108
182
|
}
|
|
109
|
-
const created = await adapter.createShipment({
|
|
110
|
-
orderId: input.orderId,
|
|
111
|
-
origin: input.origin,
|
|
112
|
-
destination: input.destination,
|
|
113
|
-
packages: input.packages,
|
|
114
|
-
serviceCode: input.serviceCode,
|
|
115
|
-
credentials: mergedCredentials,
|
|
116
|
-
labelFormat: input.labelFormat,
|
|
117
|
-
})
|
|
118
|
-
const shipment = em.create(CarrierShipment, {
|
|
119
|
-
orderId: input.orderId,
|
|
120
|
-
providerKey: input.providerKey,
|
|
121
|
-
carrierShipmentId: created.shipmentId,
|
|
122
|
-
trackingNumber: created.trackingNumber,
|
|
123
|
-
unifiedStatus: 'label_created',
|
|
124
|
-
labelUrl: created.labelUrl ?? null,
|
|
125
|
-
labelData: created.labelData ?? null,
|
|
126
|
-
organizationId: input.organizationId,
|
|
127
|
-
tenantId: input.tenantId,
|
|
128
|
-
})
|
|
129
|
-
await em.persist(shipment).flush()
|
|
130
|
-
await emitShippingEvent('shipping_carriers.shipment.created', {
|
|
131
|
-
shipmentId: shipment.id,
|
|
132
|
-
orderId: input.orderId,
|
|
133
|
-
providerKey: input.providerKey,
|
|
134
|
-
trackingNumber: created.trackingNumber,
|
|
135
|
-
organizationId: input.organizationId,
|
|
136
|
-
tenantId: input.tenantId,
|
|
137
|
-
})
|
|
138
|
-
return shipment
|
|
139
183
|
},
|
|
140
184
|
|
|
141
185
|
async getTracking(input: {
|