From 59aa455c677eae04d5c70f5baf5314a264f5c136 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 16 Jun 2026 14:28:18 -0400 Subject: [PATCH] fix(framework-editor): also delete timeline templates when deleting a framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #3154. Deleting a framework still 409'd in prod ("could not delete framework: it still has references that could not be removed automatically") because TimelineTemplate.frameworkId is a Restrict FK back to the framework and a TimelineTemplate is created per framework — the previous cascade missed it. Delete TimelineTemplates in the transaction, right after instances (so their TimelineInstances/phases have already cascade-deleted, freeing the Restrict FK TimelineInstance.templateId -> TimelineTemplate). Verified via an exhaustive scan that requirements + timeline templates are now the only non-cascade FKs into FrameworkEditorFramework, and versions/instances are handled. Closes FRAME-13 Co-Authored-By: Claude Opus 4.8 --- .../framework/framework.service.spec.ts | 22 ++++++++++++++--- .../framework/framework.service.ts | 24 ++++++++++++------- 2 files changed, 34 insertions(+), 12 deletions(-) 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 },