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
18 changes: 6 additions & 12 deletions apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,18 +131,12 @@ export class GetAllCFDocuments {
doc.caseVersion = caseVersion
if (docMeta.frameworkType) doc.frameworkType = docMeta.frameworkType

// sourcePackageURI/isModifiedFromSource are derived from the ext:opencase extension —
// keep them nested under `extensions` (not flattened to top-level fields) so they flow
// through the same X-CASE-EDITOR gating (stripExtensions) as every other extension.
const extOpencase: Record<string, unknown> = {}
if (docMeta.sourcePackageURI) extOpencase.sourcePackageURI = docMeta.sourcePackageURI
// 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) extOpencase.isModifiedFromSource = docMeta.isModifiedFromSource
if (Object.keys(extOpencase).length > 0) {
doc.extensions = { 'ext:opencase': extOpencase }
// Serve the complete, untransformed ext:opencase object verbatim (not a hand-picked
// subset) so callers that request extensions (X-CASE-EDITOR) see everything stored on
// the document — nested under `extensions` so it flows through the same X-CASE-EDITOR
// gating (stripExtensions) as every other extension.
if (docMeta.openCaseExtensions) {
doc.extensions = { 'ext:opencase': docMeta.openCaseExtensions }
}
}

Expand Down
10 changes: 5 additions & 5 deletions apps/opencase/src/application/case/endpoints/ListFrameworks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class ListFrameworks {
subject?: string
version?: string
lastChangeDateTime: string
extensions?: { 'ext:opencase': { alignmentParticipants: Array<{ identifier?: string; uri: string }> } }
extensions?: { 'ext:opencase': Record<string, unknown> }
}> = []

for (const version of versions) {
Expand All @@ -50,10 +50,10 @@ export class ListFrameworks {
subject: doc.subject,
version: doc.version,
lastChangeDateTime: doc.lastChangeDateTime.toISOString(),
// alignmentParticipants is derived from the ext:opencase extension — keep it nested
// under `extensions` (not a flat top-level field) so the caller (management controller)
// can strip it the same way the public API strips `extensions` when X-CASE-EDITOR is absent.
...(doc.alignmentParticipants ? { extensions: { 'ext:opencase': { alignmentParticipants: doc.alignmentParticipants } } } : {})
// Serve the complete, untransformed ext:opencase object verbatim (not a hand-picked
// subset), nested under `extensions` so the caller (management controller) can strip
// it the same way the public API strips `extensions` when X-CASE-EDITOR is absent.
...(doc.openCaseExtensions ? { extensions: { 'ext:opencase': doc.openCaseExtensions } } : {})
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,30 +268,31 @@ describe('GetAllCFDocuments', () => {
expect(result.CFDocuments).toHaveLength(2)
})

it('should nest sourcePackageURI and isModifiedFromSource under extensions.ext:opencase for CASE 1.1', async () => {
it('should nest the complete, untransformed ext:opencase object under extensions for CASE 1.1', async () => {
// Includes fields (importedAt, layout, edgeType) that are NOT modeled as their own
// DocumentMetadata properties — they must still be served verbatim, unmodified.
const openCaseExtensions = {
sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc',
isModifiedFromSource: true,
importedAt: '2026-09-17T14:55:51.931Z',
layout: { x: -200, y: -80, w: 400, h: 160 },
edgeType: 'default'
}
const documents = [
{
sourcedId: 'doc-1',
title: 'Mirrored Document',
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
currentFile: 'file1.json',
sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc',
isModifiedFromSource: true
openCaseExtensions
}
]

mockStore.getAllDocuments.mockImplementation((_: any, v: any) => (v === '1.0' ? documents as any : []))

const result = await getAllCFDocuments.execute({ tenantId, caseVersion })

expect(result.CFDocuments[0]).not.toHaveProperty('sourcePackageURI')
expect(result.CFDocuments[0]).not.toHaveProperty('isModifiedFromSource')
expect(result.CFDocuments[0].extensions).toEqual({
'ext:opencase': {
sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc',
isModifiedFromSource: true
}
})
expect(result.CFDocuments[0].extensions).toEqual({ 'ext:opencase': openCaseExtensions })
})

it('should omit the extensions block entirely for CASE 1.0', async () => {
Expand All @@ -301,8 +302,7 @@ describe('GetAllCFDocuments', () => {
title: 'Mirrored Document',
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
currentFile: 'file1.json',
sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc',
isModifiedFromSource: true
openCaseExtensions: { sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc' }
}
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ describe('ListFrameworks', () => {
title: 'Alignment Framework',
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
currentFile: 'frameworks/doc-1/doc-1_v0001.json',
alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }]
alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }],
openCaseExtensions: { alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }] }
}
]

Expand All @@ -225,15 +226,21 @@ describe('ListFrameworks', () => {
expect(result.frameworks[0]).not.toHaveProperty('alignmentParticipants')
})

it('should nest alignmentParticipants under extensions.ext:opencase when present', async () => {
const participants = [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }]
it('should nest the complete, untransformed ext:opencase object under extensions when present', async () => {
// Includes a field (notes) that is NOT modeled as its own DocumentMetadata property —
// it must still be served verbatim, unmodified.
const openCaseExtensions = {
alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }],
notes: 'some frontend-only annotation'
}
const docs: DocumentMetadata[] = [
{
sourcedId: 'doc-1',
title: 'Alignment Framework',
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
currentFile: 'frameworks/doc-1/doc-1_v0001.json',
alignmentParticipants: participants
alignmentParticipants: openCaseExtensions.alignmentParticipants,
openCaseExtensions
}
]

Expand All @@ -243,12 +250,10 @@ describe('ListFrameworks', () => {

const result = await listFrameworks.execute({ tenantId })

expect(result.frameworks[0].extensions).toEqual({
'ext:opencase': { alignmentParticipants: participants }
})
expect(result.frameworks[0].extensions).toEqual({ 'ext:opencase': openCaseExtensions })
})

it('should omit extensions entirely when there are no alignmentParticipants', async () => {
it('should omit extensions entirely when there is no ext:opencase data', async () => {
const docs: DocumentMetadata[] = [
{
sourcedId: 'doc-1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export interface DocumentMetadata {
archived?: boolean
/** Participant frameworks in an alignment document (extracted from ext:opencase.alignmentParticipants) */
alignmentParticipants?: Array<{ identifier?: string; uri: string }>
/**
* The complete, untransformed `extensions['ext:opencase']` object as stored on the document,
* verbatim. Callers that request extensions (X-CASE-EDITOR) should see this whole object as-is —
* the other typed fields above (sourcePackageURI, isModifiedFromSource, alignmentParticipants)
* are a lossy subset kept separately because internal logic (fork-on-edit detection, alignment
* filtering) depends on them as structured values, not because they're the full extension data.
*/
openCaseExtensions?: Record<string, unknown>
}

export interface DocumentVersionInfo {
Expand Down Expand Up @@ -178,6 +186,7 @@ export class FileFrameworkStore {
isModifiedFromSource: d.isModifiedFromSource,
archived: d.archived,
alignmentParticipants: d.alignmentParticipants,
openCaseExtensions: d.openCaseExtensions,
})
}
} catch {
Expand Down Expand Up @@ -463,6 +472,7 @@ export class FileFrameworkStore {
sourcePackageURI,
isModifiedFromSource,
alignmentParticipants,
openCaseExtensions: extOpencase && typeof extOpencase === 'object' ? extOpencase as Record<string, unknown> : undefined,
})
}

Expand Down Expand Up @@ -668,6 +678,7 @@ export class FileFrameworkStore {
isModifiedFromSource: meta.isModifiedFromSource,
archived: meta.archived,
alignmentParticipants: meta.alignmentParticipants,
openCaseExtensions: meta.openCaseExtensions,
}))

await fs.writeFile(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,41 @@ describe('FileFrameworkStore — index survives a server restart', () => {

await fs.rm(baseDataDir, { recursive: true, force: true })
})

it('preserves the full ext:opencase extension object verbatim across a restart, including unmodeled keys', async () => {
const baseDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'case-store-restart-ext-'))
const storageKey = 'doc-1'

const store1 = new FileFrameworkStore({ baseDataDir })

const extOpencase = {
sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc',
isModifiedFromSource: true,
importedAt: '2026-09-17T14:55:51.931Z',
layout: { x: -200, y: -80, w: 400, h: 160 },
edgeType: 'default'
}
const bundle = {
document: {
sourcedId: 'doc-1',
title: 'A framework',
lastChangeDateTime: '2024-01-01T00:00:00Z',
extensions: { 'ext:opencase': extOpencase }
},
items: [],
associations: [],
rubrics: [],
}
const { relativePath } = await store1.writeBundleFile(tenantId, version, storageKey, bundle)
await store1.updateIndexesForBundle(tenantId, version, storageKey, bundle, relativePath)

expect(store1.getDocumentMetadata(tenantId, version, storageKey)?.openCaseExtensions).toEqual(extOpencase)

const store2 = new FileFrameworkStore({ baseDataDir })
await store2.loadAll()

expect(store2.getDocumentMetadata(tenantId, version, storageKey)?.openCaseExtensions).toEqual(extOpencase)

await fs.rm(baseDataDir, { recursive: true, force: true })
})
})
Loading