diff --git a/apps/api/src/framework-editor/framework/framework.service.spec.ts b/apps/api/src/framework-editor/framework/framework.service.spec.ts index 8f5f17391..cc1d3e5bc 100644 --- a/apps/api/src/framework-editor/framework/framework.service.spec.ts +++ b/apps/api/src/framework-editor/framework/framework.service.spec.ts @@ -14,6 +14,9 @@ jest.mock('@db', () => { frameworkInstance: { deleteMany: jest.fn(), }, + timelineTemplate: { + deleteMany: jest.fn(), + }, frameworkVersion: { deleteMany: jest.fn(), }, @@ -108,13 +111,16 @@ describe('FrameworkEditorFrameworkService.delete (FRAME-13)', () => { }); }); - it('deletes instances, versions, requirements, then the framework — in that order', async () => { + it('deletes instances, timeline templates, versions, requirements, then the framework — in that order', async () => { const result = await service.delete('frk_1'); expect(result).toEqual({ message: 'Framework deleted successfully' }); expect(mockDb.frameworkInstance.deleteMany).toHaveBeenCalledWith({ where: { frameworkId: 'frk_1' }, }); + expect(mockDb.timelineTemplate.deleteMany).toHaveBeenCalledWith({ + where: { frameworkId: 'frk_1' }, + }); expect(mockDb.frameworkVersion.deleteMany).toHaveBeenCalledWith({ where: { frameworkId: 'frk_1' }, }); @@ -125,16 +131,26 @@ describe('FrameworkEditorFrameworkService.delete (FRAME-13)', () => { where: { id: 'frk_1' }, }); - // Order matters: instances free the currentVersion Restrict FK before - // versions are removed, and requirements before the framework itself. + // Order matters: instances must go first (they cascade TimelineInstances, + // freeing the Restrict FK TimelineInstance.templateId -> TimelineTemplate and + // FrameworkInstance.currentVersionId -> FrameworkVersion); then timeline + // templates and versions; requirements before the framework itself. const order = [ (mockDb.frameworkInstance.deleteMany as jest.Mock).mock.invocationCallOrder[0], + (mockDb.timelineTemplate.deleteMany as jest.Mock).mock.invocationCallOrder[0], (mockDb.frameworkVersion.deleteMany as jest.Mock).mock.invocationCallOrder[0], (mockDb.frameworkEditorRequirement.deleteMany as jest.Mock).mock .invocationCallOrder[0], (mockDb.frameworkEditorFramework.delete as jest.Mock).mock.invocationCallOrder[0], ]; expect(order).toEqual([...order].sort((a, b) => a - b)); + // Timeline templates must be removed AFTER instances (their TimelineInstances + // cascade-delete with the instance, freeing the templateId Restrict FK). + expect( + (mockDb.timelineTemplate.deleteMany as jest.Mock).mock.invocationCallOrder[0], + ).toBeGreaterThan( + (mockDb.frameworkInstance.deleteMany as jest.Mock).mock.invocationCallOrder[0], + ); }); it('maps a residual FK conflict (P2003) to a ConflictException', async () => { diff --git a/apps/api/src/framework-editor/framework/framework.service.ts b/apps/api/src/framework-editor/framework/framework.service.ts index 434747d2c..a75413541 100644 --- a/apps/api/src/framework-editor/framework/framework.service.ts +++ b/apps/api/src/framework-editor/framework/framework.service.ts @@ -102,21 +102,27 @@ export class FrameworkEditorFrameworkService { async delete(id: string) { await this.findById(id); - // A framework may have published versions and org-level instances that - // reference it. Delete the dependency graph in order inside one transaction: - // 1. instances — cascades their org controls/maps/links/sync-operations - // AND frees the Restrict FK on - // FrameworkInstance.currentVersionId -> FrameworkVersion - // 2. versions — now unreferenced by instances or sync-operations - // 3. requirements — Restrict FK back to the framework - // 4. the framework — cascades the editor-side control/policy/task/document - // links + ISMS docs + // A framework may have published versions, org-level instances, and timeline + // templates that reference it. Delete the dependency graph in order inside one + // transaction: + // 1. instances — cascades their org controls/maps/links/sync-ops AND + // their TimelineInstances+phases, and frees the Restrict + // FK on FrameworkInstance.currentVersionId -> Version + // 2. timeline templates — TimelineTemplate.frameworkId is a Restrict FK back + // to the framework; its TimelineInstances are already + // gone via step 1, so it (and its phase templates) can + // now be removed + // 3. versions — now unreferenced by instances or sync-operations + // 4. requirements — Restrict FK back to the framework + // 5. the framework — cascades the editor-side control/policy/task/document + // links + ISMS docs // Deleting the framework directly would cascade versions and instances // together, which trips the currentVersionId Restrict (P2003) depending on // cascade order; the explicit ordering avoids that. try { await db.$transaction([ db.frameworkInstance.deleteMany({ where: { frameworkId: id } }), + db.timelineTemplate.deleteMany({ where: { frameworkId: id } }), db.frameworkVersion.deleteMany({ where: { frameworkId: id } }), db.frameworkEditorRequirement.deleteMany({ where: { frameworkId: id },