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
4 changes: 2 additions & 2 deletions apps/editor/src/infrastructure/caseApi/CaseApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ export type CfDocumentSummary = {
adoptionStatus?: string
lastChangeDateTime?: string
caseVersion?: string
/** URL the framework was imported from (set during import via backend). */
/** URL the framework was imported from, if known (set during import via backend). */
sourcePackageURI?: string
/** True when an imported framework has been locally modified after import. */
/** Set (true or false) once a framework has been imported/mirrored; true once it's been locally modified (forked). */
isModifiedFromSource?: boolean
/** Server-level archive flag — independent of CASE adoptionStatus */
archived?: boolean
Expand Down
14 changes: 9 additions & 5 deletions apps/editor/src/ui/shared/components/FrameworkCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ type Props = {
isUnsaved?: boolean
/** ISO date string for the last change — shown in the card footer */
lastChanged?: string
/** URL the framework was imported from — shows an "Imported" badge when set */
/** URL the framework was imported from, if known — shown in the Mirrored/Forked badge tooltip */
sourcePackageURI?: string
/** True when an imported framework has been locally modified */
/** Set (true or false) once a framework has been imported/mirrored; true once it's been locally modified (forked) */
isModifiedFromSource?: boolean
}

Expand Down Expand Up @@ -218,18 +218,22 @@ export function FrameworkCard({
Unsaved
</span>
) : null}
{sourcePackageURI ? (
{isModifiedFromSource !== undefined ? (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold',
isModifiedFromSource
? 'bg-violet-100 text-violet-700'
: 'bg-sky-100 text-sky-700',
)}
title={isModifiedFromSource ? `Imported from ${sourcePackageURI} (modified)` : `Imported from ${sourcePackageURI}`}
title={
isModifiedFromSource
? sourcePackageURI ? `Forked — originally mirrored from ${sourcePackageURI}` : 'Forked — originally mirrored, now locally modified'
: sourcePackageURI ? `Mirrored from ${sourcePackageURI}` : 'Mirrored — kept in sync with its imported source'
}
>
<CloudArrowDownIcon className="h-3 w-3" />
{isModifiedFromSource ? 'Forked' : 'Imported'}
{isModifiedFromSource ? 'Forked' : 'Mirrored'}
</span>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,16 +135,17 @@ export class CreateFramework {
}
}

// If the framework was previously imported (has sourcePackageURI in metadata),
// mark it as modified from source on subsequent saves from the editor.
// If the framework was previously imported as a mirror (has isModifiedFromSource
// in metadata, set at import time regardless of whether a source URL is known),
// mark it as modified from source — i.e. forked — on subsequent saves from the editor.
let cfDocPayload = payload.CFDocument
if (this.store) {
const docId = (cfDocPayload.sourcedId ?? cfDocPayload.identifier) as string | undefined
if (docId) {
// Check both CASE versions for existing metadata
const existingMeta = this.store.getDocumentMetadata(tenantId, caseVersion, docId)
?? this.store.getDocumentMetadata(tenantId, caseVersion === '1.0' ? '1.1' : '1.0', docId)
if (existingMeta?.sourcePackageURI) {
if (existingMeta?.isModifiedFromSource !== undefined) {
const existingExt = cfDocPayload.extensions ?? {}
const existingOpencase = (existingExt['ext:opencase'] && typeof existingExt['ext:opencase'] === 'object')
? existingExt['ext:opencase']
Expand All @@ -155,7 +156,7 @@ export class CreateFramework {
...existingExt,
'ext:opencase': {
...existingOpencase,
sourcePackageURI: existingMeta.sourcePackageURI,
...(existingMeta.sourcePackageURI ? { sourcePackageURI: existingMeta.sourcePackageURI } : {}),
isModifiedFromSource: true,
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ export class GetAllCFDocuments {
if (docMeta.version) doc.version = docMeta.version
if (docMeta.adoptionStatus) doc.adoptionStatus = docMeta.adoptionStatus
if (docMeta.sourcePackageURI) doc.sourcePackageURI = docMeta.sourcePackageURI
if (docMeta.isModifiedFromSource) doc.isModifiedFromSource = docMeta.isModifiedFromSource
// isModifiedFromSource is set (true or false) on any mirrored/forked framework,
// even one imported without a known sourcePackageURI (e.g. pasted JSON) — so it
// must be surfaced even when false, since the frontend uses its presence to
// decide whether to show the Mirrored/Forked badge at all.
if (docMeta.isModifiedFromSource !== undefined) doc.isModifiedFromSource = docMeta.isModifiedFromSource
if (docMeta.archived) doc.archived = true

// CASE v1.1-only fields: only include when not serving via v1p0
Expand Down
26 changes: 17 additions & 9 deletions apps/opencase/src/application/case/endpoints/ImportFramework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ export interface ImportFrameworkResult {

/**
* Merge or create the `ext:opencase` extension on a CFDocument payload,
* setting sourcePackageURI and marking it as a pristine import.
* marking it as a pristine mirror of the source. `endpointUrl` is only known
* when the framework was imported by URL (as opposed to a pasted JSON payload).
*/
function injectSourceProvenance (docPayload: any, endpointUrl: string): any {
function injectSourceProvenance (docPayload: any, endpointUrl?: string): any {
const existing = docPayload.extensions ?? {}
const existingOpencase = (existing['ext:opencase'] && typeof existing['ext:opencase'] === 'object')
? existing['ext:opencase']
Expand All @@ -42,7 +43,7 @@ function injectSourceProvenance (docPayload: any, endpointUrl: string): any {
...existing,
'ext:opencase': {
...existingOpencase,
sourcePackageURI: endpointUrl,
...(endpointUrl ? { sourcePackageURI: endpointUrl } : {}),
isModifiedFromSource: false,
importedAt: new Date().toISOString(),
}
Expand Down Expand Up @@ -74,7 +75,11 @@ export class ImportFramework {
}
} else {
logger.info({ tenantId, caseVersion }, 'Importing framework from provided JSON')
sourceCFPackage = normalizeCfPackageData(cfPackage)
const normalized = normalizeCfPackageData(cfPackage)
sourceCFPackage = {
...normalized,
CFDocument: injectSourceProvenance(normalized.CFDocument)
}
}

// Use CFPackage format directly (matches API response format)
Expand Down Expand Up @@ -102,17 +107,20 @@ export class ImportFramework {
}
}

// Create domain entities from CFPackage format
const document = CFDocument.fromRaw(tenantId, caseVersion, payload.CFDocument)
// Create domain entities from CFPackage format. Mirrored imports preserve the
// source's identifiers/URIs as-is rather than rewriting them onto this instance —
// they're only regenerated once the framework is edited and forked (a later task).
const preserveUris = { preserveUris: true }
const document = CFDocument.fromRaw(tenantId, caseVersion, payload.CFDocument, preserveUris)
const docId = document.sourcedId
const docJSON = document.toJSON()
const docURI = docJSON.uri
const items = (payload.CFItems ?? []).map(i => CFItem.fromRaw(tenantId, caseVersion, i, docId, docURI))
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)
CFAssociation.fromRaw(tenantId, caseVersion, a, preserveUris)
)
const rubrics = (payload.CFRubrics ?? []).map(r =>
CFRubric.fromRaw(tenantId, caseVersion, r)
CFRubric.fromRaw(tenantId, caseVersion, r, preserveUris)
)
const definitions = payload.CFDefinitions ?? null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe('ImportFramework', () => {
expect(mockRepository.saveNewVersion).toHaveBeenCalledTimes(1)
})

it('does not inject source provenance when importing from pasted JSON', async () => {
it('marks pasted JSON as a pristine mirror without a sourcePackageURI (no known source URL)', async () => {
await importFramework.execute({
tenantId,
caseVersion,
Expand All @@ -99,6 +99,42 @@ describe('ImportFramework', () => {
const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2]
const savedExtensions = savedPkg.document.toJSON().extensions
expect(savedExtensions?.['ext:opencase']?.sourcePackageURI).toBeUndefined()
expect(savedExtensions?.['ext:opencase']?.isModifiedFromSource).toBe(false)
})

it('preserves the source uri as-is (does not rewrite it onto this instance) when importing from a URL', async () => {
const foreignDocument = {
...cfDocument,
uri: 'https://source.example.org/ims/case/v1p1/CFDocuments/doc-123'
}
mockApiClient.fetchCFPackage.mockResolvedValue({
CFPackage: { CFDocument: foreignDocument, CFItems: [], CFAssociations: [], CFRubrics: [] }
})

await importFramework.execute({
tenantId,
caseVersion,
endpointUrl: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123'
})

const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2]
expect(savedPkg.document.toJSON().uri).toBe('https://source.example.org/ims/case/v1p1/CFDocuments/doc-123')
})

it('preserves the source uri as-is when importing from pasted JSON', async () => {
const foreignDocument = {
...cfDocument,
uri: 'https://source.example.org/ims/case/v1p1/CFDocuments/doc-123'
}

await importFramework.execute({
tenantId,
caseVersion,
cfPackage: { CFDocument: foreignDocument }
})

const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2]
expect(savedPkg.document.toJSON().uri).toBe('https://source.example.org/ims/case/v1p1/CFDocuments/doc-123')
})

it('throws when neither endpointUrl nor cfPackage is provided', async () => {
Expand Down
87 changes: 51 additions & 36 deletions apps/opencase/src/domain/case/entities/CFAssociation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,18 @@ export class CFAssociation {
return new CFAssociation(props);
}

static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any): CFAssociation {
static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, options?: { preserveUris?: boolean }): CFAssociation {
// Extract identifier from URN if present (priority over sourcedId/identifier)
let identifier = raw.sourcedId || raw.identifier
let uri = raw.uri

// If URI is a URN, extract identifier and transform URI
if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) {

if (options?.preserveUris) {
// Mirrored framework: keep the source's identifiers and URIs exactly as supplied.
if (!identifier && uri && UrnCaseUriHelper.isUrnCaseUri(uri)) {
identifier = UrnCaseUriHelper.parseUrnCaseUri(uri)?.identifier || identifier
}
} else if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) {
// If URI is a URN, extract identifier and transform URI
const parsed = UrnCaseUriHelper.parseUrnCaseUri(uri)
if (parsed) {
identifier = parsed.identifier || identifier
Expand All @@ -44,45 +49,55 @@ export class CFAssociation {
// If not a URN, generate URI based on identifier (existing behavior)
uri = this.generateURI(tenantId, caseVersion, identifier)
}

// 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

let originNodeURI: LinkData
let destinationNodeURI: LinkData
if (options?.preserveUris && raw.originNodeURI && raw.destinationNodeURI) {
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)
}
}
const 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
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)
}
}
const destinationNodeURI = {
title: raw.destinationNodeURI?.title ?? String(destinationId),
identifier: destinationId,
uri: this.generateItemURI(tenantId, caseVersion, destinationId)
}

// CFAssociationGroupingURI references a per-tenant definition entity that
// OpenCASE serves itself, so it's rebased onto the local host too.
// OpenCASE serves itself, so it's rebased onto the local host too — unless
// this is a mirrored framework, in which case it's kept as the source supplied it.
// CFAssociationGroupingURI must use LinkURI format (UUID identifier required)
const CFAssociationGroupingURI = LinkDataHelper.rebaseLinkData(raw.CFAssociationGroupingURI, caseVersion, 'CFAssociationGroupings')
if (CFAssociationGroupingURI) {
const CFAssociationGroupingURI = options?.preserveUris
? raw.CFAssociationGroupingURI
: LinkDataHelper.rebaseLinkData(raw.CFAssociationGroupingURI, caseVersion, 'CFAssociationGroupings')
if (CFAssociationGroupingURI && !options?.preserveUris) {
LinkDataHelper.validateLinkURI(CFAssociationGroupingURI, 'CFAssociationGroupingURI')
}

Expand Down
40 changes: 24 additions & 16 deletions apps/opencase/src/domain/case/entities/CFDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,18 @@ export class CFDocument {
return new CFDocument(props);
}

static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any): CFDocument {
static fromRaw(tenantId: TenantId, caseVersion: CaseVersion, raw: any, options?: { preserveUris?: boolean }): CFDocument {
// Extract identifier from URN if present (priority over sourcedId/identifier)
let identifier = raw.sourcedId || raw.identifier
let uri = raw.uri

// If URI is a URN, extract identifier and transform URI
if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) {

if (options?.preserveUris) {
// Mirrored framework: keep the source's identifiers and URIs exactly as supplied.
if (!identifier && uri && UrnCaseUriHelper.isUrnCaseUri(uri)) {
identifier = UrnCaseUriHelper.parseUrnCaseUri(uri)?.identifier || identifier
}
} else if (uri && UrnCaseUriHelper.isUrnCaseUri(uri)) {
// If URI is a URN, extract identifier and transform URI
const parsed = UrnCaseUriHelper.parseUrnCaseUri(uri)
if (parsed) {
identifier = parsed.identifier || identifier
Expand All @@ -55,22 +60,25 @@ export class CFDocument {
// If not a URN, generate URI based on identifier (existing behavior)
uri = this.generateURI(tenantId, caseVersion, identifier)
}

// 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')
// Mirrored frameworks skip this and keep the source's reference URIs as-is.
const licenseURI = options?.preserveUris ? raw.licenseURI : LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses')
const CFPackageURI = options?.preserveUris ? raw.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 = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects')
if (transformed) {
LinkDataHelper.validateLinkURI(transformed, 'CFDocument.subjectURI')
}
return transformed
}).filter((s: any): s is LinkData => s !== undefined)
: undefined
const subjectURI = options?.preserveUris
? raw.subjectURI
: Array.isArray(raw.subjectURI)
? raw.subjectURI.map((s: any) => {
const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects')
if (transformed) {
LinkDataHelper.validateLinkURI(transformed, 'CFDocument.subjectURI')
}
return transformed
}).filter((s: any): s is LinkData => s !== undefined)
: undefined

return CFDocument.create({
tenantId,
Expand Down
Loading
Loading