diff --git a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts index e3e4c3f..f94d53e 100644 --- a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts +++ b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts @@ -38,6 +38,12 @@ export type CaseDocumentSnapshot = { licenseURI?: { title?: string; identifier?: string; uri: string } /** OpenCASE: unauthenticated CASE API reads. Absent means sign-in is required. */ publicAccess?: boolean + /** + * Non-OpenCASE CASE extensions (e.g. a source system's identifiers), preserved + * verbatim for round-trip fidelity. `ext:opencase` is excluded — the editor + * reads what it needs from it (e.g. publicAccess) and regenerates it on save. + */ + extensions?: Record } /** diff --git a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts index dea64bd..f7d651a 100644 --- a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts +++ b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts @@ -57,6 +57,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot): lastChangeDateTime: doc.lastChangeDateTime, licenseURI: doc.licenseURI, publicAccess: doc.publicAccess === true ? true : undefined, + extensions: doc.extensions, } const items: Framework['items'] = new Map() @@ -84,7 +85,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot): statusEndDate: it.statusEndDate, caseUri: it.uri, lastChangeDateTime: it.lastChangeDateTime, - ...it.extensions, + extensions: it.extensions, }, } items.set(item.id, item) @@ -109,7 +110,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot): CFAssociationGroupingTitle: a.CFAssociationGroupingTitle, notes: a.notes, lastChangeDateTime: a.lastChangeDateTime, - ...a.extensions, + extensions: a.extensions, }, } associations.set(assoc.id, assoc) diff --git a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts index 8f10bbf..064de9c 100644 --- a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts @@ -297,7 +297,8 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot } : undefined - const opencaseExt = asRecord(asRecord(doc.extensions)?.['ext:opencase']) + const { 'ext:opencase': rawOpencaseExt, ...customDocExtensions } = asRecord(doc.extensions) ?? {} + const opencaseExt = asRecord(rawOpencaseExt) const publicAccess = opencaseExt?.publicAccess === true ? true : undefined // Extract CFAssociationGroupings from CFDefinitions @@ -344,6 +345,7 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot lastChangeDateTime: asString(doc.lastChangeDateTime), licenseURI, publicAccess, + extensions: Object.keys(customDocExtensions).length > 0 ? customDocExtensions : undefined, }, items, associations, diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.extensionsRoundTrip.test.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.extensionsRoundTrip.test.ts new file mode 100644 index 0000000..ccbd2de --- /dev/null +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.extensionsRoundTrip.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { frameworkToCfPackage } from './toCasePackage' +import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader' +import { toReactFlowGraph } from '@/ui/editor/reactflow/mapping/toReactFlow' +import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph' +import { hasFrameworkDataChanged } from '@/domain/framework/hasFrameworkDataChanged' +import type { CFDocument, CFItem, CFPackage } from '@/domain/case/types' +import type { Framework, Item } from '@/domain/framework/model/types' +import type { ItemId } from '@/domain/shared/types' + +const DOC_ID = 'doc-1' + +// A package as created through the API by an external sync script (GitHub #42/#43): +// custom extensions sit alongside the editor's own ext:opencase on every entity. +function makePackage(): CFPackage { + return { + CFDocument: { + identifier: DOC_ID, + uri: `/ims/case/v1p1/CFDocuments/${DOC_ID}`, + title: 'Curriculum', + creator: 'Sync', + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + caseVersion: '1.1', + extensions: { + 'ext:opencase': { publicAccess: true }, + sourceSystem: { name: 'smartschool', curriculumId: 'cur-9' }, + }, + }, + CFItems: [ + { + identifier: 'item-1', + uri: '/ims/case/v1p1/CFItems/item-1', + fullStatement: 'Item 1', + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + extensions: { 'ext:opencase': { colorBand: '#ff0000' }, smartschoolId: 'ss-1' }, + }, + ], + CFAssociations: [ + { + identifier: 'assoc-root', + uri: '/ims/case/v1p1/CFAssociations/assoc-root', + associationType: 'isChildOf', + originNodeURI: { identifier: 'item-1', uri: '/ims/case/v1p1/CFItems/item-1', title: 'Item 1' }, + destinationNodeURI: { identifier: DOC_ID, uri: `/ims/case/v1p1/CFDocuments/${DOC_ID}`, title: 'Curriculum' }, + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + extensions: { 'ext:opencase': { originHandle: 'top' }, minimumGoal: 'MG-7' }, + }, + ], + } as unknown as CFPackage +} + +function loadIntoEditor() { + const framework = loadFrameworkFromCfPackage(makePackage()) + if (!framework) throw new Error('failed to load framework') + return toReactFlowGraph({ framework }) +} + +function saveFromEditor(graph: ReturnType) { + const { framework, layout } = fromEditorGraph({ graph }) + return frameworkToCfPackage({ framework, caseVersion: '1.1', layout }) +} + +function withItemMetadata(framework: Framework, patch: Record): Framework { + const items = new Map(framework.items) + const item = items.get('item-1' as ItemId) as Item + items.set(item.id, { ...item, metadata: { ...item.metadata, ...patch } }) + return { ...framework, items } +} + +describe('CASE extensions round-trip through the editor', () => { + it('shows custom item and document extensions on the canvas, without ext:opencase on the document', () => { + const graph = loadIntoEditor() + const itemNode = graph.nodes.find((n) => (n.data as { cfItem?: CFItem }).cfItem?.identifier === 'item-1') + const docNode = graph.nodes.find((n) => (n.data as { cfDocument?: CFDocument }).cfDocument?.identifier === DOC_ID) + + expect((itemNode?.data as { cfItem: CFItem }).cfItem.extensions).toMatchObject({ smartschoolId: 'ss-1' }) + expect((docNode?.data as { cfDocument: CFDocument }).cfDocument.extensions).toEqual({ + sourceSystem: { name: 'smartschool', curriculumId: 'cur-9' }, + }) + }) + + it('preserves custom extensions on the document, items and associations when saving', () => { + const saved = saveFromEditor(loadIntoEditor()) + + expect(saved.CFDocument.extensions).toEqual({ + sourceSystem: { name: 'smartschool', curriculumId: 'cur-9' }, + 'ext:opencase': expect.objectContaining({ publicAccess: true }), + }) + + const item = saved.CFItems?.find((i) => i.identifier === 'item-1') + expect(item?.extensions).toMatchObject({ smartschoolId: 'ss-1', 'ext:opencase': { colorBand: '#ff0000' } }) + + const assoc = saved.CFAssociations?.find((a) => a.identifier === 'assoc-root') + expect(assoc?.extensions).toMatchObject({ minimumGoal: 'MG-7', 'ext:opencase': { originHandle: 'top' } }) + }) + + it('keeps custom document extensions even when there is no ext:opencase data to write', () => { + const framework = loadFrameworkFromCfPackage(makePackage())! + const noOpencase = { ...framework, metadata: { ...framework.metadata, publicAccess: undefined } } + const saved = frameworkToCfPackage({ framework: noOpencase, caseVersion: '1.1' }) + + expect(saved.CFDocument.extensions).toEqual({ sourceSystem: { name: 'smartschool', curriculumId: 'cur-9' } }) + }) + + it('regenerates item ext:opencase from editor state so a cleared colorBand does not come back', () => { + const { framework } = fromEditorGraph({ graph: loadIntoEditor() }) + const cleared = withItemMetadata(framework, { colorBand: undefined }) + const saved = frameworkToCfPackage({ framework: cleared, caseVersion: '1.1' }) + + expect(saved.CFItems?.[0].extensions).toEqual({ smartschoolId: 'ss-1' }) + }) + + it('treats an edited custom extension as a data change, but not a change to ext:opencase', () => { + const { framework } = fromEditorGraph({ graph: loadIntoEditor() }) + const original = framework.items.get('item-1' as ItemId)!.metadata!.extensions as Record + + const layoutOnly = withItemMetadata(framework, { extensions: { ...original, 'ext:opencase': { colorBand: '#00ff00' } } }) + const customEdit = withItemMetadata(framework, { extensions: { ...original, smartschoolId: 'ss-2' } }) + + expect(hasFrameworkDataChanged(framework, layoutOnly)).toBe(false) + expect(hasFrameworkDataChanged(framework, customEdit)).toBe(true) + }) +}) diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts index ecc4b7a..8783e8c 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts @@ -155,15 +155,17 @@ function frameworkToCfDocument( title: docTitle, identifier: fwId, }, + // meta.extensions holds the document's non-OpenCASE extensions (never ext:opencase), + // so ext:opencase is always regenerated from editor state while the rest round-trip. extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks || meta.publicAccess === true) - ? mergeOpencaseExtension(undefined, { + ? mergeOpencaseExtension(meta.extensions, { layout, edgeType: options?.edgeType, linkedFrameworks: options?.linkedFrameworks, remoteItemLinks: options?.remoteItemLinks, ...(meta.publicAccess === true ? { publicAccess: true } : {}), }) - : undefined, + : meta.extensions, } return document @@ -197,7 +199,11 @@ function itemToCfItem( return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : undefined } - const existingExtensions = (md.extensions as CaseExtensions | undefined) ?? undefined + // Keep every non-OpenCASE extension verbatim. An item's ext:opencase only holds + // layout and colorBand, both regenerated below from current editor state, so the + // loaded copy is dropped rather than letting stale values (e.g. a cleared colorBand) survive. + const { [OPENCASE_EXT_KEY]: _staleOpencaseExt, ...customExtensions } = (md.extensions as CaseExtensions | undefined) ?? {} + const existingExtensions = Object.keys(customExtensions).length > 0 ? customExtensions : undefined const cfItem: CFItem & { sourcedId: string } = { identifier: itemId, diff --git a/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts b/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts index 4363843..47459b4 100644 --- a/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts +++ b/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts @@ -4,9 +4,10 @@ import type { Association, Framework, Item } from '@/domain/framework/model/type * Canvas-only concerns (node position, per-node color band, edge handle * anchors, edge render style) show up in TWO different metadata shapes * depending on where the Framework snapshot came from: - * - Freshly loaded from CASE data (`caseToDomainFramework.ts`'s - * `...it.extensions`/`...a.extensions` spread): nested one level, under - * a literal `metadata['ext:opencase']` key. + * - Stored as CASE extensions: under `metadata.extensions['ext:opencase']` + * (or a top-level `metadata['ext:opencase']` key from older builds). + * Other keys in `metadata.extensions` are real framework data and ARE + * compared — editing a custom extension is a data change. * - Re-derived from the live canvas during an editing session * (`fromEditorGraph.ts`, called on every save and to capture the * fork-detection baseline): `colorBand` (items) and `originHandle`/ @@ -19,7 +20,11 @@ import type { Association, Framework, Item } from '@/domain/framework/model/type */ function stripLayoutMetadata(metadata: Record | undefined): Record { if (!metadata) return {} - const { 'ext:opencase': _omit, colorBand: _colorBand, originHandle: _originHandle, destinationHandle: _destinationHandle, ...rest } = metadata + const { 'ext:opencase': _omit, colorBand: _colorBand, originHandle: _originHandle, destinationHandle: _destinationHandle, extensions, ...rest } = metadata + if (extensions && typeof extensions === 'object') { + const { 'ext:opencase': _omitExt, ...customExtensions } = extensions as Record + if (Object.keys(customExtensions).length > 0) rest.extensions = customExtensions + } return rest } diff --git a/apps/editor/src/domain/framework/model/types.ts b/apps/editor/src/domain/framework/model/types.ts index 502f419..1535594 100644 --- a/apps/editor/src/domain/framework/model/types.ts +++ b/apps/editor/src/domain/framework/model/types.ts @@ -45,6 +45,11 @@ export type FrameworkMetadata = { * Persisted as `ext:opencase.publicAccess`. Absent means sign-in is required. */ publicAccess?: boolean + /** + * Non-OpenCASE CASE extensions on the CFDocument, preserved verbatim on save. + * Never contains `ext:opencase`, which is regenerated from editor state. + */ + extensions?: Record } export type ItemMetadata = Record diff --git a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx index a0a8978..07a5081 100644 --- a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx @@ -80,6 +80,9 @@ export default memo(function NodePropertiesPanel({ const cfItem: CFItem | undefined = node && isItemNode(node) ? node.data.cfItem : undefined const cfDocument: CFDocument | undefined = node && isFrameworkNode(node) ? node.data.cfDocument : undefined const externalData: ExternalFrameworkNodeData | undefined = node && isExternalFrameworkNode(node) ? node.data : undefined + // Custom fields shown in the Extensions section; ext:opencase is editor-internal state. + const customExtensionEntries = Object.entries((isFramework ? cfDocument?.extensions : cfItem?.extensions) ?? {}) + .filter(([k]) => k !== 'ext:opencase') useEffect(() => { setConceptInput(cfItem?.conceptKeywordsURI?.title ?? '') @@ -692,9 +695,9 @@ export default memo(function NodePropertiesPanel({ {/* ── Extensions ── */} - {Object.entries(isFramework ? cfDocument?.extensions ?? {} : cfItem?.extensions ?? {}).length ? ( + {customExtensionEntries.length ? (
- {Object.entries(isFramework ? cfDocument?.extensions ?? {} : cfItem?.extensions ?? {}).map(([k, v]) => ( + {customExtensionEntries.map(([k, v]) => (
{k}
diff --git a/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts b/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts index a68ce14..ac87c4f 100644 --- a/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts +++ b/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts @@ -73,6 +73,7 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr lastChangeDateTime: doc?.lastChangeDateTime, licenseURI: doc?.licenseURI, publicAccess: doc?.publicAccess === true ? true : undefined, + extensions: doc?.extensions, } const items: Framework['items'] = new Map() diff --git a/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts b/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts index 1cabb68..cc497bd 100644 --- a/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts +++ b/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts @@ -108,6 +108,7 @@ function mapDomainFrameworkToCfDocument(framework: Framework): CFDocument { CFPackageURI: { uri: `urn:case:package:${id}` }, licenseURI: meta.licenseURI, publicAccess: meta.publicAccess === true ? true : undefined, + extensions: meta.extensions, } } @@ -119,7 +120,8 @@ function mapDomainItemToCfItem(framework: Framework, itemId: string): CFItem { const s = (k: string) => (typeof md[k] === 'string' ? (md[k] as string) : undefined) const a = (k: string) => (Array.isArray(md[k]) ? (md[k] as unknown[]).filter((x): x is string => typeof x === 'string') : undefined) - // Extract colorBand from ext:opencase extension (may be in extensions map or flattened into metadata) + // Extract colorBand from ext:opencase extension (in the extensions map; a top-level + // metadata['ext:opencase'] key is accepted for frameworks mapped by older builds) const rawExtensions = md.extensions as Record | undefined const opencaseExt = (rawExtensions?.[OPENCASE_EXT_KEY] ?? md[OPENCASE_EXT_KEY]) as Record | undefined const colorBand = typeof opencaseExt?.colorBand === 'string' ? opencaseExt.colorBand : s('colorBand') @@ -161,9 +163,8 @@ function mapDomainAssociationToCfAssociation(framework: Framework, association: const s = (k: string) => (typeof md[k] === 'string' ? (md[k] as string) : undefined) const n = (k: string) => (typeof md[k] === 'number' ? (md[k] as number) : undefined) - // Reconstruct extensions: caseToDomainFramework spreads extensions directly into metadata - // (e.g. metadata["ext:opencase"] = {...}), while fromEditorGraph stores them as metadata.extensions. - // Handle both patterns. + // Extensions live in metadata.extensions (both caseToDomainFramework and fromEditorGraph). + // A top-level metadata["ext:opencase"] key is accepted for frameworks mapped by older builds. const rawExtensions = md.extensions as Record | undefined const opencaseExt = md[OPENCASE_EXT_KEY] as Record | undefined const extensions = rawExtensions ?? (opencaseExt ? { [OPENCASE_EXT_KEY]: opencaseExt } : undefined) diff --git a/apps/opencase/src/application/case/endpoints/CreateFramework.ts b/apps/opencase/src/application/case/endpoints/CreateFramework.ts index 2a78526..150cad1 100644 --- a/apps/opencase/src/application/case/endpoints/CreateFramework.ts +++ b/apps/opencase/src/application/case/endpoints/CreateFramework.ts @@ -195,9 +195,19 @@ export class CreateFramework { } } + const storedPkg = resolvedStorageKey ? await this.pkgRepo.load(tenantId, caseVersion, resolvedStorageKey) : null + + // The editor can't author rubrics or CFPackage-level extensions and omits + // them on save. Omitted (undefined) means "keep what's stored"; an explicit + // value — including an empty CFRubrics array — replaces it. + if (payload.CFRubrics === undefined && storedPkg) { + cfRubricsPayload = storedPkg.rubrics.map(r => r.toJSON()) + } + const packageExtensions = payload.extensions !== undefined ? payload.extensions : storedPkg?.extensions + if (resolvedStorageKey) { const existingMeta = this.store.getDocumentMetadata(tenantId, caseVersion, resolvedStorageKey) - const existingPkg = existingMeta ? await this.pkgRepo.load(tenantId, caseVersion, resolvedStorageKey) : null + const existingPkg = existingMeta ? storedPkg : null if (getOpenCaseIsModifiedFromSource(existingMeta) !== undefined && existingPkg) { preserveUris = { preserveUris: getOpenCaseIsModifiedFromSource(existingMeta) === false } @@ -287,7 +297,7 @@ export class CreateFramework { ) const definitions = payload.CFDefinitions ?? null - const pkg = new CFPackage({ document, items, associations, rubrics, definitions }) + const pkg = new CFPackage({ document, items, associations, rubrics, definitions, extensions: packageExtensions }) if (forked) { // A fork always writes a new version — no idempotency short-circuit. @@ -307,21 +317,23 @@ export class CreateFramework { // Idempotency: if this doc already exists and the resulting stored bundle would be identical, // don't create a new version. // Compare using CFPackage format (CFDocument, CFItems, etc.) to match validation format - const existing = await this.pkgRepo.load(tenantId, caseVersion, resolvedStorageKey) + const existing = storedPkg if (existing) { const existingBundle = { CFDocument: existing.document.toJSON(), CFItems: sortById(existing.items.map(i => i.toJSON())), CFAssociations: sortById(existing.associations.map(a => a.toJSON())), CFRubrics: sortById((existing.rubrics ?? []).map(r => r.toJSON())), - CFDefinitions: existing.definitions ?? null + CFDefinitions: existing.definitions ?? null, + extensions: existing.extensions ?? null } const newBundle = { CFDocument: docJSON, CFItems: sortById(items.map(i => i.toJSON())), CFAssociations: sortById(associations.map(a => a.toJSON())), CFRubrics: sortById(rubrics.map(r => r.toJSON())), - CFDefinitions: pkg.definitions + CFDefinitions: pkg.definitions, + extensions: pkg.extensions ?? null } if (stableStringify(existingBundle) === stableStringify(newBundle)) { diff --git a/apps/opencase/src/application/case/endpoints/DeleteCFAssociation.ts b/apps/opencase/src/application/case/endpoints/DeleteCFAssociation.ts index 505a52e..f1433e2 100644 --- a/apps/opencase/src/application/case/endpoints/DeleteCFAssociation.ts +++ b/apps/opencase/src/application/case/endpoints/DeleteCFAssociation.ts @@ -51,7 +51,8 @@ export class DeleteCFAssociation { items: existingPkg.items, associations, rubrics: existingPkg.rubrics, - definitions: existingPkg.definitions + definitions: existingPkg.definitions, + extensions: existingPkg.extensions }) await this.pkgRepo.saveNewVersion(tenantId, caseVersion, updatedPkg, storageKey) diff --git a/apps/opencase/src/application/case/endpoints/DeleteCFItem.ts b/apps/opencase/src/application/case/endpoints/DeleteCFItem.ts index 5cca5bb..5b6b9a7 100644 --- a/apps/opencase/src/application/case/endpoints/DeleteCFItem.ts +++ b/apps/opencase/src/application/case/endpoints/DeleteCFItem.ts @@ -71,7 +71,8 @@ export class DeleteCFItem { items, associations, rubrics: existingPkg.rubrics, - definitions: existingPkg.definitions + definitions: existingPkg.definitions, + extensions: existingPkg.extensions }) await this.pkgRepo.saveNewVersion(tenantId, caseVersion, updatedPkg, storageKey) diff --git a/apps/opencase/src/application/case/endpoints/ImportFramework.ts b/apps/opencase/src/application/case/endpoints/ImportFramework.ts index 855b08a..783c335 100644 --- a/apps/opencase/src/application/case/endpoints/ImportFramework.ts +++ b/apps/opencase/src/application/case/endpoints/ImportFramework.ts @@ -10,6 +10,7 @@ import { CFAssociation } from '../../../domain/case/entities/CFAssociation' import { CFRubric } from '../../../domain/case/entities/CFRubric' import { CFPackage } from '../../../domain/case/entities/CFPackage' import { logger } from '../../../infrastructure/logging/Logger' +import { OPENCASE_EXT_KEY, stripOpenCaseExtensions } from '../services/openCaseExtensions' import type { ImportDocumentFlags } from '../types/ImportDocumentFlags' export interface ImportFrameworkCommand { @@ -33,9 +34,11 @@ export interface ImportFrameworkResult { } /** - * Merge or create the `ext:opencase` extension on a CFDocument payload, - * 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). + * Create the `ext:opencase` extension on a CFDocument payload, marking it as a + * pristine mirror of the source. The payload must already have had any incoming + * `ext:opencase` discarded (see execute) — OpenCASE's own state is never taken + * from an import source. `endpointUrl` is only known when the framework was + * imported by URL (as opposed to a pasted JSON payload). */ function injectSourceProvenance ( docPayload: any, @@ -43,12 +46,8 @@ function injectSourceProvenance ( documentFlags?: ImportDocumentFlags ): any { const existing = docPayload.extensions ?? {} - const existingOpencase = (existing['ext:opencase'] && typeof existing['ext:opencase'] === 'object') - ? existing['ext:opencase'] - : {} const extPatch: Record = { - ...existingOpencase, // Only record a source URL when one is known — pasted JSON has no // publisher endpoint, so the field is left off entirely rather than // written as undefined. @@ -74,7 +73,7 @@ function injectSourceProvenance ( ...docPayload, extensions: { ...existing, - 'ext:opencase': extPatch + [OPENCASE_EXT_KEY]: extPatch } } } @@ -93,17 +92,21 @@ export class ImportFramework { throw new Error('Either endpointUrl or cfPackage must be provided') } + // Provider extensions are kept verbatim, but any `ext:opencase` in the source is + // discarded on every entity — OpenCASE's own state (publicAccess, readOnly, layout…) + // must never be settable through an imported file. let sourceCFPackage: CFPackageResponse['CFPackage'] if (endpointUrl) { logger.info({ tenantId, caseVersion, endpointUrl }, 'Importing framework from endpoint') const response = await this.apiClient.fetchCFPackage(endpointUrl, accessToken) + const source = stripOpenCaseExtensions(response.CFPackage) sourceCFPackage = { - ...response.CFPackage, - CFDocument: injectSourceProvenance(response.CFPackage.CFDocument, endpointUrl, documentFlags) + ...source, + CFDocument: injectSourceProvenance(source.CFDocument, endpointUrl, documentFlags) } } else { logger.info({ tenantId, caseVersion }, 'Importing framework from provided JSON') - const normalized = normalizeCfPackageData(cfPackage) + const normalized = stripOpenCaseExtensions(normalizeCfPackageData(cfPackage)) const provenanceUri = (sourcePackageUri ?? endpointUrl ?? '').trim() sourceCFPackage = { ...normalized, @@ -153,7 +156,7 @@ export class ImportFramework { ) const definitions = payload.CFDefinitions ?? null - const pkg = new CFPackage({ document, items, associations, rubrics, definitions }) + const pkg = new CFPackage({ document, items, associations, rubrics, definitions, extensions: payload.extensions }) // Every new document gets a storage key that's independent of any // CASE identifier from the moment it's created — never inferred from diff --git a/apps/opencase/src/application/case/endpoints/UpdateCFAssociation.ts b/apps/opencase/src/application/case/endpoints/UpdateCFAssociation.ts index 939129e..d5ab621 100644 --- a/apps/opencase/src/application/case/endpoints/UpdateCFAssociation.ts +++ b/apps/opencase/src/application/case/endpoints/UpdateCFAssociation.ts @@ -67,7 +67,8 @@ export class UpdateCFAssociation { items: existingPkg.items, associations, rubrics: existingPkg.rubrics, - definitions: existingPkg.definitions + definitions: existingPkg.definitions, + extensions: existingPkg.extensions }) await this.pkgRepo.saveNewVersion(tenantId, caseVersion, updatedPkg, storageKey) diff --git a/apps/opencase/src/application/case/endpoints/UpdateCFDocument.ts b/apps/opencase/src/application/case/endpoints/UpdateCFDocument.ts index 29735de..92dc80f 100644 --- a/apps/opencase/src/application/case/endpoints/UpdateCFDocument.ts +++ b/apps/opencase/src/application/case/endpoints/UpdateCFDocument.ts @@ -77,7 +77,8 @@ export class UpdateCFDocument { items, associations, rubrics, - definitions + definitions, + extensions: existingPkg.extensions }) await this.pkgRepo.saveNewVersion(tenantId, caseVersion, updatedPkg, storageKey) diff --git a/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.preserveStored.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.preserveStored.test.ts new file mode 100644 index 0000000..b57d232 --- /dev/null +++ b/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.preserveStored.test.ts @@ -0,0 +1,135 @@ +import { CreateFramework } from '../CreateFramework' +import { CFPackageRepository } from '../../ports/CFPackageRepository' +import { FileFrameworkStore, type DocumentMetadata } from '../../../../infrastructure/persistence/file/FileFrameworkStore' +import { CFDocument } from '../../../../domain/case/entities/CFDocument' +import { CFItem } from '../../../../domain/case/entities/CFItem' +import { CFRubric } from '../../../../domain/case/entities/CFRubric' +import { CFPackage } from '../../../../domain/case/entities/CFPackage' + +// The editor can't author rubrics or CFPackage-level extensions and never sends them +// on save. Omitting them must keep what's stored rather than wiping it. +describe('CreateFramework — keeps stored rubrics and package extensions when omitted', () => { + let mockRepository: jest.Mocked + let mockStore: jest.Mocked + let createFramework: CreateFramework + + const tenantId = 'test-tenant' + const caseVersion = '1.1' + const docId = 'doc-123' + const packageExtensions = { syncedFrom: 'smartschool', syncRunId: 'run-42' } + + const buildStoredPkg = (opts: { pristineMirror?: boolean } = {}) => { + const preserveUris = { preserveUris: opts.pristineMirror === true } + const document = CFDocument.fromRaw(tenantId, caseVersion, { + sourcedId: docId, + uri: `/ims/case/v1p1/CFDocuments/${docId}`, + title: 'Framework', + creator: 'Provider', + lastChangeDateTime: '2024-01-01T00:00:00Z', + ...(opts.pristineMirror + ? { extensions: { 'ext:opencase': { isModifiedFromSource: false, importedAt: '2024-01-01T00:00:00Z' } } } + : {}) + }, preserveUris) + const item = CFItem.fromRaw(tenantId, caseVersion, { + sourcedId: 'item-1', + uri: '/ims/case/v1p1/CFItems/item-1', + fullStatement: 'Original statement', + lastChangeDateTime: '2024-01-01T00:00:00Z' + }, docId, document.toJSON().uri, preserveUris) + const rubric = CFRubric.fromRaw(tenantId, caseVersion, { + identifier: 'rubric-1', + uri: '/ims/case/v1p1/CFRubrics/rubric-1', + title: 'Rubric', + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { rubricSourceId: 'rs-1' } + }, preserveUris) + return new CFPackage({ document, items: [item], associations: [], rubrics: [rubric], definitions: null, extensions: packageExtensions }) + } + + // Shaped like an editor save: no CFRubrics and no package-level extensions. + const editorPayload = (fullStatement = 'Original statement') => ({ + CFDocument: { + identifier: docId, + uri: `/ims/case/v1p1/CFDocuments/${docId}`, + title: 'Framework', + creator: 'Provider', + lastChangeDateTime: '2024-01-01T00:00:00Z' + }, + CFItems: [{ + identifier: 'item-1', + uri: '/ims/case/v1p1/CFItems/item-1', + fullStatement, + lastChangeDateTime: '2024-01-01T00:00:00Z' + }], + CFAssociations: [] + }) + + const existingDocument = (opts: { pristineMirror?: boolean } = {}) => { + const meta: DocumentMetadata = { + sourcedId: docId, + title: 'Framework', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), + currentFile: `frameworks/${docId}/${docId}_v0001.json`, + ...(opts.pristineMirror ? { openCaseExtensions: { isModifiedFromSource: false } } : {}) + } + mockStore.resolveStorageKey.mockImplementation((_t, v) => (v === caseVersion ? docId : null)) + mockStore.getDocumentMetadata.mockReturnValue(meta) + mockRepository.load.mockResolvedValue(buildStoredPkg(opts)) + } + + const savedPkg = () => mockRepository.saveNewVersion.mock.calls[0][2] as CFPackage + + beforeEach(() => { + mockRepository = { + load: jest.fn().mockResolvedValue(null), + saveNewVersion: jest.fn().mockResolvedValue(undefined) + } as any + mockStore = { + resolveStorageKey: jest.fn().mockReturnValue(null), + getDocumentMetadata: jest.fn().mockReturnValue(null) + } as any + createFramework = new CreateFramework(mockRepository, undefined, mockStore) + }) + + it('keeps the stored rubrics and package extensions when a save omits them', async () => { + existingDocument() + + const result = await createFramework.execute({ tenantId, caseVersion, payload: editorPayload('Edited statement') }) + + expect(result.status).toBe('published') + expect(savedPkg().rubrics.map(r => r.toJSON())).toEqual([ + expect.objectContaining({ identifier: 'rubric-1', extensions: { rubricSourceId: 'rs-1' } }) + ]) + expect(savedPkg().extensions).toEqual(packageExtensions) + }) + + it('reports unchanged when only omitted rubrics/extensions would differ', async () => { + existingDocument() + + const result = await createFramework.execute({ tenantId, caseVersion, payload: editorPayload() }) + + expect(result.status).toBe('unchanged') + expect(mockRepository.saveNewVersion).not.toHaveBeenCalled() + }) + + it('replaces stored rubrics and package extensions when the payload provides them explicitly', async () => { + existingDocument() + + await createFramework.execute({ + tenantId, + caseVersion, + payload: { ...editorPayload(), CFRubrics: [], extensions: { replaced: true } } + }) + + expect(savedPkg().rubrics).toEqual([]) + expect(savedPkg().extensions).toEqual({ replaced: true }) + }) + + it('does not fork a pristine mirror just because the save omitted its rubrics', async () => { + existingDocument({ pristineMirror: true }) + + const result = await createFramework.execute({ tenantId, caseVersion, payload: editorPayload() }) + + expect((result as any).forked).toBeFalsy() + }) +}) diff --git a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts index 864d1c1..4b2f4e6 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts @@ -238,5 +238,58 @@ describe('ImportFramework', () => { importFramework.execute({ tenantId, caseVersion, cfPackage: { CFItems: [] } }) ).rejects.toThrow('Invalid CFPackage data: missing CFDocument') }) + + describe('extensions', () => { + const providerPackage = () => ({ + CFDocument: { + ...cfDocument, + extensions: { 'ext:opencase': { publicAccess: true, readOnly: true }, sourceSystem: 'smartschool' } + }, + CFItems: [{ + identifier: 'item-1', + uri: '/ims/case/v1p1/CFItems/item-1', + fullStatement: 'Item 1', + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { 'ext:opencase': { colorBand: 'red' }, smartschoolId: 'ss-1' } + }], + CFAssociations: [{ + identifier: 'assoc-1', + uri: '/ims/case/v1p1/CFAssociations/assoc-1', + associationType: 'isChildOf', + originNodeURI: { identifier: 'item-1', uri: '/ims/case/v1p1/CFItems/item-1', title: 'Item 1' }, + destinationNodeURI: { identifier: 'doc-123', uri: '/ims/case/v1p1/CFDocuments/doc-123', title: 'Test Document' }, + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { 'ext:opencase': { originHandle: 'top' } } + }], + CFRubrics: [], + extensions: { syncRunId: 'run-42', 'ext:opencase': { anything: true } } + }) + + const expectProviderExtensionsKeptAndOpenCaseDiscarded = () => { + const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] + const docOpencase = savedPkg.document.toJSON().extensions['ext:opencase'] + + expect(savedPkg.document.toJSON().extensions.sourceSystem).toBe('smartschool') + // Only server-injected provenance survives — nothing from the import file. + expect(docOpencase.isModifiedFromSource).toBe(false) + expect(docOpencase).not.toHaveProperty('publicAccess') + expect(docOpencase).not.toHaveProperty('readOnly') + + expect(savedPkg.items[0].toJSON().extensions).toEqual({ smartschoolId: 'ss-1' }) + expect(savedPkg.associations[0].toJSON().extensions).toBeUndefined() + expect(savedPkg.extensions).toEqual({ syncRunId: 'run-42' }) + } + + it('keeps provider extensions and discards ext:opencase when importing pasted JSON', async () => { + await importFramework.execute({ tenantId, caseVersion, cfPackage: { CFPackage: providerPackage() } }) + expectProviderExtensionsKeptAndOpenCaseDiscarded() + }) + + it('keeps provider extensions and discards ext:opencase when importing from a URL', async () => { + mockApiClient.fetchCFPackage.mockResolvedValue({ CFPackage: providerPackage() } as any) + await importFramework.execute({ tenantId, caseVersion, endpointUrl: 'https://example.org/ims/case/v1p1/CFPackages/doc-123' }) + expectProviderExtensionsKeptAndOpenCaseDiscarded() + }) + }) }) }) diff --git a/apps/opencase/src/application/case/services/openCaseExtensions.ts b/apps/opencase/src/application/case/services/openCaseExtensions.ts new file mode 100644 index 0000000..c586184 --- /dev/null +++ b/apps/opencase/src/application/case/services/openCaseExtensions.ts @@ -0,0 +1,32 @@ +/** The extension key under which OpenCASE stores its own editor/app state. */ +export const OPENCASE_EXT_KEY = 'ext:opencase' + +/** + * Remove the OpenCASE-proprietary `ext:opencase` key from every `extensions` + * object anywhere in a CASE payload (CFPackage, CFDocument list, single entity…). + * All other extensions are framework data and are left as-is; an `extensions` + * object left empty is removed entirely. Extension values are opaque + * third-party data and are never walked into. Returns a new value; the input + * is not mutated. + */ +export function stripOpenCaseExtensions (payload: T): T { + if (!payload || typeof payload !== 'object') return payload + + const walk = (value: any): any => { + if (!value || typeof value !== 'object') return value + if (Array.isArray(value)) return value.map(walk) + + const out: any = {} + for (const [k, v] of Object.entries(value)) { + if (k === 'extensions' && v && typeof v === 'object' && !Array.isArray(v)) { + const { [OPENCASE_EXT_KEY]: _opencase, ...rest } = v as Record + if (Object.keys(rest).length > 0) out[k] = rest + continue + } + out[k] = walk(v) + } + return out + } + + return walk(payload) +} diff --git a/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts b/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts index 5a0f0ac..46da75a 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileCFPackageRepository.ts @@ -44,8 +44,9 @@ export class FileCFPackageRepository implements CFPackageRepository { CFRubric.fromRaw(tenantId, version, r, preserveUris) ) const definitions = bundle.definitions ?? null + const extensions = bundle.extensions - return new CFPackage({ document, items, associations, rubrics, definitions }) + return new CFPackage({ document, items, associations, rubrics, definitions, extensions }) } /** @@ -63,7 +64,7 @@ export class FileCFPackageRepository implements CFPackageRepository { storageKey: string ): Promise { const docId = pkg.document.sourcedId - const bundle: { document: unknown, items: unknown[], associations: unknown[], rubrics: unknown[], definitions?: unknown } = { + const bundle: { document: unknown, items: unknown[], associations: unknown[], rubrics: unknown[], definitions?: unknown, extensions?: Record } = { document: pkg.document.toJSON(), items: pkg.items.map(i => i.toJSON()), associations: pkg.associations.map(a => a.toJSON()), @@ -73,6 +74,10 @@ export class FileCFPackageRepository implements CFPackageRepository { if (pkg.definitions) { bundle.definitions = pkg.definitions } + // CFPackage-level extensions (CASE 1.1, [0..1]) + if (pkg.extensions) { + bundle.extensions = pkg.extensions + } // Guard against GUID reuse across different frameworks/entities before writing. this.store.assertNoEntityIdReuse(tenantId, version, docId, storageKey, bundle) diff --git a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts index 26d684e..eb24107 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts @@ -387,7 +387,7 @@ export class FileFrameworkStore { tenantId: TenantId, version: CaseVersion, storageKey: string - ): Promise<{ document: any, items?: any[], associations?: any[], rubrics?: any[], definitions?: any } | null> { + ): Promise<{ document: any, items?: any[], associations?: any[], rubrics?: any[], definitions?: any, extensions?: Record } | null> { logger.info({ tenantId, version, storageKey }, 'loadDocumentBundle') const meta = this.documents.get(tenantId)?.get(version)?.get(storageKey) if (!meta) return null diff --git a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileCFPackageRepository.test.ts b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileCFPackageRepository.test.ts index 2bab3b3..af7f9b5 100644 --- a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileCFPackageRepository.test.ts +++ b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileCFPackageRepository.test.ts @@ -334,5 +334,38 @@ describe('FileCFPackageRepository', () => { ).rejects.toThrow('File system error'); }); }); + describe('CFPackage-level extensions', () => { + const tenantId = 'test-tenant'; + const version = '1.1'; + const document = CFDocument.fromRaw(tenantId, version, { + sourcedId: 'doc-123', + uri: '/ims/case/v1p1/CFDocuments/doc-123', + title: 'Test Document', + creator: 'Test Creator', + lastChangeDateTime: '2024-01-01T00:00:00Z' + }); + + it('writes package extensions into the bundle', async () => { + mockStore.writeBundleFile.mockResolvedValue({ relativePath: 'frameworks/doc-123/doc-123_v0001.json' }); + const pkg = new CFPackage({ document, items: [], associations: [], extensions: { syncRunId: 'run-42' } }); + + await repository.saveNewVersion(tenantId, version, pkg, 'doc-123'); + + expect(mockStore.writeBundleFile.mock.calls[0][3]).toMatchObject({ extensions: { syncRunId: 'run-42' } }); + }); + + it('reads package extensions back from the bundle', async () => { + mockStore.loadDocumentBundle.mockResolvedValue({ + document: document.toJSON(), + items: [], + associations: [], + extensions: { syncRunId: 'run-42' } + }); + + const pkg = await repository.load(tenantId, version, 'doc-123'); + + expect(pkg?.extensions).toEqual({ syncRunId: 'run-42' }); + }); + }); }); diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFAssociationGroupingsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFAssociationGroupingsController.ts index b04086f..95127ab 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFAssociationGroupingsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFAssociationGroupingsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFAssociationGrouping } from '../../../../../application/case/endpoints/GetCFAssociationGrouping' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -44,7 +44,7 @@ export class CFAssociationGroupingsControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFAssociationsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFAssociationsController.ts index 4565c3d..0132360 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFAssociationsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFAssociationsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFAssociation } from '../../../../../application/case/endpoints/GetCFAssociation' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -57,7 +57,7 @@ export class CFAssociationsControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFConceptsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFConceptsController.ts index e36d200..4a1c0c1 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFConceptsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFConceptsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFConcept } from '../../../../../application/case/endpoints/GetCFConcept' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -44,7 +44,7 @@ export class CFConceptsControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFDocumentsController.ts index 202a1ee..409a7fe 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFDocumentsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFDocument } from '../../../../../application/case/endpoints/GetCFDocument' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -54,7 +54,7 @@ export class CFDocumentsControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemAssociationsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemAssociationsController.ts index e56052a..9bded75 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemAssociationsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemAssociationsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFItemAssociations } from '../../../../../application/case/endpoints/GetCFItemAssociations' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyQueryToArray, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyQueryToArray, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -64,7 +64,7 @@ export class CFItemAssociationsControllerV1p1 { const baseUrl = getBaseUrl(req) let body = absolutizeCaseUris(responseBody, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemTypesController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemTypesController.ts index 769ff5c..0fa090c 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemTypesController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemTypesController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFItemType } from '../../../../../application/case/endpoints/GetCFItemType' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -44,7 +44,7 @@ export class CFItemTypesControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemsController.ts index 51da983..d9241d9 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFItemsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFItem } from '../../../../../application/case/endpoints/GetCFItem' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -58,7 +58,7 @@ export class CFItemsControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFLicensesController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFLicensesController.ts index 4549996..e11f1db 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFLicensesController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFLicensesController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFLicense } from '../../../../../application/case/endpoints/GetCFLicense' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -44,7 +44,7 @@ export class CFLicensesControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFPackagesController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFPackagesController.ts index 2a49522..171be6e 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFPackagesController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFPackagesController.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { GetCFPackage } from '../../../../../application/case/endpoints/GetCFPackage'; import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter'; -import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -54,7 +54,7 @@ export class CFPackagesControllerV1p1 { const baseUrl = getBaseUrl(req) let body = absolutizeCaseUris(result, baseUrl) if (!wantsOpenCaseExtensions(req)) { - body = stripExtensions(body) + body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body); diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFRubricsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFRubricsController.ts index 081dbf9..123d867 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFRubricsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFRubricsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFRubric } from '../../../../../application/case/endpoints/GetCFRubric' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -55,7 +55,7 @@ export class CFRubricsControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFSubjectsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFSubjectsController.ts index 2dbb0fb..adde937 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFSubjectsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFSubjectsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetCFSubject } from '../../../../../application/case/endpoints/GetCFSubject' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, applyFieldSelectionToEntity, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' import { getParam } from '../../../utils/expressParams' import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' @@ -44,7 +44,7 @@ export class CFSubjectsControllerV1p1 { entity = applyFieldSelectionToEntity(entity, parsed.value.fields) } let body = absolutizeCaseUris(entity, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts index 85bde3c..b0cb481 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { GetAllCFDocuments } from '../../../../../application/case/endpoints/GetAllCFDocuments' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' -import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' +import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripOpenCaseExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' export class GetAllCFDocumentsControllerV1p1 { constructor (private readonly getAllCFDocuments: GetAllCFDocuments) {} @@ -36,7 +36,7 @@ export class GetAllCFDocumentsControllerV1p1 { const baseUrl = getBaseUrl(req) let body = absolutizeCaseUris(result, baseUrl) - if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } + if (!wantsOpenCaseExtensions(req)) { body = stripOpenCaseExtensions(body) } if (setEtagAndHandleNotModified(req, res, body)) return return res.status(200).json(body) } catch (error: any) { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts index b823116..d173b00 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts @@ -167,6 +167,32 @@ describe('GetAllCFDocumentsControllerV1p1', () => { expect(body.CFDocuments[0]).not.toHaveProperty('extensions') }) + it('should strip only ext:opencase and keep other extensions when X-CASE-EDITOR header is absent', async () => { + const result = { + CFDocuments: [ + { + identifier: 'doc-123', + uri: '/ims/case/v1p1/CFDocuments/doc-123', + title: 'Document With Custom Extensions', + lastChangeDateTime: '2024-01-01T00:00:00.000Z', + extensions: { + 'ext:opencase': { publicAccess: true }, + smartschoolId: 'ss-42' + } + } + ] + } + + mockGetAllCFDocuments.execute.mockResolvedValue(result) + ;(mockRequest as any).tenantId = 'test-tenant' + ;(mockRequest as any).isAuthenticated = true + + await controller.getAll(mockRequest as Request, mockResponse as Response) + + const body = responseJson.mock.calls[0][0] + expect(body.CFDocuments[0].extensions).toEqual({ smartschoolId: 'ss-42' }) + }) + it('should keep extensions in the response when X-CASE-EDITOR header is present', async () => { const result = { CFDocuments: [ diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/utils/__tests__/httpUtils.test.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/utils/__tests__/httpUtils.test.ts new file mode 100644 index 0000000..98f34a0 --- /dev/null +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/utils/__tests__/httpUtils.test.ts @@ -0,0 +1,65 @@ +import { stripExtensions, stripOpenCaseExtensions } from '../httpUtils' + +describe('stripOpenCaseExtensions', () => { + it('removes only ext:opencase and keeps other extensions on every entity in a package', () => { + const pkg = { + CFDocument: { + identifier: 'doc-1', + extensions: { 'ext:opencase': { layout: {} }, sourceSystem: 'smartschool' } + }, + CFItems: [ + { identifier: 'item-1', extensions: { 'ext:opencase': { colorBand: 'red' }, smartschoolId: 'ss-1' } }, + { identifier: 'item-2', extensions: { smartschoolId: 'ss-2' } } + ], + CFAssociations: [ + { identifier: 'assoc-1', extensions: { 'ext:opencase': { originHandle: 'top' }, minimumGoal: 'MG-7' } } + ] + } + + expect(stripOpenCaseExtensions(pkg)).toEqual({ + CFDocument: { identifier: 'doc-1', extensions: { sourceSystem: 'smartschool' } }, + CFItems: [ + { identifier: 'item-1', extensions: { smartschoolId: 'ss-1' } }, + { identifier: 'item-2', extensions: { smartschoolId: 'ss-2' } } + ], + CFAssociations: [ + { identifier: 'assoc-1', extensions: { minimumGoal: 'MG-7' } } + ] + }) + }) + + it('drops the extensions property when only ext:opencase was present', () => { + const result = stripOpenCaseExtensions({ identifier: 'item-1', extensions: { 'ext:opencase': { colorBand: 'red' } } }) + expect(result).toEqual({ identifier: 'item-1' }) + }) + + it('does not walk into extension values', () => { + const nested = { extensions: { 'ext:opencase': 'third-party data that happens to reuse the key' } } + const result = stripOpenCaseExtensions({ extensions: { vendor: nested } }) + expect(result).toEqual({ extensions: { vendor: nested } }) + }) + + it('does not mutate the input', () => { + const input = { extensions: { 'ext:opencase': { a: 1 }, custom: 2 } } + stripOpenCaseExtensions(input) + expect(input).toEqual({ extensions: { 'ext:opencase': { a: 1 }, custom: 2 } }) + }) + + it('returns primitives and null unchanged', () => { + expect(stripOpenCaseExtensions(null)).toBeNull() + expect(stripOpenCaseExtensions('x')).toBe('x') + }) +}) + +describe('stripExtensions', () => { + it('removes every extensions property (CASE v1.0 has no extensions field)', () => { + const pkg = { + CFDocument: { identifier: 'doc-1', extensions: { sourceSystem: 'smartschool' } }, + CFItems: [{ identifier: 'item-1', extensions: { 'ext:opencase': {}, smartschoolId: 'ss-1' } }] + } + expect(stripExtensions(pkg)).toEqual({ + CFDocument: { identifier: 'doc-1' }, + CFItems: [{ identifier: 'item-1' }] + }) + }) +}) diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/utils/httpUtils.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/utils/httpUtils.ts index 2012766..6c47c30 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/utils/httpUtils.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/utils/httpUtils.ts @@ -4,6 +4,9 @@ import crypto from 'node:crypto' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' import { UrnCaseUriHelper } from '../../../../../domain/case/value-objects/LinkData' import { CaseVersion } from '../../../../../domain/case/value-objects/Identifiers' +import { stripOpenCaseExtensions } from '../../../../../application/case/services/openCaseExtensions' + +export { stripOpenCaseExtensions } export type OrderBy = 'asc' | 'desc' @@ -133,9 +136,9 @@ export function absolutizeCaseUris (payload: T, baseUrl: string, caseVersion: } /** - * Strip all `extensions` properties from a CFPackage response. - * OpenCASE extensions are proprietary and should only be included - * when the client explicitly requests them via the X-CASE-EDITOR header. + * Strip all `extensions` properties from a response. Used by the CASE v1.0 + * endpoints, whose data model has no `extensions` field; v1.1 endpoints use + * {@link stripOpenCaseExtensions} instead. */ export function stripExtensions (payload: T): T { if (!payload || typeof payload !== 'object') return payload