@open-mercato/core 0.10.1-develop.7749.1.ea3edf75e3 → 0.10.1-develop.7752.1.fd7c06569b

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 (40) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/services/principalService.js +3 -2
  3. package/dist/modules/api_keys/services/principalService.js.map +2 -2
  4. package/dist/modules/api_keys/services/sessionKeyRoles.js +29 -0
  5. package/dist/modules/api_keys/services/sessionKeyRoles.js.map +7 -0
  6. package/dist/modules/auth/cli.js +13 -2
  7. package/dist/modules/auth/cli.js.map +2 -2
  8. package/dist/modules/auth/services/rbacService.js +18 -10
  9. package/dist/modules/auth/services/rbacService.js.map +2 -2
  10. package/dist/modules/business_rules/lib/action-executor.js +2 -1
  11. package/dist/modules/business_rules/lib/action-executor.js.map +2 -2
  12. package/dist/modules/directory/utils/organizationScope.js +11 -4
  13. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  14. package/dist/modules/entities/subscribers/seed-tenant-encryption-maps-retry.js +14 -0
  15. package/dist/modules/entities/subscribers/seed-tenant-encryption-maps-retry.js.map +7 -0
  16. package/dist/modules/entities/subscribers/seed-tenant-encryption-maps.js +37 -0
  17. package/dist/modules/entities/subscribers/seed-tenant-encryption-maps.js.map +7 -0
  18. package/dist/modules/warranty_claims/commands/claim-lines.js +25 -1
  19. package/dist/modules/warranty_claims/commands/claim-lines.js.map +3 -3
  20. package/dist/modules/warranty_claims/commands/claims.js +19 -1
  21. package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
  22. package/dist/modules/warranty_claims/commands/shared.js +30 -0
  23. package/dist/modules/warranty_claims/commands/shared.js.map +2 -2
  24. package/package.json +7 -7
  25. package/src/modules/api_keys/services/principalService.ts +3 -2
  26. package/src/modules/api_keys/services/sessionKeyRoles.ts +35 -0
  27. package/src/modules/auth/cli.ts +11 -4
  28. package/src/modules/auth/services/rbacService.ts +22 -9
  29. package/src/modules/business_rules/lib/action-executor.ts +2 -3
  30. package/src/modules/directory/utils/organizationScope.ts +11 -4
  31. package/src/modules/entities/subscribers/seed-tenant-encryption-maps-retry.ts +12 -0
  32. package/src/modules/entities/subscribers/seed-tenant-encryption-maps.ts +38 -0
  33. package/src/modules/warranty_claims/commands/claim-lines.ts +26 -1
  34. package/src/modules/warranty_claims/commands/claims.ts +19 -1
  35. package/src/modules/warranty_claims/commands/shared.ts +36 -1
  36. package/src/modules/warranty_claims/i18n/de.json +1 -0
  37. package/src/modules/warranty_claims/i18n/en.json +1 -0
  38. package/src/modules/warranty_claims/i18n/es.json +1 -0
  39. package/src/modules/warranty_claims/i18n/ko.json +1 -0
  40. package/src/modules/warranty_claims/i18n/pl.json +1 -0
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "crypto";
2
+ import { LockMode } from "@mikro-orm/core";
2
3
  import { CrudHttpError, assertFound } from "@open-mercato/shared/lib/crud/errors";
3
4
  import { invalidateCrudCache } from "@open-mercato/shared/lib/crud/cache";
4
5
  import { enforceCommandOptimisticLockWithGuards } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
@@ -44,6 +45,33 @@ async function requireScopedClaim(em, id, scope, options = {}, notFoundMessage =
44
45
  const claim = await loadScopedClaim(em, id, scope, options);
45
46
  return assertFound(claim, notFoundMessage);
46
47
  }
48
+ async function assertUniqueClaimLineNumbers(lineNumbers) {
49
+ const usedNumbers = /* @__PURE__ */ new Set();
50
+ for (const lineNo of lineNumbers) {
51
+ if (usedNumbers.has(lineNo)) {
52
+ const { translate } = await resolveTranslations();
53
+ const code = "warranty_claims.errors.duplicateLineNo";
54
+ throw new CrudHttpError(409, {
55
+ error: translate(code, "Another line on this claim already uses that line number. Remove the line that uses this number, then retry."),
56
+ code
57
+ });
58
+ }
59
+ usedNumbers.add(lineNo);
60
+ }
61
+ }
62
+ async function assertClaimLineNumberAvailable(em, claim, lineNo, excludedLineId, allowCurrentNumber = false) {
63
+ const scope = { tenantId: claim.tenantId, organizationId: claim.organizationId };
64
+ await requireScopedClaim(em, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE });
65
+ const siblings = await findWithDecryption(
66
+ em,
67
+ WarrantyClaimLine,
68
+ { claim: claim.id, lineNo, deletedAt: null, tenantId: scope.tenantId, organizationId: scope.organizationId },
69
+ {},
70
+ scope
71
+ );
72
+ if (allowCurrentNumber && siblings.some((line) => line.id === excludedLineId)) return;
73
+ await assertUniqueClaimLineNumbers([lineNo, ...siblings.filter((line) => line.id !== excludedLineId).map((line) => line.lineNo)]);
74
+ }
47
75
  function appendClaimEvent(em, claim, kind, input = {}) {
48
76
  const event = em.create(WarrantyClaimEvent, {
49
77
  id: randomUUID(),
@@ -154,7 +182,9 @@ export {
154
182
  WARRANTY_CLAIM_LINE_RESOURCE_KIND,
155
183
  WARRANTY_CLAIM_RESOURCE_KIND,
156
184
  appendClaimEvent,
185
+ assertClaimLineNumberAvailable,
157
186
  assertFound2 as assertFound,
187
+ assertUniqueClaimLineNumbers,
158
188
  enforceWarrantyClaimOptimisticLock,
159
189
  ensureOrganizationScope,
160
190
  ensureSameScope,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/warranty_claims/commands/shared.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { FindOneOptions } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError, assertFound } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { emitCrudSideEffects, flushCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n WarrantyClaim,\n WarrantyClaimEvent,\n WarrantyClaimLine,\n} from '../data/entities'\nimport type {\n WarrantyClaimEventKind,\n WarrantyClaimEventVisibility,\n} from '../data/validators'\nimport { computeHeaderRollups } from '../lib/stateMachine'\n\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\nexport const WARRANTY_CLAIM_RESOURCE_KIND = 'warranty_claims.claim'\nexport const WARRANTY_CLAIM_LINE_RESOURCE_KIND = 'warranty_claims.claim_line'\n\nexport type WarrantyClaimScope = {\n organizationId: string\n tenantId: string\n}\n\nexport type VersionedRecord = {\n id: string\n updatedAt?: Date | string | null\n}\n\nexport type ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type AppendClaimEventInput = {\n visibility?: WarrantyClaimEventVisibility\n body?: string | null\n payload?: Record<string, unknown> | null\n actorUserId?: string | null\n actorCustomerId?: string | null\n}\n\nexport async function enforceWarrantyClaimOptimisticLock(\n ctx: CommandRuntimeContext,\n record: VersionedRecord | null | undefined,\n resourceKind = WARRANTY_CLAIM_RESOURCE_KIND,\n expected?: string | Date | null,\n): Promise<void> {\n if (!record) return\n await enforceCommandOptimisticLockWithGuards(ctx.container, {\n resourceKind,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n expected,\n request: ctx.request ?? null,\n })\n}\n\nexport async function throwUndoRefusal(key: string, fallback: string): Promise<never> {\n const { translate } = await resolveTranslations()\n throw new CrudHttpError(409, { error: translate(key, fallback), code: key })\n}\n\nexport async function loadScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n): Promise<WarrantyClaim | null> {\n return findOneWithDecryption(\n em,\n WarrantyClaim,\n { id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n options,\n scope,\n )\n}\n\n/**\n * `notFoundMessage` is passed to `assertFound` verbatim, so request-scoped callers\n * must hand in an already-translated sentence; the key default only suits the\n * command surface, which runs without a request locale.\n */\nexport async function requireScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n notFoundMessage = 'warranty_claims.errors.notFound',\n): Promise<WarrantyClaim> {\n const claim = await loadScopedClaim(em, id, scope, options)\n return assertFound(claim, notFoundMessage)\n}\n\nexport function appendClaimEvent(\n em: EntityManager,\n claim: WarrantyClaim,\n kind: WarrantyClaimEventKind,\n input: AppendClaimEventInput = {},\n): WarrantyClaimEvent {\n const event = em.create(WarrantyClaimEvent, {\n id: randomUUID(),\n claim,\n organizationId: claim.organizationId,\n tenantId: claim.tenantId,\n kind,\n visibility: input.visibility ?? 'internal',\n body: input.body ?? null,\n payload: input.payload ?? null,\n actorUserId: input.actorUserId ?? null,\n actorCustomerId: input.actorCustomerId ?? null,\n createdAt: new Date(),\n })\n em.persist(event)\n return event\n}\n\nexport async function reconcileVendorRecoverySourceClaim(\n ctx: ResolverContext,\n input: { claimId: string; tenantId: string; organizationId: string },\n): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const recoveryClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: input.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!recoveryClaim || recoveryClaim.claimType !== 'vendor_recovery' || !recoveryClaim.sourceClaimId) return\n\n const sourceClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: recoveryClaim.sourceClaimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!sourceClaim) return\n\n const resolvedChildren = await findWithDecryption(\n em,\n WarrantyClaim,\n {\n sourceClaimId: sourceClaim.id,\n claimType: 'vendor_recovery',\n status: { $in: ['resolved', 'closed'] },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const childLines = resolvedChildren.length\n ? await findWithDecryption(\n em,\n WarrantyClaimLine,\n {\n claim: { $in: resolvedChildren.map((child) => child.id) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n : []\n const recoveredTotal = computeHeaderRollups(childLines).totalApprovedAmount\n await withAtomicFlush(\n em,\n [\n () => {\n sourceClaim.totalRecoveredAmount = String(recoveredTotal)\n sourceClaim.updatedAt = new Date()\n },\n ],\n { transaction: true, label: 'warranty_claims.vendor_recovery.reconciliation' },\n )\n\n const dataEngine = ctx.resolve<DataEngine>('dataEngine')\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: sourceClaim,\n identifiers: {\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n },\n indexer: { entityType: 'warranty_claims:warranty_claim' },\n events: {\n module: 'warranty_claims',\n entity: 'claim',\n persistent: true,\n buildPayload: () => ({\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n claimType: sourceClaim.claimType,\n status: sourceClaim.status,\n }),\n },\n })\n await flushCrudSideEffects(dataEngine)\n await invalidateCrudCache(\n ctx as unknown as Parameters<typeof invalidateCrudCache>[0],\n 'warranty_claims.claim',\n { id: sourceClaim.id, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },\n input.tenantId,\n 'warranty_claims.vendor_recovery.reconciliation',\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'crypto'\nimport { LockMode, type FindOneOptions } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError, assertFound } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { emitCrudSideEffects, flushCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n WarrantyClaim,\n WarrantyClaimEvent,\n WarrantyClaimLine,\n} from '../data/entities'\nimport type {\n WarrantyClaimEventKind,\n WarrantyClaimEventVisibility,\n} from '../data/validators'\nimport { computeHeaderRollups } from '../lib/stateMachine'\n\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\nexport const WARRANTY_CLAIM_RESOURCE_KIND = 'warranty_claims.claim'\nexport const WARRANTY_CLAIM_LINE_RESOURCE_KIND = 'warranty_claims.claim_line'\n\nexport type WarrantyClaimScope = {\n organizationId: string\n tenantId: string\n}\n\nexport type VersionedRecord = {\n id: string\n updatedAt?: Date | string | null\n}\n\nexport type ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type AppendClaimEventInput = {\n visibility?: WarrantyClaimEventVisibility\n body?: string | null\n payload?: Record<string, unknown> | null\n actorUserId?: string | null\n actorCustomerId?: string | null\n}\n\nexport async function enforceWarrantyClaimOptimisticLock(\n ctx: CommandRuntimeContext,\n record: VersionedRecord | null | undefined,\n resourceKind = WARRANTY_CLAIM_RESOURCE_KIND,\n expected?: string | Date | null,\n): Promise<void> {\n if (!record) return\n await enforceCommandOptimisticLockWithGuards(ctx.container, {\n resourceKind,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n expected,\n request: ctx.request ?? null,\n })\n}\n\nexport async function throwUndoRefusal(key: string, fallback: string): Promise<never> {\n const { translate } = await resolveTranslations()\n throw new CrudHttpError(409, { error: translate(key, fallback), code: key })\n}\n\nexport async function loadScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n): Promise<WarrantyClaim | null> {\n return findOneWithDecryption(\n em,\n WarrantyClaim,\n { id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n options,\n scope,\n )\n}\n\n/**\n * `notFoundMessage` is passed to `assertFound` verbatim, so request-scoped callers\n * must hand in an already-translated sentence; the key default only suits the\n * command surface, which runs without a request locale.\n */\nexport async function requireScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n notFoundMessage = 'warranty_claims.errors.notFound',\n): Promise<WarrantyClaim> {\n const claim = await loadScopedClaim(em, id, scope, options)\n return assertFound(claim, notFoundMessage)\n}\n\nexport async function assertUniqueClaimLineNumbers(lineNumbers: Iterable<number>): Promise<void> {\n const usedNumbers = new Set<number>()\n for (const lineNo of lineNumbers) {\n if (usedNumbers.has(lineNo)) {\n const { translate } = await resolveTranslations()\n const code = 'warranty_claims.errors.duplicateLineNo'\n throw new CrudHttpError(409, {\n error: translate(code, 'Another line on this claim already uses that line number. Remove the line that uses this number, then retry.'),\n code,\n })\n }\n usedNumbers.add(lineNo)\n }\n}\n\nexport async function assertClaimLineNumberAvailable(\n em: EntityManager,\n claim: WarrantyClaim,\n lineNo: number,\n excludedLineId?: string,\n allowCurrentNumber = false,\n): Promise<void> {\n const scope = { tenantId: claim.tenantId, organizationId: claim.organizationId }\n await requireScopedClaim(em, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })\n const siblings = await findWithDecryption(\n em,\n WarrantyClaimLine,\n { claim: claim.id, lineNo, deletedAt: null, tenantId: scope.tenantId, organizationId: scope.organizationId },\n {},\n scope,\n )\n if (allowCurrentNumber && siblings.some((line) => line.id === excludedLineId)) return\n await assertUniqueClaimLineNumbers([lineNo, ...siblings.filter((line) => line.id !== excludedLineId).map((line) => line.lineNo)])\n}\n\nexport function appendClaimEvent(\n em: EntityManager,\n claim: WarrantyClaim,\n kind: WarrantyClaimEventKind,\n input: AppendClaimEventInput = {},\n): WarrantyClaimEvent {\n const event = em.create(WarrantyClaimEvent, {\n id: randomUUID(),\n claim,\n organizationId: claim.organizationId,\n tenantId: claim.tenantId,\n kind,\n visibility: input.visibility ?? 'internal',\n body: input.body ?? null,\n payload: input.payload ?? null,\n actorUserId: input.actorUserId ?? null,\n actorCustomerId: input.actorCustomerId ?? null,\n createdAt: new Date(),\n })\n em.persist(event)\n return event\n}\n\nexport async function reconcileVendorRecoverySourceClaim(\n ctx: ResolverContext,\n input: { claimId: string; tenantId: string; organizationId: string },\n): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const recoveryClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: input.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!recoveryClaim || recoveryClaim.claimType !== 'vendor_recovery' || !recoveryClaim.sourceClaimId) return\n\n const sourceClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: recoveryClaim.sourceClaimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!sourceClaim) return\n\n const resolvedChildren = await findWithDecryption(\n em,\n WarrantyClaim,\n {\n sourceClaimId: sourceClaim.id,\n claimType: 'vendor_recovery',\n status: { $in: ['resolved', 'closed'] },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const childLines = resolvedChildren.length\n ? await findWithDecryption(\n em,\n WarrantyClaimLine,\n {\n claim: { $in: resolvedChildren.map((child) => child.id) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n : []\n const recoveredTotal = computeHeaderRollups(childLines).totalApprovedAmount\n await withAtomicFlush(\n em,\n [\n () => {\n sourceClaim.totalRecoveredAmount = String(recoveredTotal)\n sourceClaim.updatedAt = new Date()\n },\n ],\n { transaction: true, label: 'warranty_claims.vendor_recovery.reconciliation' },\n )\n\n const dataEngine = ctx.resolve<DataEngine>('dataEngine')\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: sourceClaim,\n identifiers: {\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n },\n indexer: { entityType: 'warranty_claims:warranty_claim' },\n events: {\n module: 'warranty_claims',\n entity: 'claim',\n persistent: true,\n buildPayload: () => ({\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n claimType: sourceClaim.claimType,\n status: sourceClaim.status,\n }),\n },\n })\n await flushCrudSideEffects(dataEngine)\n await invalidateCrudCache(\n ctx as unknown as Parameters<typeof invalidateCrudCache>[0],\n 'warranty_claims.claim',\n { id: sourceClaim.id, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },\n input.tenantId,\n 'warranty_claims.vendor_recovery.reconciliation',\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["assertFound"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7749.1.ea3edf75e3",
3
+ "version": "0.10.1-develop.7752.1.fd7c06569b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.4.3"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7749.1.ea3edf75e3",
270
- "@open-mercato/shared": "0.10.1-develop.7749.1.ea3edf75e3",
271
- "@open-mercato/ui": "0.10.1-develop.7749.1.ea3edf75e3",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7752.1.fd7c06569b",
270
+ "@open-mercato/shared": "0.10.1-develop.7752.1.fd7c06569b",
271
+ "@open-mercato/ui": "0.10.1-develop.7752.1.fd7c06569b",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7749.1.ea3edf75e3",
277
- "@open-mercato/shared": "0.10.1-develop.7749.1.ea3edf75e3",
278
- "@open-mercato/ui": "0.10.1-develop.7749.1.ea3edf75e3",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7752.1.fd7c06569b",
277
+ "@open-mercato/shared": "0.10.1-develop.7752.1.fd7c06569b",
278
+ "@open-mercato/ui": "0.10.1-develop.7752.1.fd7c06569b",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -3,6 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import type { ApiKeyPrincipalService, PrincipalScope } from '@open-mercato/shared/lib/auth/principal-service'
4
4
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
5
  import { ApiKey } from '../data/entities'
6
+ import { resolveApiKeyRoleIds } from './sessionKeyRoles'
6
7
 
7
8
  function normalizeIds(ids: unknown): string[] {
8
9
  if (!Array.isArray(ids)) return []
@@ -24,10 +25,10 @@ export class DefaultApiKeyPrincipalService implements ApiKeyPrincipalService {
24
25
  deletedAt: null,
25
26
  $or: [{ organizationId: null }, { organizationId: scope.organizationId }],
26
27
  } as FilterQuery<ApiKey>,
27
- { fields: ['rolesJson', 'expiresAt'] as const },
28
+ { fields: ['rolesJson', 'expiresAt', 'tenantId', 'sessionTokenHash', 'sessionUserId', 'sessionSecretEncrypted', 'opencodeSessionId'] as const },
28
29
  scope,
29
30
  )
30
31
  if (!apiKey || (apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now())) return []
31
- return normalizeIds(apiKey.rolesJson)
32
+ return normalizeIds(await resolveApiKeyRoleIds(this.em, apiKey, scope.tenantId))
32
33
  }
33
34
  }
@@ -0,0 +1,35 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { ApiKey } from '../data/entities'
3
+ import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
4
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+
6
+ export function isSessionBoundApiKey(key: ApiKey): boolean {
7
+ return Boolean(key.sessionTokenHash || key.sessionUserId || key.sessionSecretEncrypted || key.opencodeSessionId)
8
+ }
9
+
10
+ export async function resolveApiKeyRoleIds(
11
+ em: EntityManager,
12
+ key: ApiKey,
13
+ tenantId: string | null = key.tenantId ?? null,
14
+ ): Promise<string[]> {
15
+ const issuedRoleIds = Array.isArray(key.rolesJson)
16
+ ? key.rolesJson.filter((roleId): roleId is string => typeof roleId === 'string' && roleId.length > 0)
17
+ : []
18
+ if (!isSessionBoundApiKey(key)) return issuedRoleIds
19
+ const ownerId = key.sessionUserId?.trim()
20
+ if (!ownerId || !tenantId || tenantId !== key.tenantId || !issuedRoleIds.length) return []
21
+
22
+ const links = await findWithDecryption(
23
+ em,
24
+ UserRole,
25
+ {
26
+ user: { id: ownerId, tenantId, deletedAt: null },
27
+ role: { id: { $in: issuedRoleIds }, tenantId, deletedAt: null },
28
+ deletedAt: null,
29
+ },
30
+ { populate: ['role'] },
31
+ { tenantId, organizationId: null },
32
+ )
33
+ const currentRoleIds = new Set(links.map((link) => typeof link.role === 'string' ? link.role : link.role.id))
34
+ return issuedRoleIds.filter((roleId) => currentRoleIds.has(roleId))
35
+ }
@@ -4,6 +4,7 @@ import { OM_LOG_LEVEL_ENV, resetLogLevelCache } from '@open-mercato/shared/lib/l
4
4
  import { resetServerLoggerCache } from '@open-mercato/shared/lib/logger'
5
5
  import { hash } from 'bcryptjs'
6
6
  import type { EntityManager } from '@mikro-orm/postgresql'
7
+ import type { EventBus } from '@open-mercato/events/types'
7
8
  import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
8
9
  import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
9
10
  import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
@@ -407,13 +408,19 @@ const addOrganization: ModuleCli = {
407
408
  return
408
409
  }
409
410
  const { resolve } = await createRequestContainer()
410
- const em = resolve('em') as any
411
- // Create tenant implicitly for simplicity
412
- const tenant = em.create(Tenant, { name: `${name} Tenant` })
411
+ const em = resolve('em') as EntityManager
412
+ const eventBus = resolve('eventBus') as EventBus
413
+ const tenant = em.create(Tenant, { name: `${name} Tenant` }, { partial: true })
413
414
  await em.persist(tenant).flush()
414
- const org = em.create(Organization, { name, tenant, logoPreserveAspectRatio: false })
415
+ await eventBus.emit('directory.tenant.created', {
416
+ id: String(tenant.id), tenantId: null, organizationId: null,
417
+ }, { persistent: true, tenantId: null, organizationId: null, rethrowHandlerErrors: true })
418
+ const org = em.create(Organization, { name, tenant, logoPreserveAspectRatio: false }, { partial: true })
415
419
  await em.persist(org).flush()
416
420
  await rebuildHierarchyForTenant(em, String(tenant.id))
421
+ await eventBus.emit('directory.organization.created', {
422
+ id: String(org.id), tenantId: String(tenant.id), organizationId: String(org.id),
423
+ }, { persistent: true, tenantId: String(tenant.id), organizationId: String(org.id), rethrowHandlerErrors: true })
417
424
  console.log('Organization created with id', org.id, 'in tenant', tenant.id)
418
425
  },
419
426
  }
@@ -3,6 +3,7 @@ import type { CacheStrategy } from '@open-mercato/cache'
3
3
  import { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'
4
4
  import { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
5
5
  import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
6
+ import { isSessionBoundApiKey, resolveApiKeyRoleIds } from '@open-mercato/core/modules/api_keys/services/sessionKeyRoles'
6
7
  import { Organization } from '@open-mercato/core/modules/directory/data/entities'
7
8
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
9
  import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
@@ -443,7 +444,7 @@ export class RbacService {
443
444
  organizations: string[] | null
444
445
  }> {
445
446
  const cacheKey = this.getCacheKey(userId, scope)
446
- const cached = await this.getFromCache(cacheKey)
447
+ const cached = userId.startsWith('api_key:') ? null : await this.getFromCache(cacheKey)
447
448
  if (cached) return cached
448
449
 
449
450
  // Direct user-level super-admin grants and unrestricted role-level
@@ -467,8 +468,13 @@ export class RbacService {
467
468
  await this.setCache(cacheKey, result, userId, scope)
468
469
  return result
469
470
  }
471
+ const cacheable = !isSessionBoundApiKey(key)
472
+ if (cacheable) {
473
+ const cachedKeyAcl = await this.getFromCache(cacheKey)
474
+ if (cachedKeyAcl) return cachedKeyAcl
475
+ }
470
476
  const tenantId = scope.tenantId || key.tenantId || null
471
- const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []
477
+ const roleIds = await resolveApiKeyRoleIds(em, key, tenantId)
472
478
  const keyOrganizationId = typeof key.organizationId === 'string' && key.organizationId.trim().length > 0
473
479
  ? key.organizationId.trim()
474
480
  : null
@@ -534,7 +540,7 @@ export class RbacService {
534
540
  // bypass the key or role organization bounds.
535
541
  if (isSuper && organizations !== null && !features.includes('*')) features.push('*')
536
542
  const result = { isSuperAdmin: isSuper && organizations === null, features, organizations }
537
- await this.setCache(cacheKey, result, userId, scope)
543
+ if (cacheable) await this.setCache(cacheKey, result, userId, scope)
538
544
  return result
539
545
  }
540
546
 
@@ -645,7 +651,7 @@ export class RbacService {
645
651
  return { isSuperAdmin: false, features: [], organizations: null }
646
652
  }
647
653
  const tenantId = scope.tenantId || key.tenantId || null
648
- const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []
654
+ const roleIds = await resolveApiKeyRoleIds(em, key, tenantId)
649
655
  const keyOrganizationId = typeof key.organizationId === 'string' && key.organizationId.trim().length > 0
650
656
  ? key.organizationId.trim()
651
657
  : null
@@ -1009,12 +1015,16 @@ export class RbacService {
1009
1015
  tenantId: string,
1010
1016
  ): Promise<FeatureOrganizationGrants> {
1011
1017
  const cacheKey = `rbac:feature-organizations:${userId}:${tenantId}`
1012
- if (this.cache) {
1018
+ const key = userId.startsWith('api_key:')
1019
+ ? await this.em.fork().findOne(ApiKey, { id: userId.slice('api_key:'.length), deletedAt: null })
1020
+ : undefined
1021
+ const cacheable = key === undefined || (key !== null && !isSessionBoundApiKey(key))
1022
+ if (this.cache && cacheable) {
1013
1023
  const cached = await this.cache.get(cacheKey)
1014
1024
  if (isFeatureOrganizationGrants(cached)) return cached
1015
1025
  }
1016
- const grants = await this.queryFeatureOrganizationGrants(userId, tenantId)
1017
- if (this.cache) {
1026
+ const grants = await this.queryFeatureOrganizationGrants(userId, tenantId, key)
1027
+ if (this.cache && cacheable) {
1018
1028
  await this.cache.set(cacheKey, grants, {
1019
1029
  ttl: this.cacheTtlMs,
1020
1030
  tags: this.buildCacheTags(userId, { tenantId, organizationId: null }),
@@ -1026,11 +1036,14 @@ export class RbacService {
1026
1036
  private async queryFeatureOrganizationGrants(
1027
1037
  userId: string,
1028
1038
  tenantId: string,
1039
+ knownApiKey?: ApiKey | null,
1029
1040
  ): Promise<FeatureOrganizationGrants> {
1030
1041
  const em = this.em.fork()
1031
1042
  if (userId.startsWith('api_key:')) {
1032
1043
  const apiKeyId = userId.slice('api_key:'.length)
1033
- const key = await em.findOne(ApiKey, { id: apiKeyId, deletedAt: null })
1044
+ const key = knownApiKey === undefined
1045
+ ? await em.findOne(ApiKey, { id: apiKeyId, deletedAt: null })
1046
+ : knownApiKey
1034
1047
  if (
1035
1048
  !key
1036
1049
  || (key.expiresAt && key.expiresAt.getTime() < Date.now())
@@ -1039,7 +1052,7 @@ export class RbacService {
1039
1052
  return { kind: 'none' }
1040
1053
  }
1041
1054
 
1042
- const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []
1055
+ const roleIds = await resolveApiKeyRoleIds(em, key, tenantId)
1043
1056
  if (!roleIds.length) return { kind: 'none' }
1044
1057
  const roleAcls = await em.find(RoleAcl, {
1045
1058
  tenantId,
@@ -10,6 +10,7 @@ import {
10
10
  import type { RbacService } from '../../auth/services/rbacService'
11
11
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
12
  import type { OpenMercatoEndpointOption } from './openmercato-call-options-types'
13
+ import { resolveApiKeyRoleIds } from '@open-mercato/core/modules/api_keys/services/sessionKeyRoles'
13
14
 
14
15
  /**
15
16
  * Action definition
@@ -519,9 +520,7 @@ async function handleCallOpenMercato(
519
520
  throw new Error('CALL_OPEN_MERCATO action requires an active API key profile in scope')
520
521
  }
521
522
 
522
- const roleIds = Array.isArray(apiKeyProfile.rolesJson)
523
- ? apiKeyProfile.rolesJson.filter((roleId): roleId is string => typeof roleId === 'string' && roleId.length > 0)
524
- : []
523
+ const roleIds = await resolveApiKeyRoleIds(em, apiKeyProfile, tenantId)
525
524
  if (roleIds.length === 0) {
526
525
  throw new Error('CALL_OPEN_MERCATO action requires an API key profile with at least one role')
527
526
  }
@@ -2,6 +2,8 @@ import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import type { FilterQuery } from '@mikro-orm/core'
3
3
  import type { AwilixContainer } from 'awilix'
4
4
  import { Organization } from '@open-mercato/core/modules/directory/data/entities'
5
+ import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
6
+ import { isSessionBoundApiKey } from '@open-mercato/core/modules/api_keys/services/sessionKeyRoles'
5
7
  import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'
6
8
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
9
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
@@ -504,9 +506,14 @@ export async function resolveOrganizationScopeForRequest({
504
506
  }
505
507
 
506
508
  const resolveScope = async (): Promise<OrganizationScope> => {
507
- if (cache && cacheKey && typeof cache.get === 'function') {
509
+ let crossRequestCache = cache
510
+ if (crossRequestCache && userId?.startsWith('api_key:')) {
511
+ const key = await em.findOne(ApiKey, { id: userId.slice('api_key:'.length), deletedAt: null })
512
+ if (!key || isSessionBoundApiKey(key)) crossRequestCache = null
513
+ }
514
+ if (crossRequestCache && cacheKey && typeof crossRequestCache.get === 'function') {
508
515
  try {
509
- const cached = await cache.get(cacheKey)
516
+ const cached = await crossRequestCache.get(cacheKey)
510
517
  if (isValidCachedScope(cached)) return cached
511
518
  } catch (err) {
512
519
  logger.warn('Cache read failed', { err })
@@ -521,9 +528,9 @@ export async function resolveOrganizationScopeForRequest({
521
528
  tenantId: effectiveTenantId,
522
529
  })
523
530
 
524
- if (cache && cacheKey && userId && typeof cache.set === 'function') {
531
+ if (crossRequestCache && cacheKey && userId && typeof crossRequestCache.set === 'function') {
525
532
  try {
526
- await cache.set(cacheKey, baseScope, {
533
+ await crossRequestCache.set(cacheKey, baseScope, {
527
534
  ttl: ttlMs,
528
535
  tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),
529
536
  })
@@ -0,0 +1,12 @@
1
+ import type { ResolverContext } from './seed-organization-encryption-maps'
2
+ import seedTenantEncryptionMaps from './seed-tenant-encryption-maps'
3
+
4
+ export const metadata = {
5
+ event: 'directory.tenant.created',
6
+ persistent: true,
7
+ id: 'entities:seed-tenant-encryption-maps-retry',
8
+ }
9
+
10
+ export default function retryHandle(payload: unknown, ctx: ResolverContext): Promise<void> {
11
+ return seedTenantEncryptionMaps(payload, ctx)
12
+ }
@@ -0,0 +1,38 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
3
+ import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
5
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
6
+ import { getModules } from '@open-mercato/shared/lib/modules/registry'
7
+ import { upsertEncryptionMaps, invalidateEncryptionMapScopes } from '@open-mercato/core/modules/entities/lib/encryption-maps'
8
+ import type { ResolverContext } from './seed-organization-encryption-maps'
9
+
10
+ const logger = createLogger('entities')
11
+
12
+ export const metadata = {
13
+ event: 'directory.tenant.created',
14
+ persistent: false,
15
+ id: 'entities:seed-tenant-encryption-maps',
16
+ }
17
+
18
+ export default async function handle(payload: unknown, ctx: ResolverContext): Promise<void> {
19
+ if (!isTenantDataEncryptionEnabled()) return
20
+ if (!payload || typeof payload !== 'object' || !('id' in payload)) return
21
+ const tenantId = typeof payload.id === 'string' ? payload.id : null
22
+ if (!tenantId) return
23
+
24
+ const em = ctx.resolve<EntityManager>('em').fork()
25
+ await upsertEncryptionMaps(em, tenantId, null, getModules(), () => {}, async (scope) => {
26
+ try {
27
+ const encryptionService = ctx.resolve<Pick<TenantDataEncryptionService, 'invalidateMap'>>('tenantEncryptionService')
28
+ await invalidateEncryptionMapScopes(encryptionService, [scope])
29
+ } catch (err) {
30
+ logger.warn('Tenant encryption maps seeded but cache invalidation failed', { tenantId, err })
31
+ getTelemetryRuntime()?.reportError(err, {
32
+ module: 'entities',
33
+ code: 'entities.tenant_encryption_map_invalidation_failed',
34
+ attributes: { tenantId },
35
+ })
36
+ }
37
+ })
38
+ }
@@ -39,6 +39,8 @@ import {
39
39
  WARRANTY_CLAIM_LINE_RESOURCE_KIND,
40
40
  WARRANTY_CLAIM_RESOURCE_KIND,
41
41
  appendClaimEvent,
42
+ assertClaimLineNumberAvailable,
43
+ assertUniqueClaimLineNumbers,
42
44
  enforceWarrantyClaimOptimisticLock,
43
45
  ensureOrganizationScope,
44
46
  ensureTenantScope,
@@ -455,6 +457,11 @@ async function loadLineSnapshot(
455
457
  return line ? snapshotLine(line) : null
456
458
  }
457
459
 
460
+ async function assertLineSnapshotNumberAvailable(em: EntityManager, claim: WarrantyClaim, snapshot: LineSnapshot): Promise<void> {
461
+ if (!snapshot.deletedAt) await assertClaimLineNumberAvailable(em, claim, snapshot.lineNo, snapshot.id, true)
462
+ else await requireScopedClaim(em, claim.id, { tenantId: claim.tenantId, organizationId: claim.organizationId }, { lockMode: LockMode.PESSIMISTIC_WRITE })
463
+ }
464
+
458
465
  function restoreLineFromSnapshot(line: WarrantyClaimLine, snapshot: LineSnapshot): void {
459
466
  line.lineNo = snapshot.lineNo
460
467
  line.productId = snapshot.productId
@@ -694,6 +701,7 @@ const createClaimLineCommand: CommandHandler<ClaimLineCreateInput, { lineId: str
694
701
  }]]]), { currentClaimId: claim.id })
695
702
  }
696
703
  nextLineNo = input.lineNo ?? existingLines.reduce((max, entry) => Math.max(max, entry.lineNo), 0) + 1
704
+ await assertUniqueClaimLineNumbers([nextLineNo, ...existingLines.filter((line) => line.lineNo === nextLineNo).map((line) => line.lineNo)])
697
705
  },
698
706
  () => {
699
707
  line = em.create(WarrantyClaimLine, buildLineCreateData(claim, input, nextLineNo))
@@ -760,9 +768,9 @@ const updateClaimLineCommand: CommandHandler<ClaimLineUpdateInput, { lineId: str
760
768
  const guardsClaimedQty = hasOwn(input, 'qtyClaimed') || hasOwn(input, 'orderLineId')
761
769
  await withAtomicFlush(em, [
762
770
  async () => {
763
- if (!guardsClaimedQty && !effectiveOrderLineId) return
764
771
  await requireScopedClaim(em, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true })
765
772
  assertParentMutable(claim)
773
+ if (input.lineNo !== undefined) await assertClaimLineNumberAvailable(em, claim, input.lineNo, line.id)
766
774
  if (effectiveOrderLineId && (claim.orderId || effectiveOrderLineId !== (line.orderLineId ?? null))) {
767
775
  await validateClaimReferences(ctx, scope, {
768
776
  lineOrderRefs: [{
@@ -815,6 +823,7 @@ const updateClaimLineCommand: CommandHandler<ClaimLineUpdateInput, { lineId: str
815
823
  await withAtomicFlush(em, [
816
824
  async () => {
817
825
  state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
826
+ await assertLineSnapshotNumberAvailable(em, state.claim, before)
818
827
  },
819
828
  () => restoreLineFromSnapshot(state.line, before),
820
829
  () => recomputeClaimRollups(em, state.claim),
@@ -845,6 +854,9 @@ const deleteClaimLineCommand: CommandHandler<LineDeleteInput, { lineId: string;
845
854
  assertParentMutable(claim)
846
855
  await enforceWarrantyClaimOptimisticLock(ctx, line, WARRANTY_CLAIM_LINE_RESOURCE_KIND)
847
856
  await withAtomicFlush(em, [
857
+ async () => {
858
+ await requireScopedClaim(em, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
859
+ },
848
860
  () => {
849
861
  line.deletedAt = new Date()
850
862
  line.updatedAt = new Date()
@@ -871,6 +883,7 @@ const deleteClaimLineCommand: CommandHandler<LineDeleteInput, { lineId: string;
871
883
  await withAtomicFlush(em, [
872
884
  async () => {
873
885
  state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
886
+ await assertLineSnapshotNumberAvailable(em, state.claim, before)
874
887
  },
875
888
  () => {
876
889
  restoreLineFromSnapshot(state.line, before)
@@ -907,6 +920,9 @@ const receiveClaimLineCommand: CommandHandler<ClaimLineReceiveInput, { lineId: s
907
920
  const shouldQuarantine = Boolean(effectiveSettings.quarantineGrades?.includes(grade))
908
921
 
909
922
  await withAtomicFlush(em, [
923
+ async () => {
924
+ await requireScopedClaim(em, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
925
+ },
910
926
  () => {
911
927
  line.conditionGrade = grade
912
928
  if (input.inspectionNotes !== undefined) line.inspectionNotes = input.inspectionNotes ?? null
@@ -973,6 +989,7 @@ const receiveClaimLineCommand: CommandHandler<ClaimLineReceiveInput, { lineId: s
973
989
  await withAtomicFlush(em, [
974
990
  async () => {
975
991
  state = await requireCurrentLineUndoState(em, payload?.after, receivingParentUndoRule)
992
+ await assertLineSnapshotNumberAvailable(em, state.claim, before)
976
993
  },
977
994
  () => restoreLineFromSnapshot(state.line, before),
978
995
  () => recomputeClaimRollups(em, state.claim),
@@ -1002,6 +1019,9 @@ const releaseClaimLineQuarantineCommand: CommandHandler<ClaimLineReleaseQuaranti
1002
1019
  await enforceWarrantyClaimOptimisticLock(ctx, line, WARRANTY_CLAIM_LINE_RESOURCE_KIND, input.updatedAt ?? undefined)
1003
1020
 
1004
1021
  await withAtomicFlush(em, [
1022
+ async () => {
1023
+ await requireScopedClaim(em, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
1024
+ },
1005
1025
  () => {
1006
1026
  line.quarantineStatus = 'released'
1007
1027
  line.updatedAt = new Date()
@@ -1038,6 +1058,7 @@ const releaseClaimLineQuarantineCommand: CommandHandler<ClaimLineReleaseQuaranti
1038
1058
  await withAtomicFlush(em, [
1039
1059
  async () => {
1040
1060
  state = await requireCurrentLineUndoState(em, payload?.after, receivingParentUndoRule)
1061
+ await assertLineSnapshotNumberAvailable(em, state.claim, before)
1041
1062
  },
1042
1063
  () => restoreLineFromSnapshot(state.line, before),
1043
1064
  () => recomputeClaimRollups(em, state.claim),
@@ -1067,6 +1088,9 @@ const setClaimLineAssessmentCommand: CommandHandler<LineSetAssessmentInput, { li
1067
1088
  await enforceWarrantyClaimOptimisticLock(ctx, line, WARRANTY_CLAIM_LINE_RESOURCE_KIND, input.updatedAt ?? undefined)
1068
1089
 
1069
1090
  await withAtomicFlush(em, [
1091
+ async () => {
1092
+ await requireScopedClaim(em, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
1093
+ },
1070
1094
  () => {
1071
1095
  line.assessmentPayload = input.assessmentPayload
1072
1096
  line.updatedAt = new Date()
@@ -1102,6 +1126,7 @@ const setClaimLineAssessmentCommand: CommandHandler<LineSetAssessmentInput, { li
1102
1126
  await withAtomicFlush(em, [
1103
1127
  async () => {
1104
1128
  state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
1129
+ await assertLineSnapshotNumberAvailable(em, state.claim, before)
1105
1130
  },
1106
1131
  () => restoreLineFromSnapshot(state.line, before),
1107
1132
  () => recomputeClaimRollups(em, state.claim),
@@ -64,6 +64,7 @@ import type { WarrantyEntitlementInput, WarrantyEntitlementResolver } from '../s
64
64
  import {
65
65
  WARRANTY_CLAIM_RESOURCE_KIND,
66
66
  appendClaimEvent,
67
+ assertUniqueClaimLineNumbers,
67
68
  enforceWarrantyClaimOptimisticLock,
68
69
  ensureOrganizationScope,
69
70
  ensureTenantScope,
@@ -675,9 +676,25 @@ async function assertUndoSnapshotCurrent(ctx: CommandRuntimeContext, after: Clai
675
676
 
676
677
  async function restoreSnapshot(em: EntityManager, before: ClaimSnapshot, after: ClaimSnapshot): Promise<WarrantyClaim> {
677
678
  const { claim, lines } = await requireCurrentUndoSnapshot(em, after)
678
- restoreClaimDelta(claim, before, after)
679
679
  const beforeLinesById = new Map((Array.isArray(before.lines) ? before.lines : []).map((line) => [line.id, line]))
680
680
  const afterLinesById = new Map((Array.isArray(after.lines) ? after.lines : []).map((line) => [line.id, line]))
681
+ const projectedLines = []
682
+ for (const line of lines) {
683
+ const beforeLine = beforeLinesById.get(line.id)
684
+ const afterLine = afterLinesById.get(line.id)
685
+ if (!beforeLine || !afterLine) return throwClaimUndoConflict()
686
+ const deletedAt = beforeLine.deletedAt !== afterLine.deletedAt ? beforeLine.deletedAt : line.deletedAt
687
+ const lineNo = beforeLine.lineNo !== afterLine.lineNo ? beforeLine.lineNo : line.lineNo
688
+ projectedLines.push({ id: line.id, lineNo, deletedAt, changesNumber: Boolean(line.deletedAt) || lineNo !== line.lineNo })
689
+ }
690
+ for (const projected of projectedLines) {
691
+ if (projected.deletedAt || !projected.changesNumber) continue
692
+ await assertUniqueClaimLineNumbers([
693
+ projected.lineNo,
694
+ ...projectedLines.filter((line) => !line.deletedAt && line.id !== projected.id && line.lineNo === projected.lineNo).map((line) => line.lineNo),
695
+ ])
696
+ }
697
+ restoreClaimDelta(claim, before, after)
681
698
  for (const line of lines) {
682
699
  const beforeLine = beforeLinesById.get(line.id)
683
700
  const afterLine = afterLinesById.get(line.id)
@@ -1511,6 +1528,7 @@ const createClaimCommand: CommandHandler<ClaimCreateInput, { claimId: string }>
1511
1528
  isUndoable: true,
1512
1529
  async execute(rawInput, ctx) {
1513
1530
  const input = parseCommandInput(claimCreateSchema, rawInput)
1531
+ await assertUniqueClaimLineNumbers((input.lines ?? []).map((line, index) => line.lineNo ?? index + 1))
1514
1532
  const scope = resolveScope(ctx, input)
1515
1533
  const em = (ctx.container.resolve('em') as EntityManager).fork()
1516
1534
  await validateClaimReferences(ctx, scope, {