Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export class CreateFramework {
return {
document: doc,
items: itemsPayload.map((i: any) => CFItem.fromRaw(tenantId, caseVersion, i, dId, dURI, uriOptions)),
associations: assocPayload.map((a: any) => CFAssociation.fromRaw(tenantId, caseVersion, a, uriOptions)),
associations: assocPayload.map((a: any) => CFAssociation.fromRaw(tenantId, caseVersion, a, { ...uriOptions, documentIdentifiers: [dId] })),
rubrics: rubricsPayload.map((r: any) => CFRubric.fromRaw(tenantId, caseVersion, r, uriOptions))
}
}
Expand Down Expand Up @@ -295,7 +295,7 @@ export class CreateFramework {
CFItem.fromRaw(tenantId, caseVersion, i, docId, docURI, preserveUris)
)
const associations = cfAssociationsPayload.map((a: any) =>
CFAssociation.fromRaw(tenantId, caseVersion, a, preserveUris)
CFAssociation.fromRaw(tenantId, caseVersion, a, { ...preserveUris, documentIdentifiers: [docId] })
)
const rubrics = cfRubricsPayload.map((r: any) =>
CFRubric.fromRaw(tenantId, caseVersion, r, preserveUris)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export class ImportFramework {
const docURI = docJSON.uri
const items = (payload.CFItems ?? []).map(i => CFItem.fromRaw(tenantId, caseVersion, i, docId, docURI, preserveUris))
const associations = (payload.CFAssociations ?? []).map(a =>
CFAssociation.fromRaw(tenantId, caseVersion, a, preserveUris)
CFAssociation.fromRaw(tenantId, caseVersion, a, { ...preserveUris, documentIdentifiers: [docId] })
)
const rubrics = (payload.CFRubrics ?? []).map(r =>
CFRubric.fromRaw(tenantId, caseVersion, r, preserveUris)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ export class UpdateCFAssociation {
}

// Update the specific association
const updatedAssociation = CFAssociation.fromRaw(tenantId, caseVersion, payload)
const updatedAssociation = CFAssociation.fromRaw(tenantId, caseVersion, payload, {
documentIdentifiers: [existingPkg.document.sourcedId]
})

// Replace the association in the associations array
const associations = existingPkg.associations.map(a =>
Expand Down
135 changes: 98 additions & 37 deletions apps/opencase/src/domain/case/entities/CFAssociation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,17 @@ export class CFAssociation {
return new CFAssociation(props);
}

static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, options?: { preserveUris?: boolean }): CFAssociation {
/**
* @param options.preserveUris keep the source's identifiers and URIs exactly as supplied (mirrored frameworks)
* @param options.documentIdentifiers identifiers known to be CFDocuments (e.g. the package's own CFDocument);
* association nodes with one of these identifiers get a /CFDocuments/{id} URI
*/
static fromRaw(
tenantId: TenantId,
caseVersion: CaseVersion,
raw: any,
options?: { preserveUris?: boolean, documentIdentifiers?: Iterable<string> }
): CFAssociation {
// Extract identifier from URN if present (priority over sourcedId/identifier)
let identifier = raw.sourcedId || raw.identifier
let uri = raw.uri
Expand Down Expand Up @@ -56,38 +66,15 @@ export class CFAssociation {
originNodeURI = raw.originNodeURI
destinationNodeURI = raw.destinationNodeURI
} else {
// 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'
const originUriFromSource = raw.originNodeURI?.uri
if (originUriFromSource && UrnCaseUriHelper.isUrnCaseUri(originUriFromSource)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(originUriFromSource)
if (parsed) {
originId = parsed.identifier || originId
}
}
originNodeURI = {
title: raw.originNodeURI?.title ?? String(originId),
identifier: originId,
uri: this.generateItemURI(tenantId, caseVersion, originId)
}

let destinationId = raw.destinationNodeURI?.identifier ?? raw.destinationNode ?? 'unknown'
const destinationUriFromSource = raw.destinationNodeURI?.uri
if (destinationUriFromSource && UrnCaseUriHelper.isUrnCaseUri(destinationUriFromSource)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(destinationUriFromSource)
if (parsed) {
destinationId = parsed.identifier || destinationId
}
}
destinationNodeURI = {
title: raw.destinationNodeURI?.title ?? String(destinationId),
identifier: destinationId,
uri: this.generateItemURI(tenantId, caseVersion, destinationId)
}
// originNodeURI/destinationNodeURI reference a CFItem or a CFDocument, 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, and whether the node
// is a CFDocument or a CFItem, are trusted from the source data. Nodes with a
// non-CASE targetType are kept as supplied (see buildNodeLinkData).
const documentIdentifiers = new Set<string>(options?.documentIdentifiers ?? [])
originNodeURI = this.buildNodeLinkData(tenantId, caseVersion, raw.originNodeURI, raw.originNode, documentIdentifiers)
destinationNodeURI = this.buildNodeLinkData(tenantId, caseVersion, raw.destinationNodeURI, raw.destinationNode, documentIdentifiers)
}

// CFAssociationGroupingURI references a per-tenant definition entity that
Expand Down Expand Up @@ -127,6 +114,77 @@ export class CFAssociation {
return `${basePath}/CFItems/${identifier}`;
}

private static generateDocumentURI(tenantId: TenantId, caseVersion: CaseVersion, identifier: string): string {
const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0';
return `${basePath}/CFDocuments/${identifier}`;
}

/**
* Builds the local LinkData for an association node (origin or destination).
* The node is treated as a CFDocument when its identifier is a known document
* identifier, when the source supplied a `urn:case:document:` URN, or when the
* source URI (relative or absolute, any CASE version) points at
* `/CFDocuments/{identifier}`. Otherwise it is a CFItem.
*/
private static buildNodeLinkData(
tenantId: TenantId,
caseVersion: CaseVersion,
link: any,
legacyNodeId: unknown,
documentIdentifiers: Set<string>
): LinkData {
// CASE 1.1 lets an association point outside CASE (spec §1.9.3). A node whose
// targetType is anything other than 'CASE' is not served by OpenCASE, so it is
// kept exactly as supplied: original uri and targetType.
const targetType: unknown = link?.targetType
if (typeof targetType === 'string' && targetType !== 'CASE' && link?.uri) {
return {
title: link.title ?? String(link.identifier ?? legacyNodeId ?? link.uri),
identifier: link.identifier ?? legacyNodeId ?? link.uri,
uri: link.uri,
targetType
}
}

let id = link?.identifier ?? legacyNodeId ?? 'unknown'
const uriFromSource: unknown = link?.uri
let isDocument = false

if (typeof uriFromSource === 'string' && UrnCaseUriHelper.isUrnCaseUri(uriFromSource)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(uriFromSource)
if (parsed) {
id = parsed.identifier || id
isDocument = parsed.type === 'document'
}
} else if (typeof uriFromSource === 'string') {
isDocument = this.isDocumentUri(uriFromSource, String(id))
}

if (documentIdentifiers.has(String(id))) isDocument = true

const result: LinkData = {
title: link?.title ?? String(id),
identifier: id,
uri: isDocument
? this.generateDocumentURI(tenantId, caseVersion, id)
: this.generateItemURI(tenantId, caseVersion, id)
}
if (targetType === 'CASE') result.targetType = targetType
return result
}

/** True when `uri` is a CASE CFDocuments URI for `identifier` (e.g. `…/ims/case/v1p1/CFDocuments/{identifier}`). */
private static isDocumentUri(uri: string, identifier: string): boolean {
const path = uri.split(/[?#]/)[0].replace(/\/+$/, '')
const match = path.match(/\/CFDocuments\/([^/]+)$/)
if (!match) return false
try {
return decodeURIComponent(match[1]) === identifier
} catch {
return match[1] === identifier
}
}

private static createLinkDataFromString(nodeId: string, tenantId: TenantId, caseVersion: CaseVersion): LinkData {
return {
title: nodeId,
Expand Down Expand Up @@ -155,10 +213,13 @@ export class CFAssociation {
if (effectiveVersion === '1.0') {
delete result.notes
delete result.extensions
if (result.originNodeURI && typeof result.originNodeURI === 'object') delete result.originNodeURI.targetType
if (result.destinationNodeURI && typeof result.destinationNodeURI === 'object') delete result.destinationNodeURI.targetType
if (result.CFDocumentURI && typeof result.CFDocumentURI === 'object') delete result.CFDocumentURI.targetType
if (result.CFAssociationGroupingURI && typeof result.CFAssociationGroupingURI === 'object') delete result.CFAssociationGroupingURI.targetType
// Copy before stripping so the entity's own LinkData keeps targetType.
for (const key of ['originNodeURI', 'destinationNodeURI', 'CFDocumentURI', 'CFAssociationGroupingURI']) {
if (result[key] && typeof result[key] === 'object') {
const { targetType: _targetType, ...link } = result[key]
result[key] = link
}
}
}

return result;
Expand Down
148 changes: 148 additions & 0 deletions apps/opencase/src/domain/case/entities/__tests__/CFAssociation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,154 @@ describe('CFAssociation', () => {
expect(json.destinationNodeURI.identifier).toBe('item-2');
});

describe('node URIs pointing at a CFDocument (#44)', () => {
const docId = '11111111-1111-4111-8111-111111111111';
const itemId = '22222222-2222-4222-8222-222222222222';

it('keeps a /CFDocuments/ destination when the source URI points at a CFDocument', () => {
const raw = {
identifier: '33333333-3333-4333-8333-333333333333',
associationType: 'exactMatchOf',
originNodeURI: { title: 'Mathematics', identifier: itemId, uri: `https://opencase.example.org/ims/case/v1p1/CFItems/${itemId}` },
destinationNodeURI: { title: 'Primary Education – Mathematics', identifier: docId, uri: `https://opencase.example.org/ims/case/v1p1/CFDocuments/${docId}` }
};

const json = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON();

expect(json.originNodeURI.uri).toBe(`/ims/case/v1p1/CFItems/${itemId}`);
expect(json.destinationNodeURI.uri).toBe(`/ims/case/v1p1/CFDocuments/${docId}`);
expect(json.destinationNodeURI.identifier).toBe(docId);
});

it('recognises relative, foreign-host and other-version CFDocuments URIs', () => {
for (const uri of [
`/ims/case/v1p1/CFDocuments/${docId}`,
`https://standards.example.org/ims/case/v1p0/CFDocuments/${docId}/`,
`https://standards.example.org/ims/case/v1p1/CFDocuments/${docId}?x=1`
]) {
const raw = {
identifier: 'assoc-1',
associationType: 'isChildOf',
originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` },
destinationNodeURI: { title: 'Doc', identifier: docId, uri }
};
expect(CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON().destinationNodeURI.uri)
.toBe(`/ims/case/v1p1/CFDocuments/${docId}`);
}
});

it('uses the CASE version of the package for the CFDocuments path', () => {
const raw = {
identifier: 'assoc-1',
associationType: 'isChildOf',
originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p0/CFItems/${itemId}` },
destinationNodeURI: { title: 'Doc', identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }
};
expect(CFAssociation.fromRaw(tenantId, '1.0', raw).toJSON().destinationNodeURI.uri)
.toBe(`/ims/case/v1p0/CFDocuments/${docId}`);
});

it('treats a urn:case:document: URN as a CFDocument', () => {
const raw = {
identifier: 'assoc-1',
associationType: 'isChildOf',
originNodeURI: { title: 'Item', identifier: itemId, uri: `urn:case:item:${itemId}` },
destinationNodeURI: { title: 'Doc', identifier: docId, uri: `urn:case:document:${docId}` }
};
const json = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON();
expect(json.originNodeURI.uri).toBe(`/ims/case/v1p1/CFItems/${itemId}`);
expect(json.destinationNodeURI.uri).toBe(`/ims/case/v1p1/CFDocuments/${docId}`);
});

it('uses /CFDocuments/ for a known document identifier, even when the source URI says CFItems or is missing', () => {
const raw = {
identifier: 'assoc-1',
associationType: 'isChildOf',
originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` },
destinationNodeURI: { title: 'Doc', identifier: docId, uri: `/ims/case/v1p1/CFItems/${docId}` }
};
expect(CFAssociation.fromRaw(tenantId, caseVersion, raw, { documentIdentifiers: [docId] }).toJSON().destinationNodeURI.uri)
.toBe(`/ims/case/v1p1/CFDocuments/${docId}`);

const legacy = { sourcedId: 'assoc-2', originNode: itemId, destinationNode: docId, associationType: 'isChildOf' };
expect(CFAssociation.fromRaw(tenantId, caseVersion, legacy, { documentIdentifiers: [docId] }).toJSON().destinationNodeURI.uri)
.toBe(`/ims/case/v1p1/CFDocuments/${docId}`);
});

it('does not treat a CFDocuments URI for a different identifier as a document', () => {
const raw = {
identifier: 'assoc-1',
associationType: 'isChildOf',
originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` },
destinationNodeURI: { title: 'Other', identifier: 'item-2', uri: `/ims/case/v1p1/CFDocuments/${docId}` }
};
expect(CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON().destinationNodeURI.uri)
.toBe('/ims/case/v1p1/CFItems/item-2');
});

it('is stable on reload: a stored /CFDocuments/ URI stays a /CFDocuments/ URI', () => {
const raw = {
identifier: 'assoc-1',
associationType: 'isChildOf',
originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` },
destinationNodeURI: { title: 'Doc', identifier: docId, uri: `https://opencase.example.org/ims/case/v1p1/CFDocuments/${docId}` }
};
const first = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON();
const second = CFAssociation.fromRaw(tenantId, caseVersion, first).toJSON();
expect(second.destinationNodeURI).toEqual(first.destinationNodeURI);
});
});

describe('nodes outside CASE (targetType, #49)', () => {
const itemId = '22222222-2222-4222-8222-222222222222';
const external = {
title: 'External goal 1.2.3',
identifier: '44444444-4444-4444-8444-444444444444',
uri: 'https://standards.example.org/goals/1.2.3',
targetType: 'ext:external'
};
const raw = {
identifier: '55555555-5555-4555-8555-555555555555',
associationType: 'isRelatedTo',
originNodeURI: { title: 'Item', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` },
destinationNodeURI: external
};

it('keeps a non-CASE destination exactly as supplied', () => {
const json = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON();
expect(json.destinationNodeURI).toEqual(external);
expect(json.originNodeURI.uri).toBe(`/ims/case/v1p1/CFItems/${itemId}`);
});

it('round-trips unchanged (fromRaw → toJSON → fromRaw)', () => {
const first = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON();
const second = CFAssociation.fromRaw(tenantId, caseVersion, first).toJSON();
expect(second).toEqual(first);
});

it('keeps targetType CASE and still rebases the uri onto the local host', () => {
const json = CFAssociation.fromRaw(tenantId, caseVersion, {
...raw,
destinationNodeURI: { title: 'Item 2', identifier: 'item-2', uri: 'https://standards.example.org/ims/case/v1p1/CFItems/item-2', targetType: 'CASE' }
}).toJSON();
expect(json.destinationNodeURI).toEqual({
title: 'Item 2', identifier: 'item-2', uri: '/ims/case/v1p1/CFItems/item-2', targetType: 'CASE'
});
});

it('drops targetType when serialized as CASE 1.0', () => {
const json = CFAssociation.fromRaw(tenantId, caseVersion, raw).toJSON('1.0');
expect(json.destinationNodeURI.targetType).toBeUndefined();
expect(json.destinationNodeURI.uri).toBe(external.uri);
});

it('does not lose targetType on the entity after a CASE 1.0 serialization', () => {
const assoc = CFAssociation.fromRaw(tenantId, caseVersion, raw);
assoc.toJSON('1.0');
expect(assoc.toJSON().destinationNodeURI.targetType).toBe('ext:external');
});
});

it('should rewrite CFAssociationGroupingURI to the local host on import', () => {
const raw = {
sourcedId: 'assoc-123',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class FileCFPackageRepository implements CFPackageRepository {
CFItem.fromRaw(tenantId, version, i, document.sourcedId, docURI, preserveUris)
)
const associations = (bundle.associations ?? []).map((a: unknown) =>
CFAssociation.fromRaw(tenantId, version, a, preserveUris)
CFAssociation.fromRaw(tenantId, version, a, { ...preserveUris, documentIdentifiers: [document.sourcedId] })
)
const rubrics = (bundle.rubrics ?? []).map((r: unknown) =>
CFRubric.fromRaw(tenantId, version, r, preserveUris)
Expand Down