@open-mercato/core 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7237.1.635faf2c7a

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 (170) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/availability_policy/index.js +41 -0
  3. package/dist/generated/entities/availability_policy/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +4 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +21 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/attachments/data/entities.js +3 -3
  9. package/dist/modules/attachments/data/entities.js.map +2 -2
  10. package/dist/modules/availability/acl.js +16 -0
  11. package/dist/modules/availability/acl.js.map +7 -0
  12. package/dist/modules/availability/api/check/route.js +95 -0
  13. package/dist/modules/availability/api/check/route.js.map +7 -0
  14. package/dist/modules/availability/api/openapi.js +27 -0
  15. package/dist/modules/availability/api/openapi.js.map +7 -0
  16. package/dist/modules/availability/api/policies/resolve-preview/route.js +58 -0
  17. package/dist/modules/availability/api/policies/resolve-preview/route.js.map +7 -0
  18. package/dist/modules/availability/api/policies/route.js +188 -0
  19. package/dist/modules/availability/api/policies/route.js.map +7 -0
  20. package/dist/modules/availability/backend/availability/check/page.js +185 -0
  21. package/dist/modules/availability/backend/availability/check/page.js.map +7 -0
  22. package/dist/modules/availability/backend/availability/check/page.meta.js +16 -0
  23. package/dist/modules/availability/backend/availability/check/page.meta.js.map +7 -0
  24. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js +79 -0
  25. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js.map +7 -0
  26. package/dist/modules/availability/backend/availability/policies/ScopeFields.js +53 -0
  27. package/dist/modules/availability/backend/availability/policies/ScopeFields.js.map +7 -0
  28. package/dist/modules/availability/backend/availability/policies/[id]/page.js +152 -0
  29. package/dist/modules/availability/backend/availability/policies/[id]/page.js.map +7 -0
  30. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js +12 -0
  31. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js.map +7 -0
  32. package/dist/modules/availability/backend/availability/policies/create/page.js +94 -0
  33. package/dist/modules/availability/backend/availability/policies/create/page.js.map +7 -0
  34. package/dist/modules/availability/backend/availability/policies/create/page.meta.js +12 -0
  35. package/dist/modules/availability/backend/availability/policies/create/page.meta.js.map +7 -0
  36. package/dist/modules/availability/backend/availability/policies/formGroups.js +75 -0
  37. package/dist/modules/availability/backend/availability/policies/formGroups.js.map +7 -0
  38. package/dist/modules/availability/backend/availability/policies/page.js +176 -0
  39. package/dist/modules/availability/backend/availability/policies/page.js.map +7 -0
  40. package/dist/modules/availability/backend/availability/policies/page.meta.js +16 -0
  41. package/dist/modules/availability/backend/availability/policies/page.meta.js.map +7 -0
  42. package/dist/modules/availability/commands/policies.js +312 -0
  43. package/dist/modules/availability/commands/policies.js.map +7 -0
  44. package/dist/modules/availability/commands/scope.js +19 -0
  45. package/dist/modules/availability/commands/scope.js.map +7 -0
  46. package/dist/modules/availability/data/entities.js +95 -0
  47. package/dist/modules/availability/data/entities.js.map +7 -0
  48. package/dist/modules/availability/data/validators.js +53 -0
  49. package/dist/modules/availability/data/validators.js.map +7 -0
  50. package/dist/modules/availability/di.js +41 -0
  51. package/dist/modules/availability/di.js.map +7 -0
  52. package/dist/modules/availability/events.js +18 -0
  53. package/dist/modules/availability/events.js.map +7 -0
  54. package/dist/modules/availability/index.js +15 -0
  55. package/dist/modules/availability/index.js.map +7 -0
  56. package/dist/modules/availability/lib/policyResolution.js +155 -0
  57. package/dist/modules/availability/lib/policyResolution.js.map +7 -0
  58. package/dist/modules/availability/lib/tryResolve.js +11 -0
  59. package/dist/modules/availability/lib/tryResolve.js.map +7 -0
  60. package/dist/modules/availability/migrations/Migration20260922095844_availability.js +16 -0
  61. package/dist/modules/availability/migrations/Migration20260922095844_availability.js.map +7 -0
  62. package/dist/modules/availability/setup.js +12 -0
  63. package/dist/modules/availability/setup.js.map +7 -0
  64. package/dist/modules/entities/cli.js +33 -12
  65. package/dist/modules/entities/cli.js.map +2 -2
  66. package/dist/modules/inbox_ops/api/settings/route.js +7 -32
  67. package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
  68. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +2 -2
  69. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
  70. package/dist/modules/inbox_ops/components/proposals/ActionCard.js +1 -1
  71. package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
  72. package/dist/modules/inbox_ops/lib/ensure-settings.js +47 -0
  73. package/dist/modules/inbox_ops/lib/ensure-settings.js.map +7 -0
  74. package/dist/modules/inbox_ops/lib/executionEngine.js.map +1 -1
  75. package/dist/modules/inbox_ops/lib/executionHelpers.js +0 -23
  76. package/dist/modules/inbox_ops/lib/executionHelpers.js.map +2 -2
  77. package/dist/modules/inbox_ops/lib/payloadEnrichment.js +0 -3
  78. package/dist/modules/inbox_ops/lib/payloadEnrichment.js.map +2 -2
  79. package/dist/modules/inbox_ops/setup.js +2 -21
  80. package/dist/modules/inbox_ops/setup.js.map +2 -2
  81. package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +1 -1
  82. package/dist/modules/sales/data/entities.js +3 -1
  83. package/dist/modules/sales/data/entities.js.map +2 -2
  84. package/dist/modules/sales/inbox-actions.js +0 -12
  85. package/dist/modules/sales/inbox-actions.js.map +2 -2
  86. package/dist/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.js +20 -0
  87. package/dist/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.js.map +7 -0
  88. package/dist/modules/wms/di.js +3 -0
  89. package/dist/modules/wms/di.js.map +2 -2
  90. package/dist/modules/wms/lib/availabilityCache.js +85 -0
  91. package/dist/modules/wms/lib/availabilityCache.js.map +7 -0
  92. package/dist/modules/wms/lib/availabilityCalculation.js +178 -0
  93. package/dist/modules/wms/lib/availabilityCalculation.js.map +7 -0
  94. package/dist/modules/wms/lib/availabilityProvider.js +14 -0
  95. package/dist/modules/wms/lib/availabilityProvider.js.map +7 -0
  96. package/dist/modules/wms/lib/sqlInClause.js +9 -0
  97. package/dist/modules/wms/lib/sqlInClause.js.map +7 -0
  98. package/dist/modules/wms/lib/tryResolve.js +11 -0
  99. package/dist/modules/wms/lib/tryResolve.js.map +7 -0
  100. package/dist/modules/wms/subscribers/invalidate-availability-cache.js +16 -0
  101. package/dist/modules/wms/subscribers/invalidate-availability-cache.js.map +7 -0
  102. package/generated/entities/availability_policy/index.ts +19 -0
  103. package/generated/entities.ids.generated.ts +4 -0
  104. package/generated/entity-fields-registry.ts +21 -0
  105. package/package.json +7 -7
  106. package/src/modules/attachments/data/entities.ts +5 -3
  107. package/src/modules/auth/i18n/de.json +3 -0
  108. package/src/modules/auth/i18n/en.json +3 -0
  109. package/src/modules/auth/i18n/es.json +3 -0
  110. package/src/modules/auth/i18n/ko.json +3 -0
  111. package/src/modules/auth/i18n/pl.json +3 -0
  112. package/src/modules/availability/acl.ts +12 -0
  113. package/src/modules/availability/api/check/route.ts +114 -0
  114. package/src/modules/availability/api/openapi.ts +28 -0
  115. package/src/modules/availability/api/policies/resolve-preview/route.ts +67 -0
  116. package/src/modules/availability/api/policies/route.ts +224 -0
  117. package/src/modules/availability/backend/availability/check/page.meta.ts +12 -0
  118. package/src/modules/availability/backend/availability/check/page.tsx +223 -0
  119. package/src/modules/availability/backend/availability/policies/ResolutionPreviewPanel.tsx +106 -0
  120. package/src/modules/availability/backend/availability/policies/ScopeFields.tsx +55 -0
  121. package/src/modules/availability/backend/availability/policies/[id]/page.meta.ts +8 -0
  122. package/src/modules/availability/backend/availability/policies/[id]/page.tsx +182 -0
  123. package/src/modules/availability/backend/availability/policies/create/page.meta.ts +8 -0
  124. package/src/modules/availability/backend/availability/policies/create/page.tsx +105 -0
  125. package/src/modules/availability/backend/availability/policies/formGroups.ts +77 -0
  126. package/src/modules/availability/backend/availability/policies/page.meta.ts +12 -0
  127. package/src/modules/availability/backend/availability/policies/page.tsx +196 -0
  128. package/src/modules/availability/commands/policies.ts +358 -0
  129. package/src/modules/availability/commands/scope.ts +26 -0
  130. package/src/modules/availability/data/entities.ts +92 -0
  131. package/src/modules/availability/data/validators.ts +70 -0
  132. package/src/modules/availability/di.ts +54 -0
  133. package/src/modules/availability/events.ts +17 -0
  134. package/src/modules/availability/i18n/de.json +81 -0
  135. package/src/modules/availability/i18n/en.json +81 -0
  136. package/src/modules/availability/i18n/es.json +81 -0
  137. package/src/modules/availability/i18n/ko.json +81 -0
  138. package/src/modules/availability/i18n/pl.json +81 -0
  139. package/src/modules/availability/index.ts +13 -0
  140. package/src/modules/availability/lib/policyResolution.ts +264 -0
  141. package/src/modules/availability/lib/tryResolve.ts +8 -0
  142. package/src/modules/availability/migrations/.snapshot-open-mercato.json +375 -0
  143. package/src/modules/availability/migrations/Migration20260922095844_availability.ts +13 -0
  144. package/src/modules/availability/setup.ts +10 -0
  145. package/src/modules/entities/cli.ts +53 -13
  146. package/src/modules/inbox_ops/api/settings/route.ts +9 -32
  147. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +2 -2
  148. package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +1 -1
  149. package/src/modules/inbox_ops/i18n/de.json +1 -1
  150. package/src/modules/inbox_ops/i18n/en.json +1 -1
  151. package/src/modules/inbox_ops/i18n/es.json +1 -1
  152. package/src/modules/inbox_ops/i18n/ko.json +1 -1
  153. package/src/modules/inbox_ops/i18n/pl.json +1 -1
  154. package/src/modules/inbox_ops/lib/ensure-settings.ts +77 -0
  155. package/src/modules/inbox_ops/lib/executionEngine.ts +1 -1
  156. package/src/modules/inbox_ops/lib/executionHelpers.ts +0 -27
  157. package/src/modules/inbox_ops/lib/payloadEnrichment.ts +1 -5
  158. package/src/modules/inbox_ops/setup.ts +2 -21
  159. package/src/modules/inbox_ops/subscribers/extractionWorker.ts +1 -1
  160. package/src/modules/sales/data/entities.ts +2 -0
  161. package/src/modules/sales/inbox-actions.ts +0 -20
  162. package/src/modules/sales/migrations/.snapshot-open-mercato.json +20 -0
  163. package/src/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.ts +74 -0
  164. package/src/modules/wms/di.ts +6 -0
  165. package/src/modules/wms/lib/availabilityCache.ts +136 -0
  166. package/src/modules/wms/lib/availabilityCalculation.ts +289 -0
  167. package/src/modules/wms/lib/availabilityProvider.ts +29 -0
  168. package/src/modules/wms/lib/sqlInClause.ts +21 -0
  169. package/src/modules/wms/lib/tryResolve.ts +8 -0
  170. package/src/modules/wms/subscribers/invalidate-availability-cache.ts +30 -0
@@ -16,6 +16,7 @@ import { createKmsService, type KmsService, type TenantDek } from '@open-mercato
16
16
  import {
17
17
  decryptWithAesGcm,
18
18
  decryptWithAesGcmStrict,
19
+ isEncryptedPayloadShape,
19
20
  TenantDataEncryptionError,
20
21
  TenantDataEncryptionErrorCode,
21
22
  } from '@open-mercato/shared/lib/encryption/aes'
@@ -448,12 +449,6 @@ function resolveMapMeta(
448
449
  return { entityId, meta, fields, tenantId }
449
450
  }
450
451
 
451
- function isEncryptedPayload(value: unknown): boolean {
452
- if (typeof value !== 'string') return false
453
- const parts = value.split(':')
454
- return parts.length === 4 && parts[3] === 'v1'
455
- }
456
-
457
452
  function formatValueForColumn(prop: any, value: unknown): unknown {
458
453
  if (value === null || value === undefined) return value
459
454
  const types = Array.isArray(prop?.columnTypes) ? prop.columnTypes : []
@@ -550,6 +545,10 @@ const rotateEncryptionKey: ModuleCli = {
550
545
  }
551
546
 
552
547
  const oldDekCache = new Map<string, TenantDek | null>()
548
+ // Rows this run could not rotate because their ciphertext opens under neither
549
+ // --old-key nor the current tenant key — surfaced in the closing summary so an
550
+ // operator does not have to grep console.warn output for a batch of thousands.
551
+ const unrecoverableRows: Array<{ entityId: string; field: string; rowId: unknown }> = []
553
552
  // A dry run must not provision key material. `encryptEntityPayload` creates and
554
553
  // persists a tenant DEK in KMS/Vault the first time it runs for a tenant, so a
555
554
  // read-only preview would silently mutate KMS state (#5950). Probe read-only
@@ -599,6 +598,8 @@ const rotateEncryptionKey: ModuleCli = {
599
598
  const list = Array.isArray(rows) ? rows : []
600
599
  if (!list.length) return 0
601
600
  const dekAvailable = dryRun ? await hasExistingDek(scope.tenantId) : true
601
+ // The scope pins one tenant, so the current DEK is the same for every row here.
602
+ const currentDek = rotate && oldKms ? await encryptionService.getDek(scope.tenantId) : null
602
603
  let updated = 0
603
604
  for (const row of list) {
604
605
  const payload: Record<string, unknown> = {}
@@ -607,7 +608,13 @@ const rotateEncryptionKey: ModuleCli = {
607
608
  const col = resolved?.columnName
608
609
  if (!col) continue
609
610
  const rawValue = row[col]
610
- if (rotate && !isEncryptedPayload(rawValue)) {
611
+ // Rotate mode only touches values that look like an envelope (candidates to
612
+ // re-key); encrypt mode only touches values that do not (plaintext to seal).
613
+ // A shape-valid envelope encountered in encrypt mode is either already
614
+ // correctly encrypted or sealed under a foreign DEK — either way, handing it
615
+ // to encryptEntityPayload would either no-op or throw WRONG_KEY and abort the
616
+ // whole run (#5951); mirror the filter the update path already applies below.
617
+ if (isEncryptedPayloadShape(rawValue) ? !rotate : rotate) {
611
618
  continue
612
619
  }
613
620
  payload[rule.field] = rawValue
@@ -628,10 +635,24 @@ const rotateEncryptionKey: ModuleCli = {
628
635
  }
629
636
  for (const rule of fields) {
630
637
  const value = payload[rule.field]
631
- if (typeof value !== 'string' || !isEncryptedPayload(value)) continue
638
+ if (typeof value !== 'string' || !isEncryptedPayloadShape(value)) continue
632
639
  const decrypted = decryptWithOldKey(value, oldDek)
633
- if (decrypted === null) continue
634
- payload[rule.field] = parseDecryptedFieldValue(decrypted)
640
+ if (decrypted !== null) {
641
+ payload[rule.field] = parseDecryptedFieldValue(decrypted)
642
+ continue
643
+ }
644
+ // The old key did not open it. If the current key does, the row was already
645
+ // rotated (a resumed run) — leave it in the payload, where the service's own
646
+ // already-encrypted check skips it. Otherwise no key we hold can open it, and
647
+ // handing it to the encrypt path would abort the whole batch (#5951). Drop the
648
+ // field so this row is reported and skipped instead.
649
+ if (currentDek && decryptWithAesGcm(value, currentDek.key) !== null) continue
650
+ delete payload[rule.field]
651
+ if (rule.hashField) delete payload[rule.hashField]
652
+ unrecoverableRows.push({ entityId, field: rule.field, rowId: row[pk] })
653
+ console.warn(
654
+ `Skipping ${entityId}.${rule.field} for row ${row[pk]}: its ciphertext opens under neither --old-key nor the current tenant key. Re-run with the key that sealed it.`,
655
+ )
635
656
  }
636
657
  }
637
658
  if (!dekAvailable) {
@@ -643,7 +664,7 @@ const rotateEncryptionKey: ModuleCli = {
643
664
  if (!col) return false
644
665
  const value = row[col]
645
666
  if (value === null || value === undefined) return false
646
- return rotate ? isEncryptedPayload(value) : !isEncryptedPayload(value)
667
+ return rotate ? isEncryptedPayloadShape(value) : !isEncryptedPayloadShape(value)
647
668
  })
648
669
  if (wouldChange) updated += 1
649
670
  continue
@@ -662,7 +683,7 @@ const rotateEncryptionKey: ModuleCli = {
662
683
  if (!col) continue
663
684
  const nextValue = (encrypted as any)[rule.field]
664
685
  if (nextValue !== undefined && nextValue !== row[col]) {
665
- if (!rotate && isEncryptedPayload(row[col])) continue
686
+ if (!rotate && isEncryptedPayloadShape(row[col])) continue
666
687
  updates[col] = formatValueForColumn(resolved?.prop, nextValue)
667
688
  }
668
689
  if (rule.hashField) {
@@ -709,6 +730,20 @@ const rotateEncryptionKey: ModuleCli = {
709
730
  } else {
710
731
  console.log('All mapped entity fields already encrypted for the selected scope.')
711
732
  }
733
+ if (unrecoverableRows.length) {
734
+ const maxListed = 20
735
+ console.log(
736
+ `\n⚠️ ${unrecoverableRows.length} field(s) could not be rotated — their ciphertext opens under neither `
737
+ + '--old-key nor the current tenant key, so they were left untouched. See '
738
+ + '"Key management" in apps/docs/docs/architecture/data-encryption.mdx for how to resolve these manually.',
739
+ )
740
+ for (const row of unrecoverableRows.slice(0, maxListed)) {
741
+ console.log(` - ${row.entityId}.${row.field} row=${String(row.rowId)}`)
742
+ }
743
+ if (unrecoverableRows.length > maxListed) {
744
+ console.log(` ... and ${unrecoverableRows.length - maxListed} more`)
745
+ }
746
+ }
712
747
  },
713
748
  }
714
749
 
@@ -1201,8 +1236,13 @@ const backfillSystemEncryption: ModuleCli = {
1201
1236
  const resolved = resolveProperty(meta, rule.field)
1202
1237
  if (!resolved.columnName) continue
1203
1238
  const rawValue = row[resolved.columnName]
1239
+ // Only plaintext belongs in the payload. An already-encrypted column produced no
1240
+ // update anyway (the service skips what decrypts under the current key), but if the
1241
+ // system key ever changed, passing its ciphertext through would abort the whole
1242
+ // backfill rather than let the row's genuinely-plaintext fields through (#5951).
1243
+ if (rawValue !== null && rawValue !== undefined && isEncryptedPayloadShape(rawValue)) continue
1204
1244
  payload[rule.field] = rawValue
1205
- if (rawValue !== null && rawValue !== undefined && !isEncryptedPayload(rawValue)) hasPlaintext = true
1245
+ if (rawValue !== null && rawValue !== undefined) hasPlaintext = true
1206
1246
  if (rule.hashField) {
1207
1247
  const resolvedHash = resolveProperty(meta, rule.hashField)
1208
1248
  if (resolvedHash.columnName) payload[rule.hashField] = row[resolvedHash.columnName]
@@ -1,12 +1,11 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { runWithCacheTenant } from '@open-mercato/cache'
4
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
4
  import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
6
5
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
- import { InboxSettings } from '../../data/entities'
8
6
  import { updateSettingsSchema } from '../../data/validators'
9
7
  import { resolveRequestContext, handleRouteError } from '../routeHelpers'
8
+ import { ensureInboxSettings } from '../../lib/ensure-settings'
10
9
  import {
11
10
  resolveCache,
12
11
  createSettingsCacheKey,
@@ -36,20 +35,10 @@ export async function GET(req: Request) {
36
35
  }
37
36
  }
38
37
 
39
- const settings = await findOneWithDecryption(
40
- ctx.em,
41
- InboxSettings,
42
- {
43
- organizationId: ctx.organizationId,
44
- tenantId: ctx.tenantId,
45
- deletedAt: null,
46
- },
47
- undefined,
48
- ctx.scope,
49
- )
38
+ const { settings } = await ensureInboxSettings(ctx.em, ctx.scope)
50
39
 
51
40
  const responseBody = {
52
- settings: settings ? {
41
+ settings: {
53
42
  id: settings.id,
54
43
  inboxAddress: settings.inboxAddress,
55
44
  isActive: settings.isActive,
@@ -57,7 +46,7 @@ export async function GET(req: Request) {
57
46
  // Surface only whether a per-tenant secret exists — never the value.
58
47
  webhookSecretSet: Boolean(settings.webhookSecret),
59
48
  updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : (settings.updatedAt ?? null),
60
- } : null,
49
+ },
61
50
  }
62
51
 
63
52
  if (cache) {
@@ -88,21 +77,7 @@ export async function PATCH(req: Request) {
88
77
  return NextResponse.json({ error: 'Invalid request', details: parsed.error.issues }, { status: 400 })
89
78
  }
90
79
 
91
- const settings = await findOneWithDecryption(
92
- ctx.em,
93
- InboxSettings,
94
- {
95
- organizationId: ctx.organizationId,
96
- tenantId: ctx.tenantId,
97
- deletedAt: null,
98
- },
99
- undefined,
100
- ctx.scope,
101
- )
102
-
103
- if (!settings) {
104
- return NextResponse.json({ error: 'Settings not found' }, { status: 404 })
105
- }
80
+ const { em: settingsEm, settings } = await ensureInboxSettings(ctx.em, ctx.scope)
106
81
 
107
82
  // Optimistic lock: refuse a stale overwrite when two tabs edit the same inbox
108
83
  // settings record. Strictly additive — a no-op without the expected-version header.
@@ -129,7 +104,10 @@ export async function PATCH(req: Request) {
129
104
  settings.webhookSecret = parsed.data.webhookSecret ? parsed.data.webhookSecret : null
130
105
  }
131
106
 
132
- await ctx.em.flush()
107
+ // Flush through `settingsEm` — after a concurrent-bootstrap recovery this
108
+ // is a clean fork, not `ctx.em`, whose unit of work would otherwise retry
109
+ // the losing insert.
110
+ await settingsEm.flush()
133
111
 
134
112
  const cache = resolveCache(ctx.container)
135
113
  await runWithCacheTenant(ctx.tenantId, () => invalidateSettingsCache(cache, ctx.tenantId))
@@ -166,7 +144,6 @@ export const openApi: OpenApiRouteDoc = {
166
144
  description: 'Updates working language and/or active status',
167
145
  responses: [
168
146
  { status: 200, description: 'Updated settings' },
169
- { status: 404, description: 'Settings not found' },
170
147
  ],
171
148
  },
172
149
  },
@@ -46,8 +46,8 @@ export default function InboxSettingsPage() {
46
46
  try {
47
47
  const result = await apiCall<{ settings: { inboxAddress?: string; isActive?: boolean; workingLanguage?: string; updatedAt?: string | null } | null }>('/api/inbox_ops/settings')
48
48
  if (!cancelled) {
49
- if (result?.ok && result.result?.settings) {
50
- setSettings(result.result.settings)
49
+ if (result?.ok) {
50
+ setSettings(result.result?.settings ?? null)
51
51
  } else {
52
52
  setError(t('inbox_ops.settings.load_failed', 'Failed to load settings'))
53
53
  }
@@ -31,7 +31,7 @@ export function useDiscrepancyDescriptions(): (description: string, foundValue?:
31
31
  const t = useT()
32
32
  const translations: Record<string, string> = {
33
33
  'inbox_ops.discrepancy.desc.no_channel': t('inbox_ops.discrepancy.desc.no_channel', 'No sales channel available. Create a channel in Sales settings before accepting this order.'),
34
- 'inbox_ops.discrepancy.desc.no_currency': t('inbox_ops.discrepancy.desc.no_currency', 'No currency could be resolved for this order. Set a currency code or configure a sales channel with a default currency.'),
34
+ 'inbox_ops.discrepancy.desc.no_currency': t('inbox_ops.discrepancy.desc.no_currency', 'No currency could be resolved for this order. Set a currency code on the order.'),
35
35
  'inbox_ops.discrepancy.desc.product_not_matched': t('inbox_ops.discrepancy.desc.product_not_matched', 'Product could not be matched to any catalog product'),
36
36
  'inbox_ops.discrepancy.desc.no_matching_contact': t('inbox_ops.discrepancy.desc.no_matching_contact', 'No matching contact found'),
37
37
  'inbox_ops.discrepancy.desc.draft_reply_no_contact': t('inbox_ops.discrepancy.desc.draft_reply_no_contact', 'Draft reply target has no matching contact. Create the contact first.'),
@@ -52,7 +52,7 @@
52
52
  "inbox_ops.discrepancy.desc.draft_reply_no_contact": "Der Empf\u00e4nger des Antwortentwurfs hat keinen passenden Kontakt. Erstellen Sie zuerst den Kontakt.",
53
53
  "inbox_ops.discrepancy.desc.duplicate_order_reference": "Eine Bestellung mit dieser Kundenreferenz existiert bereits",
54
54
  "inbox_ops.discrepancy.desc.no_channel": "Kein Vertriebskanal verf\u00fcgbar. Erstellen Sie einen Kanal in den Vertriebseinstellungen, bevor Sie diese Bestellung akzeptieren.",
55
- "inbox_ops.discrepancy.desc.no_currency": "F\u00fcr diese Bestellung konnte keine W\u00e4hrung ermittelt werden. Legen Sie einen W\u00e4hrungscode fest oder konfigurieren Sie einen Vertriebskanal mit einer Standardw\u00e4hrung.",
55
+ "inbox_ops.discrepancy.desc.no_currency": "F\u00fcr diese Bestellung konnte keine W\u00e4hrung ermittelt werden. Legen Sie einen W\u00e4hrungscode f\u00fcr die Bestellung fest.",
56
56
  "inbox_ops.discrepancy.desc.no_matching_contact": "Kein passender Kontakt gefunden",
57
57
  "inbox_ops.discrepancy.desc.product_not_matched": "Produkt konnte keinem Katalogprodukt zugeordnet werden",
58
58
  "inbox_ops.discrepancy.expected": "Erwartet",
@@ -52,7 +52,7 @@
52
52
  "inbox_ops.discrepancy.desc.draft_reply_no_contact": "Draft reply target has no matching contact. Create the contact first.",
53
53
  "inbox_ops.discrepancy.desc.duplicate_order_reference": "An order with this customer reference already exists",
54
54
  "inbox_ops.discrepancy.desc.no_channel": "No sales channel available. Create a channel in Sales settings before accepting this order.",
55
- "inbox_ops.discrepancy.desc.no_currency": "No currency could be resolved for this order. Set a currency code or configure a sales channel with a default currency.",
55
+ "inbox_ops.discrepancy.desc.no_currency": "No currency could be resolved for this order. Set a currency code on the order.",
56
56
  "inbox_ops.discrepancy.desc.no_matching_contact": "No matching contact found",
57
57
  "inbox_ops.discrepancy.desc.product_not_matched": "Product could not be matched to any catalog product",
58
58
  "inbox_ops.discrepancy.expected": "Expected",
@@ -52,7 +52,7 @@
52
52
  "inbox_ops.discrepancy.desc.draft_reply_no_contact": "El destinatario del borrador no tiene un contacto coincidente. Cree el contacto primero.",
53
53
  "inbox_ops.discrepancy.desc.duplicate_order_reference": "Ya existe un pedido con esta referencia de cliente",
54
54
  "inbox_ops.discrepancy.desc.no_channel": "No hay canal de ventas disponible. Cree un canal en la configuraci\u00f3n de ventas antes de aceptar este pedido.",
55
- "inbox_ops.discrepancy.desc.no_currency": "No se pudo determinar la moneda para este pedido. Establezca un c\u00f3digo de moneda o configure un canal de ventas con una moneda predeterminada.",
55
+ "inbox_ops.discrepancy.desc.no_currency": "No se pudo determinar la moneda para este pedido. Establezca un c\u00f3digo de moneda para el pedido.",
56
56
  "inbox_ops.discrepancy.desc.no_matching_contact": "No se encontr\u00f3 un contacto coincidente",
57
57
  "inbox_ops.discrepancy.desc.product_not_matched": "El producto no pudo vincularse a ning\u00fan producto del cat\u00e1logo",
58
58
  "inbox_ops.discrepancy.expected": "Esperado",
@@ -52,7 +52,7 @@
52
52
  "inbox_ops.discrepancy.desc.draft_reply_no_contact": "답장 초안의 대상에 일치하는 연락처가 없습니다. 먼저 연락처를 생성하세요.",
53
53
  "inbox_ops.discrepancy.desc.duplicate_order_reference": "이 고객 참조 번호로 등록된 주문이 이미 존재합니다",
54
54
  "inbox_ops.discrepancy.desc.no_channel": "사용 가능한 판매 채널이 없습니다. 이 주문을 수락하기 전에 판매 설정에서 채널을 생성하세요.",
55
- "inbox_ops.discrepancy.desc.no_currency": "이 주문의 통화를 확인할 수 없습니다. 통화 코드를 설정하거나 기본 통화가 지정된 판매 채널을 구성하세요.",
55
+ "inbox_ops.discrepancy.desc.no_currency": "이 주문의 통화를 확인할 수 없습니다. 주문에 통화 코드를 설정하세요.",
56
56
  "inbox_ops.discrepancy.desc.no_matching_contact": "일치하는 연락처를 찾을 수 없습니다",
57
57
  "inbox_ops.discrepancy.desc.product_not_matched": "상품을 카탈로그 상품과 일치시킬 수 없습니다",
58
58
  "inbox_ops.discrepancy.expected": "예상",
@@ -52,7 +52,7 @@
52
52
  "inbox_ops.discrepancy.desc.draft_reply_no_contact": "Odbiorca szkicu odpowiedzi nie ma pasuj\u0105cego kontaktu. Najpierw utw\u00f3rz kontakt.",
53
53
  "inbox_ops.discrepancy.desc.duplicate_order_reference": "Zam\u00f3wienie z t\u0105 referencj\u0105 klienta ju\u017c istnieje",
54
54
  "inbox_ops.discrepancy.desc.no_channel": "Brak dost\u0119pnego kana\u0142u sprzeda\u017cy. Utw\u00f3rz kana\u0142 w ustawieniach sprzeda\u017cy przed akceptacj\u0105 tego zam\u00f3wienia.",
55
- "inbox_ops.discrepancy.desc.no_currency": "Nie mo\u017cna ustali\u0107 waluty dla tego zam\u00f3wienia. Ustaw kod waluty lub skonfiguruj kana\u0142 sprzeda\u017cy z domy\u015bln\u0105 walut\u0105.",
55
+ "inbox_ops.discrepancy.desc.no_currency": "Nie mo\u017cna ustali\u0107 waluty dla tego zam\u00f3wienia. Ustaw kod waluty dla zam\u00f3wienia.",
56
56
  "inbox_ops.discrepancy.desc.no_matching_contact": "Nie znaleziono pasuj\u0105cego kontaktu",
57
57
  "inbox_ops.discrepancy.desc.product_not_matched": "Nie uda\u0142o si\u0119 dopasowa\u0107 produktu do katalogu",
58
58
  "inbox_ops.discrepancy.expected": "Oczekiwano",
@@ -0,0 +1,77 @@
1
+ import { UniqueConstraintViolationException } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { InboxSettings } from '../data/entities'
5
+
6
+ export interface EnsureInboxSettingsScope {
7
+ tenantId: string
8
+ organizationId: string
9
+ }
10
+
11
+ export interface EnsureInboxSettingsResult {
12
+ // The manager the returned `settings` row is actually managed by — a
13
+ // concurrent-bootstrap recovery returns a clean fork, never the manager
14
+ // whose insert just failed, so callers must flush further writes through
15
+ // this manager rather than the one they passed in.
16
+ em: EntityManager
17
+ settings: InboxSettings
18
+ }
19
+
20
+ function isUniqueViolation(error: unknown): boolean {
21
+ if (error instanceof UniqueConstraintViolationException) return true
22
+ if (!error || typeof error !== 'object') return false
23
+ if ((error as { code?: string }).code === '23505') return true
24
+ const message = (error as { message?: string }).message
25
+ return typeof message === 'string' && message.includes('duplicate key')
26
+ }
27
+
28
+ async function findExisting(em: EntityManager, scope: EnsureInboxSettingsScope) {
29
+ return findOneWithDecryption(
30
+ em,
31
+ InboxSettings,
32
+ { tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
33
+ undefined,
34
+ scope,
35
+ )
36
+ }
37
+
38
+ // Lazily bootstraps the tenant's InboxSettings row when missing — e.g. a
39
+ // tenant provisioned before this module existed and never ran setup.ts's
40
+ // onTenantCreated hook. Idempotent: a second call finds the row just created.
41
+ export async function ensureInboxSettings(
42
+ em: EntityManager,
43
+ scope: EnsureInboxSettingsScope,
44
+ ): Promise<EnsureInboxSettingsResult> {
45
+ const existing = await findExisting(em, scope)
46
+ if (existing) return { em, settings: existing }
47
+
48
+ const domain = process.env.INBOX_OPS_DOMAIN || 'inbox.mercato.local'
49
+ const slug = scope.organizationId.slice(0, 8)
50
+ const inboxAddress = `ops-${slug}@${domain}`
51
+ const settings = em.create(InboxSettings, {
52
+ tenantId: scope.tenantId,
53
+ organizationId: scope.organizationId,
54
+ inboxAddress,
55
+ isActive: true,
56
+ })
57
+ em.persist(settings)
58
+
59
+ try {
60
+ await em.flush()
61
+ } catch (error) {
62
+ // Two concurrent bootstraps (e.g. two tabs) race to insert the same
63
+ // deterministic inboxAddress; the loser refetches the winner's row
64
+ // instead of surfacing a raw unique-constraint error to the caller.
65
+ // Recover on a clean fork: `em`'s unit of work still owns the
66
+ // half-persisted `settings` entity scheduled for the failed insert, so
67
+ // flushing `em` again later (e.g. after a caller-side PATCH mutation)
68
+ // would retry that same rejected insert.
69
+ if (!isUniqueViolation(error)) throw error
70
+ const recoveryEm = em.fork()
71
+ const winner = await findExisting(recoveryEm, scope)
72
+ if (!winner) throw error
73
+ return { em: recoveryEm, settings: winner }
74
+ }
75
+
76
+ return { em, settings }
77
+ }
@@ -27,7 +27,7 @@ export interface CrossModuleEntities {
27
27
  CustomerEntity: EntityClass<CommonEntityFields & { id: string; kind: string; displayName: string; primaryEmail?: string | null }>
28
28
  SalesOrder: EntityClass<CommonEntityFields & { id: string; orderNumber: string; currencyCode: string; comments?: string | null; customerReference?: string | null }>
29
29
  SalesShipment: EntityClass<CommonEntityFields & { id: string; order: unknown }>
30
- SalesChannel: EntityClass<CommonEntityFields & { id: string; name: string; currencyCode?: string; metadata?: Record<string, unknown> | null }>
30
+ SalesChannel: EntityClass<CommonEntityFields & { id: string; name: string; metadata?: Record<string, unknown> | null }>
31
31
  Dictionary: EntityClass<CommonEntityFields & { id: string; key: string }>
32
32
  DictionaryEntry: EntityClass<CommonEntityFields & { id: string; label: string; value: string; normalizedValue?: string | null; dictionary: unknown }>
33
33
  }
@@ -246,33 +246,6 @@ export async function resolveFirstChannelId(ctx: ExecutionHelperContext): Promis
246
246
  }
247
247
  }
248
248
 
249
- export async function resolveChannelCurrency(
250
- ctx: ExecutionHelperContext,
251
- channelId: string | null,
252
- ): Promise<string | null> {
253
- const SalesChannelClass = resolveEntityClass(ctx, 'SalesChannel')
254
- if (!SalesChannelClass) return null
255
-
256
- try {
257
- const where: Record<string, unknown> = {
258
- tenantId: ctx.tenantId,
259
- organizationId: ctx.organizationId,
260
- deletedAt: null,
261
- }
262
- if (channelId) where.id = channelId
263
- const channel = await findOneWithDecryption(
264
- ctx.em,
265
- SalesChannelClass,
266
- where,
267
- channelId ? undefined : { orderBy: { name: 'ASC' } },
268
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
269
- )
270
- return channel?.currencyCode ?? null
271
- } catch {
272
- return null
273
- }
274
- }
275
-
276
249
  export async function resolveEffectiveDocumentKind(
277
250
  ctx: ExecutionHelperContext,
278
251
  channelId: string,
@@ -17,7 +17,6 @@ interface CatalogProduct {
17
17
  interface SalesChannelLike {
18
18
  id: string
19
19
  name: string
20
- currencyCode?: string
21
20
  tenantId?: string
22
21
  organizationId?: string
23
22
  deletedAt?: Date | null
@@ -54,7 +53,7 @@ export async function enrichOrderPayload(
54
53
  const enriched = { ...payload }
55
54
  const warnings: string[] = []
56
55
 
57
- // 1. Resolve channelId if missing, and resolve currencyCode from channel
56
+ // 1. Resolve channelId if missing
58
57
  if (ctx.salesChannelClass) {
59
58
  try {
60
59
  const channelWhere: Record<string, unknown> = {
@@ -74,9 +73,6 @@ export async function enrichOrderPayload(
74
73
  )
75
74
  if (channel) {
76
75
  if (!enriched.channelId) enriched.channelId = channel.id
77
- if (!enriched.currencyCode && channel.currencyCode) {
78
- enriched.currencyCode = channel.currencyCode
79
- }
80
76
  } else {
81
77
  warnings.push('no_channel_resolved')
82
78
  }
@@ -1,6 +1,5 @@
1
1
  import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
- import { InboxSettings } from './data/entities'
2
+ import { ensureInboxSettings } from './lib/ensure-settings'
4
3
 
5
4
  export const setup: ModuleSetupConfig = {
6
5
  defaultRoleFeatures: {
@@ -20,25 +19,7 @@ export const setup: ModuleSetupConfig = {
20
19
  },
21
20
 
22
21
  async onTenantCreated({ em, tenantId, organizationId }) {
23
- const exists = await findOneWithDecryption(
24
- em,
25
- InboxSettings,
26
- { tenantId, organizationId, deletedAt: null },
27
- undefined,
28
- { tenantId, organizationId },
29
- )
30
- if (!exists) {
31
- const domain = process.env.INBOX_OPS_DOMAIN || 'inbox.mercato.local'
32
- const slug = organizationId.slice(0, 8)
33
- const inboxAddress = `ops-${slug}@${domain}`
34
- em.persist(em.create(InboxSettings, {
35
- tenantId,
36
- organizationId,
37
- inboxAddress,
38
- isActive: true,
39
- }))
40
- }
41
- await em.flush()
22
+ await ensureInboxSettings(em, { tenantId, organizationId })
42
23
  },
43
24
 
44
25
  async seedDefaults() {},
@@ -48,7 +48,7 @@ interface ExtractionEntityClasses {
48
48
  catalogProduct?: EntityClass<{ id: string; name: string; sku?: string | null; tenantId?: string; organizationId?: string; deletedAt?: Date | null }>
49
49
  catalogProductPrice?: EntityClass<{ product?: unknown; unitPriceNet?: string | null; unitPriceGross?: string | null; currencyCode?: string | null; tenantId?: string; organizationId?: string; deletedAt?: Date | null; createdAt?: Date }>
50
50
  salesOrder?: EntityClass<{ id: string; orderNumber: string; customerReference?: string | null; tenantId?: string; organizationId?: string; deletedAt?: Date | null }>
51
- salesChannel?: EntityClass<{ id: string; name: string; currencyCode?: string; tenantId?: string; organizationId?: string; deletedAt?: Date | null }>
51
+ salesChannel?: EntityClass<{ id: string; name: string; tenantId?: string; organizationId?: string; deletedAt?: Date | null }>
52
52
  customerAddress?: EntityClass<{ id: string; isPrimary: boolean; tenantId?: string; organizationId?: string; entity?: { id: string } | string; createdAt?: Date }>
53
53
  }
54
54
 
@@ -1759,6 +1759,8 @@ export class SalesPayment {
1759
1759
 
1760
1760
  @Entity({ tableName: 'sales_payment_allocations' })
1761
1761
  @Index({ name: 'sales_payment_allocations_scope_idx', properties: ['payment', 'organizationId', 'tenantId'] })
1762
+ @Index({ name: 'sales_payment_allocations_order_idx', properties: ['order'] })
1763
+ @Index({ name: 'sales_payment_allocations_invoice_idx', properties: ['invoice'] })
1762
1764
  export class SalesPaymentAllocation {
1763
1765
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
1764
1766
  id!: string
@@ -8,7 +8,6 @@ import {
8
8
  buildSourceMetadata,
9
9
  resolveOrderByReference,
10
10
  resolveFirstChannelId,
11
- resolveChannelCurrency,
12
11
  resolveEffectiveDocumentKind,
13
12
  resolveShipmentStatusEntryId,
14
13
  resolveCustomerEntityIdByEmail,
@@ -288,23 +287,6 @@ async function executeUpdateShipmentAction(
288
287
  return { createdEntityId: shipment.id, createdEntityType: 'sales_shipment' }
289
288
  }
290
289
 
291
- // ---------------------------------------------------------------------------
292
- // Normalization helper for order/quote payloads
293
- // ---------------------------------------------------------------------------
294
-
295
- async function normalizeOrderPayload(
296
- payload: Record<string, unknown>,
297
- ctx: InboxActionExecutionContext,
298
- ): Promise<Record<string, unknown>> {
299
- if (!payload.currencyCode) {
300
- const hCtx = asHelperContext(ctx)
301
- const channelId = typeof payload.channelId === 'string' ? payload.channelId : null
302
- const resolved = await resolveChannelCurrency(hCtx, channelId)
303
- if (resolved) payload.currencyCode = resolved
304
- }
305
- return payload
306
- }
307
-
308
290
  // ---------------------------------------------------------------------------
309
291
  // Exported action definitions
310
292
  // ---------------------------------------------------------------------------
@@ -323,7 +305,6 @@ export const inboxActions: InboxActionDefinition[] = [
323
305
  'For create_order / create_quote: each line item MUST have "productName" (the product name goes here, NOT in "description"). Include currencyCode and customerName.',
324
306
  'For create_order / create_quote: extract shippingAddress and billingAddress as structured objects when addresses are mentioned. Parse street, city, postal code, country from the text. Do NOT put address data in notes.',
325
307
  ],
326
- normalizePayload: normalizeOrderPayload,
327
308
  execute: (action, ctx) => executeCreateDocumentAction(action, ctx, 'order'),
328
309
  },
329
310
  {
@@ -332,7 +313,6 @@ export const inboxActions: InboxActionDefinition[] = [
332
313
  payloadSchema: orderPayloadSchema,
333
314
  label: 'Create Quote',
334
315
  promptSchema: '(shared with create_order)',
335
- normalizePayload: normalizeOrderPayload,
336
316
  execute: (action, ctx) => executeCreateDocumentAction(action, ctx, 'quote'),
337
317
  },
338
318
  {
@@ -6232,6 +6232,26 @@
6232
6232
  "keyName": "sales_payment_allocations_scope_idx",
6233
6233
  "primary": false,
6234
6234
  "unique": false
6235
+ },
6236
+ {
6237
+ "columnNames": [
6238
+ "order_id"
6239
+ ],
6240
+ "composite": false,
6241
+ "constraint": false,
6242
+ "keyName": "sales_payment_allocations_order_idx",
6243
+ "primary": false,
6244
+ "unique": false
6245
+ },
6246
+ {
6247
+ "columnNames": [
6248
+ "invoice_id"
6249
+ ],
6250
+ "composite": false,
6251
+ "constraint": false,
6252
+ "keyName": "sales_payment_allocations_invoice_idx",
6253
+ "primary": false,
6254
+ "unique": false
6235
6255
  }
6236
6256
  ],
6237
6257
  "checks": [],