@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/api_keys/services/principalService.js +3 -2
- package/dist/modules/api_keys/services/principalService.js.map +2 -2
- package/dist/modules/api_keys/services/sessionKeyRoles.js +29 -0
- package/dist/modules/api_keys/services/sessionKeyRoles.js.map +7 -0
- package/dist/modules/auth/cli.js +13 -2
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +18 -10
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/business_rules/lib/action-executor.js +2 -1
- package/dist/modules/business_rules/lib/action-executor.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +11 -4
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/entities/subscribers/seed-tenant-encryption-maps-retry.js +14 -0
- package/dist/modules/entities/subscribers/seed-tenant-encryption-maps-retry.js.map +7 -0
- package/dist/modules/entities/subscribers/seed-tenant-encryption-maps.js +37 -0
- package/dist/modules/entities/subscribers/seed-tenant-encryption-maps.js.map +7 -0
- package/dist/modules/warranty_claims/commands/claim-lines.js +25 -1
- package/dist/modules/warranty_claims/commands/claim-lines.js.map +3 -3
- package/dist/modules/warranty_claims/commands/claims.js +19 -1
- package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
- package/dist/modules/warranty_claims/commands/shared.js +30 -0
- package/dist/modules/warranty_claims/commands/shared.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/services/principalService.ts +3 -2
- package/src/modules/api_keys/services/sessionKeyRoles.ts +35 -0
- package/src/modules/auth/cli.ts +11 -4
- package/src/modules/auth/services/rbacService.ts +22 -9
- package/src/modules/business_rules/lib/action-executor.ts +2 -3
- package/src/modules/directory/utils/organizationScope.ts +11 -4
- package/src/modules/entities/subscribers/seed-tenant-encryption-maps-retry.ts +12 -0
- package/src/modules/entities/subscribers/seed-tenant-encryption-maps.ts +38 -0
- package/src/modules/warranty_claims/commands/claim-lines.ts +26 -1
- package/src/modules/warranty_claims/commands/claims.ts +19 -1
- package/src/modules/warranty_claims/commands/shared.ts +36 -1
- package/src/modules/warranty_claims/i18n/de.json +1 -0
- package/src/modules/warranty_claims/i18n/en.json +1 -0
- package/src/modules/warranty_claims/i18n/es.json +1 -0
- package/src/modules/warranty_claims/i18n/ko.json +1 -0
- 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
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;
|
|
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.
|
|
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.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
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.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
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.
|
|
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
|
+
}
|
package/src/modules/auth/cli.ts
CHANGED
|
@@ -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
|
|
411
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
531
|
+
if (crossRequestCache && cacheKey && userId && typeof crossRequestCache.set === 'function') {
|
|
525
532
|
try {
|
|
526
|
-
await
|
|
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, {
|