diff --git a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts index 14ef5b2..d1d4056 100644 --- a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts +++ b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts @@ -13,8 +13,6 @@ export interface GetAllCFDocumentsQuery { filter?: string fields?: string[] includeArchived?: boolean - /** When true, include OpenCASE-proprietary fields derived from ext:opencase (e.g. sourcePackageURI, isModifiedFromSource). Set when the request carries the X-CASE-EDITOR header. */ - includeOpenCaseExtensions?: boolean } export class GetAllCFDocuments { @@ -126,22 +124,26 @@ export class GetAllCFDocuments { if (docMeta.subject) doc.subject = docMeta.subject if (docMeta.version) doc.version = docMeta.version if (docMeta.adoptionStatus) doc.adoptionStatus = docMeta.adoptionStatus - // sourcePackageURI/isModifiedFromSource are derived from the ext:opencase extension and - // are OpenCASE-proprietary — only surface them to callers that requested extensions. - if (query.includeOpenCaseExtensions) { - if (docMeta.sourcePackageURI) doc.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) doc.isModifiedFromSource = docMeta.isModifiedFromSource - } if (docMeta.archived) doc.archived = true // CASE v1.1-only fields: only include when not serving via v1p0 if (!isV1p0) { 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 = {} + 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 } + } } // Apply field selection if specified diff --git a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts index 886b485..e61b360 100644 --- a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts +++ b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts @@ -10,8 +10,6 @@ export interface ListFrameworksQuery { frameworkType?: string /** When set, only alignment frameworks listing this docId as a participant are returned */ participantId?: string - /** When true, include OpenCASE-proprietary fields derived from ext:opencase (e.g. alignmentParticipants). Set when the request carries the X-CASE-EDITOR header. */ - includeOpenCaseExtensions?: boolean } export class ListFrameworks { @@ -30,7 +28,7 @@ export class ListFrameworks { subject?: string version?: string lastChangeDateTime: string - alignmentParticipants?: Array<{ identifier?: string; uri: string }> + extensions?: { 'ext:opencase': { alignmentParticipants: Array<{ identifier?: string; uri: string }> } } }> = [] for (const version of versions) { @@ -52,9 +50,10 @@ export class ListFrameworks { subject: doc.subject, version: doc.version, lastChangeDateTime: doc.lastChangeDateTime.toISOString(), - // alignmentParticipants is derived from the ext:opencase extension and is - // OpenCASE-proprietary — only surface it to callers that requested extensions. - ...(query.includeOpenCaseExtensions && doc.alignmentParticipants ? { alignmentParticipants: doc.alignmentParticipants } : {}) + // 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 } } } : {}) }) } } diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts index cb1f52c..354ff48 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts @@ -268,7 +268,7 @@ describe('GetAllCFDocuments', () => { expect(result.CFDocuments).toHaveLength(2) }) - it('should omit sourcePackageURI and isModifiedFromSource when includeOpenCaseExtensions is not set', async () => { + it('should nest sourcePackageURI and isModifiedFromSource under extensions.ext:opencase for CASE 1.1', async () => { const documents = [ { sourcedId: 'doc-1', @@ -286,9 +286,15 @@ describe('GetAllCFDocuments', () => { 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 + } + }) }) - it('should include sourcePackageURI and isModifiedFromSource when includeOpenCaseExtensions is true', async () => { + it('should omit the extensions block entirely for CASE 1.0', async () => { const documents = [ { sourcedId: 'doc-1', @@ -296,16 +302,15 @@ describe('GetAllCFDocuments', () => { lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'file1.json', sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc', - isModifiedFromSource: false + isModifiedFromSource: true } ] mockStore.getAllDocuments.mockImplementation((_: any, v: any) => (v === '1.0' ? documents as any : [])) - const result = await getAllCFDocuments.execute({ tenantId, caseVersion, includeOpenCaseExtensions: true }) + const result = await getAllCFDocuments.execute({ tenantId, caseVersion: '1.0' }) - expect(result.CFDocuments[0].sourcePackageURI).toBe('https://standards.example.org/ims/case/v1p1/CFPackages/abc') - expect(result.CFDocuments[0].isModifiedFromSource).toBe(false) + expect(result.CFDocuments[0]).not.toHaveProperty('extensions') }) }) }) diff --git a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts index 8e1b117..cbb1194 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts @@ -205,7 +205,7 @@ describe('ListFrameworks', () => { expect(result.frameworks).toHaveLength(2) }) - it('should omit alignmentParticipants when includeOpenCaseExtensions is not set', async () => { + it('should not surface alignmentParticipants as a flat top-level field', async () => { const docs: DocumentMetadata[] = [ { sourcedId: 'doc-1', @@ -225,7 +225,7 @@ describe('ListFrameworks', () => { expect(result.frameworks[0]).not.toHaveProperty('alignmentParticipants') }) - it('should include alignmentParticipants when includeOpenCaseExtensions is true', async () => { + it('should nest alignmentParticipants under extensions.ext:opencase when present', async () => { const participants = [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }] const docs: DocumentMetadata[] = [ { @@ -241,9 +241,30 @@ describe('ListFrameworks', () => { .mockReturnValueOnce([]) // For 1.0 .mockReturnValueOnce(docs) // For 1.1 - const result = await listFrameworks.execute({ tenantId, includeOpenCaseExtensions: true }) + const result = await listFrameworks.execute({ tenantId }) + + expect(result.frameworks[0].extensions).toEqual({ + 'ext:opencase': { alignmentParticipants: participants } + }) + }) + + it('should omit extensions entirely when there are no alignmentParticipants', async () => { + const docs: DocumentMetadata[] = [ + { + sourcedId: 'doc-1', + title: 'Plain Framework', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), + currentFile: 'frameworks/doc-1/doc-1_v0001.json' + } + ] + + mockStore.getAllDocuments + .mockReturnValueOnce([]) // For 1.0 + .mockReturnValueOnce(docs) // For 1.1 + + const result = await listFrameworks.execute({ tenantId }) - expect(result.frameworks[0].alignmentParticipants).toEqual(participants) + expect(result.frameworks[0]).not.toHaveProperty('extensions') }) }) }) diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts b/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts index 467f49c..b8b150c 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts @@ -32,8 +32,14 @@ export class CFPackagesManagementController { const frameworkType = typeof req.query.frameworkType === 'string' ? req.query.frameworkType : undefined const participantId = typeof req.query.participantId === 'string' ? req.query.participantId : undefined - const includeOpenCaseExtensions = req.header('X-CASE-EDITOR') !== undefined - const result = await this.listFrameworks.execute({ tenantId, caseVersion, includeArchived, frameworkType, participantId, includeOpenCaseExtensions }) + const result = await this.listFrameworks.execute({ tenantId, caseVersion, includeArchived, frameworkType, participantId }) + + // OpenCASE-proprietary data (e.g. alignmentParticipants) is nested under `extensions` on + // each framework, same as the public CASE API — strip it unless the caller requested it. + if (req.header('X-CASE-EDITOR') === undefined && Array.isArray(result.frameworks)) { + result.frameworks = result.frameworks.map(({ extensions, ...rest }: any) => rest) + } + return res.status(200).json(result) } catch (error: any) { return res.status(400).json({ error: error.message || 'List failed' }) diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts b/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts index 9ea7bb8..f6b0a7c 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts @@ -62,24 +62,41 @@ describe('CFPackagesManagementController', () => { expect(mockListFrameworks.execute).toHaveBeenCalledWith({ tenantId: 'test-tenant', caseVersion: '1.0', - includeArchived: false, - includeOpenCaseExtensions: false + includeArchived: false }) expect(responseStatus).toHaveBeenCalledWith(200) }) - it('requests OpenCASE extensions when X-CASE-EDITOR header is present', async () => { + it('strips extensions from frameworks when X-CASE-EDITOR header is absent', async () => { + ;(mockRequest as any).tenantId = 'test-tenant' + mockListFrameworks.execute.mockResolvedValueOnce({ + frameworks: [{ sourcedId: 'doc-1', extensions: { 'ext:opencase': { alignmentParticipants: [] } } }], + total: 1, + tenantId: 'test-tenant' + } as any) + + await (controller.list as any)(mockRequest as Request, mockResponse as Response, next) + + const body = responseJson.mock.calls[0][0] + expect(body.frameworks[0]).not.toHaveProperty('extensions') + }) + + it('keeps extensions on frameworks when X-CASE-EDITOR header is present', async () => { ;(mockRequest as any).tenantId = 'test-tenant' ;(mockRequest.header as jest.Mock).mockImplementation((name: string) => name === 'X-CASE-EDITOR' ? 'true' : undefined ) - mockListFrameworks.execute.mockResolvedValueOnce({ frameworks: [], total: 0, tenantId: 'test-tenant' } as any) + const extensions = { 'ext:opencase': { alignmentParticipants: [] } } + mockListFrameworks.execute.mockResolvedValueOnce({ + frameworks: [{ sourcedId: 'doc-1', extensions }], + total: 1, + tenantId: 'test-tenant' + } as any) await (controller.list as any)(mockRequest as Request, mockResponse as Response, next) - expect(mockListFrameworks.execute).toHaveBeenCalledWith( - expect.objectContaining({ includeOpenCaseExtensions: true }) - ) + const body = responseJson.mock.calls[0][0] + expect(body.frameworks[0].extensions).toEqual(extensions) }) it('archives a CFPackage by id (soft delete by default)', async () => { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts index 77c8637..6bac8d8 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts @@ -32,8 +32,7 @@ export class GetAllCFDocumentsControllerV1p0 { orderBy, filter, fields, - includeArchived, - includeOpenCaseExtensions: wantsOpenCaseExtensions(req) + includeArchived }) // Access control: unauthenticated requests only see public frameworks 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 58ee0ef..81c9a13 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 @@ -32,8 +32,7 @@ export class GetAllCFDocumentsControllerV1p1 { orderBy, filter, fields, - includeArchived, - includeOpenCaseExtensions: wantsOpenCaseExtensions(req) + includeArchived }) // Access control: unauthenticated requests only see public frameworks 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 689fb42..4dc9d9b 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 @@ -65,8 +65,7 @@ describe('GetAllCFDocumentsControllerV1p1', () => { orderBy: undefined, filter: undefined, fields: undefined, - includeArchived: false, - includeOpenCaseExtensions: false + includeArchived: false }) expect(responseStatus).toHaveBeenCalledWith(200) expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris(result as any, 'http://localhost')) @@ -103,14 +102,47 @@ describe('GetAllCFDocumentsControllerV1p1', () => { orderBy: 'asc', filter: 'test', fields: ['title', 'identifier'], - includeArchived: false, - includeOpenCaseExtensions: false + includeArchived: false }) expect(responseStatus).toHaveBeenCalledWith(200) }) - it('should pass includeOpenCaseExtensions=true when X-CASE-EDITOR header is present', async () => { - const result = { CFDocuments: [] } + it('should strip extensions from the response when X-CASE-EDITOR header is absent', async () => { + const result = { + CFDocuments: [ + { + identifier: 'doc-123', + uri: '/ims/case/v1p1/CFDocuments/doc-123', + title: 'Mirrored Document', + lastChangeDateTime: '2024-01-01T00:00:00.000Z', + extensions: { 'ext:opencase': { sourcePackageURI: 'https://example.org/ims/case/v1p1/CFPackages/abc' } } + } + ] + } + + 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]).not.toHaveProperty('extensions') + }) + + it('should keep extensions in the response when X-CASE-EDITOR header is present', async () => { + const result = { + CFDocuments: [ + { + identifier: 'doc-123', + uri: '/ims/case/v1p1/CFDocuments/doc-123', + title: 'Mirrored Document', + lastChangeDateTime: '2024-01-01T00:00:00.000Z', + extensions: { 'ext:opencase': { sourcePackageURI: 'https://example.org/ims/case/v1p1/CFPackages/abc' } } + } + ] + } + mockGetAllCFDocuments.execute.mockResolvedValue(result) ;(mockRequest as any).tenantId = 'test-tenant' ;(mockRequest as any).isAuthenticated = true @@ -120,9 +152,10 @@ describe('GetAllCFDocumentsControllerV1p1', () => { await controller.getAll(mockRequest as Request, mockResponse as Response) - expect(mockGetAllCFDocuments.execute).toHaveBeenCalledWith( - expect.objectContaining({ includeOpenCaseExtensions: true }) - ) + const body = responseJson.mock.calls[0][0] + expect(body.CFDocuments[0].extensions).toEqual({ + 'ext:opencase': { sourcePackageURI: 'https://example.org/ims/case/v1p1/CFPackages/abc' } + }) }) it('should return 400 for invalid limit', async () => {