@open-mercato/core 0.6.6-develop.6257.1.6d0af84d26 → 0.6.6-develop.6290.1.4bb5a8ba3f

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 (220) 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/data_sync/workers/sync-scheduled.js +13 -14
  43. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  44. package/dist/modules/directory/components/TenantSelect.js +29 -16
  45. package/dist/modules/directory/components/TenantSelect.js.map +2 -2
  46. package/dist/modules/entities/api/records.js +6 -0
  47. package/dist/modules/entities/api/records.js.map +2 -2
  48. package/dist/modules/feature_toggles/api/global/route.js +17 -15
  49. package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
  50. package/dist/modules/feature_toggles/api/openapi.js +2 -2
  51. package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
  52. package/dist/modules/feature_toggles/commands/global.js +14 -0
  53. package/dist/modules/feature_toggles/commands/global.js.map +2 -2
  54. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +6 -0
  55. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  56. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js +3 -1
  57. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js.map +2 -2
  58. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +12 -5
  59. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
  60. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js +10 -1
  61. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js.map +2 -2
  62. package/dist/modules/payment_gateways/api/cancel/route.js +5 -0
  63. package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
  64. package/dist/modules/payment_gateways/api/capture/route.js +5 -0
  65. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  66. package/dist/modules/payment_gateways/api/refund/route.js +5 -0
  67. package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
  68. package/dist/modules/payment_gateways/lib/gateway-service.js +52 -25
  69. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  70. package/dist/modules/payment_gateways/lib/status-machine.js +13 -0
  71. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  72. package/dist/modules/perspectives/api/[tableId]/route.js +8 -2
  73. package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
  74. package/dist/modules/perspectives/data/entities.js +33 -3
  75. package/dist/modules/perspectives/data/entities.js.map +2 -2
  76. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js +63 -0
  77. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js.map +7 -0
  78. package/dist/modules/perspectives/services/perspectiveService.js +17 -0
  79. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  80. package/dist/modules/planner/commands/availability-weekly.js +33 -1
  81. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  82. package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -2
  83. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  84. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js +35 -0
  85. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js.map +7 -0
  86. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +18 -19
  87. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
  88. package/dist/modules/progress/api/active/route.js +1 -1
  89. package/dist/modules/progress/api/active/route.js.map +2 -2
  90. package/dist/modules/progress/lib/progressService.js.map +1 -1
  91. package/dist/modules/progress/lib/progressServiceImpl.js +33 -8
  92. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  93. package/dist/modules/query_index/api/status.js +19 -24
  94. package/dist/modules/query_index/api/status.js.map +3 -3
  95. package/dist/modules/query_index/lib/coverage.js +35 -0
  96. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  97. package/dist/modules/query_index/lib/engine.js +11 -6
  98. package/dist/modules/query_index/lib/engine.js.map +2 -2
  99. package/dist/modules/resources/api/resource-types.js +5 -37
  100. package/dist/modules/resources/api/resource-types.js.map +2 -2
  101. package/dist/modules/resources/api/resourceTypeCounts.js +35 -0
  102. package/dist/modules/resources/api/resourceTypeCounts.js.map +7 -0
  103. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
  104. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +1 -1
  105. package/dist/modules/resources/backend/resources/resource-types/page.js +2 -1
  106. package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
  107. package/dist/modules/sales/components/documents/ReturnDialog.js +4 -1
  108. package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
  109. package/dist/modules/sales/components/documents/ShipmentDialog.js +17 -2
  110. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  111. package/dist/modules/sales/di.js +4 -0
  112. package/dist/modules/sales/di.js.map +2 -2
  113. package/dist/modules/sales/services/salesOrderService.js +26 -0
  114. package/dist/modules/sales/services/salesOrderService.js.map +7 -0
  115. package/dist/modules/shipping_carriers/api/shipments/route.js +8 -0
  116. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  117. package/dist/modules/shipping_carriers/data/entities.js +38 -0
  118. package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
  119. package/dist/modules/shipping_carriers/data/validators.js +2 -1
  120. package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
  121. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js +76 -0
  122. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js.map +7 -0
  123. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js +2 -1
  124. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js.map +2 -2
  125. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +20 -2
  126. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
  127. package/dist/modules/shipping_carriers/lib/shipping-service.js +82 -43
  128. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  129. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js +14 -0
  130. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js.map +7 -0
  131. package/dist/modules/staff/lib/timesheets-ui/ListView.js +15 -6
  132. package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
  133. package/dist/modules/translations/components/TranslationManager.js +9 -18
  134. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  135. package/generated/entities/carrier_shipment_idempotency_key/index.ts +8 -0
  136. package/generated/entities.ids.generated.ts +2 -1
  137. package/generated/entity-fields-registry.ts +10 -0
  138. package/package.json +9 -8
  139. package/src/modules/catalog/lib/productResolution.ts +75 -0
  140. package/src/modules/communication_channels/workers/channel-import-history.ts +1 -1
  141. package/src/modules/currencies/api/fetch-configs/route.ts +36 -0
  142. package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +20 -17
  143. package/src/modules/currencies/i18n/de.json +2 -1
  144. package/src/modules/currencies/i18n/en.json +2 -1
  145. package/src/modules/currencies/i18n/es.json +2 -1
  146. package/src/modules/currencies/i18n/pl.json +2 -1
  147. package/src/modules/customers/api/companies/[id]/route.ts +214 -189
  148. package/src/modules/customers/api/people/[id]/route.ts +106 -80
  149. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +19 -12
  150. package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +8 -6
  151. package/src/modules/customers/components/detail/AiActionChips.tsx +6 -3
  152. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +16 -18
  153. package/src/modules/data_sync/api/runs/[id]/cancel.ts +1 -1
  154. package/src/modules/data_sync/backend/data-sync/page.tsx +11 -58
  155. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +13 -20
  156. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +10 -8
  157. package/src/modules/data_sync/lib/id-mapping.ts +58 -1
  158. package/src/modules/data_sync/lib/sync-engine.ts +2 -2
  159. package/src/modules/data_sync/lib/syncRunStatus.ts +39 -0
  160. package/src/modules/data_sync/workers/sync-scheduled.ts +13 -14
  161. package/src/modules/directory/components/TenantSelect.tsx +33 -16
  162. package/src/modules/entities/api/records.ts +14 -0
  163. package/src/modules/feature_toggles/api/global/route.ts +16 -14
  164. package/src/modules/feature_toggles/api/openapi.ts +2 -2
  165. package/src/modules/feature_toggles/commands/global.ts +15 -0
  166. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +7 -0
  167. package/src/modules/messages/components/message-detail/hooks/useMessageDetails.ts +2 -0
  168. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +14 -4
  169. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.ts +15 -1
  170. package/src/modules/payment_gateways/api/cancel/route.ts +5 -0
  171. package/src/modules/payment_gateways/api/capture/route.ts +5 -0
  172. package/src/modules/payment_gateways/api/refund/route.ts +5 -0
  173. package/src/modules/payment_gateways/lib/gateway-service.ts +58 -25
  174. package/src/modules/payment_gateways/lib/status-machine.ts +16 -0
  175. package/src/modules/perspectives/api/[tableId]/route.ts +8 -2
  176. package/src/modules/perspectives/data/entities.ts +43 -3
  177. package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +69 -25
  178. package/src/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.ts +65 -0
  179. package/src/modules/perspectives/services/perspectiveService.ts +17 -0
  180. package/src/modules/planner/commands/availability-weekly.ts +48 -1
  181. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +31 -10
  182. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.ts +36 -0
  183. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +22 -23
  184. package/src/modules/progress/api/active/route.ts +1 -1
  185. package/src/modules/progress/lib/progressService.ts +2 -2
  186. package/src/modules/progress/lib/progressServiceImpl.ts +33 -8
  187. package/src/modules/query_index/api/status.ts +28 -32
  188. package/src/modules/query_index/lib/coverage.ts +63 -0
  189. package/src/modules/query_index/lib/engine.ts +37 -24
  190. package/src/modules/resources/api/resource-types.ts +4 -44
  191. package/src/modules/resources/api/resourceTypeCounts.ts +74 -0
  192. package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
  193. package/src/modules/resources/backend/resources/resource-types/page.tsx +1 -0
  194. package/src/modules/sales/components/documents/ReturnDialog.tsx +4 -1
  195. package/src/modules/sales/components/documents/ShipmentDialog.tsx +20 -2
  196. package/src/modules/sales/di.ts +6 -0
  197. package/src/modules/sales/services/salesOrderService.ts +38 -0
  198. package/src/modules/shipping_carriers/api/shipments/route.ts +8 -0
  199. package/src/modules/shipping_carriers/data/entities.ts +33 -0
  200. package/src/modules/shipping_carriers/data/validators.ts +1 -0
  201. package/src/modules/shipping_carriers/i18n/de.json +1 -0
  202. package/src/modules/shipping_carriers/i18n/en.json +1 -0
  203. package/src/modules/shipping_carriers/i18n/es.json +1 -0
  204. package/src/modules/shipping_carriers/i18n/pl.json +1 -0
  205. package/src/modules/shipping_carriers/lib/shipment-idempotency.ts +119 -0
  206. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.ts +3 -1
  207. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +24 -2
  208. package/src/modules/shipping_carriers/lib/shipping-service.ts +87 -43
  209. package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +197 -0
  210. package/src/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.ts +14 -0
  211. package/src/modules/staff/i18n/de.json +1 -0
  212. package/src/modules/staff/i18n/en.json +1 -0
  213. package/src/modules/staff/i18n/es.json +1 -0
  214. package/src/modules/staff/i18n/pl.json +1 -0
  215. package/src/modules/staff/lib/timesheets-ui/ListView.tsx +15 -7
  216. package/src/modules/translations/components/TranslationManager.tsx +17 -24
  217. package/src/modules/translations/i18n/de.json +1 -0
  218. package/src/modules/translations/i18n/en.json +1 -0
  219. package/src/modules/translations/i18n/es.json +1 -0
  220. package/src/modules/translations/i18n/pl.json +1 -0
@@ -3,11 +3,10 @@ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
3
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
4
  import { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
5
5
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
6
- import type { EntityManager } from '@mikro-orm/postgresql'
7
- import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
- import { ResourcesResource, ResourcesResourceType } from '../data/entities'
6
+ import { ResourcesResourceType } from '../data/entities'
9
7
  import { resourcesResourceTypeCreateSchema, resourcesResourceTypeUpdateSchema } from '../data/validators'
10
8
  import { sanitizeSearchTerm } from './helpers'
9
+ import { attachResourceTypeCounts } from './resourceTypeCounts'
11
10
  import { E } from '#generated/entities.ids.generated'
12
11
  import { createResourcesCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from './openapi'
13
12
 
@@ -47,6 +46,7 @@ const listSchema = z
47
46
  ids: z.string().optional(),
48
47
  sortField: z.string().optional(),
49
48
  sortDir: z.enum(['asc', 'desc']).optional(),
49
+ withResourceCounts: z.string().optional(),
50
50
  })
51
51
  .passthrough()
52
52
 
@@ -131,47 +131,7 @@ const crud = makeCrudRoute({
131
131
  },
132
132
  },
133
133
  hooks: {
134
- afterList: async (payload, ctx) => {
135
- const items: Array<Record<string, unknown>> = Array.isArray(payload?.items)
136
- ? (payload.items as Array<Record<string, unknown>>)
137
- : []
138
- if (!items.length) return
139
- const typeIds = items
140
- .map((item) => (typeof item.id === 'string' ? item.id : null))
141
- .filter((id): id is string => typeof id === 'string' && id.length > 0)
142
- if (!typeIds.length) return
143
- const em = (ctx.container.resolve('em') as EntityManager).fork()
144
- const tenantId = ctx.organizationScope?.tenantId ?? ctx.auth?.tenantId ?? null
145
- const orgIds = ctx.organizationIds ?? ctx.organizationScope?.filterIds ?? null
146
- const orgFilter =
147
- Array.isArray(orgIds) && orgIds.length > 0
148
- ? { $in: orgIds }
149
- : ctx.selectedOrganizationId ?? ctx.organizationScope?.selectedId ?? null
150
- const scope = { tenantId, organizationId: ctx.selectedOrganizationId ?? ctx.organizationScope?.selectedId ?? null }
151
- const resources = await findWithDecryption(
152
- em,
153
- ResourcesResource,
154
- {
155
- resourceTypeId: { $in: typeIds },
156
- deletedAt: null,
157
- ...(tenantId ? { tenantId } : {}),
158
- ...(orgFilter ? { organizationId: orgFilter } : {}),
159
- },
160
- { fields: ['id', 'resourceTypeId'] },
161
- scope,
162
- )
163
- const countMap = new Map<string, number>()
164
- resources.forEach((resource) => {
165
- const typeId = resource.resourceTypeId ?? null
166
- if (!typeId) return
167
- countMap.set(typeId, (countMap.get(typeId) ?? 0) + 1)
168
- })
169
- items.forEach((item) => {
170
- const id = typeof item.id === 'string' ? item.id : null
171
- if (!id) return
172
- item.resourceCount = countMap.get(id) ?? 0
173
- })
174
- },
134
+ afterList: (payload, ctx) => attachResourceTypeCounts(payload, ctx),
175
135
  },
176
136
  })
177
137
 
@@ -0,0 +1,74 @@
1
+ import type { Kysely } from 'kysely'
2
+ import { sql } from 'kysely'
3
+ import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
4
+ import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
5
+
6
+ type ResourceCountDatabase = {
7
+ resources_resources: {
8
+ resource_type_id: string | null
9
+ deleted_at: Date | null
10
+ tenant_id: string | null
11
+ organization_id: string | null
12
+ }
13
+ }
14
+
15
+ type KyselyCapableEntityManager = { getKysely: () => Kysely<ResourceCountDatabase> }
16
+
17
+ type ResourceTypeCountContext = Pick<
18
+ CrudCtx,
19
+ 'container' | 'auth' | 'organizationScope' | 'organizationIds' | 'selectedOrganizationId'
20
+ > & { query?: { withResourceCounts?: unknown } }
21
+
22
+ type ResourceTypeListPayload = { items?: unknown } | null | undefined
23
+
24
+ type ResourceCountRow = { resource_type_id: string | null; count: string | number }
25
+
26
+ export async function attachResourceTypeCounts(
27
+ payload: ResourceTypeListPayload,
28
+ ctx: ResourceTypeCountContext,
29
+ ): Promise<void> {
30
+ // Resource counts are opt-in: only callers that render the count (the
31
+ // resource-types admin table and its delete gate) pass `withResourceCounts`.
32
+ // Select/filter callers (resource dropdowns, group labels) skip the scan
33
+ // entirely so ordinary type lookups no longer pay an O(resources) cost.
34
+ if (!parseBooleanFromUnknown(ctx.query?.withResourceCounts)) return
35
+
36
+ const items = Array.isArray(payload?.items)
37
+ ? (payload!.items as Array<Record<string, unknown>>)
38
+ : []
39
+ if (!items.length) return
40
+
41
+ const typeIds = items
42
+ .map((item) => (typeof item.id === 'string' ? item.id : null))
43
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
44
+ if (!typeIds.length) return
45
+
46
+ const tenantId = ctx.organizationScope?.tenantId ?? ctx.auth?.tenantId ?? null
47
+ const orgIds = ctx.organizationIds ?? ctx.organizationScope?.filterIds ?? null
48
+ const singleOrgId = ctx.selectedOrganizationId ?? ctx.organizationScope?.selectedId ?? null
49
+
50
+ const db = (ctx.container.resolve('em') as unknown as KyselyCapableEntityManager).getKysely()
51
+ let query = db
52
+ .selectFrom('resources_resources')
53
+ .select(['resource_type_id', sql<string>`count(*)`.as('count')])
54
+ .where('resource_type_id', 'in', typeIds)
55
+ .where('deleted_at', 'is', null)
56
+ if (tenantId) query = query.where('tenant_id', '=', tenantId)
57
+ if (Array.isArray(orgIds) && orgIds.length > 0) {
58
+ query = query.where('organization_id', 'in', orgIds)
59
+ } else if (singleOrgId) {
60
+ query = query.where('organization_id', '=', singleOrgId)
61
+ }
62
+ const rows = (await query.groupBy('resource_type_id').execute()) as ResourceCountRow[]
63
+
64
+ const countMap = new Map<string, number>()
65
+ for (const row of rows) {
66
+ if (typeof row.resource_type_id !== 'string') continue
67
+ const count = typeof row.count === 'string' ? Number.parseInt(row.count, 10) : Number(row.count)
68
+ countMap.set(row.resource_type_id, Number.isFinite(count) ? count : 0)
69
+ }
70
+ for (const item of items) {
71
+ if (typeof item.id !== 'string') continue
72
+ item.resourceCount = countMap.get(item.id) ?? 0
73
+ }
74
+ }
@@ -34,7 +34,7 @@ export default function ResourcesResourceTypeEditPage({ params }: { params?: { i
34
34
  setIsNotFound(false)
35
35
  try {
36
36
  const payload = await readApiResultOrThrow<ResourceTypesResponse>(
37
- `/api/resources/resource-types?ids=${encodeURIComponent(resourceTypeId)}&page=1&pageSize=1`,
37
+ `/api/resources/resource-types?ids=${encodeURIComponent(resourceTypeId)}&page=1&pageSize=1&withResourceCounts=true`,
38
38
  undefined,
39
39
  { errorMessage: t('resources.resourceTypes.errors.load', 'Failed to load resource types.') },
40
40
  )
@@ -208,6 +208,7 @@ export default function ResourcesResourceTypesPage() {
208
208
  const params = new URLSearchParams({
209
209
  page: String(page),
210
210
  pageSize: String(PAGE_SIZE),
211
+ withResourceCounts: 'true',
211
212
  })
212
213
  const sort = sorting[0]
213
214
  if (sort?.id) {
@@ -11,6 +11,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
11
11
  import { apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
12
12
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
13
13
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
14
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
14
15
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
15
16
  import { computeAvailableReturnQuantity } from '@open-mercato/core/modules/sales/lib/returnQuantity'
16
17
  import { handleSectionMutationError } from './optimisticLock'
@@ -136,7 +137,9 @@ export function ReturnDialog({ open, orderId, lines, documentUpdatedAt, onClose,
136
137
  onClose()
137
138
  return
138
139
  }
139
- flash(t('sales.returns.errors.create', 'Failed to create return.'), 'error')
140
+ const normalized = normalizeCrudServerError(err)
141
+ const fallback = t('sales.returns.errors.create', 'Failed to create return.')
142
+ flash(normalized.message || fallback, 'error')
140
143
  } finally {
141
144
  setSaving(false)
142
145
  }
@@ -255,6 +255,20 @@ const extractShipmentAddressSnapshot = (
255
255
  return normalizeAddressSnapshot(raw as Record<string, unknown>)
256
256
  }
257
257
 
258
+ const addressOptionsSignature = (options: ShipmentAddressOption[]): string =>
259
+ options.map((option) => `${snapshotKey(option.snapshot) ?? ''}::${option.id}`).join('||')
260
+
261
+ function useStableAddressOptions(options: ShipmentAddressOption[]): ShipmentAddressOption[] {
262
+ const signature = addressOptionsSignature(options)
263
+ const stableRef = React.useRef(options)
264
+ const signatureRef = React.useRef(signature)
265
+ if (signatureRef.current !== signature) {
266
+ signatureRef.current = signature
267
+ stableRef.current = options
268
+ }
269
+ return stableRef.current
270
+ }
271
+
258
272
  export function ShipmentDialog({
259
273
  open,
260
274
  mode,
@@ -323,7 +337,7 @@ export function ShipmentDialog({
323
337
  [shipmentAddressSnapshot, t],
324
338
  )
325
339
 
326
- const baseAddressOptions = React.useMemo(
340
+ const computedBaseAddressOptions = React.useMemo(
327
341
  () =>
328
342
  dedupeAddressOptions(
329
343
  [shippingAddressOption, shipmentAddressOption].filter(
@@ -332,6 +346,7 @@ export function ShipmentDialog({
332
346
  ),
333
347
  [shipmentAddressOption, shippingAddressOption],
334
348
  )
349
+ const baseAddressOptions = useStableAddressOptions(computedBaseAddressOptions)
335
350
 
336
351
  const preferredAddressId = React.useMemo(() => {
337
352
  const shipmentKey = snapshotKey(shipmentAddressSnapshot)
@@ -452,7 +467,10 @@ export function ShipmentDialog({
452
467
 
453
468
  const mergeAddressOptions = React.useCallback(
454
469
  (options: ShipmentAddressOption[]) =>
455
- setAddressOptions((prev) => dedupeAddressOptions([...prev, ...options])),
470
+ setAddressOptions((prev) => {
471
+ const next = dedupeAddressOptions([...prev, ...options])
472
+ return next.length === prev.length ? prev : next
473
+ }),
456
474
  [],
457
475
  )
458
476
 
@@ -7,6 +7,7 @@ import { registerOptimisticLockReaders } from '@open-mercato/shared/lib/crud/opt
7
7
  import { DefaultSalesCalculationService } from './services/salesCalculationService'
8
8
  import { DefaultTaxCalculationService } from './services/taxCalculationService'
9
9
  import { SalesDocumentNumberGenerator } from './services/salesDocumentNumberGenerator'
10
+ import { DefaultSalesOrderService } from './services/salesOrderService'
10
11
  import {
11
12
  SalesOrder,
12
13
  SalesOrderLine,
@@ -85,6 +86,11 @@ export function register(container: AppContainer) {
85
86
  })
86
87
  .singleton()
87
88
  .proxy(),
89
+ salesOrderService: asFunction(({ em }: AppCradle) => {
90
+ return new DefaultSalesOrderService(em)
91
+ })
92
+ .singleton()
93
+ .proxy(),
88
94
  SalesOrder: asValue(SalesOrder),
89
95
  SalesOrderLine: asValue(SalesOrderLine),
90
96
  SalesOrderAdjustment: asValue(SalesOrderAdjustment),
@@ -0,0 +1,38 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { SalesOrder } from '../data/entities'
4
+
5
+ export type SalesOrderLookupScope = {
6
+ organizationId: string
7
+ tenantId: string
8
+ }
9
+
10
+ export interface SalesOrderService {
11
+ findByExternalReference(
12
+ externalReference: string,
13
+ scope: SalesOrderLookupScope,
14
+ ): Promise<SalesOrder | null>
15
+ }
16
+
17
+ export class DefaultSalesOrderService implements SalesOrderService {
18
+ constructor(private readonly em: EntityManager) {}
19
+
20
+ async findByExternalReference(
21
+ externalReference: string,
22
+ scope: SalesOrderLookupScope,
23
+ ): Promise<SalesOrder | null> {
24
+ if (!externalReference) return null
25
+ return findOneWithDecryption(
26
+ this.em,
27
+ SalesOrder,
28
+ {
29
+ externalReference,
30
+ organizationId: scope.organizationId,
31
+ tenantId: scope.tenantId,
32
+ deletedAt: null,
33
+ },
34
+ undefined,
35
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
36
+ )
37
+ }
38
+ }
@@ -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
  }