Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/editor/src/infrastructure/caseApi/CaseApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type OpenCaseListExtensions = {
alignmentParticipants?: Array<{ identifier?: string; uri: string }>
/** When true, the CASE API serves this framework without authentication. */
publicAccess?: boolean
/** Server-level archive flag — independent of CASE adoptionStatus. */
archived?: boolean
}

export type AlignmentFrameworkSummary = {
Expand Down Expand Up @@ -54,8 +56,6 @@ export type CfDocumentSummary = {
adoptionStatus?: string
lastChangeDateTime?: string
caseVersion?: string
/** Server-level archive flag — independent of CASE adoptionStatus */
archived?: boolean
readOnly?: boolean
cgeFrameworkId?: string
/** OpenCASE-proprietary data (e.g. sourcePackageURI/isModifiedFromSource for the mirror/fork badge) — only present when the request sends X-CASE-EDITOR. */
Expand Down
2 changes: 1 addition & 1 deletion apps/editor/src/ui/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export default function HomeScreen({
try {
const docs = await api.listCfDocuments({ caseVersion: 'v1p1' })
// Filter to only server-level archived frameworks
const archived = docs.filter((d) => d.archived === true)
const archived = docs.filter((d) => d.extensions?.['ext:opencase']?.archived === true)
setArchivedFrameworks(archived)
} catch (e: unknown) {
setArchivedFrameworks([])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type CaseVersion, type TenantId } from '../../../domain/case/value-objects/Identifiers'
import { logger } from '../../../infrastructure/logging/Logger'
import { type FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore'
import { type FileFrameworkStore, getOpenCaseArchived } from '../../../infrastructure/persistence/file/FileFrameworkStore'
import { buildCFPackageURI } from '../services/packageUri'

export interface GetAllCFDocumentsQuery {
Expand Down Expand Up @@ -46,7 +46,7 @@ export class GetAllCFDocuments {

// Filter server-level archived documents unless includeArchived is true
if (!query.includeArchived) {
documents = documents.filter(doc => doc.meta.archived !== true)
documents = documents.filter(doc => !getOpenCaseArchived(doc.meta))
}

// Apply filtering (basic implementation - can be enhanced)
Expand Down Expand Up @@ -118,7 +118,6 @@ 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.archived) doc.archived = true

// CASE v1.1-only fields: only include when not serving via v1p0
if (!isV1p0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type CFPackageRepository } from '../ports/CFPackageRepository'
import { type CaseVersion, type SourcedId, type TenantId } from '../../../domain/case/value-objects/Identifiers'
import { logger } from '../../../infrastructure/logging/Logger'
import { type FileFrameworkStore, getOpenCaseAlignmentParticipants } from '../../../infrastructure/persistence/file/FileFrameworkStore'
import { type FileFrameworkStore, getOpenCaseAlignmentParticipants, getOpenCaseArchived } from '../../../infrastructure/persistence/file/FileFrameworkStore'

export interface GetCFItemAssociationsQuery {
tenantId: TenantId
Expand Down Expand Up @@ -49,7 +49,7 @@ export class GetCFItemAssociations {
const alignmentDocs = this.store.getAllDocuments(query.tenantId, storageVersion).filter(
meta =>
meta.frameworkType === 'Alignment' &&
!meta.archived &&
!getOpenCaseArchived(meta) &&
getOpenCaseAlignmentParticipants(meta)?.some(p => p.identifier === pkg.document.sourcedId)
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type CaseVersion, type TenantId } from '../../../domain/case/value-objects/Identifiers'
import { type FileFrameworkStore, getOpenCaseAlignmentParticipants } from '../../../infrastructure/persistence/file/FileFrameworkStore'
import { type FileFrameworkStore, getOpenCaseAlignmentParticipants, getOpenCaseArchived } from '../../../infrastructure/persistence/file/FileFrameworkStore'
import { logger } from '../../../infrastructure/logging/Logger'

export interface ListFrameworksQuery {
Expand Down Expand Up @@ -39,7 +39,7 @@ export class ListFrameworks {
for (const version of versions) {
const documents = this.store.getAllDocuments(query.tenantId, version)
for (const doc of documents) {
if (!query.includeArchived && doc.archived === true) continue
if (!query.includeArchived && getOpenCaseArchived(doc)) continue
if (query.frameworkType && doc.frameworkType !== query.frameworkType) continue
if (query.participantId) {
const participates = getOpenCaseAlignmentParticipants(doc)?.some(p => p.identifier === query.participantId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ describe('GetAllCFDocuments', () => {
lastChangeDateTime: new Date('2024-01-02T00:00:00Z'),
currentFile: 'file2.json',
adoptionStatus: 'Implemented',
archived: true
openCaseExtensions: { archived: true }
},
{
sourcedId: 'doc-3',
Expand Down Expand Up @@ -253,7 +253,7 @@ describe('GetAllCFDocuments', () => {
lastChangeDateTime: new Date('2024-01-02T00:00:00Z'),
currentFile: 'file2.json',
adoptionStatus: 'Implemented',
archived: true
openCaseExtensions: { archived: true }
}
]

Expand All @@ -268,6 +268,28 @@ describe('GetAllCFDocuments', () => {
expect(result.CFDocuments).toHaveLength(2)
})

it('should serve archived only inside ext:opencase, never as a top-level CFDocument field', async () => {
const documents = [
{
sourcedId: 'doc-1',
title: 'Archived Document',
lastChangeDateTime: new Date('2024-01-02T00:00:00Z'),
currentFile: 'file1.json',
openCaseExtensions: { archived: true }
}
]

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

const v1p1 = await getAllCFDocuments.execute({ tenantId, caseVersion: '1.1', includeArchived: true })
expect(v1p1.CFDocuments[0]).not.toHaveProperty('archived')
expect(v1p1.CFDocuments[0].extensions['ext:opencase'].archived).toBe(true)

const v1p0 = await getAllCFDocuments.execute({ tenantId, caseVersion: '1.0', includeArchived: true })
expect(v1p0.CFDocuments[0]).not.toHaveProperty('archived')
expect(v1p0.CFDocuments[0]).not.toHaveProperty('extensions')
})

it('should nest the complete, untransformed ext:opencase object under extensions for CASE 1.1', async () => {
// Includes fields (importedAt, layout, edgeType) that are NOT modeled as their own
// DocumentMetadata properties — they must still be served verbatim, unmodified.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,6 @@ describe('GetCFItemAssociations', () => {
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
currentFile: 'frameworks/align-doc-1/align-doc-1_v0001.json',
frameworkType: 'Alignment',
archived: false,
openCaseExtensions: {
alignmentParticipants: [
{ identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` },
Expand Down Expand Up @@ -251,8 +250,8 @@ describe('GetCFItemAssociations', () => {
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
currentFile: 'frameworks/align-doc-archived/align-doc-archived_v0001.json',
frameworkType: 'Alignment',
archived: true,
openCaseExtensions: {
archived: true,
alignmentParticipants: [{ identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ describe('ListFrameworks', () => {
lastChangeDateTime: new Date('2024-01-02T00:00:00Z'),
currentFile: 'frameworks/doc-2/doc-2_v0001.json',
adoptionStatus: 'Implemented',
archived: true
openCaseExtensions: { archived: true }
},
{
sourcedId: 'doc-3',
Expand Down Expand Up @@ -189,7 +189,7 @@ describe('ListFrameworks', () => {
lastChangeDateTime: new Date('2024-01-02T00:00:00Z'),
currentFile: 'frameworks/doc-2/doc-2_v0001.json',
adoptionStatus: 'Implemented',
archived: true
openCaseExtensions: { archived: true }
}
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ export interface DocumentMetadata {
currentFile: string // relative to tenant/version root
adoptionStatus?: string // CASE domain field — NOT used for server-level archive filtering
licenseIdentifier?: string // UUID of the assigned CFLicense
/** Server-level archive flag — independent of CASE adoptionStatus */
archived?: boolean
/** When true, framework is a cached remote reference and cannot be mutated. */
readOnly?: boolean
/** CASE Global coalition registry ID (read-only caches). */
Expand Down Expand Up @@ -67,6 +65,11 @@ export function getOpenCasePublicAccess (meta: DocumentMetadata | undefined | nu
return meta?.openCaseExtensions?.publicAccess === true
}

/** True when the framework is archived (server-level soft delete, independent of CASE adoptionStatus). */
export function getOpenCaseArchived (meta: DocumentMetadata | undefined | null): boolean {
return meta?.openCaseExtensions?.archived === true
}

export interface DocumentVersionInfo {
file: string
lastChangeDateTime: Date
Expand Down Expand Up @@ -245,12 +248,13 @@ export class FileFrameworkStore {
currentFile: d.currentFile,
adoptionStatus: d.adoptionStatus,
licenseIdentifier: d.licenseIdentifier,
archived: d.archived,
readOnly: d.readOnly === true,
cgeFrameworkId: typeof d.cgeFrameworkId === 'string' ? d.cgeFrameworkId : undefined,
linkedFromDocId: typeof d.linkedFromDocId === 'string' ? d.linkedFromDocId : undefined,
cgeCachedAt: d.cgeCachedAt ? new Date(d.cgeCachedAt as string | number | Date) : undefined,
openCaseExtensions: d.openCaseExtensions,
// `archived` used to be a top-level index field — fold it into openCaseExtensions,
// where it now lives. The old field is dropped the next time the index is written.
openCaseExtensions: d.archived === true ? { ...d.openCaseExtensions, archived: true } : d.openCaseExtensions,
})
}
} catch {
Expand Down Expand Up @@ -748,7 +752,6 @@ export class FileFrameworkStore {
currentFile: meta.currentFile,
adoptionStatus: meta.adoptionStatus,
licenseIdentifier: meta.licenseIdentifier,
archived: meta.archived,
readOnly: meta.readOnly,
cgeFrameworkId: meta.cgeFrameworkId,
linkedFromDocId: meta.linkedFromDocId,
Expand Down Expand Up @@ -1127,15 +1130,16 @@ export class FileFrameworkStore {
if (!meta) {
throw new Error(`Document ${storageKey} not found in index for tenant ${tenantId} version ${version}`)
}
meta.archived = archived || undefined // omit false to keep index clean
const { archived: _previous, ...rest } = meta.openCaseExtensions ?? {}
meta.openCaseExtensions = archived ? { ...rest, archived: true } : rest // omit false to keep index clean
}

/**
* Check whether a document is archived at the server level.
*/
isDocumentArchived (tenantId: TenantId, version: CaseVersion, storageKey: string): boolean {
const meta = this.getDocumentMetadata(tenantId, version, storageKey)
return meta?.archived === true
return getOpenCaseArchived(meta)
}

removeDefinitionsFromIndexForDocument (tenantId: TenantId, version: CaseVersion, storageKey: string): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,77 @@ describe('FileFrameworkStore — index survives a server restart', () => {

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

it('stores the archived flag in openCaseExtensions alongside the existing ext:opencase keys, across a restart', async () => {
const baseDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'case-store-restart-archived-'))
const storageKey = 'doc-1'
const idxDir = path.join(baseDataDir, 'tenants', tenantId, 'v1p1', 'indexes')

const store1 = new FileFrameworkStore({ baseDataDir })
const extOpencase = { isModifiedFromSource: false, importedAt: '2026-10-19T12:20:29.751Z' }
const bundle = {
document: { sourcedId: 'doc-1', title: 'A framework', lastChangeDateTime: '2024-01-01T00:00:00Z', extensions: { 'ext:opencase': extOpencase } },
items: [],
associations: [],
rubrics: [],
}
const { relativePath } = await store1.writeBundleFile(tenantId, version, storageKey, bundle)
await store1.updateIndexesForBundle(tenantId, version, storageKey, bundle, relativePath)

store1.setDocumentArchived(tenantId, version, storageKey, true)
await store1.writeIndexesToDisk(tenantId, version)

const persisted = JSON.parse(await fs.readFile(path.join(idxDir, 'documents.json'), 'utf8'))
expect(persisted[0]).not.toHaveProperty('archived')
expect(persisted[0].openCaseExtensions).toEqual({ ...extOpencase, archived: true })

const store2 = new FileFrameworkStore({ baseDataDir })
await store2.loadAll()
expect(store2.isDocumentArchived(tenantId, version, storageKey)).toBe(true)

store2.setDocumentArchived(tenantId, version, storageKey, false)
expect(store2.isDocumentArchived(tenantId, version, storageKey)).toBe(false)
expect(store2.getDocumentMetadata(tenantId, version, storageKey)?.openCaseExtensions).toEqual(extOpencase)

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

it('folds a legacy top-level archived flag in documents.json into openCaseExtensions on load', async () => {
const baseDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'case-store-restart-legacy-archived-'))
const storageKey = 'doc-1'
const idxDir = path.join(baseDataDir, 'tenants', tenantId, 'v1p1', 'indexes')
await fs.mkdir(idxDir, { recursive: true })

const extOpencase = { isModifiedFromSource: false }
await fs.writeFile(
path.join(idxDir, 'documents.json'),
JSON.stringify([
{
storageKey,
sourcedId: storageKey,
title: 'A framework',
lastChangeDateTime: '2024-01-01T00:00:00Z',
currentFile: 'frameworks/doc-1/doc-1_v0001.json',
archived: true,
openCaseExtensions: extOpencase,
},
]),
'utf8'
)

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

expect(store.isDocumentArchived(tenantId, version, storageKey)).toBe(true)
const meta = store.getDocumentMetadata(tenantId, version, storageKey)
expect(meta).not.toHaveProperty('archived')
expect(meta?.openCaseExtensions).toEqual({ ...extOpencase, archived: true })

await store.writeIndexesToDisk(tenantId, version)
const persisted = JSON.parse(await fs.readFile(path.join(idxDir, 'documents.json'), 'utf8'))
expect(persisted[0]).not.toHaveProperty('archived')
expect(persisted[0].openCaseExtensions).toEqual({ ...extOpencase, archived: true })

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