diff --git a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts index 8ed00c7..14ef5b2 100644 --- a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts +++ b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts @@ -13,6 +13,8 @@ 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 { @@ -124,12 +126,16 @@ export class GetAllCFDocuments { if (docMeta.subject) doc.subject = docMeta.subject if (docMeta.version) doc.version = docMeta.version if (docMeta.adoptionStatus) doc.adoptionStatus = docMeta.adoptionStatus - 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 + // 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 diff --git a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts index 8191e76..886b485 100644 --- a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts +++ b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts @@ -10,6 +10,8 @@ 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 { @@ -50,7 +52,9 @@ export class ListFrameworks { subject: doc.subject, version: doc.version, lastChangeDateTime: doc.lastChangeDateTime.toISOString(), - alignmentParticipants: doc.alignmentParticipants, + // 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 } : {}) }) } } 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 74a6fd4..cb1f52c 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts @@ -267,6 +267,46 @@ describe('GetAllCFDocuments', () => { expect(result.CFDocuments).toHaveLength(2) }) + + it('should omit sourcePackageURI and isModifiedFromSource when includeOpenCaseExtensions is not set', async () => { + 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 + } + ] + + 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') + }) + + it('should include sourcePackageURI and isModifiedFromSource when includeOpenCaseExtensions is true', async () => { + 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: false + } + ] + + mockStore.getAllDocuments.mockImplementation((_: any, v: any) => (v === '1.0' ? documents as any : [])) + + const result = await getAllCFDocuments.execute({ tenantId, caseVersion, includeOpenCaseExtensions: true }) + + expect(result.CFDocuments[0].sourcePackageURI).toBe('https://standards.example.org/ims/case/v1p1/CFPackages/abc') + expect(result.CFDocuments[0].isModifiedFromSource).toBe(false) + }) }) }) 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 3517b05..8e1b117 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts @@ -204,6 +204,47 @@ describe('ListFrameworks', () => { expect(result.frameworks).toHaveLength(2) }) + + it('should omit alignmentParticipants when includeOpenCaseExtensions is not set', async () => { + 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: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }] + } + ] + + mockStore.getAllDocuments + .mockReturnValueOnce([]) // For 1.0 + .mockReturnValueOnce(docs) // For 1.1 + + const result = await listFrameworks.execute({ tenantId }) + + expect(result.frameworks[0]).not.toHaveProperty('alignmentParticipants') + }) + + it('should include alignmentParticipants when includeOpenCaseExtensions is true', async () => { + const participants = [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }] + 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 + } + ] + + mockStore.getAllDocuments + .mockReturnValueOnce([]) // For 1.0 + .mockReturnValueOnce(docs) // For 1.1 + + const result = await listFrameworks.execute({ tenantId, includeOpenCaseExtensions: true }) + + expect(result.frameworks[0].alignmentParticipants).toEqual(participants) + }) }) }) 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 758e961..467f49c 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts @@ -32,7 +32,8 @@ 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 result = await this.listFrameworks.execute({ tenantId, caseVersion, includeArchived, frameworkType, participantId }) + const includeOpenCaseExtensions = req.header('X-CASE-EDITOR') !== undefined + const result = await this.listFrameworks.execute({ tenantId, caseVersion, includeArchived, frameworkType, participantId, includeOpenCaseExtensions }) 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 235d2fd..9ea7bb8 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 @@ -40,8 +40,9 @@ describe('CFPackagesManagementController', () => { mockRequest = { params: { tenantId: 'test-tenant' }, query: {}, - body: {} - } + body: {}, + header: jest.fn().mockReturnValue(undefined) + } as any mockResponse = { status: responseStatus, @@ -61,11 +62,26 @@ describe('CFPackagesManagementController', () => { expect(mockListFrameworks.execute).toHaveBeenCalledWith({ tenantId: 'test-tenant', caseVersion: '1.0', - includeArchived: false + includeArchived: false, + includeOpenCaseExtensions: false }) expect(responseStatus).toHaveBeenCalledWith(200) }) + it('requests OpenCASE extensions 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) + + await (controller.list as any)(mockRequest as Request, mockResponse as Response, next) + + expect(mockListFrameworks.execute).toHaveBeenCalledWith( + expect.objectContaining({ includeOpenCaseExtensions: true }) + ) + }) + it('archives a CFPackage by id (soft delete by default)', async () => { ;(mockRequest as any).tenantId = 'test-tenant' mockRequest.params = { tenantId: 'test-tenant', id: 'doc-1' } as any 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 6bac8d8..77c8637 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,7 +32,8 @@ export class GetAllCFDocumentsControllerV1p0 { orderBy, filter, fields, - includeArchived + includeArchived, + includeOpenCaseExtensions: wantsOpenCaseExtensions(req) }) // 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 81c9a13..58ee0ef 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,7 +32,8 @@ export class GetAllCFDocumentsControllerV1p1 { orderBy, filter, fields, - includeArchived + includeArchived, + includeOpenCaseExtensions: wantsOpenCaseExtensions(req) }) // 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 c6925c0..689fb42 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,7 +65,8 @@ describe('GetAllCFDocumentsControllerV1p1', () => { orderBy: undefined, filter: undefined, fields: undefined, - includeArchived: false + includeArchived: false, + includeOpenCaseExtensions: false }) expect(responseStatus).toHaveBeenCalledWith(200) expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris(result as any, 'http://localhost')) @@ -102,11 +103,28 @@ describe('GetAllCFDocumentsControllerV1p1', () => { orderBy: 'asc', filter: 'test', fields: ['title', 'identifier'], - includeArchived: false + includeArchived: false, + includeOpenCaseExtensions: false }) expect(responseStatus).toHaveBeenCalledWith(200) }) + it('should pass includeOpenCaseExtensions=true when X-CASE-EDITOR header is present', async () => { + const result = { CFDocuments: [] } + mockGetAllCFDocuments.execute.mockResolvedValue(result) + ;(mockRequest as any).tenantId = 'test-tenant' + ;(mockRequest as any).isAuthenticated = true + ;(mockRequest.header as jest.Mock).mockImplementation((name: string) => + name === 'X-CASE-EDITOR' ? 'true' : undefined + ) + + await controller.getAll(mockRequest as Request, mockResponse as Response) + + expect(mockGetAllCFDocuments.execute).toHaveBeenCalledWith( + expect.objectContaining({ includeOpenCaseExtensions: true }) + ) + }) + it('should return 400 for invalid limit', async () => { ;(mockRequest as any).tenantId = 'test-tenant' mockRequest.query = { limit: '-1' }