@open-mercato/core 0.6.6-develop.6256.1.9fc16aedc4 → 0.6.6-develop.6286.1.d985c5c374

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/carrier_shipment_idempotency_key/index.js +19 -0
  3. package/dist/generated/entities/carrier_shipment_idempotency_key/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +2 -1
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +10 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/lib/productResolution.js +45 -0
  9. package/dist/modules/catalog/lib/productResolution.js.map +7 -0
  10. package/dist/modules/communication_channels/workers/channel-import-history.js +1 -1
  11. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  12. package/dist/modules/currencies/api/fetch-configs/route.js +34 -0
  13. package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
  14. package/dist/modules/currencies/components/CurrencyFetchingConfig.js +17 -15
  15. package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
  16. package/dist/modules/customers/api/companies/[id]/route.js +176 -161
  17. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  18. package/dist/modules/customers/api/people/[id]/route.js +75 -59
  19. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  20. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +17 -10
  21. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
  22. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +6 -4
  23. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +2 -2
  24. package/dist/modules/customers/components/detail/AiActionChips.js +5 -2
  25. package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
  26. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +19 -21
  27. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  28. package/dist/modules/data_sync/api/runs/[id]/cancel.js +1 -1
  29. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  30. package/dist/modules/data_sync/backend/data-sync/page.js +9 -45
  31. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  32. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +11 -17
  33. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  34. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +12 -10
  35. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  36. package/dist/modules/data_sync/lib/id-mapping.js +46 -1
  37. package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-engine.js +2 -2
  39. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  40. package/dist/modules/data_sync/lib/syncRunStatus.js +30 -0
  41. package/dist/modules/data_sync/lib/syncRunStatus.js.map +7 -0
  42. package/dist/modules/entities/api/records.js +6 -0
  43. package/dist/modules/entities/api/records.js.map +2 -2
  44. package/dist/modules/feature_toggles/api/global/route.js +17 -15
  45. package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
  46. package/dist/modules/feature_toggles/api/openapi.js +2 -2
  47. package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
  48. package/dist/modules/feature_toggles/commands/global.js +14 -0
  49. package/dist/modules/feature_toggles/commands/global.js.map +2 -2
  50. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +6 -0
  51. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  52. package/dist/modules/messages/api/[id]/conversation/archive/route.js +25 -7
  53. package/dist/modules/messages/api/[id]/conversation/archive/route.js.map +2 -2
  54. package/dist/modules/messages/api/[id]/conversation/read/route.js +24 -6
  55. package/dist/modules/messages/api/[id]/conversation/read/route.js.map +2 -2
  56. package/dist/modules/messages/api/[id]/route.js +13 -1
  57. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  58. package/dist/modules/messages/api/openapi.js +3 -1
  59. package/dist/modules/messages/api/openapi.js.map +2 -2
  60. package/dist/modules/messages/commands/conversation.js +77 -0
  61. package/dist/modules/messages/commands/conversation.js.map +2 -2
  62. package/dist/modules/messages/components/MessageDetailPageClient.js +16 -4
  63. package/dist/modules/messages/components/MessageDetailPageClient.js.map +2 -2
  64. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js +3 -1
  65. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js.map +2 -2
  66. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +29 -4
  67. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
  68. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js +10 -1
  69. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js.map +2 -2
  70. package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js +13 -11
  71. package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js.map +2 -2
  72. package/dist/modules/payment_gateways/api/cancel/route.js +5 -0
  73. package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
  74. package/dist/modules/payment_gateways/api/capture/route.js +5 -0
  75. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  76. package/dist/modules/payment_gateways/api/refund/route.js +5 -0
  77. package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
  78. package/dist/modules/payment_gateways/lib/gateway-service.js +52 -25
  79. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  80. package/dist/modules/payment_gateways/lib/status-machine.js +13 -0
  81. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  82. package/dist/modules/perspectives/api/[tableId]/route.js +8 -2
  83. package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
  84. package/dist/modules/perspectives/data/entities.js +33 -3
  85. package/dist/modules/perspectives/data/entities.js.map +2 -2
  86. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js +63 -0
  87. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js.map +7 -0
  88. package/dist/modules/perspectives/services/perspectiveService.js +17 -0
  89. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  90. package/dist/modules/planner/commands/availability-weekly.js +33 -1
  91. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  92. package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -2
  93. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  94. package/dist/modules/progress/api/active/route.js +1 -1
  95. package/dist/modules/progress/api/active/route.js.map +2 -2
  96. package/dist/modules/progress/lib/progressService.js.map +1 -1
  97. package/dist/modules/progress/lib/progressServiceImpl.js +33 -8
  98. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  99. package/dist/modules/query_index/api/status.js +19 -24
  100. package/dist/modules/query_index/api/status.js.map +3 -3
  101. package/dist/modules/query_index/lib/coverage.js +35 -0
  102. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  103. package/dist/modules/query_index/lib/engine.js +11 -6
  104. package/dist/modules/query_index/lib/engine.js.map +2 -2
  105. package/dist/modules/resources/api/resource-types.js +5 -37
  106. package/dist/modules/resources/api/resource-types.js.map +2 -2
  107. package/dist/modules/resources/api/resourceTypeCounts.js +35 -0
  108. package/dist/modules/resources/api/resourceTypeCounts.js.map +7 -0
  109. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
  110. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +1 -1
  111. package/dist/modules/resources/backend/resources/resource-types/page.js +2 -1
  112. package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
  113. package/dist/modules/sales/components/documents/ReturnDialog.js +4 -1
  114. package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
  115. package/dist/modules/sales/components/documents/ShipmentDialog.js +17 -2
  116. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  117. package/dist/modules/sales/di.js +4 -0
  118. package/dist/modules/sales/di.js.map +2 -2
  119. package/dist/modules/sales/services/salesOrderService.js +26 -0
  120. package/dist/modules/sales/services/salesOrderService.js.map +7 -0
  121. package/dist/modules/shipping_carriers/api/shipments/route.js +8 -0
  122. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  123. package/dist/modules/shipping_carriers/data/entities.js +38 -0
  124. package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
  125. package/dist/modules/shipping_carriers/data/validators.js +2 -1
  126. package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
  127. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js +76 -0
  128. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js.map +7 -0
  129. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js +2 -1
  130. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js.map +2 -2
  131. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +20 -2
  132. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
  133. package/dist/modules/shipping_carriers/lib/shipping-service.js +82 -43
  134. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  135. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js +14 -0
  136. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js.map +7 -0
  137. package/dist/modules/staff/lib/timesheets-ui/ListView.js +15 -6
  138. package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
  139. package/dist/modules/translations/components/TranslationManager.js +9 -18
  140. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  141. package/generated/entities/carrier_shipment_idempotency_key/index.ts +8 -0
  142. package/generated/entities.ids.generated.ts +2 -1
  143. package/generated/entity-fields-registry.ts +10 -0
  144. package/package.json +9 -8
  145. package/src/modules/catalog/lib/productResolution.ts +75 -0
  146. package/src/modules/communication_channels/workers/channel-import-history.ts +1 -1
  147. package/src/modules/currencies/api/fetch-configs/route.ts +36 -0
  148. package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +20 -17
  149. package/src/modules/currencies/i18n/de.json +2 -1
  150. package/src/modules/currencies/i18n/en.json +2 -1
  151. package/src/modules/currencies/i18n/es.json +2 -1
  152. package/src/modules/currencies/i18n/pl.json +2 -1
  153. package/src/modules/customers/api/companies/[id]/route.ts +214 -189
  154. package/src/modules/customers/api/people/[id]/route.ts +106 -80
  155. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +19 -12
  156. package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +8 -6
  157. package/src/modules/customers/components/detail/AiActionChips.tsx +6 -3
  158. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +16 -18
  159. package/src/modules/data_sync/api/runs/[id]/cancel.ts +1 -1
  160. package/src/modules/data_sync/backend/data-sync/page.tsx +11 -58
  161. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +13 -20
  162. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +10 -8
  163. package/src/modules/data_sync/lib/id-mapping.ts +58 -1
  164. package/src/modules/data_sync/lib/sync-engine.ts +2 -2
  165. package/src/modules/data_sync/lib/syncRunStatus.ts +39 -0
  166. package/src/modules/entities/api/records.ts +14 -0
  167. package/src/modules/feature_toggles/api/global/route.ts +16 -14
  168. package/src/modules/feature_toggles/api/openapi.ts +2 -2
  169. package/src/modules/feature_toggles/commands/global.ts +15 -0
  170. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +7 -0
  171. package/src/modules/messages/api/[id]/conversation/archive/route.ts +29 -6
  172. package/src/modules/messages/api/[id]/conversation/read/route.ts +29 -6
  173. package/src/modules/messages/api/[id]/route.ts +15 -0
  174. package/src/modules/messages/api/openapi.ts +2 -0
  175. package/src/modules/messages/commands/conversation.ts +87 -0
  176. package/src/modules/messages/components/MessageDetailPageClient.tsx +16 -4
  177. package/src/modules/messages/components/message-detail/hooks/useMessageDetails.ts +2 -0
  178. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +39 -4
  179. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.ts +15 -1
  180. package/src/modules/messages/components/message-detail/panels/MainMessageHeader.tsx +29 -14
  181. package/src/modules/messages/components/message-detail/types.ts +2 -0
  182. package/src/modules/messages/i18n/de.json +4 -0
  183. package/src/modules/messages/i18n/en.json +4 -0
  184. package/src/modules/messages/i18n/es.json +4 -0
  185. package/src/modules/messages/i18n/pl.json +4 -0
  186. package/src/modules/payment_gateways/api/cancel/route.ts +5 -0
  187. package/src/modules/payment_gateways/api/capture/route.ts +5 -0
  188. package/src/modules/payment_gateways/api/refund/route.ts +5 -0
  189. package/src/modules/payment_gateways/lib/gateway-service.ts +58 -25
  190. package/src/modules/payment_gateways/lib/status-machine.ts +16 -0
  191. package/src/modules/perspectives/api/[tableId]/route.ts +8 -2
  192. package/src/modules/perspectives/data/entities.ts +43 -3
  193. package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +69 -25
  194. package/src/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.ts +65 -0
  195. package/src/modules/perspectives/services/perspectiveService.ts +17 -0
  196. package/src/modules/planner/commands/availability-weekly.ts +48 -1
  197. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +31 -10
  198. package/src/modules/progress/api/active/route.ts +1 -1
  199. package/src/modules/progress/lib/progressService.ts +2 -2
  200. package/src/modules/progress/lib/progressServiceImpl.ts +33 -8
  201. package/src/modules/query_index/api/status.ts +28 -32
  202. package/src/modules/query_index/lib/coverage.ts +63 -0
  203. package/src/modules/query_index/lib/engine.ts +37 -24
  204. package/src/modules/resources/api/resource-types.ts +4 -44
  205. package/src/modules/resources/api/resourceTypeCounts.ts +74 -0
  206. package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
  207. package/src/modules/resources/backend/resources/resource-types/page.tsx +1 -0
  208. package/src/modules/sales/components/documents/ReturnDialog.tsx +4 -1
  209. package/src/modules/sales/components/documents/ShipmentDialog.tsx +20 -2
  210. package/src/modules/sales/di.ts +6 -0
  211. package/src/modules/sales/services/salesOrderService.ts +38 -0
  212. package/src/modules/shipping_carriers/api/shipments/route.ts +8 -0
  213. package/src/modules/shipping_carriers/data/entities.ts +33 -0
  214. package/src/modules/shipping_carriers/data/validators.ts +1 -0
  215. package/src/modules/shipping_carriers/i18n/de.json +1 -0
  216. package/src/modules/shipping_carriers/i18n/en.json +1 -0
  217. package/src/modules/shipping_carriers/i18n/es.json +1 -0
  218. package/src/modules/shipping_carriers/i18n/pl.json +1 -0
  219. package/src/modules/shipping_carriers/lib/shipment-idempotency.ts +119 -0
  220. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.ts +3 -1
  221. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +24 -2
  222. package/src/modules/shipping_carriers/lib/shipping-service.ts +87 -43
  223. package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +197 -0
  224. package/src/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.ts +14 -0
  225. package/src/modules/staff/i18n/de.json +1 -0
  226. package/src/modules/staff/i18n/en.json +1 -0
  227. package/src/modules/staff/i18n/es.json +1 -0
  228. package/src/modules/staff/i18n/pl.json +1 -0
  229. package/src/modules/staff/lib/timesheets-ui/ListView.tsx +15 -7
  230. package/src/modules/translations/components/TranslationManager.tsx +17 -24
  231. package/src/modules/translations/i18n/de.json +1 -0
  232. package/src/modules/translations/i18n/en.json +1 -0
  233. package/src/modules/translations/i18n/es.json +1 -0
  234. 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(payload),
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: payload as unknown as Record<string, unknown>,
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 { adapter, credentials } = await resolveAdapter(input.providerKey, {
97
- organizationId: input.organizationId,
98
- tenantId: input.tenantId,
99
- })
100
- const mergedCredentials = {
101
- ...credentials,
102
- ...(input.senderPhone !== undefined ? { senderPhone: input.senderPhone } : {}),
103
- ...(input.senderEmail !== undefined ? { senderEmail: input.senderEmail } : {}),
104
- ...(input.receiverPhone !== undefined ? { receiverPhone: input.receiverPhone } : {}),
105
- ...(input.receiverEmail !== undefined ? { receiverEmail: input.receiverEmail } : {}),
106
- ...(input.targetPoint !== undefined ? { targetPoint: input.targetPoint } : {}),
107
- ...(input.c2cSendingMethod !== undefined ? { c2cSendingMethod: input.c2cSendingMethod } : {}),
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: {