From 28df5871dbd469c2a2e242fdd505460f834323cb Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Wed, 26 Aug 2026 15:09:37 -0400 Subject: [PATCH] Updates to handle processing of item URIs that were missed on the first pass. Also enables the automatic centering on the root node when opening a canvas view. --- apps/editor/src/ui/editor/EditorCanvas.tsx | 53 ++++++++++++- .../src/domain/case/entities/CFAssociation.ts | 77 +++++-------------- .../src/domain/case/entities/CFDocument.ts | 49 ++---------- .../src/domain/case/entities/CFItem.ts | 52 ++----------- .../src/domain/case/entities/CFRubric.ts | 6 +- .../entities/__tests__/CFAssociation.test.ts | 47 +++++++++++ .../entities/__tests__/CFDocument.test.ts | 30 ++++++++ .../case/entities/__tests__/CFItem.test.ts | 36 +++++++++ .../case/entities/__tests__/CFRubric.test.ts | 32 ++++++++ .../src/domain/case/value-objects/LinkData.ts | 37 +++++++++ 10 files changed, 268 insertions(+), 151 deletions(-) create mode 100644 apps/opencase/src/domain/case/entities/__tests__/CFRubric.test.ts diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index c096845..62c2a23 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -17,6 +17,7 @@ import FloatingAddButton from '@/ui/editor/components/FloatingAddButton' import AddExternalFrameworkDialog from '@/ui/editor/components/AddExternalFrameworkDialog' import ViewCFPackageDialog from '@/ui/editor/components/ViewCFPackageDialog' import { useEditor } from '@/ui/editor/state/EditorContext' +import { isFrameworkNode, getNodeSize } from '@/ui/editor/state/helpers/nodeGeometry' import type { CaseEditorNodeType, CaseEditorEdge } from '@/ui/editor/reactflow/types' import type { CFDocument, CFItem, CFPackage } from '@/domain/case/types' import { useAuth } from '@/app/providers/AuthProvider' @@ -85,6 +86,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const reactFlowRef = useRef | null>(null) const [rfReady, setRfReady] = useState(false) const didInitialViewportRef = useRef(false) + const prevLayoutVersionRef = useRef(null) const [leaveOpen, setLeaveOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) const [externalFwDialogOpen, setExternalFwDialogOpen] = useState(false) @@ -1015,6 +1017,44 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen [ensureNodeVisible, logSelectionDebug, selectedNodeIds.length, selectedEdgeIds.length], ) + // Center on the framework's root node, at a fixed, comfortable zoom — + // regardless of framework size — so the user lands oriented on the root + // instead of a fit-to-everything view that can shrink the root to a speck + // in a large framework. Runs on the very first paint AND whenever the user + // switches layout mode (Hierarchy/Star "views"), since a layout switch + // moves every node and should re-orient the same way a fresh load does. + const centerOnRoot = useCallback(() => { + const instance = reactFlowRef.current + const wrap = reactFlowWrapRef.current + if (!instance || !wrap) return + + const DEFAULT_ROOT_ZOOM = 1 + const animate = didInitialViewportRef.current + const duration = animate ? 200 : 0 + + const center = () => { + const instance2 = reactFlowRef.current + if (!instance2) return + const root = instance2.getNodes().find(isFrameworkNode) + if (!root) return + const { w, h } = getNodeSize(root) + instance2.setCenter(root.position.x + w / 2, root.position.y + h / 2, { + zoom: DEFAULT_ROOT_ZOOM, + duration, + }) + didInitialViewportRef.current = true + } + + // Two rAFs to let React Flow apply any pending node measurements/positions. + const id = globalThis.requestAnimationFrame(() => center()) + const id2 = globalThis.requestAnimationFrame(() => center()) + + return () => { + globalThis.cancelAnimationFrame(id) + globalThis.cancelAnimationFrame(id2) + } + }, []) + const fitToContents = useCallback(() => { const instance = reactFlowRef.current const wrap = reactFlowWrapRef.current @@ -1103,10 +1143,17 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen } }, []) - // Make the initial viewport leave room for the floating header so the top-most node isn't hidden behind it. + // First paint, and any explicit layout-mode switch (Hierarchy/Star "reset"), + // center on the root node. A layout switch moves every node, so it should + // re-orient the same way a fresh load does. Node-count-only changes (items + // added/removed without a layout switch) instead fall back to fitting the + // whole graph, leaving room for the floating header so the top-most node + // isn't hidden behind it. useEffect(() => { if (!rfReady) return - const cleanup = fitToContents() + const layoutChanged = prevLayoutVersionRef.current === null || prevLayoutVersionRef.current !== layoutVersion + prevLayoutVersionRef.current = layoutVersion + const cleanup = layoutChanged ? centerOnRoot() : fitToContents() const onResize = () => { fitToContents() } @@ -1115,7 +1162,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen cleanup?.() globalThis.removeEventListener('resize', onResize) } - }, [rfReady, nodesWithCallbacks.length, layoutVersion, fitToContents]) + }, [rfReady, nodesWithCallbacks.length, layoutVersion, fitToContents, centerOnRoot]) return (
diff --git a/apps/opencase/src/domain/case/entities/CFAssociation.ts b/apps/opencase/src/domain/case/entities/CFAssociation.ts index 8cff888..d156218 100644 --- a/apps/opencase/src/domain/case/entities/CFAssociation.ts +++ b/apps/opencase/src/domain/case/entities/CFAssociation.ts @@ -45,45 +45,43 @@ export class CFAssociation { uri = this.generateURI(tenantId, caseVersion, identifier) } - // Transform originNodeURI - extract identifier from URN if present + // originNodeURI/destinationNodeURI always reference a CFItem (or CFDocument) + // within the SAME package being imported, so — like CFItem.CFDocumentURI — + // their uri is always regenerated to point at the local host, regardless of + // what URI shape (URN, absolute foreign-host URL, or relative path) the + // source supplied. Only the identifier is trusted from the source data. let originId = raw.originNodeURI?.identifier ?? raw.originNode ?? 'unknown' - let originUri = raw.originNodeURI?.uri - if (originUri && UrnCaseUriHelper.isUrnCaseUri(originUri)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(originUri) + const originUriFromSource = raw.originNodeURI?.uri + if (originUriFromSource && UrnCaseUriHelper.isUrnCaseUri(originUriFromSource)) { + const parsed = UrnCaseUriHelper.parseUrnCaseUri(originUriFromSource) if (parsed) { originId = parsed.identifier || originId - originUri = UrnCaseUriHelper.urnCaseToRelativePath(originUri, caseVersion) } - } else { - originUri = originUri || this.generateItemURI(tenantId, caseVersion, originId) } const originNodeURI = { title: raw.originNodeURI?.title ?? String(originId), identifier: originId, - uri: originUri + uri: this.generateItemURI(tenantId, caseVersion, originId) } - - // Transform destinationNodeURI - extract identifier from URN if present + let destinationId = raw.destinationNodeURI?.identifier ?? raw.destinationNode ?? 'unknown' - let destinationUri = raw.destinationNodeURI?.uri - if (destinationUri && UrnCaseUriHelper.isUrnCaseUri(destinationUri)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(destinationUri) + const destinationUriFromSource = raw.destinationNodeURI?.uri + if (destinationUriFromSource && UrnCaseUriHelper.isUrnCaseUri(destinationUriFromSource)) { + const parsed = UrnCaseUriHelper.parseUrnCaseUri(destinationUriFromSource) if (parsed) { destinationId = parsed.identifier || destinationId - destinationUri = UrnCaseUriHelper.urnCaseToRelativePath(destinationUri, caseVersion) } - } else { - destinationUri = destinationUri || this.generateItemURI(tenantId, caseVersion, destinationId) } const destinationNodeURI = { title: raw.destinationNodeURI?.title ?? String(destinationId), identifier: destinationId, - uri: destinationUri + uri: this.generateItemURI(tenantId, caseVersion, destinationId) } - - // Transform CFAssociationGroupingURI if present + + // CFAssociationGroupingURI references a per-tenant definition entity that + // OpenCASE serves itself, so it's rebased onto the local host too. // CFAssociationGroupingURI must use LinkURI format (UUID identifier required) - const CFAssociationGroupingURI = this.transformLinkData(raw.CFAssociationGroupingURI, caseVersion) + const CFAssociationGroupingURI = LinkDataHelper.rebaseLinkData(raw.CFAssociationGroupingURI, caseVersion, 'CFAssociationGroupings') if (CFAssociationGroupingURI) { LinkDataHelper.validateLinkURI(CFAssociationGroupingURI, 'CFAssociationGroupingURI') } @@ -104,45 +102,6 @@ export class CFAssociation { }); } - /** - * Transforms a LinkData object's URI if it's a URN, otherwise returns it unchanged - */ - private static transformLinkData(linkData: any, caseVersion: CaseVersion): LinkData | undefined { - if (!linkData) return undefined - - // If it's already a LinkData object with a URI - if (typeof linkData === 'object' && linkData.uri) { - const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData.uri, caseVersion) - // If URI was a URN, also extract identifier from it - let identifier = linkData.identifier - if (linkData.uri && UrnCaseUriHelper.isUrnCaseUri(linkData.uri)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData.uri) - if (parsed) { - identifier = parsed.identifier || identifier - } - } - return { - ...linkData, - uri: transformedUri || linkData.uri, - identifier: identifier || linkData.identifier - } - } - - // If it's a string URI, transform it - if (typeof linkData === 'string') { - const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData, caseVersion) - const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData) - const identifier = parsed?.identifier || LinkDataHelper.extractIdFromURI(linkData) - return { - title: identifier || linkData, - identifier: identifier || linkData, - uri: transformedUri || linkData - } - } - - return linkData - } - private static generateURI(tenantId: TenantId, caseVersion: CaseVersion, identifier: string): string { const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0'; return `${basePath}/CFAssociations/${identifier}`; diff --git a/apps/opencase/src/domain/case/entities/CFDocument.ts b/apps/opencase/src/domain/case/entities/CFDocument.ts index b57fe8d..2604f1d 100644 --- a/apps/opencase/src/domain/case/entities/CFDocument.ts +++ b/apps/opencase/src/domain/case/entities/CFDocument.ts @@ -56,13 +56,15 @@ export class CFDocument { uri = this.generateURI(tenantId, caseVersion, identifier) } - // Transform LinkData URIs if they are URNs - const licenseURI = this.transformLinkData(raw.licenseURI, caseVersion) - const CFPackageURI = this.transformLinkData(raw.CFPackageURI, caseVersion) + // Rebase reference URIs onto the local host — these point at per-tenant + // definition entities (licenses, packages, subjects) that OpenCASE serves + // itself, so they must resolve locally rather than to the source host. + const licenseURI = LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses') + const CFPackageURI = LinkDataHelper.rebaseLinkData(raw.CFPackageURI, caseVersion, 'CFPackages') // subjectURI must use LinkURI format (UUID identifier required) const subjectURI = Array.isArray(raw.subjectURI) ? raw.subjectURI.map((s: any) => { - const transformed = this.transformLinkData(s, caseVersion) + const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects') if (transformed) { LinkDataHelper.validateLinkURI(transformed, 'CFDocument.subjectURI') } @@ -97,45 +99,6 @@ export class CFDocument { }); } - /** - * Transforms a LinkData object's URI if it's a URN, otherwise returns it unchanged - */ - private static transformLinkData(linkData: any, caseVersion: CaseVersion): LinkData | undefined { - if (!linkData) return undefined - - // If it's already a LinkData object with a URI - if (typeof linkData === 'object' && linkData.uri) { - const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData.uri, caseVersion) - // If URI was a URN, also extract identifier from it - let identifier = linkData.identifier - if (linkData.uri && UrnCaseUriHelper.isUrnCaseUri(linkData.uri)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData.uri) - if (parsed) { - identifier = parsed.identifier || identifier - } - } - return { - ...linkData, - uri: transformedUri || linkData.uri, - identifier: identifier || linkData.identifier - } - } - - // If it's a string URI, transform it - if (typeof linkData === 'string') { - const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData, caseVersion) - const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData) - const identifier = parsed?.identifier || LinkDataHelper.extractIdFromURI(linkData) - return { - title: identifier || linkData, - identifier: identifier || linkData, - uri: transformedUri || linkData - } - } - - return linkData - } - private static generateURI(tenantId: TenantId, caseVersion: CaseVersion, identifier: string): string { // Generate a URI based on tenant, version, and identifier const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0'; diff --git a/apps/opencase/src/domain/case/entities/CFItem.ts b/apps/opencase/src/domain/case/entities/CFItem.ts index 0f5e5f4..5c17422 100644 --- a/apps/opencase/src/domain/case/entities/CFItem.ts +++ b/apps/opencase/src/domain/case/entities/CFItem.ts @@ -80,14 +80,17 @@ export class CFItem { uri: generatedDocUri } - // Transform LinkData URIs if they are URNs - const CFItemTypeURI = this.transformLinkData(raw.CFItemTypeURI, caseVersion) - const conceptKeywordsURI = this.transformLinkData(raw.conceptKeywordsURI, caseVersion) - const licenseURI = this.transformLinkData(raw.licenseURI, caseVersion) + // Rebase reference URIs onto the local host — these point at per-tenant + // definition entities (item types, concepts, licenses, subjects) that + // OpenCASE serves itself, so they must resolve locally rather than to + // the source host. + const CFItemTypeURI = LinkDataHelper.rebaseLinkData(raw.CFItemTypeURI, caseVersion, 'CFItemTypes') + const conceptKeywordsURI = LinkDataHelper.rebaseLinkData(raw.conceptKeywordsURI, caseVersion, 'CFConcepts') + const licenseURI = LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses') // subjectURI must use LinkURI format (UUID identifier required) const subjectURI = Array.isArray(raw.subjectURI) ? raw.subjectURI.map((s: any) => { - const transformed = this.transformLinkData(s, caseVersion) + const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects') if (transformed) { LinkDataHelper.validateLinkURI(transformed, 'CFItem.subjectURI') } @@ -124,45 +127,6 @@ export class CFItem { }); } - /** - * Transforms a LinkData object's URI if it's a URN, otherwise returns it unchanged - */ - private static transformLinkData(linkData: any, caseVersion: CaseVersion): LinkData | undefined { - if (!linkData) return undefined - - // If it's already a LinkData object with a URI - if (typeof linkData === 'object' && linkData.uri) { - const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData.uri, caseVersion) - // If URI was a URN, also extract identifier from it - let identifier = linkData.identifier - if (linkData.uri && UrnCaseUriHelper.isUrnCaseUri(linkData.uri)) { - const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData.uri) - if (parsed) { - identifier = parsed.identifier || identifier - } - } - return { - ...linkData, - uri: transformedUri || linkData.uri, - identifier: identifier || linkData.identifier - } - } - - // If it's a string URI, transform it - if (typeof linkData === 'string') { - const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData, caseVersion) - const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData) - const identifier = parsed?.identifier || LinkDataHelper.extractIdFromURI(linkData) - return { - title: identifier || linkData, - identifier: identifier || linkData, - uri: transformedUri || linkData - } - } - - return linkData - } - private static generateURI(tenantId: TenantId, caseVersion: CaseVersion, identifier: string): string { const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0'; return `${basePath}/CFItems/${identifier}`; diff --git a/apps/opencase/src/domain/case/entities/CFRubric.ts b/apps/opencase/src/domain/case/entities/CFRubric.ts index 701e147..d08a97d 100644 --- a/apps/opencase/src/domain/case/entities/CFRubric.ts +++ b/apps/opencase/src/domain/case/entities/CFRubric.ts @@ -35,8 +35,10 @@ export class CFRubric { identifier = parsed.identifier || identifier uri = UrnCaseUriHelper.urnCaseToRelativePath(uri, caseVersion) } - } else if (!uri && identifier) { - // Generate URI based on identifier if not provided + } else if (identifier) { + // Not a URN — always regenerate based on identifier, same as + // CFDocument/CFItem/CFAssociation, so a source-supplied absolute URL + // doesn't leave the rubric's own uri pointing at the source host. uri = this.generateURI(tenantId, caseVersion, identifier) } diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts index b88a312..6ef8897 100644 --- a/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts +++ b/apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts @@ -100,6 +100,53 @@ describe('CFAssociation', () => { expect(assoc.toJSON().associationType).toBe('isChildOf'); expect(assoc.toJSON().sequenceNumber).toBe(1); }); + + it('should rewrite originNodeURI/destinationNodeURI to the local host on import, even when the source supplied absolute foreign-host URLs (POR-730)', () => { + const raw = { + sourcedId: 'assoc-123', + uri: 'https://standards.example.org/ims/case/v1p0/CFAssociations/assoc-123', + associationType: 'isChildOf', + originNodeURI: { + title: 'Item 1', + identifier: 'item-1', + uri: 'https://standards.example.org/ims/case/v1p0/CFItems/item-1' + }, + destinationNodeURI: { + title: 'Item 2', + identifier: 'item-2', + uri: 'https://standards.example.org/ims/case/v1p0/CFItems/item-2' + } + }; + + const assoc = CFAssociation.fromRaw(tenantId, caseVersion, raw); + const json = assoc.toJSON(); + + expect(json.uri).toBe('/ims/case/v1p1/CFAssociations/assoc-123'); + expect(json.originNodeURI.uri).toBe('/ims/case/v1p1/CFItems/item-1'); + expect(json.destinationNodeURI.uri).toBe('/ims/case/v1p1/CFItems/item-2'); + // identifiers are preserved from the source + expect(json.originNodeURI.identifier).toBe('item-1'); + expect(json.destinationNodeURI.identifier).toBe('item-2'); + }); + + it('should rewrite CFAssociationGroupingURI to the local host on import', () => { + const raw = { + sourcedId: 'assoc-123', + originNode: 'item-1', + destinationNode: 'item-2', + associationType: 'isChildOf', + CFAssociationGroupingURI: { + title: 'Grouping', + identifier: 'c0c0c0c0-0000-4000-a000-000000000009', + uri: 'https://standards.example.org/ims/case/v1p0/CFAssociationGroupings/c0c0c0c0-0000-4000-a000-000000000009' + } + }; + + const assoc = CFAssociation.fromRaw(tenantId, caseVersion, raw); + const json = assoc.toJSON(); + + expect(json.CFAssociationGroupingURI.uri).toBe('/ims/case/v1p1/CFAssociationGroupings/c0c0c0c0-0000-4000-a000-000000000009'); + }); }); describe('toJSON', () => { diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFDocument.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFDocument.test.ts index 241061f..ccb49cd 100644 --- a/apps/opencase/src/domain/case/entities/__tests__/CFDocument.test.ts +++ b/apps/opencase/src/domain/case/entities/__tests__/CFDocument.test.ts @@ -108,6 +108,36 @@ describe('CFDocument', () => { expect(json.lastChangeDateTime).toBe('2024-01-01T12:30:45.000Z'); }); + + it('should rewrite licenseURI/CFPackageURI/subjectURI to the local host on import (POR-730)', () => { + const raw = { + sourcedId: 'doc-123', + title: 'Test Document', + lastChangeDateTime: '2024-01-01T00:00:00Z', + licenseURI: { + title: 'License', + identifier: 'c0c0c0c0-0000-4000-a000-000000000002', + uri: 'https://standards.example.org/ims/case/v1p0/CFLicenses/c0c0c0c0-0000-4000-a000-000000000002' + }, + CFPackageURI: { + title: 'Package', + identifier: 'doc-123', + uri: 'https://standards.example.org/ims/case/v1p0/CFPackages/doc-123' + }, + subjectURI: [{ + title: 'Subject', + identifier: 'c0c0c0c0-0000-4000-a000-000000000012', + uri: 'https://standards.example.org/ims/case/v1p0/CFSubjects/c0c0c0c0-0000-4000-a000-000000000012' + }] + }; + + const doc = CFDocument.fromRaw(tenantId, caseVersion, raw); + const json = doc.toJSON(); + + expect(json.licenseURI.uri).toBe('/ims/case/v1p1/CFLicenses/c0c0c0c0-0000-4000-a000-000000000002'); + expect(json.CFPackageURI.uri).toBe('/ims/case/v1p1/CFPackages/doc-123'); + expect(json.subjectURI[0].uri).toBe('/ims/case/v1p1/CFSubjects/c0c0c0c0-0000-4000-a000-000000000012'); + }); }); describe('toJSON', () => { diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFItem.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFItem.test.ts index dd47c29..ca53c28 100644 --- a/apps/opencase/src/domain/case/entities/__tests__/CFItem.test.ts +++ b/apps/opencase/src/domain/case/entities/__tests__/CFItem.test.ts @@ -102,6 +102,42 @@ describe('CFItem', () => { expect(item.toJSON().fullStatement).toBe('Test statement'); expect(item.toJSON().humanCodingScheme).toBe('MATH.1'); }); + + it('should rewrite CFItemTypeURI/conceptKeywordsURI/licenseURI/subjectURI to the local host on import (POR-730)', () => { + const raw = { + sourcedId: 'item-123', + fullStatement: 'Test statement', + CFItemType: 'Standard', + CFItemTypeURI: { + title: 'Standard', + identifier: 'c0c0c0c0-0000-4000-a000-000000000010', + uri: 'https://standards.example.org/uri/c0c0c0c0-0000-4000-a000-000000000010' + }, + conceptKeywordsURI: { + title: 'Concept', + identifier: 'c0c0c0c0-0000-4000-a000-000000000011', + uri: 'https://standards.example.org/ims/case/v1p0/CFConcepts/c0c0c0c0-0000-4000-a000-000000000011' + }, + licenseURI: { + title: 'License', + identifier: 'c0c0c0c0-0000-4000-a000-000000000002', + uri: 'https://standards.example.org/ims/case/v1p0/CFLicenses/c0c0c0c0-0000-4000-a000-000000000002' + }, + subjectURI: [{ + title: 'Subject', + identifier: 'c0c0c0c0-0000-4000-a000-000000000012', + uri: 'https://standards.example.org/ims/case/v1p0/CFSubjects/c0c0c0c0-0000-4000-a000-000000000012' + }] + }; + + const item = CFItem.fromRaw(tenantId, caseVersion, raw, 'doc-123', '/ims/case/v1p1/CFDocuments/doc-123'); + const json = item.toJSON(); + + expect(json.CFItemTypeURI.uri).toBe('/ims/case/v1p1/CFItemTypes/c0c0c0c0-0000-4000-a000-000000000010'); + expect(json.conceptKeywordsURI.uri).toBe('/ims/case/v1p1/CFConcepts/c0c0c0c0-0000-4000-a000-000000000011'); + expect(json.licenseURI.uri).toBe('/ims/case/v1p1/CFLicenses/c0c0c0c0-0000-4000-a000-000000000002'); + expect(json.subjectURI[0].uri).toBe('/ims/case/v1p1/CFSubjects/c0c0c0c0-0000-4000-a000-000000000012'); + }); }); describe('toJSON', () => { diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFRubric.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFRubric.test.ts new file mode 100644 index 0000000..fa002f5 --- /dev/null +++ b/apps/opencase/src/domain/case/entities/__tests__/CFRubric.test.ts @@ -0,0 +1,32 @@ +import { CFRubric } from '../CFRubric'; +import { CaseVersion, TenantId } from '../../value-objects/Identifiers'; + +describe('CFRubric', () => { + const tenantId: TenantId = 'test-tenant'; + const caseVersion: CaseVersion = '1.1'; + + describe('fromRaw', () => { + it('should generate a local uri when none is supplied', () => { + const raw = { + identifier: 'rubric-123', + title: 'Test Rubric' + }; + + const rubric = CFRubric.fromRaw(tenantId, caseVersion, raw); + + expect(rubric.toJSON().uri).toBe('/ims/case/v1p1/CFRubrics/rubric-123'); + }); + + it('should rewrite an absolute foreign-host uri to the local host on import (POR-730)', () => { + const raw = { + identifier: 'rubric-123', + title: 'Test Rubric', + uri: 'https://standards.example.org/ims/case/v1p0/CFRubrics/rubric-123' + }; + + const rubric = CFRubric.fromRaw(tenantId, caseVersion, raw); + + expect(rubric.toJSON().uri).toBe('/ims/case/v1p1/CFRubrics/rubric-123'); + }); + }); +}); diff --git a/apps/opencase/src/domain/case/value-objects/LinkData.ts b/apps/opencase/src/domain/case/value-objects/LinkData.ts index 974a5ef..3381e57 100644 --- a/apps/opencase/src/domain/case/value-objects/LinkData.ts +++ b/apps/opencase/src/domain/case/value-objects/LinkData.ts @@ -86,6 +86,43 @@ export class LinkDataHelper { static forItem(uri: string, identifier: string, title?: string): LinkData { return { title: title || identifier, identifier, uri } } + + /** + * Rebases a LinkData reference (or raw string/object) onto the local host by + * unconditionally regenerating its `uri` as a relative CASE path for the given + * resource segment (e.g. 'CFItemTypes', 'CFLicenses', 'CFSubjects', 'CFConcepts', + * 'CFAssociationGroupings', 'CFPackages') — using whatever identifier can be + * recovered from the source data (explicit identifier, urn:case: URI, or the + * last path segment / UUID found in an ordinary URI). + * + * Used at import time so references to entities OpenCASE hosts locally by + * identifier (item types, licenses, subjects, concepts, association groupings, + * packages) always resolve against the new host instead of lingering on the + * source system's, regardless of what shape of URI the source supplied. + */ + static rebaseLinkData( + linkData: string | LinkData | { uri?: string, identifier?: string, title?: string } | undefined, + caseVersion: CaseVersion, + resourceSegment: string + ): LinkData | undefined { + if (!linkData) return undefined + + const raw = typeof linkData === 'string' ? { uri: linkData } : linkData + + let identifier = raw.identifier + if (!identifier && raw.uri) { + const parsed = UrnCaseUriHelper.parseUrnCaseUri(raw.uri) + identifier = parsed?.identifier ?? this.extractIdFromURI(raw.uri) + } + if (!identifier) return undefined + + const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0' + return { + title: raw.title || identifier, + identifier, + uri: `${basePath}/${resourceSegment}/${identifier}` + } + } } /**