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
22 changes: 19 additions & 3 deletions apps/api/src/framework-editor/framework/framework.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ jest.mock('@db', () => {
frameworkInstance: {
deleteMany: jest.fn(),
},
timelineTemplate: {
deleteMany: jest.fn(),
},
frameworkVersion: {
deleteMany: jest.fn(),
},
Expand Down Expand Up @@ -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' },
});
Expand All @@ -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 () => {
Expand Down
24 changes: 15 additions & 9 deletions apps/api/src/framework-editor/framework/framework.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading