From 75180757dd436b6f03c2c504d57b2584f19f9b7f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 23 Apr 2026 17:22:26 -0400
Subject: [PATCH 1/3] feat: enabled framework versioning and updating existing
frameworks when new versions come out
[dev] [Marfuen] mariano/cs-273-framework-versioning
---
apps/api/src/app.module.ts | 2 +
.../assistant-chat/assistant-chat-tools.ts | 2 +-
apps/api/src/controls/controls.service.ts | 24 +-
.../dto/publish-version.dto.ts | 13 +
.../framework-manifest-builder.spec.ts | 106 +++
.../framework-manifest-builder.ts | 98 +++
.../framework-versions.controller.spec.ts | 196 +++++
.../framework-versions.controller.ts | 65 ++
.../framework-versions.module.ts | 10 +
.../framework-versions.service.spec.ts | 89 +++
.../framework-versions.service.ts | 189 +++++
.../framework/framework.service.ts | 10 +
.../frameworks/dto/rollback-framework.dto.ts | 7 +
.../src/frameworks/dto/sync-framework.dto.ts | 7 +
.../cross-framework-refs.spec.ts | 29 +
.../cross-framework-refs.ts | 25 +
.../framework-diff.spec.ts | 87 +++
.../framework-versioning/framework-diff.ts | 192 +++++
.../framework-drift.spec.ts | 54 ++
.../framework-versioning/framework-drift.ts | 56 ++
.../framework-rollback.service.spec.ts | 115 +++
.../framework-rollback.service.ts | 235 ++++++
.../framework-sync-apply.spec.ts | 303 ++++++++
.../framework-sync-apply.ts | 354 +++++++++
.../framework-sync.service.spec.ts | 64 ++
.../framework-sync.service.ts | 82 ++
.../framework-update-preview.spec.ts | 56 ++
.../framework-update-preview.ts | 278 +++++++
.../framework-versioning/manifest.types.ts | 50 ++
.../framework-versioning/org-advisory-lock.ts | 13 +
.../undo-payload.types.ts | 91 +++
.../frameworks/frameworks-scores.helper.ts | 4 +-
.../frameworks-source-loader.helper.ts | 296 ++++++++
.../frameworks/frameworks-timeline.helper.ts | 7 +-
.../frameworks/frameworks-upsert.helper.ts | 105 ++-
.../frameworks/frameworks.controller.spec.ts | 223 +++++-
.../src/frameworks/frameworks.controller.ts | 91 ++-
apps/api/src/frameworks/frameworks.module.ts | 4 +-
apps/api/src/frameworks/frameworks.service.ts | 218 +++++-
apps/api/src/policies/policies.controller.ts | 22 +-
apps/api/src/policies/policies.service.ts | 3 +-
apps/api/src/tasks/tasks.service.ts | 20 +-
.../timelines/timelines-backfill.helper.ts | 4 +-
.../src/trust-portal/trust-access.service.ts | 3 +
.../vector-store/lib/sync/sync-policies.ts | 2 +
.../lib/initialize-organization.ts | 127 ++--
.../lib/load-framework-sources.ts | 315 ++++++++
.../[orgId]/components/OnboardingTracker.tsx | 156 ++--
.../components/FrameworkDetailContent.tsx | 241 ++++++
.../components/FrameworkProgress.tsx | 63 ++
.../components/FrameworkVersioningSection.tsx | 51 ++
.../components/RollbackConfirmDialog.tsx | 62 ++
.../components/SyncConfirmDialog.tsx | 130 ++++
.../components/SyncHistorySection.tsx | 179 +++++
.../components/UpdateAvailableBanner.tsx | 52 ++
.../__tests__/UpdateAvailableBanner.test.tsx | 127 ++++
.../frameworks/[frameworkInstanceId]/page.tsx | 53 +-
.../components/ReviewUpdateContent.tsx | 700 ++++++++++++++++++
.../review-update/page.tsx | 42 ++
apps/app/src/hooks/use-framework-instance.ts | 23 +
apps/app/src/hooks/use-framework-rollback.ts | 35 +
.../src/hooks/use-framework-sync-history.ts | 34 +
apps/app/src/hooks/use-framework-sync.ts | 41 +
.../src/hooks/use-framework-update-preview.ts | 34 +
.../src/hooks/use-framework-update-status.ts | 34 +
apps/app/src/types/framework-versioning.ts | 161 ++++
.../frameworks/FrameworksClientPage.tsx | 1 +
.../[frameworkId]/FrameworkTabs.tsx | 1 +
.../frameworks/[frameworkId]/layout.tsx | 20 +-
.../[frameworkId]/versions/VersionsClient.tsx | 44 ++
.../[versionId]/VersionDetailClient.tsx | 80 ++
.../versions/[versionId]/page.tsx | 20 +
.../components/PublishVersionDialog.tsx | 240 ++++++
.../versions/components/VersionDiffView.tsx | 319 ++++++++
.../versions/components/VersionList.tsx | 95 +++
.../versions/hooks/useFrameworkDraftDiff.ts | 113 +++
.../versions/hooks/useFrameworkVersionDiff.ts | 57 ++
.../versions/hooks/useFrameworkVersions.ts | 48 ++
.../[frameworkId]/versions/page.tsx | 20 +
.../app/(pages)/frameworks/page.tsx | 9 +-
packages/db/package.json | 1 +
.../migration.sql | 101 +++
.../migration.sql | 6 +
.../migration.sql | 143 ++++
packages/db/prisma/schema/auth.prisma | 66 +-
packages/db/prisma/schema/control.prisma | 7 +
.../db/prisma/schema/framework-editor.prisma | 1 +
.../schema/framework-sync-operation.prisma | 35 +
.../db/prisma/schema/framework-version.prisma | 23 +
packages/db/prisma/schema/framework.prisma | 7 +-
packages/db/prisma/schema/policy.prisma | 7 +
packages/db/prisma/schema/requirement.prisma | 5 +
packages/db/prisma/schema/task.prisma | 5 +
packages/db/prisma/seed/seed.ts | 9 +
.../db/scripts/preview-framework-backfill.sql | 105 +++
.../backfill-framework-versions.spec.ts | 59 ++
.../scripts/backfill-framework-versions.ts | 180 +++++
packages/docs/openapi.json | 198 ++++-
98 files changed, 8273 insertions(+), 316 deletions(-)
create mode 100644 apps/api/src/framework-editor-versions/dto/publish-version.dto.ts
create mode 100644 apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts
create mode 100644 apps/api/src/framework-editor-versions/framework-manifest-builder.ts
create mode 100644 apps/api/src/framework-editor-versions/framework-versions.controller.spec.ts
create mode 100644 apps/api/src/framework-editor-versions/framework-versions.controller.ts
create mode 100644 apps/api/src/framework-editor-versions/framework-versions.module.ts
create mode 100644 apps/api/src/framework-editor-versions/framework-versions.service.spec.ts
create mode 100644 apps/api/src/framework-editor-versions/framework-versions.service.ts
create mode 100644 apps/api/src/frameworks/dto/rollback-framework.dto.ts
create mode 100644 apps/api/src/frameworks/dto/sync-framework.dto.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/cross-framework-refs.spec.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/cross-framework-refs.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-diff.spec.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-diff.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-drift.spec.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-drift.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-rollback.service.spec.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-sync.service.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/framework-update-preview.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/manifest.types.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/org-advisory-lock.ts
create mode 100644 apps/api/src/frameworks/framework-versioning/undo-payload.types.ts
create mode 100644 apps/api/src/frameworks/frameworks-source-loader.helper.ts
create mode 100644 apps/app/src/actions/organization/lib/load-framework-sources.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkProgress.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkVersioningSection.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/RollbackConfirmDialog.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/SyncConfirmDialog.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/SyncHistorySection.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/UpdateAvailableBanner.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/__tests__/UpdateAvailableBanner.test.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/page.tsx
create mode 100644 apps/app/src/hooks/use-framework-instance.ts
create mode 100644 apps/app/src/hooks/use-framework-rollback.ts
create mode 100644 apps/app/src/hooks/use-framework-sync-history.ts
create mode 100644 apps/app/src/hooks/use-framework-sync.ts
create mode 100644 apps/app/src/hooks/use-framework-update-preview.ts
create mode 100644 apps/app/src/hooks/use-framework-update-status.ts
create mode 100644 apps/app/src/types/framework-versioning.ts
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/VersionsClient.tsx
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/[versionId]/VersionDetailClient.tsx
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/[versionId]/page.tsx
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/PublishVersionDialog.tsx
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionList.tsx
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkVersionDiff.ts
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkVersions.ts
create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/page.tsx
create mode 100644 packages/db/prisma/migrations/20260422182751_framework_versioning/migration.sql
create mode 100644 packages/db/prisma/migrations/20260422183412_framework_version_publisher_to_user/migration.sql
create mode 100644 packages/db/prisma/migrations/20260423121434_backfill_framework_versions/migration.sql
create mode 100644 packages/db/prisma/schema/framework-sync-operation.prisma
create mode 100644 packages/db/prisma/schema/framework-version.prisma
create mode 100644 packages/db/scripts/preview-framework-backfill.sql
create mode 100644 packages/db/src/scripts/backfill-framework-versions.spec.ts
create mode 100644 packages/db/src/scripts/backfill-framework-versions.ts
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index b6ada5eb93..a6cc6f1e04 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -41,6 +41,7 @@ import { OrgChartModule } from './org-chart/org-chart.module';
import { TrainingModule } from './training/training.module';
import { EvidenceFormsModule } from './evidence-forms/evidence-forms.module';
import { FrameworksModule } from './frameworks/frameworks.module';
+import { FrameworkVersionsModule } from './framework-editor-versions/framework-versions.module';
import { AuditModule } from './audit/audit.module';
import { ControlsModule } from './controls/controls.module';
import { RolesModule } from './roles/roles.module';
@@ -104,6 +105,7 @@ import { TimelinesModule } from './timelines/timelines.module';
OrgChartModule,
EvidenceFormsModule,
FrameworksModule,
+ FrameworkVersionsModule,
RolesModule,
AuditModule,
ControlsModule,
diff --git a/apps/api/src/assistant-chat/assistant-chat-tools.ts b/apps/api/src/assistant-chat/assistant-chat-tools.ts
index 5e501a4c7b..b295851d1c 100644
--- a/apps/api/src/assistant-chat/assistant-chat-tools.ts
+++ b/apps/api/src/assistant-chat/assistant-chat-tools.ts
@@ -60,7 +60,7 @@ export function buildTools(ctx: ToolContext) {
}),
execute: async ({ status }: { status?: 'draft' | 'published' }) => {
const policies = await db.policy.findMany({
- where: { organizationId: ctx.organizationId, status },
+ where: { organizationId: ctx.organizationId, isArchived: false, archivedAt: null, status },
select: { id: true, name: true, description: true, department: true },
});
return policies.length === 0
diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts
index 6f436989d7..ea69421f0f 100644
--- a/apps/api/src/controls/controls.service.ts
+++ b/apps/api/src/controls/controls.service.ts
@@ -8,12 +8,15 @@ import { CreateControlDto } from './dto/create-control.dto';
const controlInclude = {
policies: {
+ where: { archivedAt: null },
select: { status: true, id: true, name: true },
},
tasks: {
+ where: { archivedAt: null },
select: { id: true, title: true, status: true },
},
requirementsMapped: {
+ where: { archivedAt: null },
include: {
frameworkInstance: {
include: { framework: true, customFramework: true },
@@ -42,6 +45,7 @@ export class ControlsService {
) {
const where: Prisma.ControlWhereInput = {
organizationId,
+ archivedAt: null,
...(options.name && {
name: { contains: options.name, mode: Prisma.QueryMode.insensitive },
}),
@@ -72,10 +76,11 @@ export class ControlsService {
const control = await db.control.findUnique({
where: { id: controlId, organizationId },
include: {
- policies: true,
- tasks: true,
+ policies: { where: { archivedAt: null } },
+ tasks: { where: { archivedAt: null } },
controlDocumentTypes: true,
requirementsMapped: {
+ where: { archivedAt: null },
include: {
frameworkInstance: {
include: { framework: true, customFramework: true },
@@ -145,12 +150,12 @@ export class ControlsService {
async getOptions(organizationId: string) {
const [policies, tasks, frameworkInstances] = await Promise.all([
db.policy.findMany({
- where: { organizationId },
+ where: { organizationId, isArchived: false, archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
db.task.findMany({
- where: { organizationId },
+ where: { organizationId, archivedAt: null },
select: { id: true, title: true },
orderBy: { title: 'asc' },
}),
@@ -300,8 +305,11 @@ export class ControlsService {
): Promise {
if (!policyIds || policyIds.length === 0) return [];
const uniqueIds = Array.from(new Set(policyIds));
+ // Exclude both user-archived (isArchived) and sync-archived (archivedAt)
+ // policies. Checking only archivedAt would let user-archived policies
+ // get re-linked to a control and surface back through the UI.
const policies = await db.policy.findMany({
- where: { id: { in: uniqueIds }, organizationId },
+ where: { id: { in: uniqueIds }, organizationId, archivedAt: null, isArchived: false },
select: { id: true },
});
if (policies.length !== uniqueIds.length) {
@@ -317,7 +325,7 @@ export class ControlsService {
if (!taskIds || taskIds.length === 0) return [];
const uniqueIds = Array.from(new Set(taskIds));
const tasks = await db.task.findMany({
- where: { id: { in: uniqueIds }, organizationId },
+ where: { id: { in: uniqueIds }, organizationId, archivedAt: null },
select: { id: true },
});
if (tasks.length !== uniqueIds.length) {
@@ -426,7 +434,7 @@ export class ControlsService {
await this.ensureControl(controlId, organizationId);
const policies = await db.policy.findMany({
- where: { id: { in: policyIds }, organizationId },
+ where: { id: { in: policyIds }, organizationId, archivedAt: null },
select: { id: true },
});
if (policies.length === 0) {
@@ -449,7 +457,7 @@ export class ControlsService {
await this.ensureControl(controlId, organizationId);
const tasks = await db.task.findMany({
- where: { id: { in: taskIds }, organizationId },
+ where: { id: { in: taskIds }, organizationId, archivedAt: null },
select: { id: true },
});
if (tasks.length === 0) {
diff --git a/apps/api/src/framework-editor-versions/dto/publish-version.dto.ts b/apps/api/src/framework-editor-versions/dto/publish-version.dto.ts
new file mode 100644
index 0000000000..c20758c601
--- /dev/null
+++ b/apps/api/src/framework-editor-versions/dto/publish-version.dto.ts
@@ -0,0 +1,13 @@
+import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
+
+export class PublishVersionDto {
+ // Semver major.minor.patch. Accepts things like "1.0.0", "2.3.11".
+ @IsString()
+ @Matches(/^\d+\.\d+\.\d+$/, { message: 'version must be MAJOR.MINOR.PATCH' })
+ version!: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(10_000)
+ releaseNotes?: string;
+}
diff --git a/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts
new file mode 100644
index 0000000000..77fd7816e2
--- /dev/null
+++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts
@@ -0,0 +1,106 @@
+import { buildManifestForFramework } from './framework-manifest-builder';
+
+jest.mock('@db', () => ({
+ db: {
+ frameworkEditorFramework: { findUnique: jest.fn() },
+ },
+}));
+import { db } from '@db';
+
+describe('buildManifestForFramework', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('produces a manifest with framework, requirements, controls, policies, tasks', async () => {
+ // Shape of the mocked result reflects the REAL schema: requirements -> controlTemplates -> policyTemplates/taskTemplates.
+ (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({
+ id: 'frk_soc2',
+ name: 'SOC 2',
+ version: 'TSC 2017 (rev 2022)',
+ description: null,
+ requirements: [
+ {
+ id: 'frk_rq_cc61',
+ identifier: 'CC6.1',
+ name: 'Logical Access',
+ description: 'x',
+ controlTemplates: [
+ {
+ id: 'frk_ct_logical_access',
+ name: 'Logical Access Controls',
+ description: 'desc',
+ requirements: [{ id: 'frk_rq_cc61' }],
+ policyTemplates: [
+ { id: 'frk_pt_acc', name: 'Access Policy', description: null, content: [{}], frequency: 'yearly', department: 'it' },
+ ],
+ taskTemplates: [
+ { id: 'frk_tt_rev', name: 'Review Access', description: 'Review quarterly', frequency: 'quarterly', department: 'it' },
+ ],
+ documentTypes: ['rbac_matrix'],
+ },
+ ],
+ },
+ ],
+ });
+
+ const manifest = await buildManifestForFramework('frk_soc2');
+
+ expect(manifest.framework.id).toBe('frk_soc2');
+ expect(manifest.framework.catalogVersion).toBe('TSC 2017 (rev 2022)');
+ expect(manifest.requirements).toHaveLength(1);
+ expect(manifest.requirements[0].identifier).toBe('CC6.1');
+ expect(manifest.controls).toHaveLength(1);
+ expect(manifest.controls[0].id).toBe('frk_ct_logical_access');
+ expect(manifest.controls[0].requirementIds).toEqual(['frk_rq_cc61']);
+ expect(manifest.controls[0].policyIds).toEqual(['frk_pt_acc']);
+ expect(manifest.controls[0].taskIds).toEqual(['frk_tt_rev']);
+ expect(manifest.policies).toHaveLength(1);
+ expect(manifest.tasks).toHaveLength(1);
+ });
+
+ it('dedupes controls/policies/tasks that appear under multiple requirements', async () => {
+ (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({
+ id: 'frk_iso',
+ name: 'ISO 27001',
+ version: '2022',
+ description: null,
+ requirements: [
+ {
+ id: 'rq_a', identifier: 'A', name: 'A', description: null,
+ controlTemplates: [
+ {
+ id: 'ct_shared', name: 'Shared', description: 'd',
+ requirements: [{ id: 'rq_a' }, { id: 'rq_b' }],
+ policyTemplates: [{ id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null }],
+ taskTemplates: [{ id: 'tt_shared', name: 'T', description: '', frequency: null, department: null }],
+ documentTypes: [],
+ },
+ ],
+ },
+ {
+ id: 'rq_b', identifier: 'B', name: 'B', description: null,
+ controlTemplates: [
+ {
+ id: 'ct_shared', name: 'Shared', description: 'd',
+ requirements: [{ id: 'rq_a' }, { id: 'rq_b' }],
+ policyTemplates: [{ id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null }],
+ taskTemplates: [{ id: 'tt_shared', name: 'T', description: '', frequency: null, department: null }],
+ documentTypes: [],
+ },
+ ],
+ },
+ ],
+ });
+
+ const manifest = await buildManifestForFramework('frk_iso');
+
+ expect(manifest.controls).toHaveLength(1);
+ expect(manifest.controls[0].requirementIds.sort()).toEqual(['rq_a', 'rq_b']);
+ expect(manifest.policies).toHaveLength(1);
+ expect(manifest.tasks).toHaveLength(1);
+ });
+
+ it('throws when framework not found', async () => {
+ (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue(null);
+ await expect(buildManifestForFramework('missing')).rejects.toThrow('Framework not found');
+ });
+});
diff --git a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts
new file mode 100644
index 0000000000..f6c8c13b3b
--- /dev/null
+++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts
@@ -0,0 +1,98 @@
+import { NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import type {
+ FrameworkManifest,
+ ManifestControl,
+ ManifestPolicy,
+ ManifestTask,
+} from '../frameworks/framework-versioning/manifest.types';
+
+export async function buildManifestForFramework(frameworkId: string): Promise {
+ const framework = await db.frameworkEditorFramework.findUnique({
+ where: { id: frameworkId },
+ include: {
+ requirements: {
+ include: {
+ controlTemplates: {
+ include: {
+ requirements: { select: { id: true } },
+ policyTemplates: true,
+ taskTemplates: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!framework) throw new NotFoundException('Framework not found');
+
+ // Collect all unique control templates across all requirements, deduped by id.
+ const controlsMap = new Map();
+ const policiesMap = new Map();
+ const tasksMap = new Map();
+
+ // A control template's `requirements` relation spans every framework that
+ // has mapped it — filter down to requirements belonging to THIS framework
+ // so the manifest doesn't reference IDs that aren't in its own `requirements`.
+ const ownRequirementIds = new Set(framework.requirements.map((r) => r.id));
+
+ for (const req of framework.requirements) {
+ for (const ct of req.controlTemplates) {
+ if (!controlsMap.has(ct.id)) {
+ controlsMap.set(ct.id, {
+ id: ct.id,
+ name: ct.name,
+ description: ct.description,
+ requirementIds: ct.requirements
+ .map((r) => r.id)
+ .filter((id) => ownRequirementIds.has(id)),
+ policyIds: ct.policyTemplates.map((p) => p.id),
+ taskIds: ct.taskTemplates.map((t) => t.id),
+ documentTypes: [...ct.documentTypes],
+ });
+ }
+ for (const pt of ct.policyTemplates) {
+ if (!policiesMap.has(pt.id)) {
+ policiesMap.set(pt.id, {
+ id: pt.id,
+ name: pt.name,
+ description: pt.description,
+ content: pt.content,
+ frequency: pt.frequency,
+ department: pt.department,
+ });
+ }
+ }
+ for (const tt of ct.taskTemplates) {
+ if (!tasksMap.has(tt.id)) {
+ tasksMap.set(tt.id, {
+ id: tt.id,
+ name: tt.name,
+ description: tt.description,
+ frequency: tt.frequency,
+ department: tt.department,
+ });
+ }
+ }
+ }
+ }
+
+ return {
+ framework: {
+ id: framework.id,
+ name: framework.name,
+ catalogVersion: framework.version,
+ description: framework.description,
+ },
+ requirements: framework.requirements.map((r) => ({
+ id: r.id,
+ identifier: r.identifier,
+ name: r.name,
+ description: r.description,
+ })),
+ controls: [...controlsMap.values()],
+ policies: [...policiesMap.values()],
+ tasks: [...tasksMap.values()],
+ };
+}
diff --git a/apps/api/src/framework-editor-versions/framework-versions.controller.spec.ts b/apps/api/src/framework-editor-versions/framework-versions.controller.spec.ts
new file mode 100644
index 0000000000..bb99a33f51
--- /dev/null
+++ b/apps/api/src/framework-editor-versions/framework-versions.controller.spec.ts
@@ -0,0 +1,196 @@
+jest.mock('@db', () => ({
+ db: {},
+}));
+
+jest.mock('../auth/platform-admin.guard', () => ({
+ PlatformAdminGuard: class MockGuard {
+ canActivate() {
+ return true;
+ }
+ },
+}));
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+import { Test, type TestingModule } from '@nestjs/testing';
+import { NotFoundException } from '@nestjs/common';
+import { PlatformAdminGuard } from '../auth/platform-admin.guard';
+import { FrameworkVersionsController, FrameworkDraftDiffController } from './framework-versions.controller';
+import { FrameworkVersionsService } from './framework-versions.service';
+import type { PublishVersionDto } from './dto/publish-version.dto';
+import type { AdminRequest } from '../admin-organizations/platform-admin-auth-context';
+
+describe('FrameworkVersionsController', () => {
+ let controller: FrameworkVersionsController;
+ const service = {
+ publish: jest.fn(),
+ list: jest.fn(),
+ get: jest.fn(),
+ getVersionDiff: jest.fn(),
+ };
+
+ const mockAdminReq: AdminRequest = { userId: 'usr_admin' };
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ const mod: TestingModule = await Test.createTestingModule({
+ controllers: [FrameworkVersionsController],
+ providers: [{ provide: FrameworkVersionsService, useValue: service }],
+ })
+ .overrideGuard(PlatformAdminGuard)
+ .useValue({ canActivate: () => true })
+ .compile();
+
+ controller = mod.get(FrameworkVersionsController);
+ });
+
+ describe('publish (POST /)', () => {
+ it('publishes a version and returns { data: version }', async () => {
+ service.publish.mockResolvedValue({ id: 'fvr_1' });
+
+ const dto: PublishVersionDto = { version: '2.0.0', releaseNotes: 'Initial release' };
+ const result = await controller.publish('frk_1', dto, mockAdminReq);
+
+ expect(service.publish).toHaveBeenCalledWith({
+ frameworkId: 'frk_1',
+ version: '2.0.0',
+ releaseNotes: 'Initial release',
+ publishedById: 'usr_admin',
+ });
+ expect(result).toEqual({ data: { id: 'fvr_1' } });
+ });
+
+ it('forwards the userId from the request', async () => {
+ service.publish.mockResolvedValue({ id: 'fvr_2' });
+
+ const dto: PublishVersionDto = { version: '1.0.0' };
+ await controller.publish('frk_2', dto, { userId: 'usr_specific' });
+
+ expect(service.publish).toHaveBeenCalledWith(
+ expect.objectContaining({ publishedById: 'usr_specific' }),
+ );
+ });
+ });
+
+ describe('list (GET /)', () => {
+ it('returns { data, count } for a framework', async () => {
+ service.list.mockResolvedValue([{ id: 'fvr_1' }, { id: 'fvr_2' }]);
+
+ const result = await controller.list('frk_1');
+
+ expect(service.list).toHaveBeenCalledWith('frk_1');
+ expect(result).toEqual({ data: [{ id: 'fvr_1' }, { id: 'fvr_2' }], count: 2 });
+ });
+
+ it('returns count 0 when no versions exist', async () => {
+ service.list.mockResolvedValue([]);
+
+ const result = await controller.list('frk_empty');
+
+ expect(result.count).toBe(0);
+ expect(result.data).toHaveLength(0);
+ });
+ });
+
+ describe('get (GET /:versionId)', () => {
+ it('returns { data: version } for a specific version', async () => {
+ service.get.mockResolvedValue({ id: 'fvr_1', version: '1.0.0' });
+
+ const result = await controller.get('frk_1', 'fvr_1');
+
+ expect(service.get).toHaveBeenCalledWith('frk_1', 'fvr_1');
+ expect(result).toEqual({ data: { id: 'fvr_1', version: '1.0.0' } });
+ });
+ });
+
+ describe('getDiff (GET /:versionId/diff)', () => {
+ it('returns { data } with version/previousVersion/diff/linkChanges', async () => {
+ const payload = {
+ version: { id: 'fvr_2', version: '1.1.0', publishedAt: new Date(), releaseNotes: null },
+ previousVersion: { id: 'fvr_1', version: '1.0.0' },
+ diff: {
+ controls: { added: [], removed: [], updated: [] },
+ requirements: { added: [], removed: [], updated: [] },
+ policies: { added: [], removed: [], updated: [] },
+ tasks: { added: [], removed: [], updated: [] },
+ requirementMapEdges: { added: [], removed: [] },
+ controlPolicyEdges: { added: [], removed: [] },
+ controlTaskEdges: { added: [], removed: [] },
+ controlDocumentTypeEdges: { added: [], removed: [] },
+ },
+ linkChanges: {
+ controlRequirement: { added: [], removed: [] },
+ controlPolicy: { added: [], removed: [] },
+ controlTask: { added: [], removed: [] },
+ controlDocumentType: { added: [], removed: [] },
+ },
+ };
+ service.getVersionDiff.mockResolvedValue(payload);
+
+ const result = await controller.getDiff('frk_1', 'fvr_2');
+
+ expect(service.getVersionDiff).toHaveBeenCalledWith('frk_1', 'fvr_2');
+ expect(result).toEqual({ data: payload });
+ });
+
+ it('propagates NotFoundException for a missing version', async () => {
+ service.getVersionDiff.mockRejectedValue(new NotFoundException('Version not found'));
+ await expect(controller.getDiff('frk_1', 'fvr_missing')).rejects.toThrow(NotFoundException);
+ });
+ });
+});
+
+describe('FrameworkDraftDiffController', () => {
+ let draftDiffController: FrameworkDraftDiffController;
+ const draftDiffService = {
+ getDraftDiff: jest.fn(),
+ };
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ const mod: TestingModule = await Test.createTestingModule({
+ controllers: [FrameworkDraftDiffController],
+ providers: [{ provide: FrameworkVersionsService, useValue: draftDiffService }],
+ })
+ .overrideGuard(PlatformAdminGuard)
+ .useValue({ canActivate: () => true })
+ .compile();
+
+ draftDiffController = mod.get(FrameworkDraftDiffController);
+ });
+
+ describe('getDraftDiff (GET /draft-diff)', () => {
+ it('returns { data } with latestVersion and diff', async () => {
+ const mockDiff = {
+ latestVersion: { id: 'fvr_1', version: '1.0.0' },
+ diff: {
+ controls: { added: [], removed: [], updated: [] },
+ tasks: { added: [], removed: [], updated: [] },
+ policies: { added: [], removed: [], updated: [] },
+ requirements: { added: [], removed: [], updated: [] },
+ },
+ };
+ draftDiffService.getDraftDiff.mockResolvedValue(mockDiff);
+
+ const result = await draftDiffController.getDraftDiff('frk_1');
+
+ expect(draftDiffService.getDraftDiff).toHaveBeenCalledWith('frk_1');
+ expect(result).toEqual({ data: mockDiff });
+ });
+
+ it('propagates NotFoundException when no published version exists', async () => {
+ draftDiffService.getDraftDiff.mockRejectedValue(
+ new NotFoundException('No published version yet — publish v1.0.0 first'),
+ );
+
+ await expect(draftDiffController.getDraftDiff('frk_empty')).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+ });
+});
diff --git a/apps/api/src/framework-editor-versions/framework-versions.controller.ts b/apps/api/src/framework-editor-versions/framework-versions.controller.ts
new file mode 100644
index 0000000000..ddd4365e41
--- /dev/null
+++ b/apps/api/src/framework-editor-versions/framework-versions.controller.ts
@@ -0,0 +1,65 @@
+import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
+import { ApiExcludeController } from '@nestjs/swagger';
+import { PlatformAdminGuard } from '../auth/platform-admin.guard';
+import { type AdminRequest } from '../admin-organizations/platform-admin-auth-context';
+import { FrameworkVersionsService } from './framework-versions.service';
+import { PublishVersionDto } from './dto/publish-version.dto';
+
+@ApiExcludeController()
+@Controller({ path: 'framework-editor/framework/:frameworkId/versions', version: '1' })
+@UseGuards(PlatformAdminGuard)
+export class FrameworkVersionsController {
+ constructor(private readonly service: FrameworkVersionsService) {}
+
+ @Post()
+ async publish(
+ @Param('frameworkId') frameworkId: string,
+ @Body() body: PublishVersionDto,
+ @Req() req: AdminRequest,
+ ) {
+ const version = await this.service.publish({
+ frameworkId,
+ version: body.version,
+ releaseNotes: body.releaseNotes,
+ publishedById: req.userId,
+ });
+ return { data: version };
+ }
+
+ @Get()
+ async list(@Param('frameworkId') frameworkId: string) {
+ const versions = await this.service.list(frameworkId);
+ return { data: versions, count: versions.length };
+ }
+
+ @Get(':versionId')
+ async get(
+ @Param('frameworkId') frameworkId: string,
+ @Param('versionId') versionId: string,
+ ) {
+ const version = await this.service.get(frameworkId, versionId);
+ return { data: version };
+ }
+
+ @Get(':versionId/diff')
+ async getDiff(
+ @Param('frameworkId') frameworkId: string,
+ @Param('versionId') versionId: string,
+ ) {
+ const data = await this.service.getVersionDiff(frameworkId, versionId);
+ return { data };
+ }
+}
+
+@ApiExcludeController()
+@Controller({ path: 'framework-editor/framework/:frameworkId', version: '1' })
+@UseGuards(PlatformAdminGuard)
+export class FrameworkDraftDiffController {
+ constructor(private readonly service: FrameworkVersionsService) {}
+
+ @Get('draft-diff')
+ async getDraftDiff(@Param('frameworkId') frameworkId: string) {
+ const data = await this.service.getDraftDiff(frameworkId);
+ return { data };
+ }
+}
diff --git a/apps/api/src/framework-editor-versions/framework-versions.module.ts b/apps/api/src/framework-editor-versions/framework-versions.module.ts
new file mode 100644
index 0000000000..a62ba5e2ea
--- /dev/null
+++ b/apps/api/src/framework-editor-versions/framework-versions.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { FrameworkVersionsController, FrameworkDraftDiffController } from './framework-versions.controller';
+import { FrameworkVersionsService } from './framework-versions.service';
+
+@Module({
+ controllers: [FrameworkVersionsController, FrameworkDraftDiffController],
+ providers: [FrameworkVersionsService],
+ exports: [FrameworkVersionsService],
+})
+export class FrameworkVersionsModule {}
diff --git a/apps/api/src/framework-editor-versions/framework-versions.service.spec.ts b/apps/api/src/framework-editor-versions/framework-versions.service.spec.ts
new file mode 100644
index 0000000000..c563537693
--- /dev/null
+++ b/apps/api/src/framework-editor-versions/framework-versions.service.spec.ts
@@ -0,0 +1,89 @@
+import { Test } from '@nestjs/testing';
+import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
+import { FrameworkVersionsService } from './framework-versions.service';
+import { buildManifestForFramework } from './framework-manifest-builder';
+
+jest.mock('./framework-manifest-builder');
+jest.mock('@db', () => ({
+ db: {
+ frameworkEditorFramework: { findUnique: jest.fn() },
+ frameworkVersion: { findUnique: jest.fn(), findMany: jest.fn(), create: jest.fn() },
+ },
+}));
+import { db } from '@db';
+
+describe('FrameworkVersionsService', () => {
+ let service: FrameworkVersionsService;
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ const mod = await Test.createTestingModule({ providers: [FrameworkVersionsService] }).compile();
+ service = mod.get(FrameworkVersionsService);
+ });
+
+ describe('publish', () => {
+ it('creates a new version using the manifest builder', async () => {
+ (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ id: 'frk_1', name: 'SOC 2' });
+ (db.frameworkVersion.findUnique as jest.Mock).mockResolvedValue(null);
+ (buildManifestForFramework as jest.Mock).mockResolvedValue({ framework: { id: 'frk_1' }, requirements: [], controls: [], policies: [], tasks: [] });
+ (db.frameworkVersion.create as jest.Mock).mockResolvedValue({ id: 'fvr_1', frameworkId: 'frk_1', version: '2.0.0' });
+
+ const result = await service.publish({ frameworkId: 'frk_1', version: '2.0.0', releaseNotes: 'fix wording', publishedById: 'mem_1' });
+
+ expect(db.frameworkVersion.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ frameworkId: 'frk_1', version: '2.0.0', publishedById: 'mem_1', releaseNotes: 'fix wording' }),
+ }));
+ expect(result.id).toBe('fvr_1');
+ });
+
+ it('rejects duplicate version', async () => {
+ (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ id: 'frk_1' });
+ (db.frameworkVersion.findUnique as jest.Mock).mockResolvedValue({ id: 'fvr_existing' });
+
+ await expect(service.publish({ frameworkId: 'frk_1', version: '1.0.0', publishedById: 'mem_1' }))
+ .rejects.toBeInstanceOf(ConflictException);
+ });
+
+ it('rejects when framework does not exist', async () => {
+ (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue(null);
+ await expect(service.publish({ frameworkId: 'missing', version: '1.0.0', publishedById: 'mem_1' }))
+ .rejects.toBeInstanceOf(NotFoundException);
+ });
+
+ it('rejects non-semver version', async () => {
+ (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ id: 'frk_1' });
+ await expect(service.publish({ frameworkId: 'frk_1', version: 'latest', publishedById: 'mem_1' }))
+ .rejects.toBeInstanceOf(BadRequestException);
+ });
+ });
+
+ describe('list', () => {
+ it('returns versions ordered by publishedAt desc', async () => {
+ (db.frameworkVersion.findMany as jest.Mock).mockResolvedValue([
+ { id: 'fvr_2', version: '2.0.0' },
+ { id: 'fvr_1', version: '1.0.0' },
+ ]);
+
+ const list = await service.list('frk_1');
+
+ expect(db.frameworkVersion.findMany).toHaveBeenCalledWith(expect.objectContaining({
+ where: { frameworkId: 'frk_1' },
+ orderBy: { publishedAt: 'desc' },
+ }));
+ expect(list).toHaveLength(2);
+ });
+ });
+
+ describe('get', () => {
+ it('returns the version', async () => {
+ (db.frameworkVersion.findUnique as jest.Mock).mockResolvedValue({ id: 'fvr_1', frameworkId: 'frk_1' });
+ const v = await service.get('frk_1', 'fvr_1');
+ expect(v.id).toBe('fvr_1');
+ });
+
+ it('404s when the version does not belong to the framework', async () => {
+ (db.frameworkVersion.findUnique as jest.Mock).mockResolvedValue({ id: 'fvr_1', frameworkId: 'frk_other' });
+ await expect(service.get('frk_1', 'fvr_1')).rejects.toBeInstanceOf(NotFoundException);
+ });
+ });
+});
diff --git a/apps/api/src/framework-editor-versions/framework-versions.service.ts b/apps/api/src/framework-editor-versions/framework-versions.service.ts
new file mode 100644
index 0000000000..179df1edfa
--- /dev/null
+++ b/apps/api/src/framework-editor-versions/framework-versions.service.ts
@@ -0,0 +1,189 @@
+import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import { buildManifestForFramework } from './framework-manifest-builder';
+import { diffManifests } from '../frameworks/framework-versioning/framework-diff';
+import type { FrameworkManifest } from '../frameworks/framework-versioning/manifest.types';
+
+const SEMVER = /^\d+\.\d+\.\d+$/;
+
+export interface PublishParams {
+ frameworkId: string;
+ version: string;
+ releaseNotes?: string;
+ publishedById: string;
+}
+
+@Injectable()
+export class FrameworkVersionsService {
+ async publish({ frameworkId, version, releaseNotes, publishedById }: PublishParams) {
+ if (!SEMVER.test(version)) {
+ throw new BadRequestException('version must be MAJOR.MINOR.PATCH');
+ }
+
+ const framework = await db.frameworkEditorFramework.findUnique({ where: { id: frameworkId } });
+ if (!framework) throw new NotFoundException('Framework not found');
+
+ const existing = await db.frameworkVersion.findUnique({
+ where: { frameworkId_version: { frameworkId, version } },
+ });
+ if (existing) throw new ConflictException(`Version ${version} already published`);
+
+ const manifest = await buildManifestForFramework(frameworkId);
+
+ return db.frameworkVersion.create({
+ data: {
+ frameworkId,
+ version,
+ releaseNotes: releaseNotes ?? null,
+ manifest: manifest as unknown as object,
+ publishedById,
+ },
+ });
+ }
+
+ async list(frameworkId: string) {
+ return db.frameworkVersion.findMany({
+ where: { frameworkId },
+ orderBy: { publishedAt: 'desc' },
+ select: {
+ id: true,
+ version: true,
+ publishedAt: true,
+ publishedById: true,
+ publishedBy: { select: { id: true, name: true, email: true } },
+ releaseNotes: true,
+ },
+ });
+ }
+
+ async get(frameworkId: string, versionId: string) {
+ const v = await db.frameworkVersion.findUnique({ where: { id: versionId } });
+ if (!v || v.frameworkId !== frameworkId) throw new NotFoundException('Version not found');
+ return v;
+ }
+
+ async getDraftDiff(frameworkId: string) {
+ const latest = await db.frameworkVersion.findFirst({
+ where: { frameworkId },
+ orderBy: { publishedAt: 'desc' },
+ });
+ if (!latest) {
+ throw new NotFoundException('No published version yet — publish v1.0.0 first');
+ }
+ const fromManifest = latest.manifest as unknown as FrameworkManifest;
+ const toManifest = await buildManifestForFramework(frameworkId);
+ const diff = diffManifests(fromManifest, toManifest);
+
+ return {
+ latestVersion: { id: latest.id, version: latest.version },
+ diff,
+ linkChanges: resolveLinkChanges(diff, fromManifest, toManifest),
+ };
+ }
+
+ /**
+ * Diff a specific published version against the version that was published
+ * immediately before it. When no prior version exists (i.e., this is the
+ * first published version), diffs against an empty manifest so everything
+ * reads as "added" — giving CX a full view of what the framework contained
+ * at its inception.
+ */
+ async getVersionDiff(frameworkId: string, versionId: string) {
+ const current = await db.frameworkVersion.findUnique({ where: { id: versionId } });
+ if (!current || current.frameworkId !== frameworkId) {
+ throw new NotFoundException('Version not found');
+ }
+
+ const previous = await db.frameworkVersion.findFirst({
+ where: { frameworkId, publishedAt: { lt: current.publishedAt } },
+ orderBy: { publishedAt: 'desc' },
+ });
+
+ const toManifest = current.manifest as unknown as FrameworkManifest;
+ const fromManifest: FrameworkManifest = previous
+ ? (previous.manifest as unknown as FrameworkManifest)
+ : {
+ framework: toManifest.framework,
+ requirements: [],
+ controls: [],
+ policies: [],
+ tasks: [],
+ };
+
+ const diff = diffManifests(fromManifest, toManifest);
+
+ return {
+ version: { id: current.id, version: current.version, publishedAt: current.publishedAt, releaseNotes: current.releaseNotes },
+ previousVersion: previous ? { id: previous.id, version: previous.version } : null,
+ diff,
+ linkChanges: resolveLinkChanges(diff, fromManifest, toManifest),
+ };
+ }
+}
+
+function resolveLinkChanges(
+ diff: ReturnType,
+ fromManifest: FrameworkManifest,
+ toManifest: FrameworkManifest,
+) {
+ const nameFor = (
+ key: K,
+ id: string,
+ fallback: string,
+ ): { name: string; identifier?: string } => {
+ const list = [
+ ...(toManifest[key] as Array<{ id: string; name?: string; identifier?: string }>),
+ ...(fromManifest[key] as Array<{ id: string; name?: string; identifier?: string }>),
+ ];
+ const hit = list.find((x) => x.id === id);
+ return {
+ name: hit?.name ?? fallback,
+ identifier: hit?.identifier,
+ };
+ };
+
+ return {
+ controlRequirement: {
+ added: diff.requirementMapEdges.added.map((e) => ({
+ controlName: nameFor('controls', e.controlTemplateId, 'Unknown control').name,
+ requirementName: nameFor('requirements', e.requirementTemplateId, 'Unknown requirement').name,
+ requirementIdentifier: nameFor('requirements', e.requirementTemplateId, '').identifier ?? '',
+ })),
+ removed: diff.requirementMapEdges.removed.map((e) => ({
+ controlName: nameFor('controls', e.controlTemplateId, 'Unknown control').name,
+ requirementName: nameFor('requirements', e.requirementTemplateId, 'Unknown requirement').name,
+ requirementIdentifier: nameFor('requirements', e.requirementTemplateId, '').identifier ?? '',
+ })),
+ },
+ controlPolicy: {
+ added: diff.controlPolicyEdges.added.map((e) => ({
+ controlName: nameFor('controls', e.controlTemplateId, 'Unknown control').name,
+ policyName: nameFor('policies', e.policyTemplateId, 'Unknown policy').name,
+ })),
+ removed: diff.controlPolicyEdges.removed.map((e) => ({
+ controlName: nameFor('controls', e.controlTemplateId, 'Unknown control').name,
+ policyName: nameFor('policies', e.policyTemplateId, 'Unknown policy').name,
+ })),
+ },
+ controlTask: {
+ added: diff.controlTaskEdges.added.map((e) => ({
+ controlName: nameFor('controls', e.controlTemplateId, 'Unknown control').name,
+ taskName: nameFor('tasks', e.taskTemplateId, 'Unknown task').name,
+ })),
+ removed: diff.controlTaskEdges.removed.map((e) => ({
+ controlName: nameFor('controls', e.controlTemplateId, 'Unknown control').name,
+ taskName: nameFor('tasks', e.taskTemplateId, 'Unknown task').name,
+ })),
+ },
+ controlDocumentType: {
+ added: diff.controlDocumentTypeEdges.added.map((e) => ({
+ controlName: nameFor('controls', e.controlTemplateId, 'Unknown control').name,
+ formType: e.formType,
+ })),
+ removed: diff.controlDocumentTypeEdges.removed.map((e) => ({
+ controlName: nameFor('controls', e.controlTemplateId, 'Unknown control').name,
+ formType: e.formType,
+ })),
+ },
+ };
+}
diff --git a/apps/api/src/framework-editor/framework/framework.service.ts b/apps/api/src/framework-editor/framework/framework.service.ts
index ecab18013f..1b9f5c5327 100644
--- a/apps/api/src/framework-editor/framework/framework.service.ts
+++ b/apps/api/src/framework-editor/framework/framework.service.ts
@@ -22,6 +22,14 @@ export class FrameworkEditorFrameworkService {
requirements: {
select: { _count: { select: { controlTemplates: true } } },
},
+ // Latest published FrameworkVersion per framework, resolved in a single
+ // query rather than N+1 client-side fetches. Falls back to the
+ // framework's catalog version string when no versions exist yet.
+ versions: {
+ orderBy: { publishedAt: 'desc' },
+ take: 1,
+ select: { id: true, version: true, publishedAt: true },
+ },
},
});
@@ -32,8 +40,10 @@ export class FrameworkEditorFrameworkService {
(sum, r) => sum + r._count.controlTemplates,
0,
),
+ latestVersion: fw.versions[0] ?? null,
_count: undefined,
requirements: undefined,
+ versions: undefined,
}));
}
diff --git a/apps/api/src/frameworks/dto/rollback-framework.dto.ts b/apps/api/src/frameworks/dto/rollback-framework.dto.ts
new file mode 100644
index 0000000000..f5ebaa8cd0
--- /dev/null
+++ b/apps/api/src/frameworks/dto/rollback-framework.dto.ts
@@ -0,0 +1,7 @@
+import { IsString, Matches } from 'class-validator';
+
+export class RollbackFrameworkDto {
+ @IsString()
+ @Matches(/^fso_/)
+ syncOperationId!: string;
+}
diff --git a/apps/api/src/frameworks/dto/sync-framework.dto.ts b/apps/api/src/frameworks/dto/sync-framework.dto.ts
new file mode 100644
index 0000000000..c536f910f7
--- /dev/null
+++ b/apps/api/src/frameworks/dto/sync-framework.dto.ts
@@ -0,0 +1,7 @@
+import { IsString, Matches } from 'class-validator';
+
+export class SyncFrameworkDto {
+ @IsString()
+ @Matches(/^fvr_/)
+ targetVersionId!: string;
+}
diff --git a/apps/api/src/frameworks/framework-versioning/cross-framework-refs.spec.ts b/apps/api/src/frameworks/framework-versioning/cross-framework-refs.spec.ts
new file mode 100644
index 0000000000..0a9ed5a829
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/cross-framework-refs.spec.ts
@@ -0,0 +1,29 @@
+import { buildCrossFrameworkRefs } from './cross-framework-refs';
+
+describe('buildCrossFrameworkRefs', () => {
+ it('aggregates template IDs from other instances', () => {
+ const refs = buildCrossFrameworkRefs({
+ otherInstances: [
+ {
+ frameworkInstanceId: 'frm_iso',
+ manifest: {
+ framework: { id: 'frk_iso', name: 'ISO', catalogVersion: '1', description: null },
+ requirements: [],
+ controls: [{ id: 'ct_shared', name: '', description: '', requirementIds: [], policyIds: ['pt_shared'], taskIds: ['tt_shared'] }],
+ policies: [{ id: 'pt_shared', name: '', description: null, content: [], frequency: null, department: null }],
+ tasks: [{ id: 'tt_shared', name: '', description: '', frequency: null, department: null }],
+ },
+ },
+ ],
+ });
+
+ expect(refs.controlTemplateIds.has('ct_shared')).toBe(true);
+ expect(refs.policyTemplateIds.has('pt_shared')).toBe(true);
+ expect(refs.taskTemplateIds.has('tt_shared')).toBe(true);
+ });
+
+ it('returns empty sets when no other instances', () => {
+ const refs = buildCrossFrameworkRefs({ otherInstances: [] });
+ expect(refs.controlTemplateIds.size).toBe(0);
+ });
+});
diff --git a/apps/api/src/frameworks/framework-versioning/cross-framework-refs.ts b/apps/api/src/frameworks/framework-versioning/cross-framework-refs.ts
new file mode 100644
index 0000000000..f0bf6a7d43
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/cross-framework-refs.ts
@@ -0,0 +1,25 @@
+import type { FrameworkManifest } from './manifest.types';
+
+export interface CrossFrameworkRefs {
+ controlTemplateIds: Set;
+ policyTemplateIds: Set;
+ taskTemplateIds: Set;
+}
+
+export interface BuildRefsInput {
+ otherInstances: Array<{ frameworkInstanceId: string; manifest: FrameworkManifest }>;
+}
+
+export function buildCrossFrameworkRefs({ otherInstances }: BuildRefsInput): CrossFrameworkRefs {
+ const controlTemplateIds = new Set();
+ const policyTemplateIds = new Set();
+ const taskTemplateIds = new Set();
+
+ for (const { manifest } of otherInstances) {
+ for (const c of manifest.controls) controlTemplateIds.add(c.id);
+ for (const p of manifest.policies) policyTemplateIds.add(p.id);
+ for (const t of manifest.tasks) taskTemplateIds.add(t.id);
+ }
+
+ return { controlTemplateIds, policyTemplateIds, taskTemplateIds };
+}
diff --git a/apps/api/src/frameworks/framework-versioning/framework-diff.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-diff.spec.ts
new file mode 100644
index 0000000000..053e9fa257
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-diff.spec.ts
@@ -0,0 +1,87 @@
+import { diffManifests } from './framework-diff';
+import type { FrameworkManifest } from './manifest.types';
+
+function emptyManifest(): FrameworkManifest {
+ return {
+ framework: { id: 'f', name: 'n', catalogVersion: '1', description: null },
+ requirements: [],
+ controls: [],
+ policies: [],
+ tasks: [],
+ };
+}
+
+describe('diffManifests', () => {
+ it('returns empty diff for identical manifests', () => {
+ const m = emptyManifest();
+ const diff = diffManifests(m, m);
+ expect(diff.controls.added).toHaveLength(0);
+ expect(diff.controls.removed).toHaveLength(0);
+ expect(diff.controls.updated).toHaveLength(0);
+ expect(diff.requirements.added).toHaveLength(0);
+ expect(diff.policies.added).toHaveLength(0);
+ expect(diff.tasks.added).toHaveLength(0);
+ });
+
+ it('detects added controls', () => {
+ const from = emptyManifest();
+ const to = { ...emptyManifest(), controls: [{ id: 'c1', name: 'C1', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] };
+ const diff = diffManifests(from, to);
+ expect(diff.controls.added).toEqual([to.controls[0]]);
+ });
+
+ it('detects removed requirements', () => {
+ const from = { ...emptyManifest(), requirements: [{ id: 'r1', identifier: 'CC1.1', name: 'x', description: null }] };
+ const to = emptyManifest();
+ const diff = diffManifests(from, to);
+ expect(diff.requirements.removed.map((r) => r.id)).toEqual(['r1']);
+ });
+
+ it('detects updated policies by content change', () => {
+ const from = { ...emptyManifest(), policies: [{ id: 'p1', name: 'P', description: 'old', content: [], frequency: null, department: null }] };
+ const to = { ...emptyManifest(), policies: [{ id: 'p1', name: 'P', description: 'new', content: [], frequency: null, department: null }] };
+ const diff = diffManifests(from, to);
+ expect(diff.policies.updated).toHaveLength(1);
+ expect(diff.policies.updated[0].id).toBe('p1');
+ expect(diff.policies.updated[0].from.description).toBe('old');
+ expect(diff.policies.updated[0].to.description).toBe('new');
+ });
+
+ it('detects updated requirement-map edges when control→requirement links change', () => {
+ const r1 = { id: 'r1', identifier: 'R1', name: 'Req 1', description: null };
+ const r2 = { id: 'r2', identifier: 'R2', name: 'Req 2', description: null };
+ const from = {
+ ...emptyManifest(),
+ requirements: [r1, r2],
+ controls: [{ id: 'c1', name: 'C', description: '', requirementIds: ['r1'], policyIds: [], taskIds: [] }],
+ };
+ const to = {
+ ...emptyManifest(),
+ requirements: [r1, r2],
+ controls: [{ id: 'c1', name: 'C', description: '', requirementIds: ['r2'], policyIds: [], taskIds: [] }],
+ };
+ const diff = diffManifests(from, to);
+ expect(diff.requirementMapEdges.added).toContainEqual({ controlTemplateId: 'c1', requirementTemplateId: 'r2' });
+ expect(diff.requirementMapEdges.removed).toContainEqual({ controlTemplateId: 'c1', requirementTemplateId: 'r1' });
+ });
+
+ it('drops phantom edges that reference entities missing from the manifest', () => {
+ // Older snapshots sometimes stored cross-framework requirement IDs in
+ // control.requirementIds. Those IDs are not in manifest.requirements, so
+ // the diff must ignore them rather than surface them as phantom adds or
+ // removes.
+ const from = {
+ ...emptyManifest(),
+ requirements: [],
+ controls: [{ id: 'c1', name: 'C', description: '', requirementIds: ['cross_framework'], policyIds: [], taskIds: [] }],
+ };
+ const to = {
+ ...emptyManifest(),
+ requirements: [],
+ controls: [{ id: 'c1', name: 'C', description: '', requirementIds: [], policyIds: [], taskIds: [] }],
+ };
+ const diff = diffManifests(from, to);
+ expect(diff.requirementMapEdges.removed).toHaveLength(0);
+ expect(diff.requirementMapEdges.added).toHaveLength(0);
+ });
+});
diff --git a/apps/api/src/frameworks/framework-versioning/framework-diff.ts b/apps/api/src/frameworks/framework-versioning/framework-diff.ts
new file mode 100644
index 0000000000..97b491faa2
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-diff.ts
@@ -0,0 +1,192 @@
+import type {
+ FrameworkManifest,
+ ManifestControl,
+ ManifestPolicy,
+ ManifestRequirement,
+ ManifestTask,
+} from './manifest.types';
+
+export interface EntityDiff {
+ added: T[];
+ removed: T[];
+ updated: Array<{ id: string; from: T; to: T }>;
+}
+
+export interface EdgeDiff {
+ added: E[];
+ removed: E[];
+}
+
+export interface ControlRequirementEdge {
+ controlTemplateId: string;
+ requirementTemplateId: string;
+}
+
+export interface ControlPolicyEdge {
+ controlTemplateId: string;
+ policyTemplateId: string;
+}
+
+export interface ControlTaskEdge {
+ controlTemplateId: string;
+ taskTemplateId: string;
+}
+
+export interface ControlDocumentTypeEdge {
+ controlTemplateId: string;
+ formType: string;
+}
+
+export interface ManifestDiff {
+ controls: EntityDiff;
+ requirements: EntityDiff;
+ policies: EntityDiff;
+ tasks: EntityDiff;
+ requirementMapEdges: EdgeDiff;
+ controlPolicyEdges: EdgeDiff;
+ controlTaskEdges: EdgeDiff;
+ controlDocumentTypeEdges: EdgeDiff;
+}
+
+/**
+ * Drop dangling references in each control's requirement/policy/task link
+ * arrays — IDs that don't correspond to an entity in the manifest's top-level
+ * list. Older snapshots (pre-filter) stored cross-framework IDs there, which
+ * would otherwise appear in the diff as phantom add/remove edges pointing at
+ * 'Unknown' entities. This normalization is local to the diff and does not
+ * mutate the input manifests.
+ */
+function sanitizeManifestEdges(m: FrameworkManifest): FrameworkManifest {
+ const reqIds = new Set(m.requirements.map((r) => r.id));
+ const policyIds = new Set(m.policies.map((p) => p.id));
+ const taskIds = new Set(m.tasks.map((t) => t.id));
+ return {
+ ...m,
+ controls: m.controls.map((c) => ({
+ ...c,
+ requirementIds: c.requirementIds.filter((id) => reqIds.has(id)),
+ policyIds: c.policyIds.filter((id) => policyIds.has(id)),
+ taskIds: c.taskIds.filter((id) => taskIds.has(id)),
+ })),
+ };
+}
+
+export function diffManifests(fromRaw: FrameworkManifest, toRaw: FrameworkManifest): ManifestDiff {
+ const from = sanitizeManifestEdges(fromRaw);
+ const to = sanitizeManifestEdges(toRaw);
+ return {
+ controls: diffEntities(from.controls, to.controls, controlEqual),
+ requirements: diffEntities(from.requirements, to.requirements, requirementEqual),
+ policies: diffEntities(from.policies, to.policies, policyEqual),
+ tasks: diffEntities(from.tasks, to.tasks, taskEqual),
+ requirementMapEdges: diffEdges(
+ edgesFromControls(from.controls, (c) =>
+ c.requirementIds.map((id) => ({ controlTemplateId: c.id, requirementTemplateId: id })),
+ ),
+ edgesFromControls(to.controls, (c) =>
+ c.requirementIds.map((id) => ({ controlTemplateId: c.id, requirementTemplateId: id })),
+ ),
+ (a, b) =>
+ a.controlTemplateId === b.controlTemplateId &&
+ a.requirementTemplateId === b.requirementTemplateId,
+ ),
+ controlPolicyEdges: diffEdges(
+ edgesFromControls(from.controls, (c) =>
+ c.policyIds.map((id) => ({ controlTemplateId: c.id, policyTemplateId: id })),
+ ),
+ edgesFromControls(to.controls, (c) =>
+ c.policyIds.map((id) => ({ controlTemplateId: c.id, policyTemplateId: id })),
+ ),
+ (a, b) =>
+ a.controlTemplateId === b.controlTemplateId && a.policyTemplateId === b.policyTemplateId,
+ ),
+ controlTaskEdges: diffEdges(
+ edgesFromControls(from.controls, (c) =>
+ c.taskIds.map((id) => ({ controlTemplateId: c.id, taskTemplateId: id })),
+ ),
+ edgesFromControls(to.controls, (c) =>
+ c.taskIds.map((id) => ({ controlTemplateId: c.id, taskTemplateId: id })),
+ ),
+ (a, b) =>
+ a.controlTemplateId === b.controlTemplateId && a.taskTemplateId === b.taskTemplateId,
+ ),
+ controlDocumentTypeEdges: diffEdges(
+ edgesFromControls(from.controls, (c) =>
+ (c.documentTypes ?? []).map((formType) => ({ controlTemplateId: c.id, formType })),
+ ),
+ edgesFromControls(to.controls, (c) =>
+ (c.documentTypes ?? []).map((formType) => ({ controlTemplateId: c.id, formType })),
+ ),
+ (a, b) => a.controlTemplateId === b.controlTemplateId && a.formType === b.formType,
+ ),
+ };
+}
+
+function diffEntities(
+ from: T[],
+ to: T[],
+ equal: (a: T, b: T) => boolean,
+): EntityDiff {
+ const fromMap = new Map(from.map((x) => [x.id, x]));
+ const toMap = new Map(to.map((x) => [x.id, x]));
+ const added: T[] = [];
+ const removed: T[] = [];
+ const updated: EntityDiff['updated'] = [];
+
+ for (const [id, item] of toMap) {
+ const prev = fromMap.get(id);
+ if (!prev) added.push(item);
+ else if (!equal(prev, item)) updated.push({ id, from: prev, to: item });
+ }
+ for (const [id, item] of fromMap) {
+ if (!toMap.has(id)) removed.push(item);
+ }
+
+ return { added, removed, updated };
+}
+
+function diffEdges(from: E[], to: E[], equal: (a: E, b: E) => boolean): EdgeDiff {
+ const added = to.filter((x) => !from.some((y) => equal(x, y)));
+ const removed = from.filter((x) => !to.some((y) => equal(x, y)));
+ return { added, removed };
+}
+
+function edgesFromControls(
+ controls: ManifestControl[],
+ extract: (c: ManifestControl) => E[],
+): E[] {
+ return controls.flatMap(extract);
+}
+
+function controlEqual(a: ManifestControl, b: ManifestControl): boolean {
+ return a.name === b.name && a.description === b.description;
+}
+
+function requirementEqual(a: ManifestRequirement, b: ManifestRequirement): boolean {
+ return (
+ a.identifier === b.identifier && a.name === b.name && a.description === b.description
+ );
+}
+
+function policyEqual(a: ManifestPolicy, b: ManifestPolicy): boolean {
+ return (
+ a.name === b.name &&
+ a.description === b.description &&
+ a.frequency === b.frequency &&
+ a.department === b.department &&
+ jsonEqual(a.content, b.content)
+ );
+}
+
+function taskEqual(a: ManifestTask, b: ManifestTask): boolean {
+ return (
+ a.name === b.name &&
+ a.description === b.description &&
+ a.frequency === b.frequency &&
+ a.department === b.department
+ );
+}
+
+function jsonEqual(a: unknown, b: unknown): boolean {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
diff --git a/apps/api/src/frameworks/framework-versioning/framework-drift.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-drift.spec.ts
new file mode 100644
index 0000000000..73d93e483f
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-drift.spec.ts
@@ -0,0 +1,54 @@
+import { isControlEdited, isTaskEdited, isPolicyEdited } from './framework-drift';
+
+describe('framework-drift', () => {
+ describe('isControlEdited', () => {
+ it('returns false when instance matches manifest', () => {
+ expect(isControlEdited(
+ { name: 'Logical Access', description: 'desc' },
+ { id: 'c1', name: 'Logical Access', description: 'desc', requirementIds: [], policyIds: [], taskIds: [] },
+ )).toBe(false);
+ });
+ it('returns true when instance name differs', () => {
+ expect(isControlEdited(
+ { name: 'Access Controls (our name)', description: 'desc' },
+ { id: 'c1', name: 'Logical Access', description: 'desc', requirementIds: [], policyIds: [], taskIds: [] },
+ )).toBe(true);
+ });
+ it('returns true when instance description differs', () => {
+ expect(isControlEdited(
+ { name: 'Logical Access', description: 'our notes' },
+ { id: 'c1', name: 'Logical Access', description: 'desc', requirementIds: [], policyIds: [], taskIds: [] },
+ )).toBe(true);
+ });
+ });
+
+ describe('isTaskEdited', () => {
+ it('returns false when instance matches manifest', () => {
+ expect(isTaskEdited(
+ { title: 'Review', description: 'd', frequency: 'yearly', department: 'it' },
+ { id: 't1', name: 'Review', description: 'd', frequency: 'yearly', department: 'it' },
+ )).toBe(false);
+ });
+ it('returns true when instance frequency differs', () => {
+ expect(isTaskEdited(
+ { title: 'Review', description: 'd', frequency: 'quarterly', department: 'it' },
+ { id: 't1', name: 'Review', description: 'd', frequency: 'yearly', department: 'it' },
+ )).toBe(true);
+ });
+ });
+
+ describe('isPolicyEdited', () => {
+ it('returns false when fields and content match', () => {
+ expect(isPolicyEdited(
+ { name: 'P', description: 'x', content: [{ a: 1 }], frequency: null, department: null },
+ { id: 'p1', name: 'P', description: 'x', content: [{ a: 1 }], frequency: null, department: null },
+ )).toBe(false);
+ });
+ it('returns true when content differs', () => {
+ expect(isPolicyEdited(
+ { name: 'P', description: 'x', content: [{ a: 2 }], frequency: null, department: null },
+ { id: 'p1', name: 'P', description: 'x', content: [{ a: 1 }], frequency: null, department: null },
+ )).toBe(true);
+ });
+ });
+});
diff --git a/apps/api/src/frameworks/framework-versioning/framework-drift.ts b/apps/api/src/frameworks/framework-versioning/framework-drift.ts
new file mode 100644
index 0000000000..b017cf5e99
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-drift.ts
@@ -0,0 +1,56 @@
+import type { ManifestControl, ManifestPolicy, ManifestTask } from './manifest.types';
+
+export function isControlEdited(
+ instance: { name: string; description: string },
+ manifest: ManifestControl,
+): boolean {
+ return instance.name !== manifest.name || instance.description !== manifest.description;
+}
+
+export function isTaskEdited(
+ instance: { title: string; description: string; frequency: string | null; department: string | null },
+ manifest: ManifestTask,
+): boolean {
+ return (
+ instance.title !== manifest.name ||
+ instance.description !== manifest.description ||
+ instance.frequency !== manifest.frequency ||
+ instance.department !== manifest.department
+ );
+}
+
+/**
+ * Policy content can be stored either as the full TipTap doc object
+ * (`{type: 'doc', content: [...]}`) or as just the inner node array. Instance
+ * rows use the inner array (via Policy.content: Json[]); manifest entries copy
+ * the template's raw Json which may be either shape. Normalize both sides so
+ * equivalent content doesn't register as edited and block template updates.
+ */
+function normalizeTipTapContent(content: unknown): unknown {
+ if (Array.isArray(content)) return content;
+ if (
+ content &&
+ typeof content === 'object' &&
+ 'type' in content &&
+ (content as Record).type === 'doc' &&
+ 'content' in content &&
+ Array.isArray((content as Record).content)
+ ) {
+ return (content as Record).content;
+ }
+ return content;
+}
+
+export function isPolicyEdited(
+ instance: { name: string; description: string | null; content: unknown; frequency: string | null; department: string | null },
+ manifest: ManifestPolicy,
+): boolean {
+ return (
+ instance.name !== manifest.name ||
+ instance.description !== manifest.description ||
+ instance.frequency !== manifest.frequency ||
+ instance.department !== manifest.department ||
+ JSON.stringify(normalizeTipTapContent(instance.content)) !==
+ JSON.stringify(normalizeTipTapContent(manifest.content))
+ );
+}
diff --git a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.spec.ts
new file mode 100644
index 0000000000..3d7110120c
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.spec.ts
@@ -0,0 +1,115 @@
+import { Test } from '@nestjs/testing';
+import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
+import { FrameworkRollbackService } from './framework-rollback.service';
+
+jest.mock('@db', () => ({
+ db: {
+ frameworkSyncOperation: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn(), create: jest.fn() },
+ frameworkInstance: { findUnique: jest.fn(), update: jest.fn() },
+ task: { findMany: jest.fn().mockResolvedValue([]) },
+ policy: { findMany: jest.fn().mockResolvedValue([]) },
+ $transaction: jest.fn((fn: any) => fn({
+ control: { update: jest.fn(), deleteMany: jest.fn() },
+ task: { update: jest.fn(), deleteMany: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
+ policy: { update: jest.fn(), deleteMany: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
+ policyVersion: { delete: jest.fn() },
+ requirementMap: { update: jest.fn(), deleteMany: jest.fn() },
+ frameworkSyncOperation: { create: jest.fn().mockResolvedValue({ id: 'fso_rb' }), update: jest.fn() },
+ frameworkInstance: { update: jest.fn() },
+ $executeRawUnsafe: jest.fn(),
+ })),
+ },
+}));
+import { db } from '@db';
+
+describe('FrameworkRollbackService', () => {
+ let service: FrameworkRollbackService;
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ const mod = await Test.createTestingModule({ providers: [FrameworkRollbackService] }).compile();
+ service = mod.get(FrameworkRollbackService);
+ });
+
+ it('404s when the sync operation does not exist', async () => {
+ (db.frameworkSyncOperation.findUnique as jest.Mock).mockResolvedValue(null);
+ await expect(service.rollback({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', syncOperationId: 'fso_missing', memberId: 'mem_1' }))
+ .rejects.toBeInstanceOf(NotFoundException);
+ });
+
+ it('403s when sync op belongs to another org', async () => {
+ (db.frameworkSyncOperation.findUnique as jest.Mock).mockResolvedValue({ id: 'fso_1', frameworkInstance: { organizationId: 'org_other' } });
+ await expect(service.rollback({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', syncOperationId: 'fso_1', memberId: 'mem_1' }))
+ .rejects.toBeInstanceOf(ForbiddenException);
+ });
+
+ it('400s when rollback window has expired', async () => {
+ (db.frameworkSyncOperation.findUnique as jest.Mock).mockResolvedValue({
+ id: 'fso_1', kind: 'SYNC', frameworkInstanceId: 'frm_1',
+ frameworkInstance: { organizationId: 'org_1', id: 'frm_1' },
+ rollbackExpiresAt: new Date(Date.now() - 1000),
+ rolledBackByOperationId: null,
+ });
+ await expect(service.rollback({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', syncOperationId: 'fso_1', memberId: 'mem_1' }))
+ .rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('400s when sync op has already been rolled back', async () => {
+ (db.frameworkSyncOperation.findUnique as jest.Mock).mockResolvedValue({
+ id: 'fso_1', kind: 'SYNC', frameworkInstanceId: 'frm_1',
+ frameworkInstance: { organizationId: 'org_1', id: 'frm_1' },
+ rollbackExpiresAt: new Date(Date.now() + 86_400_000),
+ rolledBackByOperationId: 'fso_previous_rb',
+ });
+ await expect(service.rollback({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', syncOperationId: 'fso_1', memberId: 'mem_1' }))
+ .rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('400s when a task created by the sync has been completed since', async () => {
+ (db.frameworkSyncOperation.findUnique as jest.Mock).mockResolvedValue({
+ id: 'fso_1', kind: 'SYNC', frameworkInstanceId: 'frm_1',
+ frameworkInstance: { organizationId: 'org_1', id: 'frm_1' },
+ fromVersionId: 'fvr_v1', toVersionId: 'fvr_v2',
+ rollbackExpiresAt: new Date(Date.now() + 86_400_000),
+ rolledBackByOperationId: null,
+ undoPayload: { controls: { created: [], archived: [], contentUpdated: [] }, tasks: { created: ['tsk_new'], archived: [], contentUpdated: [] }, policies: { created: [], archived: [], contentUpdated: [], draftsAdded: [] }, requirementMaps: { created: [], archived: [] }, controlDocumentTypes: { created: [], archived: [] } },
+ });
+ (db.task.findMany as jest.Mock).mockResolvedValue([{ id: 'tsk_new', completedAt: new Date() }]);
+
+ await expect(service.rollback({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', syncOperationId: 'fso_1', memberId: 'mem_1' }))
+ .rejects.toThrow(/data loss|completed/i);
+ });
+
+ it('400s when a policy created by the sync has been published since', async () => {
+ (db.frameworkSyncOperation.findUnique as jest.Mock).mockResolvedValue({
+ id: 'fso_1', kind: 'SYNC', frameworkInstanceId: 'frm_1',
+ frameworkInstance: { organizationId: 'org_1', id: 'frm_1' },
+ fromVersionId: 'fvr_v1', toVersionId: 'fvr_v2',
+ rollbackExpiresAt: new Date(Date.now() + 86_400_000),
+ rolledBackByOperationId: null,
+ undoPayload: { controls: { created: [], archived: [], contentUpdated: [] }, tasks: { created: [], archived: [], contentUpdated: [] }, policies: { created: ['pol_new'], archived: [], contentUpdated: [], draftsAdded: [] }, requirementMaps: { created: [], archived: [] }, controlDocumentTypes: { created: [], archived: [] } },
+ });
+ (db.policy.findMany as jest.Mock).mockResolvedValue([{ id: 'pol_new', status: 'published' }]);
+
+ await expect(service.rollback({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', syncOperationId: 'fso_1', memberId: 'mem_1' }))
+ .rejects.toThrow(/data loss|published/i);
+ });
+
+ it('400s when a newer non-reversed sync exists (cannot roll back mid-chain)', async () => {
+ (db.frameworkSyncOperation.findUnique as jest.Mock).mockResolvedValue({
+ id: 'fso_1', kind: 'SYNC', frameworkInstanceId: 'frm_1',
+ frameworkInstance: { organizationId: 'org_1', id: 'frm_1' },
+ fromVersionId: 'fvr_v1', toVersionId: 'fvr_v2',
+ performedAt: new Date('2026-04-23T10:00:00Z'),
+ rollbackExpiresAt: new Date(Date.now() + 86_400_000),
+ rolledBackByOperationId: null,
+ });
+ (db.frameworkSyncOperation.findFirst as jest.Mock).mockResolvedValue({
+ id: 'fso_newer',
+ fromVersion: { version: '1.2.0' },
+ toVersion: { version: '1.3.0' },
+ });
+
+ await expect(service.rollback({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', syncOperationId: 'fso_1', memberId: 'mem_1' }))
+ .rejects.toThrow(/most recent sync|1\.2\.0|1\.3\.0/i);
+ });
+});
diff --git a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts
new file mode 100644
index 0000000000..b940e20e93
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts
@@ -0,0 +1,235 @@
+import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
+import { db, Prisma, Frequency, Departments } from '@db';
+import { lockOrganizationForSync } from './org-advisory-lock';
+import type { UndoPayload } from './undo-payload.types';
+
+export interface RollbackParams {
+ organizationId: string;
+ frameworkInstanceId: string;
+ syncOperationId: string;
+ memberId: string;
+}
+
+export interface RollbackResult {
+ rollbackOperationId: string;
+}
+
+@Injectable()
+export class FrameworkRollbackService {
+ async rollback(params: RollbackParams): Promise {
+ const syncOp = await db.frameworkSyncOperation.findUnique({
+ where: { id: params.syncOperationId },
+ include: { frameworkInstance: true },
+ });
+ if (!syncOp) throw new NotFoundException('Sync operation not found');
+ if (syncOp.frameworkInstance.organizationId !== params.organizationId) throw new ForbiddenException('Wrong organization');
+ if (syncOp.frameworkInstanceId !== params.frameworkInstanceId) throw new BadRequestException('Sync op does not belong to this framework instance');
+ if (syncOp.kind !== 'SYNC') throw new BadRequestException('Only sync operations can be rolled back');
+ if (syncOp.rolledBackByOperationId) throw new BadRequestException('Sync has already been rolled back');
+ if (!syncOp.rollbackExpiresAt || syncOp.rollbackExpiresAt <= new Date()) throw new BadRequestException('Rollback window has expired');
+
+ // Only the latest non-reversed sync can be rolled back. Rolling back an
+ // older sync in the middle of a chain would leave the instance in an
+ // inconsistent state (undo payloads only describe that single sync's
+ // deltas and can't be stacked out of order).
+ const newerActiveSync = await db.frameworkSyncOperation.findFirst({
+ where: {
+ frameworkInstanceId: syncOp.frameworkInstanceId,
+ kind: 'SYNC',
+ rolledBackByOperationId: null,
+ performedAt: { gt: syncOp.performedAt },
+ },
+ select: { id: true, fromVersion: { select: { version: true } }, toVersion: { select: { version: true } } },
+ });
+ if (newerActiveSync) {
+ throw new BadRequestException({
+ message: `Only the most recent sync can be rolled back. Roll back v${newerActiveSync.fromVersion.version} → v${newerActiveSync.toVersion.version} first.`,
+ });
+ }
+
+ const undo = syncOp.undoPayload as unknown as UndoPayload;
+
+ await assertNoDataLoss(undo);
+
+ const rollbackOp = await db.$transaction(async (tx) => {
+ await lockOrganizationForSync(tx, params.organizationId);
+ return replayUndo(tx, { syncOp, undo, memberId: params.memberId });
+ });
+
+ return { rollbackOperationId: rollbackOp.id };
+ }
+}
+
+async function assertNoDataLoss(undo: UndoPayload): Promise {
+ if (undo.tasks.created.length > 0) {
+ const completed = await db.task.findMany({
+ where: { id: { in: undo.tasks.created }, status: 'done' },
+ select: { id: true, title: true },
+ });
+ if (completed.length > 0) {
+ throw new BadRequestException({
+ message: 'Rollback would cause data loss: tasks have been completed since the sync.',
+ details: completed,
+ });
+ }
+ }
+ if (undo.policies.created.length > 0) {
+ const published = await db.policy.findMany({
+ where: { id: { in: undo.policies.created }, status: 'published' as const },
+ select: { id: true, name: true },
+ });
+ if (published.length > 0) {
+ throw new BadRequestException({
+ message: 'Rollback would cause data loss: policies have been published since the sync.',
+ details: published,
+ });
+ }
+ }
+}
+
+interface ReplayUndoCtx {
+ syncOp: {
+ id: string;
+ frameworkInstanceId: string;
+ fromVersionId: string;
+ toVersionId: string;
+ };
+ undo: UndoPayload;
+ memberId: string;
+}
+
+async function replayUndo(
+ tx: Prisma.TransactionClient,
+ ctx: ReplayUndoCtx,
+): Promise<{ id: string }> {
+ // Hard-delete rows created by the sync
+ if (ctx.undo.controls.created.length) {
+ await tx.control.deleteMany({ where: { id: { in: ctx.undo.controls.created } } });
+ }
+ if (ctx.undo.tasks.created.length) {
+ await tx.task.deleteMany({ where: { id: { in: ctx.undo.tasks.created } } });
+ }
+ if (ctx.undo.policies.created.length) {
+ await tx.policy.deleteMany({ where: { id: { in: ctx.undo.policies.created } } });
+ }
+ if (ctx.undo.requirementMaps.created.length) {
+ await tx.requirementMap.deleteMany({ where: { id: { in: ctx.undo.requirementMaps.created } } });
+ }
+
+ // ControlDocumentType: reverse hard-deletes (recreate) and hard-delete
+ // rows the sync created. Guarded against older sync ops that predate this
+ // bucket shape.
+ const cdt = ctx.undo.controlDocumentTypes ?? { created: [], deleted: [] };
+ const cdtCreated = Array.isArray(cdt.created) ? cdt.created : [];
+ const cdtDeleted = Array.isArray((cdt as { deleted?: unknown }).deleted)
+ ? (cdt as { deleted: Array<{ controlId: string; formType: string }> }).deleted
+ : [];
+ if (cdtCreated.length) {
+ await tx.controlDocumentType.deleteMany({ where: { id: { in: cdtCreated } } });
+ }
+ for (const d of cdtDeleted) {
+ await tx.controlDocumentType.create({
+ data: { controlId: d.controlId, formType: d.formType as never },
+ });
+ }
+
+ // Restore archived state
+ for (const a of ctx.undo.controls.archived) {
+ await tx.control.update({ where: { id: a.id }, data: { archivedAt: a.prevArchivedAt } });
+ }
+ for (const a of ctx.undo.tasks.archived) {
+ await tx.task.update({ where: { id: a.id }, data: { archivedAt: a.prevArchivedAt } });
+ }
+ for (const a of ctx.undo.policies.archived) {
+ await tx.policy.update({ where: { id: a.id }, data: { archivedAt: a.prevArchivedAt } });
+ }
+ for (const a of ctx.undo.requirementMaps.archived) {
+ await tx.requirementMap.update({ where: { id: a.id }, data: { archivedAt: a.prevArchivedAt } });
+ }
+
+ // Restore previous content
+ for (const u of ctx.undo.controls.contentUpdated) {
+ await tx.control.update({
+ where: { id: u.id },
+ data: { name: u.prevContent.name, description: u.prevContent.description },
+ });
+ }
+ for (const u of ctx.undo.tasks.contentUpdated) {
+ await tx.task.update({
+ where: { id: u.id },
+ data: {
+ title: u.prevContent.title,
+ description: u.prevContent.description,
+ frequency: u.prevContent.frequency as Frequency | null,
+ department: u.prevContent.department as Departments | null,
+ },
+ });
+ }
+ for (const u of ctx.undo.policies.contentUpdated) {
+ await tx.policy.update({
+ where: { id: u.id },
+ data: {
+ name: u.prevContent.name,
+ description: u.prevContent.description,
+ content: {
+ set: Array.isArray(u.prevContent.content)
+ ? (u.prevContent.content as unknown as Prisma.InputJsonValue[])
+ : [u.prevContent.content as unknown as Prisma.InputJsonValue],
+ },
+ frequency: u.prevContent.frequency as Frequency | null,
+ department: u.prevContent.department as Departments | null,
+ },
+ });
+ }
+
+ // Remove draft policy versions added by the sync
+ for (const d of ctx.undo.policies.draftsAdded) {
+ await tx.policyVersion.delete({ where: { id: d.draftVersionId } });
+ }
+
+ // Reverse implicit M:N edges: connects become disconnects and vice versa.
+ // Guard with nullish coalescing so older sync operations written before
+ // this bucket existed don't crash the rollback.
+ const cpl = ctx.undo.controlPolicyLinks ?? { connected: [], disconnected: [] };
+ const ctl = ctx.undo.controlTaskLinks ?? { connected: [], disconnected: [] };
+ for (const link of cpl.connected) {
+ await tx.control.update({ where: { id: link.controlId }, data: { policies: { disconnect: { id: link.otherId } } } });
+ }
+ for (const link of cpl.disconnected) {
+ await tx.control.update({ where: { id: link.controlId }, data: { policies: { connect: { id: link.otherId } } } });
+ }
+ for (const link of ctl.connected) {
+ await tx.control.update({ where: { id: link.controlId }, data: { tasks: { disconnect: { id: link.otherId } } } });
+ }
+ for (const link of ctl.disconnected) {
+ await tx.control.update({ where: { id: link.controlId }, data: { tasks: { connect: { id: link.otherId } } } });
+ }
+
+ // Revert framework instance version pointer
+ await tx.frameworkInstance.update({
+ where: { id: ctx.syncOp.frameworkInstanceId },
+ data: { currentVersionId: ctx.syncOp.fromVersionId },
+ });
+
+ // Create the rollback operation record
+ const rb = await tx.frameworkSyncOperation.create({
+ data: {
+ frameworkInstanceId: ctx.syncOp.frameworkInstanceId,
+ fromVersionId: ctx.syncOp.toVersionId,
+ toVersionId: ctx.syncOp.fromVersionId,
+ kind: 'ROLLBACK',
+ performedById: ctx.memberId,
+ rollbackExpiresAt: null,
+ undoPayload: {} as unknown as object,
+ summary: { reversedSyncOperationId: ctx.syncOp.id } as unknown as object,
+ },
+ });
+
+ // Mark the original sync as rolled back
+ await tx.frameworkSyncOperation.update({
+ where: { id: ctx.syncOp.id },
+ data: { rolledBackByOperationId: rb.id },
+ });
+
+ return rb;
+}
diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts
new file mode 100644
index 0000000000..b38be58084
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts
@@ -0,0 +1,303 @@
+import { applySync } from './framework-sync-apply';
+import type { FrameworkManifest } from './manifest.types';
+
+function manifest(overrides: Partial = {}): FrameworkManifest {
+ return {
+ framework: { id: 'frk_soc2', name: 'SOC 2', catalogVersion: '1', description: null },
+ requirements: [], controls: [], policies: [], tasks: [],
+ ...overrides,
+ };
+}
+
+const baseInstance = {
+ id: 'frm_1',
+ organizationId: 'org_1',
+ frameworkId: 'frk_soc2',
+ currentVersionId: 'fvr_v1',
+};
+
+function mockTx() {
+ return {
+ control: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...data, id: `ctl_new_${Math.random()}` })), update: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
+ task: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...data, id: `tsk_new_${Math.random()}` })), update: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
+ policy: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...data, id: `pol_new_${Math.random()}` })), update: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
+ policyVersion: { create: jest.fn().mockResolvedValue({ id: 'pv_new', version: 2 }), findFirst: jest.fn().mockResolvedValue({ version: 1 }) },
+ requirementMap: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...data, id: `rm_new_${Math.random()}` })), updateMany: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
+ frameworkInstance: { findMany: jest.fn().mockResolvedValue([]), update: jest.fn() },
+ frameworkSyncOperation: { create: jest.fn().mockResolvedValue({ id: 'fso_new' }) },
+ } as any;
+}
+
+describe('applySync', () => {
+ it('creates new control instance when added in target manifest (no existing row in org)', async () => {
+ const tx = mockTx();
+ const result = await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: { id: 'fvr_v1', frameworkId: 'frk_soc2', manifest: manifest() } as any,
+ targetVersion: {
+ id: 'fvr_v2',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ controls: [{ id: 'ct_new', name: 'New Control', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] }),
+ } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.control.create).toHaveBeenCalledTimes(1);
+ expect(tx.control.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ organizationId: 'org_1', name: 'New Control', description: 'd', controlTemplateId: 'ct_new' }),
+ }));
+ expect(result.syncOperationId).toBe('fso_new');
+ expect(tx.frameworkInstance.update).toHaveBeenCalledWith(expect.objectContaining({
+ where: { id: 'frm_1' },
+ data: expect.objectContaining({ currentVersionId: 'fvr_v2' }),
+ }));
+ });
+
+ it('reuses existing control row when template is already present in org', async () => {
+ const tx = mockTx();
+ tx.control.findMany.mockResolvedValue([{ id: 'ctl_existing', controlTemplateId: 'ct_shared', organizationId: 'org_1', name: 'X', description: 'Y' }]);
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: { id: 'fvr_v1', frameworkId: 'frk_soc2', manifest: manifest() } as any,
+ targetVersion: {
+ id: 'fvr_v2',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ controls: [{ id: 'ct_shared', name: 'X', description: 'Y', requirementIds: [], policyIds: [], taskIds: [] }] }),
+ } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.control.create).not.toHaveBeenCalled();
+ });
+
+ it('archives control when removed from target manifest and no other framework references it', async () => {
+ const tx = mockTx();
+ tx.control.findMany.mockResolvedValue([{ id: 'ctl_stale', controlTemplateId: 'ct_old', organizationId: 'org_1', name: 'Old', description: 'd', archivedAt: null }]);
+ tx.frameworkInstance.findMany.mockResolvedValue([]);
+
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: {
+ id: 'fvr_v1',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ controls: [{ id: 'ct_old', name: 'Old', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] }),
+ } as any,
+ targetVersion: { id: 'fvr_v2', frameworkId: 'frk_soc2', manifest: manifest() } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.control.update).toHaveBeenCalledWith(expect.objectContaining({
+ where: { id: 'ctl_stale' },
+ data: expect.objectContaining({ archivedAt: expect.any(Date) }),
+ }));
+ });
+
+ it('does NOT archive control when another framework references it', async () => {
+ const tx = mockTx();
+ tx.control.findMany.mockResolvedValue([{ id: 'ctl_shared', controlTemplateId: 'ct_shared', organizationId: 'org_1', name: 'X', description: 'Y', archivedAt: null }]);
+ tx.frameworkInstance.findMany.mockResolvedValue([{
+ id: 'frm_iso',
+ currentVersion: { manifest: manifest({ controls: [{ id: 'ct_shared', name: 'X', description: 'Y', requirementIds: [], policyIds: [], taskIds: [] }] }) },
+ }]);
+
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: {
+ id: 'fvr_v1',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ controls: [{ id: 'ct_shared', name: 'X', description: 'Y', requirementIds: [], policyIds: [], taskIds: [] }] }),
+ } as any,
+ targetVersion: { id: 'fvr_v2', frameworkId: 'frk_soc2', manifest: manifest() } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.control.update).not.toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ archivedAt: expect.any(Date) }) }));
+ });
+
+ it('overwrites control content when unedited', async () => {
+ const tx = mockTx();
+ tx.control.findMany.mockResolvedValue([{ id: 'ctl_1', controlTemplateId: 'ct_1', organizationId: 'org_1', name: 'Old', description: 'd', archivedAt: null }]);
+
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: {
+ id: 'fvr_v1',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ controls: [{ id: 'ct_1', name: 'Old', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] }),
+ } as any,
+ targetVersion: {
+ id: 'fvr_v2',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ controls: [{ id: 'ct_1', name: 'New', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] }),
+ } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.control.update).toHaveBeenCalledWith(expect.objectContaining({
+ where: { id: 'ctl_1' },
+ data: expect.objectContaining({ name: 'New', description: 'd' }),
+ }));
+ });
+
+ it('preserves control content when customer-edited', async () => {
+ const tx = mockTx();
+ tx.control.findMany.mockResolvedValue([{ id: 'ctl_1', controlTemplateId: 'ct_1', organizationId: 'org_1', name: 'My Edit', description: 'd', archivedAt: null }]);
+
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: {
+ id: 'fvr_v1',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ controls: [{ id: 'ct_1', name: 'Old', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] }),
+ } as any,
+ targetVersion: {
+ id: 'fvr_v2',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ controls: [{ id: 'ct_1', name: 'New', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] }),
+ } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.control.update).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ name: 'New' }),
+ }));
+ });
+
+ it('creates a draft PolicyVersion for published policies when template content changes', async () => {
+ const tx = mockTx();
+ tx.policy.findMany.mockResolvedValue([{
+ id: 'pol_1', policyTemplateId: 'pt_1', organizationId: 'org_1',
+ name: 'Access', description: 'd', content: [{ old: true }], frequency: null, department: null,
+ status: 'published', archivedAt: null,
+ }]);
+
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: {
+ id: 'fvr_v1',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ policies: [{ id: 'pt_1', name: 'Access', description: 'd', content: [{ old: true }], frequency: null, department: null }] }),
+ } as any,
+ targetVersion: {
+ id: 'fvr_v2',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({ policies: [{ id: 'pt_1', name: 'Access', description: 'd', content: [{ new: true }], frequency: null, department: null }] }),
+ } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.policyVersion.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ policyId: 'pol_1', content: { set: [{ new: true }] } }),
+ }));
+ expect(tx.policy.update).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ content: { set: [{ new: true }] } }),
+ }));
+ });
+
+ it('creates an initial draft PolicyVersion when a policy is added', async () => {
+ const tx = mockTx();
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: { id: 'fvr_v1', frameworkId: 'frk_soc2', manifest: manifest() } as any,
+ targetVersion: {
+ id: 'fvr_v2',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({
+ policies: [{ id: 'pt_new', name: 'New Policy', description: 'd', content: [{ body: 'x' }], frequency: null, department: null }],
+ }),
+ } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.policy.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ policyTemplateId: 'pt_new', status: 'draft' }),
+ }));
+ expect(tx.policyVersion.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ version: 1, content: { set: [{ body: 'x' }] } }),
+ }));
+ });
+
+ it('creates RequirementMap edge when link appears in target manifest', async () => {
+ const tx = mockTx();
+ tx.control.findMany.mockResolvedValue([{ id: 'ctl_1', controlTemplateId: 'ct_1', organizationId: 'org_1', name: 'C', description: 'D', archivedAt: null }]);
+
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: {
+ id: 'fvr_v1',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({
+ requirements: [{ id: 'rq_1', identifier: 'CC1', name: 'X', description: null }],
+ controls: [{ id: 'ct_1', name: 'C', description: 'D', requirementIds: [], policyIds: [], taskIds: [] }],
+ }),
+ } as any,
+ targetVersion: {
+ id: 'fvr_v2',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({
+ requirements: [{ id: 'rq_1', identifier: 'CC1', name: 'X', description: null }],
+ controls: [{ id: 'ct_1', name: 'C', description: 'D', requirementIds: ['rq_1'], policyIds: [], taskIds: [] }],
+ }),
+ } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.requirementMap.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ controlId: 'ctl_1', requirementId: 'rq_1', frameworkInstanceId: 'frm_1' }),
+ }));
+ });
+
+ it('archives RequirementMap edge when link disappears', async () => {
+ const tx = mockTx();
+ tx.control.findMany.mockResolvedValue([{ id: 'ctl_1', controlTemplateId: 'ct_1', organizationId: 'org_1', name: 'C', description: 'D', archivedAt: null }]);
+ tx.requirementMap.findMany.mockResolvedValue([{ id: 'rm_1', controlId: 'ctl_1', requirementId: 'rq_1', frameworkInstanceId: 'frm_1', archivedAt: null }]);
+
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: {
+ id: 'fvr_v1',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({
+ requirements: [{ id: 'rq_1', identifier: 'CC1', name: 'X', description: null }],
+ controls: [{ id: 'ct_1', name: 'C', description: 'D', requirementIds: ['rq_1'], policyIds: [], taskIds: [] }],
+ }),
+ } as any,
+ targetVersion: {
+ id: 'fvr_v2',
+ frameworkId: 'frk_soc2',
+ manifest: manifest({
+ requirements: [{ id: 'rq_1', identifier: 'CC1', name: 'X', description: null }],
+ controls: [{ id: 'ct_1', name: 'C', description: 'D', requirementIds: [], policyIds: [], taskIds: [] }],
+ }),
+ } as any,
+ memberId: 'mem_1',
+ });
+
+ expect(tx.requirementMap.updateMany).toHaveBeenCalledWith(expect.objectContaining({
+ where: expect.objectContaining({ id: 'rm_1' }),
+ data: expect.objectContaining({ archivedAt: expect.any(Date) }),
+ }));
+ });
+
+ it('writes a sync operation row with undoPayload and summary', async () => {
+ const tx = mockTx();
+ await applySync(tx, {
+ instance: baseInstance as any,
+ currentVersion: { id: 'fvr_v1', frameworkId: 'frk_soc2', manifest: manifest() } as any,
+ targetVersion: { id: 'fvr_v2', frameworkId: 'frk_soc2', manifest: manifest({ controls: [{ id: 'ct_new', name: 'C', description: 'D', requirementIds: [], policyIds: [], taskIds: [] }] }) } as any,
+ memberId: 'mem_1',
+ });
+
+ const createCall = tx.frameworkSyncOperation.create.mock.calls[0][0];
+ expect(createCall.data).toMatchObject({
+ frameworkInstanceId: 'frm_1',
+ fromVersionId: 'fvr_v1',
+ toVersionId: 'fvr_v2',
+ kind: 'SYNC',
+ performedById: 'mem_1',
+ });
+ expect(createCall.data.rollbackExpiresAt).toBeInstanceOf(Date);
+ expect(createCall.data.undoPayload).toBeDefined();
+ expect(createCall.data.summary).toBeDefined();
+ });
+});
diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts
new file mode 100644
index 0000000000..65b6273f41
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts
@@ -0,0 +1,354 @@
+import { Prisma, Frequency, Departments, type FrameworkInstance, type FrameworkVersion } from '@db';
+import { diffManifests } from './framework-diff';
+import { isControlEdited, isPolicyEdited, isTaskEdited } from './framework-drift';
+import { buildCrossFrameworkRefs } from './cross-framework-refs';
+import type { FrameworkManifest } from './manifest.types';
+import type { UndoPayload, SyncSummary } from './undo-payload.types';
+
+const ROLLBACK_WINDOW_DAYS = 14;
+
+export type VersionWithManifest = Omit & { manifest: FrameworkManifest };
+
+export interface ApplySyncCtx {
+ instance: FrameworkInstance;
+ currentVersion: VersionWithManifest;
+ targetVersion: VersionWithManifest;
+ memberId: string;
+}
+
+export async function applySync(
+ tx: Prisma.TransactionClient,
+ ctx: ApplySyncCtx,
+): Promise<{ syncOperationId: string }> {
+ const from = ctx.currentVersion.manifest;
+ const to = ctx.targetVersion.manifest;
+ const diff = diffManifests(from, to);
+
+ const allTemplateControlIds = new Set([...from.controls.map((c) => c.id), ...to.controls.map((c) => c.id)]);
+ const allTemplatePolicyIds = new Set([...from.policies.map((p) => p.id), ...to.policies.map((p) => p.id)]);
+ const allTemplateTaskIds = new Set([...from.tasks.map((t) => t.id), ...to.tasks.map((t) => t.id)]);
+
+ const [instanceControls, instancePolicies, instanceTasks, otherInstances] = await Promise.all([
+ tx.control.findMany({ where: { organizationId: ctx.instance.organizationId, controlTemplateId: { in: [...allTemplateControlIds] }, archivedAt: null } }),
+ tx.policy.findMany({ where: { organizationId: ctx.instance.organizationId, policyTemplateId: { in: [...allTemplatePolicyIds] }, archivedAt: null } }),
+ tx.task.findMany({ where: { organizationId: ctx.instance.organizationId, taskTemplateId: { in: [...allTemplateTaskIds] }, archivedAt: null } }),
+ tx.frameworkInstance.findMany({
+ where: { organizationId: ctx.instance.organizationId, id: { not: ctx.instance.id } },
+ include: { currentVersion: true },
+ }),
+ ]);
+
+ const ctlByTemplate = new Map(instanceControls.filter((c) => c.controlTemplateId).map((c) => [c.controlTemplateId!, c]));
+ const polByTemplate = new Map(instancePolicies.filter((p) => p.policyTemplateId).map((p) => [p.policyTemplateId!, p]));
+ const taskByTemplate = new Map(instanceTasks.filter((t) => t.taskTemplateId).map((t) => [t.taskTemplateId!, t]));
+
+ const refs = buildCrossFrameworkRefs({
+ otherInstances: otherInstances
+ .filter((i) => i.currentVersion)
+ .map((i) => ({ frameworkInstanceId: i.id, manifest: i.currentVersion!.manifest as unknown as FrameworkManifest })),
+ });
+
+ const undo: UndoPayload = {
+ controls: { created: [], archived: [], contentUpdated: [] },
+ policies: { created: [], archived: [], contentUpdated: [], draftsAdded: [] },
+ tasks: { created: [], archived: [], contentUpdated: [] },
+ requirementMaps: { created: [], archived: [] },
+ controlDocumentTypes: { created: [], deleted: [] },
+ controlPolicyLinks: { connected: [], disconnected: [] },
+ controlTaskLinks: { connected: [], disconnected: [] },
+ };
+ const summary: SyncSummary = {
+ controlsAdded: 0, controlsArchived: 0, controlsUpdatedApplied: 0, controlsUpdatedPreserved: 0,
+ policiesAdded: 0, policiesArchived: 0, policiesUpdatedApplied: 0, policiesUpdatedPreserved: 0, policiesDraftAdded: 0,
+ tasksAdded: 0, tasksArchived: 0, tasksUpdatedApplied: 0, tasksUpdatedPreserved: 0,
+ requirementMapsAdded: 0, requirementMapsArchived: 0,
+ controlDocumentTypesAdded: 0, controlDocumentTypesArchived: 0,
+ };
+
+ // --- Controls ---
+ for (const added of diff.controls.added) {
+ if (ctlByTemplate.has(added.id)) continue;
+ const created = await tx.control.create({
+ data: {
+ organizationId: ctx.instance.organizationId,
+ controlTemplateId: added.id,
+ name: added.name,
+ description: added.description,
+ },
+ });
+ ctlByTemplate.set(added.id, created);
+ undo.controls.created.push(created.id);
+ summary.controlsAdded += 1;
+ }
+ for (const removed of diff.controls.removed) {
+ const inst = ctlByTemplate.get(removed.id);
+ if (!inst) continue;
+ if (refs.controlTemplateIds.has(removed.id)) continue;
+ const prev = inst.archivedAt;
+ await tx.control.update({ where: { id: inst.id }, data: { archivedAt: new Date() } });
+ undo.controls.archived.push({ id: inst.id, prevArchivedAt: prev });
+ summary.controlsArchived += 1;
+ }
+ for (const u of diff.controls.updated) {
+ const inst = ctlByTemplate.get(u.id);
+ if (!inst) continue;
+ if (isControlEdited(inst, u.from)) {
+ summary.controlsUpdatedPreserved += 1;
+ continue;
+ }
+ undo.controls.contentUpdated.push({ id: inst.id, prevContent: { name: inst.name, description: inst.description } });
+ await tx.control.update({ where: { id: inst.id }, data: { name: u.to.name, description: u.to.description } });
+ summary.controlsUpdatedApplied += 1;
+ }
+
+ // --- Tasks ---
+ for (const added of diff.tasks.added) {
+ if (taskByTemplate.has(added.id)) continue;
+ const created = await tx.task.create({
+ data: {
+ organizationId: ctx.instance.organizationId,
+ taskTemplateId: added.id,
+ title: added.name,
+ description: added.description,
+ frequency: added.frequency as Frequency | null,
+ department: added.department as Departments | null,
+ },
+ });
+ taskByTemplate.set(added.id, created);
+ undo.tasks.created.push(created.id);
+ summary.tasksAdded += 1;
+ }
+ for (const removed of diff.tasks.removed) {
+ const inst = taskByTemplate.get(removed.id);
+ if (!inst) continue;
+ if (refs.taskTemplateIds.has(removed.id)) continue;
+ const prev = inst.archivedAt;
+ await tx.task.update({ where: { id: inst.id }, data: { archivedAt: new Date() } });
+ undo.tasks.archived.push({ id: inst.id, prevArchivedAt: prev });
+ summary.tasksArchived += 1;
+ }
+ for (const u of diff.tasks.updated) {
+ const inst = taskByTemplate.get(u.id);
+ if (!inst) continue;
+ if (isTaskEdited(inst, u.from)) {
+ summary.tasksUpdatedPreserved += 1;
+ continue;
+ }
+ undo.tasks.contentUpdated.push({
+ id: inst.id,
+ prevContent: { title: inst.title, description: inst.description, frequency: inst.frequency, department: inst.department },
+ });
+ await tx.task.update({
+ where: { id: inst.id },
+ data: { title: u.to.name, description: u.to.description, frequency: u.to.frequency as Frequency | null, department: u.to.department as Departments | null },
+ });
+ summary.tasksUpdatedApplied += 1;
+ }
+
+ // --- Policies ---
+ for (const added of diff.policies.added) {
+ if (polByTemplate.has(added.id)) continue;
+ const contentArray = toJsonArray(added.content);
+ const policyCreated = await tx.policy.create({
+ data: {
+ organizationId: ctx.instance.organizationId,
+ policyTemplateId: added.id,
+ name: added.name,
+ description: added.description,
+ content: { set: contentArray },
+ frequency: added.frequency as Frequency | null,
+ department: added.department as Departments | null,
+ status: 'draft',
+ },
+ });
+
+ // Create the initial draft PolicyVersion. Cascades on Policy delete, so
+ // rollback only needs to hard-delete the Policy row (undo.policies.created).
+ await tx.policyVersion.create({
+ data: {
+ policyId: policyCreated.id,
+ version: 1,
+ content: { set: contentArray },
+ changelog: 'Initial draft from framework template',
+ },
+ });
+
+ polByTemplate.set(added.id, policyCreated);
+ undo.policies.created.push(policyCreated.id);
+ summary.policiesAdded += 1;
+ }
+ for (const removed of diff.policies.removed) {
+ const inst = polByTemplate.get(removed.id);
+ if (!inst) continue;
+ if (refs.policyTemplateIds.has(removed.id)) continue;
+ const prev = inst.archivedAt;
+ await tx.policy.update({ where: { id: inst.id }, data: { archivedAt: new Date() } });
+ undo.policies.archived.push({ id: inst.id, prevArchivedAt: prev });
+ summary.policiesArchived += 1;
+ }
+ for (const u of diff.policies.updated) {
+ const inst = polByTemplate.get(u.id);
+ if (!inst) continue;
+ if (inst.status === 'published') {
+ const latest = await tx.policyVersion.findFirst({ where: { policyId: inst.id }, orderBy: { version: 'desc' }, select: { version: true } });
+ const nextVersion = (latest?.version ?? 0) + 1;
+ const draft = await tx.policyVersion.create({
+ data: { policyId: inst.id, version: nextVersion, content: { set: toJsonArray(u.to.content) }, changelog: 'Template update available' },
+ });
+ undo.policies.draftsAdded.push({ policyId: inst.id, draftVersionId: draft.id });
+ summary.policiesDraftAdded += 1;
+ continue;
+ }
+ if (isPolicyEdited(inst, u.from)) {
+ summary.policiesUpdatedPreserved += 1;
+ continue;
+ }
+ undo.policies.contentUpdated.push({
+ id: inst.id,
+ prevContent: { name: inst.name, description: inst.description, content: inst.content, frequency: inst.frequency, department: inst.department },
+ });
+ await tx.policy.update({
+ where: { id: inst.id },
+ data: { name: u.to.name, description: u.to.description, content: { set: toJsonArray(u.to.content) }, frequency: u.to.frequency as Frequency | null, department: u.to.department as Departments | null },
+ });
+ summary.policiesUpdatedApplied += 1;
+ }
+
+ // --- RequirementMap edges ---
+ const existingEdges = await tx.requirementMap.findMany({
+ where: { frameworkInstanceId: ctx.instance.id, archivedAt: null },
+ select: { id: true, controlId: true, requirementId: true },
+ });
+ const keyOf = (controlId: string, requirementId: string) => `${controlId}::${requirementId}`;
+ const existingByKey = new Map(
+ existingEdges.filter((e) => e.requirementId).map((e) => [keyOf(e.controlId, e.requirementId!), e]),
+ );
+
+ for (const edge of diff.requirementMapEdges.added) {
+ const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
+ if (!ctlInst) continue;
+ if (existingByKey.has(keyOf(ctlInst.id, edge.requirementTemplateId))) continue;
+ const created = await tx.requirementMap.create({
+ data: {
+ frameworkInstanceId: ctx.instance.id,
+ controlId: ctlInst.id,
+ requirementId: edge.requirementTemplateId,
+ },
+ });
+ undo.requirementMaps.created.push(created.id);
+ summary.requirementMapsAdded += 1;
+ }
+ for (const edge of diff.requirementMapEdges.removed) {
+ const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
+ if (!ctlInst) continue;
+ const existing = existingByKey.get(keyOf(ctlInst.id, edge.requirementTemplateId));
+ if (!existing) continue;
+ await tx.requirementMap.updateMany({
+ where: { id: existing.id, archivedAt: null },
+ data: { archivedAt: new Date() },
+ });
+ undo.requirementMaps.archived.push({ id: existing.id, prevArchivedAt: null });
+ summary.requirementMapsArchived += 1;
+ }
+
+ // --- Control<->Policy / Control<->Task relations (Prisma implicit M:N) ---
+ for (const edge of diff.controlPolicyEdges.added) {
+ const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
+ const polInst = polByTemplate.get(edge.policyTemplateId);
+ if (!ctlInst || !polInst) continue;
+ await tx.control.update({ where: { id: ctlInst.id }, data: { policies: { connect: { id: polInst.id } } } });
+ undo.controlPolicyLinks.connected.push({ controlId: ctlInst.id, otherId: polInst.id });
+ }
+ for (const edge of diff.controlPolicyEdges.removed) {
+ const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
+ const polInst = polByTemplate.get(edge.policyTemplateId);
+ if (!ctlInst || !polInst) continue;
+ await tx.control.update({ where: { id: ctlInst.id }, data: { policies: { disconnect: { id: polInst.id } } } });
+ undo.controlPolicyLinks.disconnected.push({ controlId: ctlInst.id, otherId: polInst.id });
+ }
+ for (const edge of diff.controlTaskEdges.added) {
+ const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
+ const tInst = taskByTemplate.get(edge.taskTemplateId);
+ if (!ctlInst || !tInst) continue;
+ await tx.control.update({ where: { id: ctlInst.id }, data: { tasks: { connect: { id: tInst.id } } } });
+ undo.controlTaskLinks.connected.push({ controlId: ctlInst.id, otherId: tInst.id });
+ }
+ for (const edge of diff.controlTaskEdges.removed) {
+ const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
+ const tInst = taskByTemplate.get(edge.taskTemplateId);
+ if (!ctlInst || !tInst) continue;
+ await tx.control.update({ where: { id: ctlInst.id }, data: { tasks: { disconnect: { id: tInst.id } } } });
+ undo.controlTaskLinks.disconnected.push({ controlId: ctlInst.id, otherId: tInst.id });
+ }
+
+ // --- Control <-> DocumentType (explicit junction table ControlDocumentType) ---
+ // formType is an enum; uniqueness is on (controlId, formType). We treat adds
+ // as real row creates and removals as hard-deletes because this table has no
+ // archivedAt column and there's no need to preserve archived edges (the
+ // formType is just metadata describing what evidence the control accepts).
+ for (const edge of diff.controlDocumentTypeEdges.added) {
+ const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
+ if (!ctlInst) continue;
+ // Idempotent create: skip if already present (shared-entity case).
+ const existing = await tx.controlDocumentType.findUnique({
+ where: { controlId_formType: { controlId: ctlInst.id, formType: edge.formType as never } },
+ select: { id: true },
+ });
+ if (existing) continue;
+ const created = await tx.controlDocumentType.create({
+ data: { controlId: ctlInst.id, formType: edge.formType as never },
+ });
+ undo.controlDocumentTypes.created.push(created.id);
+ summary.controlDocumentTypesAdded += 1;
+ }
+ for (const edge of diff.controlDocumentTypeEdges.removed) {
+ const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
+ if (!ctlInst) continue;
+ const existing = await tx.controlDocumentType.findUnique({
+ where: { controlId_formType: { controlId: ctlInst.id, formType: edge.formType as never } },
+ select: { id: true },
+ });
+ if (!existing) continue;
+ await tx.controlDocumentType.delete({ where: { id: existing.id } });
+ undo.controlDocumentTypes.deleted.push({ controlId: ctlInst.id, formType: edge.formType });
+ summary.controlDocumentTypesArchived += 1;
+ }
+
+ // --- Persist sync op + update currentVersionId ---
+ const syncOp = await tx.frameworkSyncOperation.create({
+ data: {
+ frameworkInstanceId: ctx.instance.id,
+ fromVersionId: ctx.currentVersion.id,
+ toVersionId: ctx.targetVersion.id,
+ kind: 'SYNC',
+ performedById: ctx.memberId,
+ rollbackExpiresAt: addDays(new Date(), ROLLBACK_WINDOW_DAYS),
+ undoPayload: undo as unknown as object,
+ summary: summary as unknown as object,
+ },
+ });
+
+ await tx.frameworkInstance.update({
+ where: { id: ctx.instance.id },
+ data: { currentVersionId: ctx.targetVersion.id },
+ });
+
+ return { syncOperationId: syncOp.id };
+}
+
+/**
+ * Normalize opaque manifest content (either a single JSON object or an array
+ * of them — templates are single-object, customer Policy.content is Json[])
+ * into an InputJsonValue[] for Prisma writes.
+ */
+function toJsonArray(value: unknown): Prisma.InputJsonValue[] {
+ if (value == null) return [];
+ if (Array.isArray(value)) return value as Prisma.InputJsonValue[];
+ return [value as Prisma.InputJsonValue];
+}
+
+function addDays(d: Date, days: number): Date {
+ const r = new Date(d);
+ r.setUTCDate(r.getUTCDate() + days);
+ return r;
+}
diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts
new file mode 100644
index 0000000000..af87d5b1e9
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts
@@ -0,0 +1,64 @@
+import { Test } from '@nestjs/testing';
+import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
+import { FrameworkSyncService } from './framework-sync.service';
+
+jest.mock('@db', () => {
+ const tx = {
+ frameworkInstance: { findUnique: jest.fn() },
+ frameworkVersion: { findUnique: jest.fn() },
+ };
+ return {
+ db: {
+ frameworkInstance: { findUnique: jest.fn() },
+ frameworkVersion: { findUnique: jest.fn() },
+ $transaction: jest.fn((cb: (t: typeof tx) => Promise) => cb(tx)),
+ __tx: tx,
+ },
+ };
+});
+
+jest.mock('./org-advisory-lock', () => ({
+ lockOrganizationForSync: jest.fn().mockResolvedValue(undefined),
+}));
+
+import { db } from '@db';
+const tx = (db as unknown as { __tx: { frameworkInstance: { findUnique: jest.Mock }; frameworkVersion: { findUnique: jest.Mock } } }).__tx;
+
+describe('FrameworkSyncService preconditions', () => {
+ let service: FrameworkSyncService;
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ const mod = await Test.createTestingModule({ providers: [FrameworkSyncService] }).compile();
+ service = mod.get(FrameworkSyncService);
+ });
+
+ it('404s when framework instance not found (pre-lock)', async () => {
+ (db.frameworkInstance.findUnique as jest.Mock).mockResolvedValue(null);
+ await expect(service.sync({ organizationId: 'org_1', frameworkInstanceId: 'frm_missing', targetVersionId: 'fvr_1', memberId: 'mem_1' }))
+ .rejects.toBeInstanceOf(NotFoundException);
+ });
+
+ it('403s when instance belongs to a different org (pre-lock)', async () => {
+ (db.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ id: 'frm_1', organizationId: 'org_other', currentVersionId: 'fvr_1' });
+ await expect(service.sync({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', targetVersionId: 'fvr_1', memberId: 'mem_1' }))
+ .rejects.toBeInstanceOf(ForbiddenException);
+ });
+
+ it('400s when target version belongs to a different framework (inside lock)', async () => {
+ (db.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ id: 'frm_1', organizationId: 'org_1', frameworkId: 'frk_soc2', currentVersionId: 'fvr_current' });
+ tx.frameworkInstance.findUnique.mockResolvedValue({ id: 'frm_1', organizationId: 'org_1', frameworkId: 'frk_soc2', currentVersionId: 'fvr_current' });
+ tx.frameworkVersion.findUnique
+ .mockResolvedValueOnce({ id: 'fvr_current', frameworkId: 'frk_soc2' })
+ .mockResolvedValueOnce({ id: 'fvr_target', frameworkId: 'frk_iso' });
+ await expect(service.sync({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', targetVersionId: 'fvr_target', memberId: 'mem_1' }))
+ .rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('is a no-op when instance is already at target version (pre-lock short-circuit)', async () => {
+ (db.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ id: 'frm_1', organizationId: 'org_1', frameworkId: 'frk_soc2', currentVersionId: 'fvr_target' });
+ const result = await service.sync({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', targetVersionId: 'fvr_target', memberId: 'mem_1' });
+ expect(result.kind).toBe('no-op');
+ expect(db.$transaction).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts b/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts
new file mode 100644
index 0000000000..0011c1dbdf
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts
@@ -0,0 +1,82 @@
+import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import { applySync, type VersionWithManifest } from './framework-sync-apply';
+import { lockOrganizationForSync } from './org-advisory-lock';
+
+export interface SyncParams {
+ organizationId: string;
+ frameworkInstanceId: string;
+ targetVersionId: string;
+ memberId: string;
+}
+
+export type SyncResult =
+ | { kind: 'no-op'; frameworkInstanceId: string }
+ | { kind: 'synced'; frameworkInstanceId: string; syncOperationId: string };
+
+@Injectable()
+export class FrameworkSyncService {
+ async sync(params: SyncParams): Promise {
+ // Cheap pre-lock check to short-circuit the obvious no-op without a tx.
+ // The authoritative check happens inside the lock below.
+ const precheck = await db.frameworkInstance.findUnique({
+ where: { id: params.frameworkInstanceId },
+ select: { id: true, organizationId: true, currentVersionId: true },
+ });
+ if (!precheck) throw new NotFoundException('Framework instance not found');
+ if (precheck.organizationId !== params.organizationId) {
+ throw new ForbiddenException('Wrong organization');
+ }
+ if (precheck.currentVersionId === params.targetVersionId) {
+ return { kind: 'no-op', frameworkInstanceId: precheck.id };
+ }
+
+ // Acquire the lock BEFORE reading instance/version state. Two concurrent
+ // syncs both passing a pre-lock validation on a shared `currentVersionId`
+ // would allow the second one to run applySync against a stale baseline.
+ const { syncOperationId, instanceId } = await db.$transaction(async (tx) => {
+ await lockOrganizationForSync(tx, params.organizationId);
+
+ const instance = await tx.frameworkInstance.findUnique({
+ where: { id: params.frameworkInstanceId },
+ });
+ if (!instance) throw new NotFoundException('Framework instance not found');
+ if (instance.organizationId !== params.organizationId) {
+ throw new ForbiddenException('Wrong organization');
+ }
+ if (instance.currentVersionId === params.targetVersionId) {
+ return { syncOperationId: null, instanceId: instance.id };
+ }
+
+ const [currentVersion, targetVersion] = await Promise.all([
+ instance.currentVersionId
+ ? tx.frameworkVersion.findUnique({ where: { id: instance.currentVersionId } })
+ : null,
+ tx.frameworkVersion.findUnique({ where: { id: params.targetVersionId } }),
+ ]);
+ if (!targetVersion) throw new NotFoundException('Target version not found');
+ if (!currentVersion) {
+ throw new BadRequestException('Instance is not on any version; backfill v1.0.0 first');
+ }
+ if (currentVersion.frameworkId !== instance.frameworkId) {
+ throw new BadRequestException('Version / framework mismatch');
+ }
+ if (targetVersion.frameworkId !== instance.frameworkId) {
+ throw new BadRequestException('Target version belongs to a different framework');
+ }
+
+ const result = await applySync(tx, {
+ instance,
+ currentVersion: currentVersion as unknown as VersionWithManifest,
+ targetVersion: targetVersion as unknown as VersionWithManifest,
+ memberId: params.memberId,
+ });
+ return { syncOperationId: result.syncOperationId, instanceId: instance.id };
+ });
+
+ if (syncOperationId === null) {
+ return { kind: 'no-op', frameworkInstanceId: instanceId };
+ }
+ return { kind: 'synced', frameworkInstanceId: instanceId, syncOperationId };
+ }
+}
diff --git a/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts
new file mode 100644
index 0000000000..b061835c93
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts
@@ -0,0 +1,56 @@
+import { buildUpdatePreview } from './framework-update-preview';
+import type { FrameworkManifest } from './manifest.types';
+
+const empty: FrameworkManifest = {
+ framework: { id: 'f', name: 'n', catalogVersion: '1', description: null },
+ requirements: [], controls: [], policies: [], tasks: [],
+};
+
+describe('buildUpdatePreview', () => {
+ it('classifies added control', () => {
+ const preview = buildUpdatePreview({
+ fromManifest: empty,
+ toManifest: { ...empty, controls: [{ id: 'c1', name: 'C', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] },
+ instanceControls: [],
+ instanceTasks: [],
+ instancePolicies: [],
+ });
+ expect(preview.controls.added).toHaveLength(1);
+ expect(preview.controls.archived).toHaveLength(0);
+ });
+
+ it('classifies removed control as archived', () => {
+ const preview = buildUpdatePreview({
+ fromManifest: { ...empty, controls: [{ id: 'c1', name: 'C', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] },
+ toManifest: empty,
+ instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'C', description: 'd' }],
+ instanceTasks: [],
+ instancePolicies: [],
+ });
+ expect(preview.controls.archived).toHaveLength(1);
+ });
+
+ it('classifies updated + unedited control as applied', () => {
+ const preview = buildUpdatePreview({
+ fromManifest: { ...empty, controls: [{ id: 'c1', name: 'Old', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] },
+ toManifest: { ...empty, controls: [{ id: 'c1', name: 'New', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] },
+ instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'Old', description: 'd' }],
+ instanceTasks: [],
+ instancePolicies: [],
+ });
+ expect(preview.controls.updatedApplied).toHaveLength(1);
+ expect(preview.controls.updatedPreserved).toHaveLength(0);
+ });
+
+ it('classifies updated + customer-edited control as preserved', () => {
+ const preview = buildUpdatePreview({
+ fromManifest: { ...empty, controls: [{ id: 'c1', name: 'Old', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] },
+ toManifest: { ...empty, controls: [{ id: 'c1', name: 'New', description: 'd', requirementIds: [], policyIds: [], taskIds: [] }] },
+ instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'My edit', description: 'd' }],
+ instanceTasks: [],
+ instancePolicies: [],
+ });
+ expect(preview.controls.updatedPreserved).toHaveLength(1);
+ expect(preview.controls.updatedApplied).toHaveLength(0);
+ });
+});
diff --git a/apps/api/src/frameworks/framework-versioning/framework-update-preview.ts b/apps/api/src/frameworks/framework-versioning/framework-update-preview.ts
new file mode 100644
index 0000000000..6a2e38cde4
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/framework-update-preview.ts
@@ -0,0 +1,278 @@
+import { diffManifests } from './framework-diff';
+import { isControlEdited, isPolicyEdited, isTaskEdited } from './framework-drift';
+import type {
+ FrameworkManifest,
+ ManifestControl,
+ ManifestPolicy,
+ ManifestRequirement,
+ ManifestTask,
+} from './manifest.types';
+
+export interface InstanceControl {
+ id: string;
+ controlTemplateId: string | null;
+ name: string;
+ description: string;
+}
+
+export interface InstanceTask {
+ id: string;
+ taskTemplateId: string | null;
+ title: string;
+ description: string;
+ frequency: string | null;
+ department: string | null;
+}
+
+export interface InstancePolicy {
+ id: string;
+ policyTemplateId: string | null;
+ name: string;
+ description: string | null;
+ content: unknown;
+ frequency: string | null;
+ department: string | null;
+ status: 'draft' | 'published' | string;
+}
+
+export interface PolicyUpdatePreviewBuckets {
+ added: ManifestPolicy[];
+ archived: Array<{ instanceId: string; manifest: ManifestPolicy }>;
+ updatedApplied: Array<{ instance: InstancePolicy; manifestFrom: ManifestPolicy; manifestTo: ManifestPolicy }>;
+ updatedPreserved: Array<{ instance: InstancePolicy; manifestFrom: ManifestPolicy; manifestTo: ManifestPolicy }>;
+ draftAddedForPublished: Array<{ instance: InstancePolicy; manifestTo: ManifestPolicy }>;
+}
+
+export interface UpdatePreview {
+ fromVersion: { id: string; version: string };
+ toVersion: { id: string; version: string };
+ releaseNotes: string | null;
+ controls: {
+ added: ManifestControl[];
+ archived: Array<{ instanceId: string; manifest: ManifestControl }>;
+ updatedApplied: Array<{ instance: InstanceControl; manifestFrom: ManifestControl; manifestTo: ManifestControl }>;
+ updatedPreserved: Array<{ instance: InstanceControl; manifestFrom: ManifestControl; manifestTo: ManifestControl }>;
+ };
+ tasks: {
+ added: ManifestTask[];
+ archived: Array<{ instanceId: string; manifest: ManifestTask }>;
+ updatedApplied: Array<{ instance: InstanceTask; manifestFrom: ManifestTask; manifestTo: ManifestTask }>;
+ updatedPreserved: Array<{ instance: InstanceTask; manifestFrom: ManifestTask; manifestTo: ManifestTask }>;
+ };
+ policies: PolicyUpdatePreviewBuckets;
+ requirements: {
+ added: ManifestRequirement[];
+ removed: ManifestRequirement[];
+ updated: Array<{ from: ManifestRequirement; to: ManifestRequirement }>;
+ };
+ edges: {
+ controlPolicy: EdgePreviewBuckets;
+ controlTask: EdgePreviewBuckets;
+ controlRequirement: EdgePreviewBuckets;
+ controlDocumentType: EdgePreviewBuckets;
+ };
+}
+
+export interface EdgePreviewBuckets {
+ added: T[];
+ removed: T[];
+}
+
+export interface ControlPolicyLink {
+ controlName: string;
+ policyName: string;
+}
+
+export interface ControlTaskLink {
+ controlName: string;
+ taskName: string;
+}
+
+export interface ControlRequirementLink {
+ controlName: string;
+ requirementIdentifier: string;
+ requirementName: string;
+}
+
+export interface ControlDocumentTypeLink {
+ controlName: string;
+ formType: string;
+}
+
+export interface BuildUpdatePreviewInput {
+ fromManifest: FrameworkManifest;
+ toManifest: FrameworkManifest;
+ instanceControls: InstanceControl[];
+ instanceTasks: InstanceTask[];
+ instancePolicies: InstancePolicy[];
+ // Required: every real caller pairs a preview with concrete version ids so
+ // the sync engine can operate on known snapshots. Making this optional let
+ // preview payloads ship without version identity.
+ fromVersionLabel: { id: string; version: string };
+ toVersionLabel: { id: string; version: string };
+ releaseNotes?: string | null;
+}
+
+export function buildUpdatePreview(input: BuildUpdatePreviewInput): UpdatePreview {
+ const d = diffManifests(input.fromManifest, input.toManifest);
+
+ const ctlByTemplate = new Map(
+ input.instanceControls
+ .filter((c) => c.controlTemplateId)
+ .map((c) => [c.controlTemplateId!, c]),
+ );
+ const taskByTemplate = new Map(
+ input.instanceTasks
+ .filter((t) => t.taskTemplateId)
+ .map((t) => [t.taskTemplateId!, t]),
+ );
+ const polByTemplate = new Map(
+ input.instancePolicies
+ .filter((p) => p.policyTemplateId)
+ .map((p) => [p.policyTemplateId!, p]),
+ );
+
+ const controls: UpdatePreview['controls'] = {
+ added: d.controls.added,
+ archived: [],
+ updatedApplied: [],
+ updatedPreserved: [],
+ };
+ for (const r of d.controls.removed) {
+ const inst = ctlByTemplate.get(r.id);
+ if (inst) controls.archived.push({ instanceId: inst.id, manifest: r });
+ }
+ for (const u of d.controls.updated) {
+ const inst = ctlByTemplate.get(u.id);
+ if (!inst) continue;
+ const bucket = isControlEdited(inst, u.from) ? controls.updatedPreserved : controls.updatedApplied;
+ bucket.push({ instance: inst, manifestFrom: u.from, manifestTo: u.to });
+ }
+
+ const tasks: UpdatePreview['tasks'] = {
+ added: d.tasks.added,
+ archived: [],
+ updatedApplied: [],
+ updatedPreserved: [],
+ };
+ for (const r of d.tasks.removed) {
+ const inst = taskByTemplate.get(r.id);
+ if (inst) tasks.archived.push({ instanceId: inst.id, manifest: r });
+ }
+ for (const u of d.tasks.updated) {
+ const inst = taskByTemplate.get(u.id);
+ if (!inst) continue;
+ const bucket = isTaskEdited(inst, u.from) ? tasks.updatedPreserved : tasks.updatedApplied;
+ bucket.push({ instance: inst, manifestFrom: u.from, manifestTo: u.to });
+ }
+
+ const policies: PolicyUpdatePreviewBuckets = {
+ added: d.policies.added,
+ archived: [],
+ updatedApplied: [],
+ updatedPreserved: [],
+ draftAddedForPublished: [],
+ };
+ for (const r of d.policies.removed) {
+ const inst = polByTemplate.get(r.id);
+ if (inst) policies.archived.push({ instanceId: inst.id, manifest: r });
+ }
+ for (const u of d.policies.updated) {
+ const inst = polByTemplate.get(u.id);
+ if (!inst) continue;
+ if (inst.status === 'published') {
+ policies.draftAddedForPublished.push({ instance: inst, manifestTo: u.to });
+ continue;
+ }
+ const bucket = isPolicyEdited(inst, u.from) ? policies.updatedPreserved : policies.updatedApplied;
+ bucket.push({ instance: inst, manifestFrom: u.from, manifestTo: u.to });
+ }
+
+ return {
+ fromVersion: input.fromVersionLabel,
+ toVersion: input.toVersionLabel,
+ releaseNotes: input.releaseNotes ?? null,
+ controls,
+ tasks,
+ policies,
+ requirements: {
+ added: d.requirements.added,
+ removed: d.requirements.removed,
+ updated: d.requirements.updated.map((u) => ({ from: u.from, to: u.to })),
+ },
+ edges: buildEdgeLinks(input.fromManifest, input.toManifest, d),
+ };
+}
+
+function buildEdgeLinks(
+ fromManifest: FrameworkManifest,
+ toManifest: FrameworkManifest,
+ d: ReturnType,
+): UpdatePreview['edges'] {
+ // Resolve template IDs to display names using both manifests (an edge's
+ // referent may have been removed or added within the same diff).
+ const nameForControl = (id: string) =>
+ toManifest.controls.find((c) => c.id === id)?.name ??
+ fromManifest.controls.find((c) => c.id === id)?.name ??
+ 'Unknown control';
+ const nameForPolicy = (id: string) =>
+ toManifest.policies.find((p) => p.id === id)?.name ??
+ fromManifest.policies.find((p) => p.id === id)?.name ??
+ 'Unknown policy';
+ const nameForTask = (id: string) =>
+ toManifest.tasks.find((t) => t.id === id)?.name ??
+ fromManifest.tasks.find((t) => t.id === id)?.name ??
+ 'Unknown task';
+ const requirementInfo = (id: string) => {
+ const r =
+ toManifest.requirements.find((x) => x.id === id) ??
+ fromManifest.requirements.find((x) => x.id === id);
+ return {
+ requirementIdentifier: r?.identifier ?? '',
+ requirementName: r?.name ?? 'Unknown requirement',
+ };
+ };
+
+ return {
+ controlPolicy: {
+ added: d.controlPolicyEdges.added.map((e) => ({
+ controlName: nameForControl(e.controlTemplateId),
+ policyName: nameForPolicy(e.policyTemplateId),
+ })),
+ removed: d.controlPolicyEdges.removed.map((e) => ({
+ controlName: nameForControl(e.controlTemplateId),
+ policyName: nameForPolicy(e.policyTemplateId),
+ })),
+ },
+ controlTask: {
+ added: d.controlTaskEdges.added.map((e) => ({
+ controlName: nameForControl(e.controlTemplateId),
+ taskName: nameForTask(e.taskTemplateId),
+ })),
+ removed: d.controlTaskEdges.removed.map((e) => ({
+ controlName: nameForControl(e.controlTemplateId),
+ taskName: nameForTask(e.taskTemplateId),
+ })),
+ },
+ controlRequirement: {
+ added: d.requirementMapEdges.added.map((e) => ({
+ controlName: nameForControl(e.controlTemplateId),
+ ...requirementInfo(e.requirementTemplateId),
+ })),
+ removed: d.requirementMapEdges.removed.map((e) => ({
+ controlName: nameForControl(e.controlTemplateId),
+ ...requirementInfo(e.requirementTemplateId),
+ })),
+ },
+ controlDocumentType: {
+ added: d.controlDocumentTypeEdges.added.map((e) => ({
+ controlName: nameForControl(e.controlTemplateId),
+ formType: e.formType,
+ })),
+ removed: d.controlDocumentTypeEdges.removed.map((e) => ({
+ controlName: nameForControl(e.controlTemplateId),
+ formType: e.formType,
+ })),
+ },
+ };
+}
diff --git a/apps/api/src/frameworks/framework-versioning/manifest.types.ts b/apps/api/src/frameworks/framework-versioning/manifest.types.ts
new file mode 100644
index 0000000000..d417da1a17
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/manifest.types.ts
@@ -0,0 +1,50 @@
+// Shape of FrameworkVersion.manifest. Must match the structure produced by
+// the backfill script (packages/db/src/scripts/backfill-framework-versions.ts)
+// and the manifest builder (framework-manifest-builder.ts).
+
+export interface FrameworkManifest {
+ framework: {
+ id: string;
+ name: string;
+ catalogVersion: string;
+ description: string | null;
+ };
+ requirements: ManifestRequirement[];
+ controls: ManifestControl[];
+ policies: ManifestPolicy[];
+ tasks: ManifestTask[];
+}
+
+export interface ManifestRequirement {
+ id: string; // frk_rq_*
+ identifier: string; // e.g. "CC6.1"
+ name: string;
+ description: string | null;
+}
+
+export interface ManifestControl {
+ id: string; // frk_ct_*
+ name: string;
+ description: string;
+ requirementIds: string[];
+ policyIds: string[];
+ taskIds: string[];
+ documentTypes: string[]; // EvidenceFormType enum values
+}
+
+export interface ManifestPolicy {
+ id: string; // frk_pt_*
+ name: string;
+ description: string | null;
+ content: unknown; // TipTap JSON — opaque here
+ frequency: string | null;
+ department: string | null;
+}
+
+export interface ManifestTask {
+ id: string; // frk_tt_*
+ name: string;
+ description: string;
+ frequency: string | null;
+ department: string | null;
+}
diff --git a/apps/api/src/frameworks/framework-versioning/org-advisory-lock.ts b/apps/api/src/frameworks/framework-versioning/org-advisory-lock.ts
new file mode 100644
index 0000000000..a8f46b14e7
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/org-advisory-lock.ts
@@ -0,0 +1,13 @@
+import type { Prisma } from '@db';
+
+const LOCK_NAMESPACE = 0x46575653; // 'FVSS' — framework-versioning sync/service
+
+export async function lockOrganizationForSync(
+ tx: Prisma.TransactionClient,
+ organizationId: string,
+): Promise {
+ // Tagged-template form of $executeRaw — Prisma parameterizes the values,
+ // keeping us off the `Unsafe` raw path even though the hashtext input is a
+ // controlled, server-side string.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(${LOCK_NAMESPACE}, hashtext(${organizationId}))`;
+}
diff --git a/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts
new file mode 100644
index 0000000000..3be87ab54c
--- /dev/null
+++ b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts
@@ -0,0 +1,91 @@
+export interface UndoPayload {
+ controls: EntityUndoBucket;
+ policies: PolicyUndoBucket;
+ tasks: EntityUndoBucket;
+ requirementMaps: EdgeUndoBucket;
+ // ControlDocumentType has no archivedAt column — hard-delete on remove,
+ // recreate on rollback by (controlId, formType).
+ controlDocumentTypes: ControlDocumentTypeUndoBucket;
+ // Prisma implicit many-to-many relations: each entry is one connect or
+ // disconnect between a Control and a Policy / Task instance.
+ controlPolicyLinks: ImplicitEdgeBucket;
+ controlTaskLinks: ImplicitEdgeBucket;
+}
+
+export interface EntityUndoBucket {
+ created: string[]; // IDs hard-deleted on rollback
+ archived: Array<{ id: string; prevArchivedAt: Date | null }>;
+ contentUpdated: Array<{ id: string; prevContent: Content }>;
+}
+
+export interface PolicyUndoBucket extends EntityUndoBucket {
+ // Drafts added via sync (unpublished PolicyVersion rows) — deleted on rollback.
+ draftsAdded: Array<{ policyId: string; draftVersionId: string }>;
+}
+
+export interface ControlUndoContent {
+ name: string;
+ description: string;
+}
+
+export interface TaskUndoContent {
+ title: string;
+ description: string;
+ frequency: string | null;
+ department: string | null;
+}
+
+export interface PolicyUndoContent {
+ name: string;
+ description: string | null;
+ content: unknown;
+ frequency: string | null;
+ department: string | null;
+}
+
+export interface EdgeUndoBucket {
+ created: string[];
+ archived: Array<{ id: string; prevArchivedAt: Date | null }>;
+}
+
+/**
+ * ControlDocumentType rows are hard-deleted (no archivedAt column), so the
+ * undo payload needs enough information to recreate them on rollback.
+ */
+export interface ControlDocumentTypeUndoBucket {
+ /** IDs to hard-delete on rollback (rows this sync created). */
+ created: string[];
+ /** Rows this sync hard-deleted — recreate by (controlId, formType) on rollback. */
+ deleted: Array<{ controlId: string; formType: string }>;
+}
+
+/**
+ * Implicit Prisma M:N relation edges (e.g., `control.policies`, `control.tasks`).
+ * Unlike explicit junction tables, these have no per-edge row with an archive
+ * column — so the undo payload records the raw pairs we connected/disconnected.
+ * Rollback reverses: `connected` become disconnects, `disconnected` become connects.
+ */
+export interface ImplicitEdgeBucket {
+ connected: Array<{ controlId: string; otherId: string }>;
+ disconnected: Array<{ controlId: string; otherId: string }>;
+}
+
+export interface SyncSummary {
+ controlsAdded: number;
+ controlsArchived: number;
+ controlsUpdatedApplied: number;
+ controlsUpdatedPreserved: number;
+ policiesAdded: number;
+ policiesArchived: number;
+ policiesUpdatedApplied: number;
+ policiesUpdatedPreserved: number;
+ policiesDraftAdded: number;
+ tasksAdded: number;
+ tasksArchived: number;
+ tasksUpdatedApplied: number;
+ tasksUpdatedPreserved: number;
+ requirementMapsAdded: number;
+ requirementMapsArchived: number;
+ controlDocumentTypesAdded: number;
+ controlDocumentTypesArchived: number;
+}
diff --git a/apps/api/src/frameworks/frameworks-scores.helper.ts b/apps/api/src/frameworks/frameworks-scores.helper.ts
index b629742787..468f0f92f0 100644
--- a/apps/api/src/frameworks/frameworks-scores.helper.ts
+++ b/apps/api/src/frameworks/frameworks-scores.helper.ts
@@ -15,8 +15,8 @@ const HIPAA_TRAINING_ID = 'hipaa-sat-1';
export async function getOverviewScores(organizationId: string) {
const [allPolicies, allTasks, employees, onboarding, org, hipaaInstance] =
await Promise.all([
- db.policy.findMany({ where: { organizationId } }),
- db.task.findMany({ where: { organizationId } }),
+ db.policy.findMany({ where: { organizationId, isArchived: false, archivedAt: null } }),
+ db.task.findMany({ where: { organizationId, archivedAt: null } }),
db.member.findMany({
where: { organizationId, deactivated: false },
include: { user: true },
diff --git a/apps/api/src/frameworks/frameworks-source-loader.helper.ts b/apps/api/src/frameworks/frameworks-source-loader.helper.ts
new file mode 100644
index 0000000000..8d23ff8a17
--- /dev/null
+++ b/apps/api/src/frameworks/frameworks-source-loader.helper.ts
@@ -0,0 +1,296 @@
+import type {
+ Prisma,
+ EvidenceFormType,
+ Frequency,
+ Departments,
+ TaskAutomationStatus,
+} from '@db';
+import type { FrameworkManifest } from './framework-versioning/manifest.types';
+
+/**
+ * Unified control/policy/task data and relations, sourced from either a
+ * framework's pinned FrameworkVersion.manifest or (fallback) the live
+ * framework-editor tables. Onboarding builds org-level Control/Policy/Task
+ * rows from this shape, which means a new org is pinned to the same snapshot
+ * its `currentVersionId` points at — not to whatever CX is editing live.
+ */
+export interface LoadedFrameworkSources {
+ controlTemplates: Array<{
+ id: string;
+ name: string;
+ description: string;
+ documentTypes: EvidenceFormType[];
+ }>;
+ policyTemplates: Array<{
+ id: string;
+ name: string;
+ description: string;
+ content: Prisma.JsonValue;
+ frequency: Frequency;
+ department: Departments;
+ }>;
+ taskTemplates: Array<{
+ id: string;
+ name: string;
+ description: string;
+ frequency: Frequency | null;
+ department: Departments | null;
+ automationStatus: TaskAutomationStatus;
+ }>;
+ groupedRelations: Array<{
+ controlTemplateId: string;
+ requirementTemplateIds: string[];
+ policyTemplateIds: string[];
+ taskTemplateIds: string[];
+ }>;
+ latestVersionByFrameworkId: Map;
+ frameworksWithoutVersion: string[];
+ /** requirementTemplateId → its owning frameworkEditorId (for RequirementMap). */
+ requirementToFrameworkId: Map;
+}
+
+export interface LoadSourcesInput {
+ frameworkEditorIds: string[];
+ /** Passed through for the fallback path when a framework has no version. */
+ frameworkEditorFrameworks: Prisma.FrameworkEditorFrameworkGetPayload<{
+ include: { requirements: true };
+ }>[];
+ tx: Prisma.TransactionClient;
+}
+
+export async function loadFrameworkSources({
+ frameworkEditorIds,
+ frameworkEditorFrameworks,
+ tx,
+}: LoadSourcesInput): Promise {
+ const versions = await tx.frameworkVersion.findMany({
+ where: { frameworkId: { in: frameworkEditorIds } },
+ orderBy: { publishedAt: 'desc' },
+ select: { id: true, frameworkId: true, manifest: true },
+ });
+ const latestVersionByFrameworkId = new Map();
+ const manifestByFrameworkId = new Map();
+ for (const v of versions) {
+ if (!latestVersionByFrameworkId.has(v.frameworkId)) {
+ latestVersionByFrameworkId.set(v.frameworkId, v.id);
+ manifestByFrameworkId.set(v.frameworkId, v.manifest as unknown as FrameworkManifest);
+ }
+ }
+ const frameworksWithoutVersion = frameworkEditorIds.filter(
+ (fid) => !latestVersionByFrameworkId.has(fid),
+ );
+
+ const requirementToFrameworkId = new Map();
+
+ // Collect controls/policies/tasks across all frameworks, deduped by id.
+ const controlsMap = new Map();
+ const policiesMap = new Map();
+ const tasksMap = new Map();
+
+ // groupedRelations accumulates per-control edges; when a control appears in
+ // multiple frameworks we union its requirement/policy/task id sets.
+ const relationsByControl = new Map<
+ string,
+ {
+ controlTemplateId: string;
+ requirementTemplateIds: Set;
+ policyTemplateIds: Set;
+ taskTemplateIds: Set;
+ }
+ >();
+
+ const getOrCreateRelation = (controlTemplateId: string) => {
+ let rel = relationsByControl.get(controlTemplateId);
+ if (!rel) {
+ rel = {
+ controlTemplateId,
+ requirementTemplateIds: new Set(),
+ policyTemplateIds: new Set(),
+ taskTemplateIds: new Set(),
+ };
+ relationsByControl.set(controlTemplateId, rel);
+ }
+ return rel;
+ };
+
+ // Manifest-backed frameworks
+ const manifestFrameworkIds: string[] = [];
+ for (const [frameworkId, manifest] of manifestByFrameworkId) {
+ manifestFrameworkIds.push(frameworkId);
+ for (const r of manifest.requirements) {
+ requirementToFrameworkId.set(r.id, frameworkId);
+ }
+ for (const c of manifest.controls) {
+ if (!controlsMap.has(c.id)) {
+ controlsMap.set(c.id, {
+ id: c.id,
+ name: c.name,
+ description: c.description,
+ documentTypes: (c.documentTypes ?? []) as EvidenceFormType[],
+ });
+ }
+ const rel = getOrCreateRelation(c.id);
+ for (const rid of c.requirementIds) rel.requirementTemplateIds.add(rid);
+ for (const pid of c.policyIds) rel.policyTemplateIds.add(pid);
+ for (const tid of c.taskIds) rel.taskTemplateIds.add(tid);
+ }
+ }
+
+ // Policy/task non-versioned fields: manifest carries name/description/
+ // frequency/department/content, but NOT automationStatus. Resolve that from
+ // live template rows by id. (Manifest stores frequency/department as
+ // strings — cast to the enum types at insert time.)
+ for (const [frameworkId, manifest] of manifestByFrameworkId) {
+ for (const p of manifest.policies) {
+ if (!policiesMap.has(p.id)) {
+ policiesMap.set(p.id, {
+ id: p.id,
+ name: p.name,
+ description: p.description ?? '',
+ content: p.content as Prisma.JsonValue,
+ frequency: p.frequency as unknown as Frequency,
+ department: p.department as unknown as Departments,
+ });
+ }
+ }
+ for (const t of manifest.tasks) {
+ if (!tasksMap.has(t.id)) {
+ tasksMap.set(t.id, {
+ id: t.id,
+ name: t.name,
+ description: t.description,
+ frequency: (t.frequency as unknown as Frequency | null) ?? null,
+ department: (t.department as unknown as Departments | null) ?? null,
+ // Resolved from live template below; AUTOMATED is the schema default
+ // and a safe fallback if the live template has been deleted.
+ automationStatus: 'AUTOMATED' as TaskAutomationStatus,
+ });
+ }
+ }
+ void frameworkId; // keep loop structure tidy; id used above
+ }
+
+ // Resolve automationStatus from live task templates for any manifest tasks
+ const manifestTaskIds = Array.from(tasksMap.keys());
+ if (manifestTaskIds.length > 0) {
+ const liveTasks = await tx.frameworkEditorTaskTemplate.findMany({
+ where: { id: { in: manifestTaskIds } },
+ select: { id: true, automationStatus: true },
+ });
+ for (const lt of liveTasks) {
+ const existing = tasksMap.get(lt.id);
+ if (existing) existing.automationStatus = lt.automationStatus;
+ }
+ }
+
+ // Fallback: frameworks without a published version load from live tables.
+ if (frameworksWithoutVersion.length > 0) {
+ const fallbackFrameworks = frameworkEditorFrameworks.filter((f) =>
+ frameworksWithoutVersion.includes(f.id),
+ );
+ const fallbackRequirementIds = fallbackFrameworks.flatMap((f) =>
+ f.requirements.map((r) => r.id),
+ );
+ for (const f of fallbackFrameworks) {
+ for (const r of f.requirements) {
+ if (!requirementToFrameworkId.has(r.id)) {
+ requirementToFrameworkId.set(r.id, f.id);
+ }
+ }
+ }
+
+ const liveControls = await tx.frameworkEditorControlTemplate.findMany({
+ where: { requirements: { some: { id: { in: fallbackRequirementIds } } } },
+ select: { id: true, name: true, description: true, documentTypes: true },
+ });
+ for (const lc of liveControls) {
+ if (!controlsMap.has(lc.id)) {
+ controlsMap.set(lc.id, {
+ id: lc.id,
+ name: lc.name,
+ description: lc.description,
+ documentTypes: lc.documentTypes,
+ });
+ }
+ }
+
+ const fallbackControlIds = liveControls.map((c) => c.id);
+ const controlRelationsLive = await tx.frameworkEditorControlTemplate.findMany({
+ where: { id: { in: fallbackControlIds } },
+ select: {
+ id: true,
+ requirements: {
+ where: { id: { in: fallbackRequirementIds } },
+ select: { id: true },
+ },
+ policyTemplates: { select: { id: true } },
+ taskTemplates: { select: { id: true } },
+ },
+ });
+ for (const cr of controlRelationsLive) {
+ const rel = getOrCreateRelation(cr.id);
+ for (const r of cr.requirements) rel.requirementTemplateIds.add(r.id);
+ for (const p of cr.policyTemplates) rel.policyTemplateIds.add(p.id);
+ for (const t of cr.taskTemplates) rel.taskTemplateIds.add(t.id);
+ }
+
+ const fallbackPolicyIds = controlRelationsLive.flatMap((cr) =>
+ cr.policyTemplates.map((p) => p.id),
+ );
+ if (fallbackPolicyIds.length > 0) {
+ const livePolicies = await tx.frameworkEditorPolicyTemplate.findMany({
+ where: { id: { in: fallbackPolicyIds } },
+ });
+ for (const lp of livePolicies) {
+ if (!policiesMap.has(lp.id)) {
+ policiesMap.set(lp.id, {
+ id: lp.id,
+ name: lp.name,
+ description: lp.description,
+ content: lp.content,
+ frequency: lp.frequency,
+ department: lp.department,
+ });
+ }
+ }
+ }
+
+ const fallbackTaskIds = controlRelationsLive.flatMap((cr) =>
+ cr.taskTemplates.map((t) => t.id),
+ );
+ if (fallbackTaskIds.length > 0) {
+ const liveTasks = await tx.frameworkEditorTaskTemplate.findMany({
+ where: { id: { in: fallbackTaskIds } },
+ });
+ for (const lt of liveTasks) {
+ if (!tasksMap.has(lt.id)) {
+ tasksMap.set(lt.id, {
+ id: lt.id,
+ name: lt.name,
+ description: lt.description,
+ frequency: lt.frequency,
+ department: lt.department,
+ automationStatus: lt.automationStatus,
+ });
+ }
+ }
+ }
+ }
+
+ const groupedRelations = Array.from(relationsByControl.values()).map((rel) => ({
+ controlTemplateId: rel.controlTemplateId,
+ requirementTemplateIds: Array.from(rel.requirementTemplateIds),
+ policyTemplateIds: Array.from(rel.policyTemplateIds),
+ taskTemplateIds: Array.from(rel.taskTemplateIds),
+ }));
+
+ return {
+ controlTemplates: Array.from(controlsMap.values()),
+ policyTemplates: Array.from(policiesMap.values()),
+ taskTemplates: Array.from(tasksMap.values()),
+ groupedRelations,
+ latestVersionByFrameworkId,
+ frameworksWithoutVersion,
+ requirementToFrameworkId,
+ };
+}
diff --git a/apps/api/src/frameworks/frameworks-timeline.helper.ts b/apps/api/src/frameworks/frameworks-timeline.helper.ts
index fc604bb29e..f8412f8909 100644
--- a/apps/api/src/frameworks/frameworks-timeline.helper.ts
+++ b/apps/api/src/frameworks/frameworks-timeline.helper.ts
@@ -174,10 +174,12 @@ async function runPhaseAdvancement({
where: { id: { in: frameworkInstanceIds }, organizationId },
include: {
requirementsMapped: {
+ where: { archivedAt: null },
include: {
control: {
include: {
policies: {
+ where: { archivedAt: null },
select: { id: true, name: true, status: true },
},
},
@@ -223,12 +225,13 @@ async function runPhaseAdvancement({
? db.task.findMany({
where: {
organizationId,
- controls: { some: { id: { in: allControlIds } } },
+ archivedAt: null,
+ controls: { some: { id: { in: allControlIds }, archivedAt: null } },
},
select: {
id: true,
status: true,
- controls: { select: { id: true } },
+ controls: { where: { archivedAt: null }, select: { id: true } },
},
})
: Promise.resolve([]);
diff --git a/apps/api/src/frameworks/frameworks-upsert.helper.ts b/apps/api/src/frameworks/frameworks-upsert.helper.ts
index 6d94e15220..7a6d026806 100644
--- a/apps/api/src/frameworks/frameworks-upsert.helper.ts
+++ b/apps/api/src/frameworks/frameworks-upsert.helper.ts
@@ -1,4 +1,5 @@
import { Prisma } from '@db';
+import { loadFrameworkSources } from './frameworks-source-loader.helper';
/**
* Unwraps a `{ set: [...] }` wrapper that was incorrectly stored by a
@@ -7,9 +8,9 @@ import { Prisma } from '@db';
* Filters null entries and returns InputJsonValue[] for createMany compatibility.
*/
function sanitizeJsonContent(
- value: Prisma.JsonValue[],
+ value: Prisma.JsonValue | Prisma.JsonValue[],
): Prisma.InputJsonValue[] {
- let arr = value;
+ let arr: Prisma.JsonValue[] = Array.isArray(value) ? value : [value];
if (
arr.length === 1 &&
@@ -46,49 +47,34 @@ export async function upsertOrgFrameworkStructure({
frameworkEditorFrameworks,
tx,
}: UpsertOrgFrameworkStructureInput) {
- // Get all template entities based on input frameworks
- const requirementIds = frameworkEditorFrameworks.flatMap((framework) =>
- framework.requirements.map((req) => req.id),
- );
-
- const controlTemplates = await tx.frameworkEditorControlTemplate.findMany({
- where: {
- requirements: { some: { id: { in: requirementIds } } },
- },
+ // Source data comes from each framework's pinned FrameworkVersion.manifest
+ // so a new org is aligned with the version it's about to be pinned to —
+ // not with whatever CX is editing live. Frameworks without a published
+ // version fall back to live templates (with a warning).
+ const sources = await loadFrameworkSources({
+ frameworkEditorIds: targetFrameworkEditorIds,
+ frameworkEditorFrameworks,
+ tx,
});
- const controlTemplateIds = controlTemplates.map((c) => c.id);
- const policyTemplates = await tx.frameworkEditorPolicyTemplate.findMany({
- where: {
- controlTemplates: { some: { id: { in: controlTemplateIds } } },
- },
- });
- const policyTemplateIds = policyTemplates.map((p) => p.id);
-
- const taskTemplates = await tx.frameworkEditorTaskTemplate.findMany({
- where: {
- controlTemplates: { some: { id: { in: controlTemplateIds } } },
- },
- });
- const taskTemplateIds = taskTemplates.map((t) => t.id);
+ for (const fid of sources.frameworksWithoutVersion) {
+ console.warn(
+ `upsertOrgFrameworkStructure: no FrameworkVersion for framework ${fid} — falling back to live templates and pinning currentVersionId=null. Publish v1.0.0 in the framework editor.`,
+ );
+ }
- // Get all template relations
- const controlRelations = await tx.frameworkEditorControlTemplate.findMany({
- where: { id: { in: controlTemplateIds } },
- select: {
- id: true,
- requirements: { where: { id: { in: requirementIds } } },
- policyTemplates: { where: { id: { in: policyTemplateIds } } },
- taskTemplates: { where: { id: { in: taskTemplateIds } } },
- },
- });
+ const {
+ controlTemplates,
+ policyTemplates,
+ taskTemplates,
+ groupedRelations,
+ latestVersionByFrameworkId,
+ requirementToFrameworkId,
+ } = sources;
- const groupedRelations = controlRelations.map((ct) => ({
- controlTemplateId: ct.id,
- requirementTemplateIds: ct.requirements.map((r) => r.id),
- policyTemplateIds: ct.policyTemplates.map((p) => p.id),
- taskTemplateIds: ct.taskTemplates.map((t) => t.id),
- }));
+ const controlTemplateIds = controlTemplates.map((c) => c.id);
+ const policyTemplateIds = policyTemplates.map((p) => p.id);
+ const taskTemplateIds = taskTemplates.map((t) => t.id);
// Upsert framework instances
const existingInstances = await tx.frameworkInstance.findMany({
@@ -111,6 +97,7 @@ export async function upsertOrgFrameworkStructure({
.map((framework) => ({
organizationId,
frameworkId: framework.id,
+ currentVersionId: latestVersionByFrameworkId.get(framework.id) ?? null,
}));
if (instancesToCreate.length > 0) {
@@ -180,9 +167,7 @@ export async function upsertOrgFrameworkStructure({
description: pt.description,
department: pt.department,
frequency: pt.frequency,
- content: sanitizeJsonContent(
- Array.isArray(pt.content) ? pt.content : [pt.content],
- ),
+ content: sanitizeJsonContent(pt.content),
organizationId,
policyTemplateId: pt.id,
})),
@@ -246,6 +231,8 @@ export async function upsertOrgFrameworkStructure({
title: tt.name,
description: tt.description,
automationStatus: tt.automationStatus,
+ frequency: tt.frequency,
+ department: tt.department,
organizationId,
taskTemplateId: tt.id,
})),
@@ -292,6 +279,8 @@ export async function upsertOrgFrameworkStructure({
);
const requirementMapEntries: Prisma.RequirementMapCreateManyInput[] = [];
+ const controlDocumentTypeEntries: Prisma.ControlDocumentTypeCreateManyInput[] = [];
+ const controlTemplateById = new Map(controlTemplates.map((c) => [c.id, c]));
for (const relation of groupedRelations) {
const controlId = controlMap.get(relation.controlTemplateId);
@@ -300,15 +289,8 @@ export async function upsertOrgFrameworkStructure({
const updateData: Prisma.ControlUpdateInput = {};
let needsUpdate = false;
- // Process requirements for RequirementMap
for (const reqTemplateId of relation.requirementTemplateIds) {
- let frameworkEditorId: string | undefined;
- for (const fw of frameworkEditorFrameworks) {
- if (fw.requirements.some((r) => r.id === reqTemplateId)) {
- frameworkEditorId = fw.id;
- break;
- }
- }
+ const frameworkEditorId = requirementToFrameworkId.get(reqTemplateId);
const frameworkInstanceId = frameworkEditorId
? editorToInstanceMap.get(frameworkEditorId)
: undefined;
@@ -322,7 +304,6 @@ export async function upsertOrgFrameworkStructure({
}
}
- // Connect policies
const policiesToConnect = relation.policyTemplateIds
.map((ptId) => policyMap.get(ptId))
.filter((id): id is string => !!id)
@@ -333,7 +314,6 @@ export async function upsertOrgFrameworkStructure({
needsUpdate = true;
}
- // Connect tasks
const tasksToConnect = relation.taskTemplateIds
.map((ttId) => taskMap.get(ttId))
.filter((id): id is string => !!id)
@@ -350,9 +330,17 @@ export async function upsertOrgFrameworkStructure({
data: updateData,
});
}
+
+ // ControlDocumentType: explicit junction rows. Drive from manifest/live
+ // documentTypes so the new org starts with the same evidence form types
+ // the published version specified. Skip duplicates against existing rows
+ // via the unique constraint at create time.
+ const ct = controlTemplateById.get(relation.controlTemplateId);
+ for (const formType of ct?.documentTypes ?? []) {
+ controlDocumentTypeEntries.push({ controlId, formType });
+ }
}
- // Create RequirementMap entries
if (requirementMapEntries.length > 0) {
await tx.requirementMap.createMany({
data: requirementMapEntries,
@@ -360,6 +348,13 @@ export async function upsertOrgFrameworkStructure({
});
}
+ if (controlDocumentTypeEntries.length > 0) {
+ await tx.controlDocumentType.createMany({
+ data: controlDocumentTypeEntries,
+ skipDuplicates: true,
+ });
+ }
+
return {
processedFrameworks: frameworkEditorFrameworks,
controlTemplates,
diff --git a/apps/api/src/frameworks/frameworks.controller.spec.ts b/apps/api/src/frameworks/frameworks.controller.spec.ts
index b8996ce7cf..970e46be5c 100644
--- a/apps/api/src/frameworks/frameworks.controller.spec.ts
+++ b/apps/api/src/frameworks/frameworks.controller.spec.ts
@@ -1,22 +1,58 @@
+jest.mock('@db', () => ({
+ db: {},
+ FindingType: {
+ soc2: 'soc2',
+ iso27001: 'iso27001',
+ hipaa: 'hipaa',
+ gdpr: 'gdpr',
+ nist: 'nist',
+ },
+ Frequency: {},
+ Departments: {},
+}));
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@trycompai/auth', () => ({
+ ac: { newRole: jest.fn() },
+ createAccessControl: jest.fn(),
+ adminAc: {},
+ ownerAc: {},
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { FrameworksController } from './frameworks.controller';
import { FrameworksService } from './frameworks.service';
+import { FrameworkSyncService } from './framework-versioning/framework-sync.service';
+import { FrameworkRollbackService } from './framework-versioning/framework-rollback.service';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { PermissionGuard } from '../auth/permission.guard';
-jest.mock('../auth/auth.server', () => ({
- auth: { api: { getSession: jest.fn() } },
-}));
-
describe('FrameworksController', () => {
let controller: FrameworksController;
let service: jest.Mocked;
+ let syncService: jest.Mocked;
+ let rollbackService: jest.Mocked;
const mockService = {
findAll: jest.fn(),
findAvailable: jest.fn(),
delete: jest.fn(),
+ getUpdateStatus: jest.fn(),
+ getUpdatePreview: jest.fn(),
+ getSyncHistory: jest.fn(),
+ };
+
+ const mockSyncService = {
+ sync: jest.fn(),
+ };
+
+ const mockRollbackService = {
+ rollback: jest.fn(),
};
const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
@@ -24,7 +60,11 @@ describe('FrameworksController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FrameworksController],
- providers: [{ provide: FrameworksService, useValue: mockService }],
+ providers: [
+ { provide: FrameworksService, useValue: mockService },
+ { provide: FrameworkSyncService, useValue: mockSyncService },
+ { provide: FrameworkRollbackService, useValue: mockRollbackService },
+ ],
})
.overrideGuard(HybridAuthGuard)
.useValue(mockGuard)
@@ -34,6 +74,8 @@ describe('FrameworksController', () => {
controller = module.get(FrameworksController);
service = module.get(FrameworksService);
+ syncService = module.get(FrameworkSyncService);
+ rollbackService = module.get(FrameworkRollbackService);
jest.clearAllMocks();
});
@@ -57,7 +99,7 @@ describe('FrameworksController', () => {
const result = await controller.findAll('org_1');
expect(result).toEqual({ data: mockData, count: 2 });
- expect(service.findAll).toHaveBeenCalledWith('org_1');
+ expect(service.findAll).toHaveBeenCalledWith('org_1', { includeControls: false, includeScores: false });
});
it('should return empty list when no frameworks', async () => {
@@ -115,4 +157,173 @@ describe('FrameworksController', () => {
);
});
});
+
+ describe('getUpdateStatus', () => {
+ it('should return update status with { data }', async () => {
+ const mockStatus = {
+ currentVersion: { id: 'fvr_1', version: '1.0.0' },
+ latestVersion: { id: 'fvr_2', version: '2.0.0', publishedAt: new Date(), releaseNotes: null },
+ updateAvailable: true,
+ };
+ mockService.getUpdateStatus.mockResolvedValue(mockStatus);
+
+ const result = await controller.getUpdateStatus('org_1', 'fi_1');
+
+ expect(result).toEqual({ data: mockStatus });
+ expect(service.getUpdateStatus).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ frameworkInstanceId: 'fi_1',
+ });
+ });
+
+ it('should propagate NotFoundException when instance not found', async () => {
+ mockService.getUpdateStatus.mockRejectedValue(
+ new NotFoundException('Framework instance not found'),
+ );
+
+ await expect(controller.getUpdateStatus('org_1', 'missing')).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+ });
+
+ describe('getUpdatePreview', () => {
+ it('should return update preview with { data }', async () => {
+ const mockPreview = {
+ fromVersion: { id: 'fvr_1', version: '1.0.0' },
+ toVersion: { id: 'fvr_2', version: '2.0.0' },
+ releaseNotes: 'New features',
+ controls: { added: [], archived: [], updatedApplied: [], updatedPreserved: [] },
+ tasks: { added: [], archived: [], updatedApplied: [], updatedPreserved: [] },
+ policies: { added: [], archived: [], updatedApplied: [], updatedPreserved: [], draftAddedForPublished: [] },
+ requirements: { added: [], removed: [], updated: [] },
+ };
+ mockService.getUpdatePreview.mockResolvedValue(mockPreview);
+
+ const result = await controller.getUpdatePreview('org_1', 'fi_1');
+
+ expect(result).toEqual({ data: mockPreview });
+ expect(service.getUpdatePreview).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ frameworkInstanceId: 'fi_1',
+ });
+ });
+
+ it('should propagate NotFoundException when no update available', async () => {
+ mockService.getUpdatePreview.mockRejectedValue(
+ new NotFoundException('No update available'),
+ );
+
+ await expect(controller.getUpdatePreview('org_1', 'fi_1')).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+ });
+
+ describe('syncFramework', () => {
+ const mockAuthContext = { userId: 'usr_1', organizationId: 'org_1' };
+
+ it('should delegate to syncService and return { data: result }', async () => {
+ const mockResult = { kind: 'synced', frameworkInstanceId: 'fi_1', syncOperationId: 'fso_1' };
+ mockSyncService.sync.mockResolvedValue(mockResult);
+
+ const result = await controller.syncFramework(
+ 'org_1',
+ 'fi_1',
+ { targetVersionId: 'fvr_2' },
+ mockAuthContext as never,
+ );
+
+ expect(result).toEqual({ data: mockResult });
+ expect(syncService.sync).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ frameworkInstanceId: 'fi_1',
+ targetVersionId: 'fvr_2',
+ userId: 'usr_1',
+ });
+ });
+
+ it('should return no-op result when already on target version', async () => {
+ const mockResult = { kind: 'no-op', frameworkInstanceId: 'fi_1' };
+ mockSyncService.sync.mockResolvedValue(mockResult);
+
+ const result = await controller.syncFramework(
+ 'org_1',
+ 'fi_1',
+ { targetVersionId: 'fvr_1' },
+ mockAuthContext as never,
+ );
+
+ expect(result).toEqual({ data: mockResult });
+ });
+ });
+
+ describe('rollbackFramework', () => {
+ const mockAuthContext = { userId: 'usr_1', organizationId: 'org_1' };
+
+ it('should delegate to rollbackService and return { data: result }', async () => {
+ const mockResult = { rollbackOperationId: 'fso_rb_1' };
+ mockRollbackService.rollback.mockResolvedValue(mockResult);
+
+ const result = await controller.rollbackFramework(
+ 'org_1',
+ 'fi_1',
+ { syncOperationId: 'fso_1' },
+ mockAuthContext as never,
+ );
+
+ expect(result).toEqual({ data: mockResult });
+ expect(rollbackService.rollback).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ frameworkInstanceId: 'fi_1',
+ syncOperationId: 'fso_1',
+ userId: 'usr_1',
+ });
+ });
+
+ it('should propagate NotFoundException when sync op not found', async () => {
+ mockRollbackService.rollback.mockRejectedValue(
+ new NotFoundException('Sync operation not found'),
+ );
+
+ await expect(
+ controller.rollbackFramework('org_1', 'fi_1', { syncOperationId: 'fso_missing' }, mockAuthContext as never),
+ ).rejects.toThrow(NotFoundException);
+ });
+ });
+
+ describe('getSyncHistory', () => {
+ it('should return sync history with count', async () => {
+ const mockHistory = [
+ {
+ id: 'fso_1',
+ kind: 'SYNC',
+ performedAt: new Date(),
+ performedById: 'usr_1',
+ rollbackExpiresAt: null,
+ rolledBackByOperationId: null,
+ fromVersion: { id: 'fvr_1', version: '1.0.0' },
+ toVersion: { id: 'fvr_2', version: '2.0.0' },
+ summary: null,
+ },
+ ];
+ mockService.getSyncHistory.mockResolvedValue(mockHistory);
+
+ const result = await controller.getSyncHistory('org_1', 'fi_1');
+
+ expect(result).toEqual({ data: mockHistory, count: 1 });
+ expect(service.getSyncHistory).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ frameworkInstanceId: 'fi_1',
+ });
+ });
+
+ it('should return empty list with count 0 when no history', async () => {
+ mockService.getSyncHistory.mockResolvedValue([]);
+
+ const result = await controller.getSyncHistory('org_1', 'fi_1');
+
+ expect(result).toEqual({ data: [], count: 0 });
+ });
+ });
});
diff --git a/apps/api/src/frameworks/frameworks.controller.ts b/apps/api/src/frameworks/frameworks.controller.ts
index 3276d8cc6a..ebf55cfa83 100644
--- a/apps/api/src/frameworks/frameworks.controller.ts
+++ b/apps/api/src/frameworks/frameworks.controller.ts
@@ -1,4 +1,5 @@
import {
+ BadRequestException,
Body,
Controller,
Delete,
@@ -30,13 +31,21 @@ import { CreateCustomFrameworkDto } from './dto/create-custom-framework.dto';
import { CreateCustomRequirementDto } from './dto/create-custom-requirement.dto';
import { LinkRequirementsDto } from './dto/link-requirements.dto';
import { LinkControlsDto } from './dto/link-controls.dto';
+import { SyncFrameworkDto } from './dto/sync-framework.dto';
+import { RollbackFrameworkDto } from './dto/rollback-framework.dto';
+import { FrameworkSyncService } from './framework-versioning/framework-sync.service';
+import { FrameworkRollbackService } from './framework-versioning/framework-rollback.service';
@ApiTags('Frameworks')
@ApiBearerAuth()
@UseGuards(HybridAuthGuard, PermissionGuard)
@Controller({ path: 'frameworks', version: '1' })
export class FrameworksController {
- constructor(private readonly frameworksService: FrameworksService) {}
+ constructor(
+ private readonly frameworksService: FrameworksService,
+ private readonly syncService: FrameworkSyncService,
+ private readonly rollbackService: FrameworkRollbackService,
+ ) {}
@Get()
@RequirePermission('framework', 'read')
@@ -172,6 +181,86 @@ export class FrameworksController {
);
}
+ @Get(':id/update-status')
+ @RequirePermission('framework', 'read')
+ @ApiOperation({ summary: 'Get the update status for a framework instance' })
+ async getUpdateStatus(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ ) {
+ const data = await this.frameworksService.getUpdateStatus({
+ organizationId,
+ frameworkInstanceId: id,
+ });
+ return { data };
+ }
+
+ @Get(':id/update-preview')
+ @RequirePermission('framework', 'read')
+ @ApiOperation({ summary: 'Preview changes from updating a framework instance' })
+ async getUpdatePreview(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ ) {
+ const data = await this.frameworksService.getUpdatePreview({
+ organizationId,
+ frameworkInstanceId: id,
+ });
+ return { data };
+ }
+
+ @Post(':id/sync')
+ @RequirePermission('framework', 'update')
+ @ApiOperation({ summary: 'Sync a framework instance to a target version' })
+ async syncFramework(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ @Body() body: SyncFrameworkDto,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ if (!authContext.memberId) throw new BadRequestException('Member ID not available');
+ const result = await this.syncService.sync({
+ organizationId,
+ frameworkInstanceId: id,
+ targetVersionId: body.targetVersionId,
+ memberId: authContext.memberId,
+ });
+ return { data: result };
+ }
+
+ @Post(':id/rollback')
+ @RequirePermission('framework', 'update')
+ @ApiOperation({ summary: 'Roll back a framework sync operation' })
+ async rollbackFramework(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ @Body() body: RollbackFrameworkDto,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ if (!authContext.memberId) throw new BadRequestException('Member ID not available');
+ const result = await this.rollbackService.rollback({
+ organizationId,
+ frameworkInstanceId: id,
+ syncOperationId: body.syncOperationId,
+ memberId: authContext.memberId,
+ });
+ return { data: result };
+ }
+
+ @Get(':id/sync-history')
+ @RequirePermission('framework', 'read')
+ @ApiOperation({ summary: 'Get sync history for a framework instance' })
+ async getSyncHistory(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ ) {
+ const data = await this.frameworksService.getSyncHistory({
+ organizationId,
+ frameworkInstanceId: id,
+ });
+ return { data, count: data.length };
+ }
+
@Delete(':id')
@RequirePermission('framework', 'delete')
@ApiOperation({ summary: 'Delete a framework instance' })
diff --git a/apps/api/src/frameworks/frameworks.module.ts b/apps/api/src/frameworks/frameworks.module.ts
index 53068441fe..4ec2d3466b 100644
--- a/apps/api/src/frameworks/frameworks.module.ts
+++ b/apps/api/src/frameworks/frameworks.module.ts
@@ -3,11 +3,13 @@ import { AuthModule } from '../auth/auth.module';
import { TimelinesModule } from '../timelines/timelines.module';
import { FrameworksController } from './frameworks.controller';
import { FrameworksService } from './frameworks.service';
+import { FrameworkSyncService } from './framework-versioning/framework-sync.service';
+import { FrameworkRollbackService } from './framework-versioning/framework-rollback.service';
@Module({
imports: [AuthModule, TimelinesModule],
controllers: [FrameworksController],
- providers: [FrameworksService],
+ providers: [FrameworksService, FrameworkSyncService, FrameworkRollbackService],
exports: [FrameworksService],
})
export class FrameworksModule {}
diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts
index f00ec0768e..02e2c891c8 100644
--- a/apps/api/src/frameworks/frameworks.service.ts
+++ b/apps/api/src/frameworks/frameworks.service.ts
@@ -13,6 +13,8 @@ import {
import { upsertOrgFrameworkStructure } from './frameworks-upsert.helper';
import { createTimelinesForFrameworks } from './frameworks-timeline.helper';
import { TimelinesService } from '../timelines/timelines.service';
+import type { FrameworkManifest } from './framework-versioning/manifest.types';
+import { buildUpdatePreview } from './framework-versioning/framework-update-preview';
type RequirementDef = {
id: string;
@@ -32,6 +34,7 @@ export class FrameworksService {
private async loadRequirementDefinitions(fi: {
frameworkId: string | null;
customFrameworkId: string | null;
+ currentVersionId?: string | null;
}): Promise {
if (fi.customFrameworkId) {
const rows = await db.customRequirement.findMany({
@@ -48,6 +51,29 @@ export class FrameworksService {
}));
}
if (fi.frameworkId) {
+ // Prefer the pinned version's manifest so customers see exactly what
+ // they're synced to — NOT the live template state which may have
+ // additions not yet synced.
+ if (fi.currentVersionId) {
+ const version = await db.frameworkVersion.findUnique({
+ where: { id: fi.currentVersionId },
+ select: { manifest: true },
+ });
+ if (version) {
+ const manifest = version.manifest as unknown as FrameworkManifest;
+ return [...manifest.requirements]
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((r) => ({
+ id: r.id,
+ name: r.name,
+ identifier: r.identifier,
+ description: r.description ?? '',
+ frameworkId: fi.frameworkId,
+ customFrameworkId: null,
+ }));
+ }
+ }
+ // Fallback: instances with no pinned version (shouldn't happen post-backfill).
const rows = await db.frameworkEditorRequirement.findMany({
where: { frameworkId: fi.frameworkId },
orderBy: { name: 'asc' },
@@ -78,14 +104,16 @@ export class FrameworksService {
customFramework: true,
...(includeControls && {
requirementsMapped: {
+ where: { archivedAt: null },
include: {
control: {
include: {
policies: {
+ where: { archivedAt: null },
select: { id: true, name: true, status: true },
},
controlDocumentTypes: true,
- requirementsMapped: true,
+ requirementsMapped: { where: { archivedAt: null } },
},
},
},
@@ -122,9 +150,10 @@ export class FrameworksService {
db.task.findMany({
where: {
organizationId,
- controls: { some: { organizationId } },
+ archivedAt: null,
+ controls: { some: { organizationId, archivedAt: null } },
},
- include: { controls: true },
+ include: { controls: { where: { archivedAt: null } } },
}),
db.evidenceSubmission.findMany({
where: { organizationId },
@@ -149,13 +178,15 @@ export class FrameworksService {
framework: true,
customFramework: true,
requirementsMapped: {
+ where: { archivedAt: null },
include: {
control: {
include: {
policies: {
+ where: { archivedAt: null },
select: { id: true, name: true, status: true },
},
- requirementsMapped: true,
+ requirementsMapped: { where: { archivedAt: null } },
controlDocumentTypes: true,
},
},
@@ -193,11 +224,11 @@ export class FrameworksService {
await Promise.all([
this.loadRequirementDefinitions(fi),
db.task.findMany({
- where: { organizationId, controls: { some: { organizationId } } },
- include: { controls: true },
+ where: { organizationId, archivedAt: null, controls: { some: { organizationId, archivedAt: null } } },
+ include: { controls: { where: { archivedAt: null } } },
}),
db.requirementMap.findMany({
- where: { frameworkInstanceId },
+ where: { frameworkInstanceId, archivedAt: null },
include: { control: true },
}),
allFormTypes.size > 0
@@ -395,7 +426,7 @@ export class FrameworksService {
}
const controls = await db.control.findMany({
- where: { id: { in: controlIds }, organizationId },
+ where: { id: { in: controlIds }, organizationId, archivedAt: null },
select: { id: true },
});
if (controls.length === 0) {
@@ -477,7 +508,7 @@ export class FrameworksService {
) {
const fi = await db.frameworkInstance.findUnique({
where: { id: frameworkInstanceId, organizationId },
- select: { id: true, frameworkId: true, customFrameworkId: true },
+ select: { id: true, frameworkId: true, customFrameworkId: true, currentVersionId: true },
});
if (!fi) {
throw new NotFoundException('Framework instance not found');
@@ -493,6 +524,7 @@ export class FrameworksService {
db.requirementMap.findMany({
where: {
frameworkInstanceId,
+ archivedAt: null,
...(fi.customFrameworkId
? { customRequirementId: requirementKey }
: { requirementId: requirementKey }),
@@ -501,6 +533,7 @@ export class FrameworksService {
control: {
include: {
policies: {
+ where: { archivedAt: null },
select: { id: true, name: true, status: true },
},
controlDocumentTypes: true,
@@ -509,8 +542,8 @@ export class FrameworksService {
},
}),
db.task.findMany({
- where: { organizationId },
- include: { controls: true },
+ where: { organizationId, archivedAt: null },
+ include: { controls: { where: { archivedAt: null } } },
}),
]);
@@ -561,4 +594,167 @@ export class FrameworksService {
return { success: true };
}
+
+ async getUpdateStatus(params: {
+ organizationId: string;
+ frameworkInstanceId: string;
+ }) {
+ const instance = await db.frameworkInstance.findUnique({
+ where: { id: params.frameworkInstanceId },
+ include: { currentVersion: { select: { id: true, version: true } } },
+ });
+ if (!instance || instance.organizationId !== params.organizationId) {
+ throw new NotFoundException('Framework instance not found');
+ }
+ if (!instance.frameworkId) {
+ return { currentVersion: null, latestVersion: null, updateAvailable: false };
+ }
+
+ const latest = await db.frameworkVersion.findFirst({
+ where: { frameworkId: instance.frameworkId },
+ orderBy: { publishedAt: 'desc' },
+ select: { id: true, version: true, publishedAt: true, releaseNotes: true },
+ });
+
+ return {
+ currentVersion: instance.currentVersion,
+ latestVersion: latest,
+ updateAvailable: latest !== null && latest.id !== instance.currentVersion?.id,
+ };
+ }
+
+ async getUpdatePreview(params: {
+ organizationId: string;
+ frameworkInstanceId: string;
+ }) {
+ const instance = await db.frameworkInstance.findUnique({
+ where: { id: params.frameworkInstanceId },
+ include: { currentVersion: true },
+ });
+ if (!instance || instance.organizationId !== params.organizationId) {
+ throw new NotFoundException('Framework instance not found');
+ }
+ if (!instance.currentVersion) {
+ throw new BadRequestException('Instance is not on any version');
+ }
+
+ const latest = await db.frameworkVersion.findFirst({
+ where: { frameworkId: instance.frameworkId! },
+ orderBy: { publishedAt: 'desc' },
+ });
+ if (!latest || latest.id === instance.currentVersionId) {
+ throw new NotFoundException('No update available');
+ }
+
+ const fromManifest = instance.currentVersion.manifest as unknown as FrameworkManifest;
+ const toManifest = latest.manifest as unknown as FrameworkManifest;
+ const templateControlIds = [
+ ...new Set([
+ ...fromManifest.controls.map((c) => c.id),
+ ...toManifest.controls.map((c) => c.id),
+ ]),
+ ];
+ const templatePolicyIds = [
+ ...new Set([
+ ...fromManifest.policies.map((p) => p.id),
+ ...toManifest.policies.map((p) => p.id),
+ ]),
+ ];
+ const templateTaskIds = [
+ ...new Set([
+ ...fromManifest.tasks.map((t) => t.id),
+ ...toManifest.tasks.map((t) => t.id),
+ ]),
+ ];
+
+ const [instanceControls, instancePolicies, instanceTasks] = await Promise.all([
+ db.control.findMany({
+ where: {
+ organizationId: params.organizationId,
+ controlTemplateId: { in: templateControlIds },
+ archivedAt: null,
+ },
+ }),
+ db.policy.findMany({
+ where: {
+ organizationId: params.organizationId,
+ policyTemplateId: { in: templatePolicyIds },
+ archivedAt: null,
+ },
+ }),
+ db.task.findMany({
+ where: {
+ organizationId: params.organizationId,
+ taskTemplateId: { in: templateTaskIds },
+ archivedAt: null,
+ },
+ }),
+ ]);
+
+ return buildUpdatePreview({
+ fromManifest,
+ toManifest,
+ instanceControls: instanceControls.map((c) => ({
+ id: c.id,
+ controlTemplateId: c.controlTemplateId,
+ name: c.name,
+ description: c.description,
+ })),
+ instanceTasks: instanceTasks.map((t) => ({
+ id: t.id,
+ taskTemplateId: t.taskTemplateId,
+ title: t.title,
+ description: t.description,
+ frequency: t.frequency,
+ department: t.department,
+ })),
+ instancePolicies: instancePolicies.map((p) => ({
+ id: p.id,
+ policyTemplateId: p.policyTemplateId,
+ name: p.name,
+ description: p.description,
+ content: p.content,
+ frequency: p.frequency,
+ department: p.department,
+ status: p.status,
+ })),
+ fromVersionLabel: { id: instance.currentVersion.id, version: instance.currentVersion.version },
+ toVersionLabel: { id: latest.id, version: latest.version },
+ releaseNotes: latest.releaseNotes,
+ });
+ }
+
+ async getSyncHistory(params: {
+ organizationId: string;
+ frameworkInstanceId: string;
+ }) {
+ const instance = await db.frameworkInstance.findUnique({
+ where: { id: params.frameworkInstanceId },
+ });
+ if (!instance || instance.organizationId !== params.organizationId) {
+ throw new NotFoundException('Framework instance not found');
+ }
+
+ return db.frameworkSyncOperation.findMany({
+ where: { frameworkInstanceId: params.frameworkInstanceId },
+ orderBy: { performedAt: 'desc' },
+ select: {
+ id: true,
+ kind: true,
+ performedAt: true,
+ performedById: true,
+ performedBy: {
+ select: {
+ id: true,
+ user: { select: { id: true, name: true, email: true } },
+ },
+ },
+ rollbackExpiresAt: true,
+ rolledBackByOperationId: true,
+ fromVersion: { select: { id: true, version: true } },
+ toVersion: { select: { id: true, version: true } },
+ summary: true,
+ },
+ });
+ }
}
diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts
index 0ee238b346..3f65834bf7 100644
--- a/apps/api/src/policies/policies.controller.ts
+++ b/apps/api/src/policies/policies.controller.ts
@@ -181,14 +181,14 @@ export class PoliciesController {
) {
const [policy, allControls] = await Promise.all([
db.policy.findFirst({
- where: { id, organizationId },
+ where: { id, organizationId, archivedAt: null },
select: {
id: true,
- controls: { select: { id: true, name: true, description: true } },
+ controls: { where: { archivedAt: null }, select: { id: true, name: true, description: true } },
},
}),
db.control.findMany({
- where: { organizationId },
+ where: { organizationId, archivedAt: null },
select: { id: true, name: true, description: true },
orderBy: { name: 'asc' },
}),
@@ -311,8 +311,14 @@ export class PoliciesController {
let pdfUrl: string | null = null;
if (versionId) {
+ // Apply the same archive guard to the parent policy as the non-versioned
+ // path — otherwise an archived policy's PDF could still be fetched by
+ // passing a versionId, bypassing the user-archived/sync-archived filter.
const version = await db.policyVersion.findFirst({
- where: { id: versionId, policy: { id, organizationId } },
+ where: {
+ id: versionId,
+ policy: { id, organizationId, archivedAt: null, isArchived: false },
+ },
select: { pdfUrl: true },
});
pdfUrl = version?.pdfUrl ?? null;
@@ -320,7 +326,7 @@ export class PoliciesController {
if (!pdfUrl) {
const policy = await db.policy.findFirst({
- where: { id, organizationId },
+ where: { id, organizationId, archivedAt: null, isArchived: false },
select: { pdfUrl: true },
});
pdfUrl = policy?.pdfUrl ?? null;
@@ -389,7 +395,7 @@ export class PoliciesController {
const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
const policy = await db.policy.findFirst({
- where: { id, organizationId },
+ where: { id, organizationId, archivedAt: null },
select: {
id: true,
status: true,
@@ -520,7 +526,7 @@ export class PoliciesController {
}
} else {
const policy = await db.policy.findFirst({
- where: { id, organizationId },
+ where: { id, organizationId, archivedAt: null },
select: { id: true, pdfUrl: true },
});
if (!policy) throw new NotFoundException('Policy not found');
@@ -570,7 +576,7 @@ export class PoliciesController {
}
if (!pdfUrl) {
const policy = await db.policy.findFirst({
- where: { id, organizationId },
+ where: { id, organizationId, archivedAt: null },
select: { pdfUrl: true },
});
pdfUrl = policy?.pdfUrl ?? null;
diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts
index ff4b6d2fad..225342add5 100644
--- a/apps/api/src/policies/policies.service.ts
+++ b/apps/api/src/policies/policies.service.ts
@@ -48,7 +48,7 @@ export class PoliciesService {
async findAll(organizationId: string) {
try {
const policies = await db.policy.findMany({
- where: { organizationId },
+ where: { organizationId, isArchived: false, archivedAt: null },
select: {
id: true,
name: true,
@@ -1284,6 +1284,7 @@ export class PoliciesService {
where: {
organizationId,
isArchived: false,
+ archivedAt: null,
},
select: {
id: true,
diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts
index ae4896ad14..cc4fda1350 100644
--- a/apps/api/src/tasks/tasks.service.ts
+++ b/apps/api/src/tasks/tasks.service.ts
@@ -106,6 +106,7 @@ export class TasksService {
db.task.findMany({
where: {
organizationId,
+ archivedAt: null,
...assignmentFilter,
},
...(options?.includeRelations && {
@@ -195,10 +196,11 @@ export class TasksService {
where: {
id: taskId,
organizationId,
+ archivedAt: null,
},
include: {
assignee: true,
- controls: true,
+ controls: { where: { archivedAt: null } },
approver: { include: { user: true } },
},
}),
@@ -236,6 +238,7 @@ export class TasksService {
where: {
id: taskId,
organizationId,
+ archivedAt: null,
},
});
@@ -318,7 +321,7 @@ export class TasksService {
const [controls, frameworkInstances, organization, member] =
await Promise.all([
db.control.findMany({
- where: { organizationId },
+ where: { organizationId, archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
@@ -566,6 +569,7 @@ export class TasksService {
where: {
id: taskId,
organizationId,
+ archivedAt: null,
},
select: {
id: true,
@@ -657,7 +661,7 @@ export class TasksService {
// When status changes to done, set review date based on frequency
if (updateData.status === TaskStatus.done && !updateData.reviewDate) {
const task = await db.task.findFirst({
- where: { id: taskId, organizationId },
+ where: { id: taskId, organizationId, archivedAt: null },
select: { frequency: true },
});
dataToUpdate.reviewDate = computeNextTaskReviewDate(task?.frequency);
@@ -887,7 +891,7 @@ export class TasksService {
*/
async regenerateFromTemplate(organizationId: string, taskId: string) {
const task = await db.task.findFirst({
- where: { id: taskId, organizationId },
+ where: { id: taskId, organizationId, archivedAt: null },
include: { taskTemplate: true },
});
@@ -936,6 +940,7 @@ export class TasksService {
where: {
id: taskId,
organizationId,
+ archivedAt: null,
},
});
@@ -966,7 +971,7 @@ export class TasksService {
approverId: string,
): Promise {
const task = await db.task.findFirst({
- where: { id: taskId, organizationId },
+ where: { id: taskId, organizationId, archivedAt: null },
});
if (!task) {
@@ -1080,6 +1085,7 @@ export class TasksService {
where: {
id: { in: taskIds },
organizationId,
+ archivedAt: null,
status: { notIn: ['in_review', 'done'] },
},
});
@@ -1150,7 +1156,7 @@ export class TasksService {
userId: string,
): Promise {
const task = await db.task.findFirst({
- where: { id: taskId, organizationId },
+ where: { id: taskId, organizationId, archivedAt: null },
include: {
approver: { include: { user: true } },
assignee: { include: { user: true } },
@@ -1238,7 +1244,7 @@ export class TasksService {
userId: string,
): Promise {
const task = await db.task.findFirst({
- where: { id: taskId, organizationId },
+ where: { id: taskId, organizationId, archivedAt: null },
include: {
approver: { include: { user: true } },
assignee: { include: { user: true } },
diff --git a/apps/api/src/timelines/timelines-backfill.helper.ts b/apps/api/src/timelines/timelines-backfill.helper.ts
index e818ab9dbb..ebb471a41d 100644
--- a/apps/api/src/timelines/timelines-backfill.helper.ts
+++ b/apps/api/src/timelines/timelines-backfill.helper.ts
@@ -92,7 +92,7 @@ async function queryTaskScore(
frameworkInstanceId: string,
): Promise {
const requirementMaps = await db.requirementMap.findMany({
- where: { frameworkInstanceId },
+ where: { frameworkInstanceId, archivedAt: null },
select: { controlId: true },
distinct: ['controlId'],
});
@@ -103,7 +103,7 @@ async function queryTaskScore(
}
const tasks = await db.task.findMany({
- where: { controls: { some: { id: { in: controlIds } } } },
+ where: { archivedAt: null, controls: { some: { id: { in: controlIds }, archivedAt: null } } },
select: { id: true, status: true, updatedAt: true },
distinct: ['id'],
});
diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts
index 237aba4c0f..1f6fb8f217 100644
--- a/apps/api/src/trust-portal/trust-access.service.ts
+++ b/apps/api/src/trust-portal/trust-access.service.ts
@@ -1564,6 +1564,7 @@ export class TrustAccessService {
organizationId: grant.accessRequest.organizationId,
status: 'published',
isArchived: false,
+ archivedAt: null,
},
select: {
id: true,
@@ -1998,6 +1999,7 @@ export class TrustAccessService {
organizationId: grant.accessRequest.organizationId,
status: 'published',
isArchived: false,
+ archivedAt: null,
},
select: {
id: true,
@@ -2286,6 +2288,7 @@ export class TrustAccessService {
organizationId: grant.accessRequest.organizationId,
status: 'published',
isArchived: false,
+ archivedAt: null,
},
select: {
id: true,
diff --git a/apps/api/src/vector-store/lib/sync/sync-policies.ts b/apps/api/src/vector-store/lib/sync/sync-policies.ts
index b81b85fe5c..7f9749ea2c 100644
--- a/apps/api/src/vector-store/lib/sync/sync-policies.ts
+++ b/apps/api/src/vector-store/lib/sync/sync-policies.ts
@@ -33,6 +33,8 @@ export async function fetchPolicies(
where: {
organizationId,
status: 'published',
+ isArchived: false,
+ archivedAt: null,
},
select: {
id: true,
diff --git a/apps/app/src/actions/organization/lib/initialize-organization.ts b/apps/app/src/actions/organization/lib/initialize-organization.ts
index 8c3231f9cb..846a5b0ba2 100644
--- a/apps/app/src/actions/organization/lib/initialize-organization.ts
+++ b/apps/app/src/actions/organization/lib/initialize-organization.ts
@@ -1,4 +1,5 @@
import { db, Prisma } from '@db/server';
+import { loadFrameworkSources } from './load-framework-sources';
/**
* Policy.content is Json[] (the inner nodes of a TipTap document),
@@ -49,83 +50,40 @@ export const _upsertOrgFrameworkStructureCore = async ({
}: UpsertOrgFrameworkStructureCoreInput) => {
/**
|--------------------------------------------------
- | Get All Template Entities Based on Input Frameworks
+ | Load Source Data
|--------------------------------------------------
- | Requirements from frameworkEditorFrameworks
- | ControlTemplates based on Requirements
- | PolicyTemplates based on ControlTemplates
- | TaskTemplates based on ControlTemplates
+ | Controls/policies/tasks and their relations come from each framework's
+ | pinned FrameworkVersion.manifest so a new org is aligned with the
+ | version it's about to be pinned to — not with whatever CX is editing
+ | live. Frameworks without a published version fall through to live
+ | templates with a warning.
|--------------------------------------------------
*/
- const requirementIds = frameworkEditorFrameworks.flatMap((framework) =>
- framework.requirements.map((req) => req.id),
- );
-
- const controlTemplates = await tx.frameworkEditorControlTemplate.findMany({
- where: {
- requirements: {
- some: {
- id: { in: requirementIds },
- },
- },
- },
- });
- const controlTemplateIds = controlTemplates.map((control) => control.id);
-
- const policyTemplates = await tx.frameworkEditorPolicyTemplate.findMany({
- where: {
- controlTemplates: {
- some: { id: { in: controlTemplateIds } },
- },
- },
+ const sources = await loadFrameworkSources({
+ frameworkEditorIds: targetFrameworkEditorIds,
+ frameworkEditorFrameworks,
+ tx,
});
- const policyTemplateIds = policyTemplates.map((policy) => policy.id);
- const taskTemplates = await tx.frameworkEditorTaskTemplate.findMany({
- where: {
- controlTemplates: {
- some: { id: { in: controlTemplateIds } },
- },
- },
- });
- const taskTemplateIds = taskTemplates.map((task) => task.id);
+ for (const fid of sources.frameworksWithoutVersion) {
+ console.warn(
+ `UpsertOrgFrameworkStructureCore: no FrameworkVersion for framework ${fid} — falling back to live templates and pinning currentVersionId=null. Publish v1.0.0 in the framework editor.`,
+ );
+ }
- /**
- |--------------------------------------------------
- | Get All Template Relations
- |--------------------------------------------------
- | ControlTemplates <> Requirements
- | ControlTemplates <> PolicyTemplates
- | ControlTemplates <> TaskTemplates
- |--------------------------------------------------
- */
- const controlRelations = await tx.frameworkEditorControlTemplate.findMany({
- where: {
- id: { in: controlTemplateIds },
- },
- select: {
- id: true,
- requirements: { where: { id: { in: requirementIds } } },
- policyTemplates: { where: { id: { in: policyTemplateIds } } },
- taskTemplates: { where: { id: { in: taskTemplateIds } } },
- },
- });
+ const {
+ controlTemplates,
+ policyTemplates,
+ taskTemplates,
+ groupedRelations: groupedControlTemplateRelations,
+ latestVersionByFrameworkId,
+ requirementToFrameworkId,
+ } = sources;
- const groupedControlTemplateRelations = controlRelations.map((controlTemplate) => ({
- controlTemplateId: controlTemplate.id,
- requirementTemplateIds: controlTemplate.requirements.map((req) => req.id),
- policyTemplateIds: controlTemplate.policyTemplates.map((policy) => policy.id),
- taskTemplateIds: controlTemplate.taskTemplates.map((task) => task.id),
- }));
+ const controlTemplateIds = controlTemplates.map((c) => c.id);
+ const policyTemplateIds = policyTemplates.map((p) => p.id);
+ const taskTemplateIds = taskTemplates.map((t) => t.id);
- /**
- |--------------------------------------------------
- | Upsert Framework Instances
- |--------------------------------------------------
- | Create FrameworkInstances if they don't already exist for the organization
- | and targetFrameworkEditorIds. Then, fetch all relevant instances (new + existing).
- |--------------------------------------------------
- */
const existingFrameworkInstances = await tx.frameworkInstance.findMany({
where: {
organizationId: organizationId,
@@ -145,6 +103,7 @@ export const _upsertOrgFrameworkStructureCore = async ({
.map((framework) => ({
organizationId: organizationId,
frameworkId: framework.id,
+ currentVersionId: latestVersionByFrameworkId.get(framework.id) ?? null,
}));
if (frameworkInstancesToCreateData.length > 0) {
@@ -352,6 +311,8 @@ export const _upsertOrgFrameworkStructureCore = async ({
const requirementMapEntriesToCreate: Prisma.RequirementMapCreateManyInput[] = [];
const controlToPolicyPairs: Array<{ controlId: string; policyId: string }> = [];
const controlToTaskPairs: Array<{ controlId: string; taskId: string }> = [];
+ const controlDocumentTypeEntries: Prisma.ControlDocumentTypeCreateManyInput[] = [];
+ const controlTemplateById = new Map(controlTemplates.map((c) => [c.id, c]));
for (const controlTemplateRelation of groupedControlTemplateRelations) {
const newControlId = controlTemplateIdToInstanceIdMap.get(
@@ -365,15 +326,8 @@ export const _upsertOrgFrameworkStructureCore = async ({
continue;
}
- // --- Process Requirements for RequirementMap ---
for (const reqTemplateId of controlTemplateRelation.requirementTemplateIds) {
- let frameworkEditorFrameworkIdForReq: string | undefined;
- for (const fw of frameworkEditorFrameworks) {
- if (fw.requirements.some((r) => r.id === reqTemplateId)) {
- frameworkEditorFrameworkIdForReq = fw.id;
- break;
- }
- }
+ const frameworkEditorFrameworkIdForReq = requirementToFrameworkId.get(reqTemplateId);
const frameworkInstanceId = frameworkEditorFrameworkIdForReq
? editorFrameworkIdToInstanceIdMap.get(frameworkEditorFrameworkIdForReq)
: undefined;
@@ -391,7 +345,6 @@ export const _upsertOrgFrameworkStructureCore = async ({
}
}
- // --- Collect Control <-> Policy pairs ---
for (const policyTemplateId of controlTemplateRelation.policyTemplateIds) {
const newPolicyId = policyTemplateIdToInstanceIdMap.get(policyTemplateId);
if (newPolicyId) {
@@ -403,7 +356,6 @@ export const _upsertOrgFrameworkStructureCore = async ({
}
}
- // --- Collect Control <-> Task pairs ---
for (const taskTemplateId of controlTemplateRelation.taskTemplateIds) {
const newTaskId = taskTemplateIdToInstanceIdMap.get(taskTemplateId);
if (newTaskId) {
@@ -414,6 +366,15 @@ export const _upsertOrgFrameworkStructureCore = async ({
);
}
}
+
+ // ControlDocumentType: explicit junction rows driven from manifest/live
+ // documentTypes so the org starts with the same evidence form types the
+ // published version specified. Deduped against existing rows via the
+ // unique constraint.
+ const ct = controlTemplateById.get(controlTemplateRelation.controlTemplateId);
+ for (const formType of ct?.documentTypes ?? []) {
+ controlDocumentTypeEntries.push({ controlId: newControlId, formType });
+ }
}
// Bulk-insert into the implicit M2M join tables instead of N `control.update({ connect })`
@@ -447,7 +408,6 @@ export const _upsertOrgFrameworkStructureCore = async ({
`;
}
- // --- Create RequirementMap entries ---
if (requirementMapEntriesToCreate.length > 0) {
await tx.requirementMap.createMany({
data: requirementMapEntriesToCreate,
@@ -455,6 +415,13 @@ export const _upsertOrgFrameworkStructureCore = async ({
});
}
+ if (controlDocumentTypeEntries.length > 0) {
+ await tx.controlDocumentType.createMany({
+ data: controlDocumentTypeEntries,
+ skipDuplicates: true,
+ });
+ }
+
return {
processedFrameworks: frameworkEditorFrameworks,
controlTemplates,
diff --git a/apps/app/src/actions/organization/lib/load-framework-sources.ts b/apps/app/src/actions/organization/lib/load-framework-sources.ts
new file mode 100644
index 0000000000..b0f0d11fe8
--- /dev/null
+++ b/apps/app/src/actions/organization/lib/load-framework-sources.ts
@@ -0,0 +1,315 @@
+import type {
+ Prisma,
+ EvidenceFormType,
+ Frequency,
+ Departments,
+ TaskAutomationStatus,
+} from '@db/server';
+
+/**
+ * Shape of FrameworkVersion.manifest. Kept in sync with the authoritative
+ * type at apps/api/src/frameworks/framework-versioning/manifest.types.ts.
+ * The Next.js app can't cross-import from apps/api, so the type lives here
+ * as well.
+ */
+interface ManifestFramework {
+ id: string;
+ name: string;
+ catalogVersion: string;
+ description: string | null;
+}
+interface ManifestRequirement {
+ id: string;
+ identifier: string;
+ name: string;
+ description: string | null;
+}
+interface ManifestControl {
+ id: string;
+ name: string;
+ description: string;
+ requirementIds: string[];
+ policyIds: string[];
+ taskIds: string[];
+ documentTypes: string[];
+}
+interface ManifestPolicy {
+ id: string;
+ name: string;
+ description: string | null;
+ content: unknown;
+ frequency: string | null;
+ department: string | null;
+}
+interface ManifestTask {
+ id: string;
+ name: string;
+ description: string;
+ frequency: string | null;
+ department: string | null;
+}
+interface FrameworkManifest {
+ framework: ManifestFramework;
+ requirements: ManifestRequirement[];
+ controls: ManifestControl[];
+ policies: ManifestPolicy[];
+ tasks: ManifestTask[];
+}
+
+export interface LoadedFrameworkSources {
+ controlTemplates: Array<{
+ id: string;
+ name: string;
+ description: string;
+ documentTypes: EvidenceFormType[];
+ }>;
+ policyTemplates: Array<{
+ id: string;
+ name: string;
+ description: string;
+ content: Prisma.JsonValue;
+ frequency: Frequency;
+ department: Departments;
+ }>;
+ taskTemplates: Array<{
+ id: string;
+ name: string;
+ description: string;
+ frequency: Frequency | null;
+ department: Departments | null;
+ automationStatus: TaskAutomationStatus;
+ }>;
+ groupedRelations: Array<{
+ controlTemplateId: string;
+ requirementTemplateIds: string[];
+ policyTemplateIds: string[];
+ taskTemplateIds: string[];
+ }>;
+ latestVersionByFrameworkId: Map;
+ frameworksWithoutVersion: string[];
+ requirementToFrameworkId: Map;
+}
+
+export interface LoadSourcesInput {
+ frameworkEditorIds: string[];
+ frameworkEditorFrameworks: Prisma.FrameworkEditorFrameworkGetPayload<{
+ include: { requirements: true };
+ }>[];
+ tx: Prisma.TransactionClient;
+}
+
+export async function loadFrameworkSources({
+ frameworkEditorIds,
+ frameworkEditorFrameworks,
+ tx,
+}: LoadSourcesInput): Promise {
+ const versions = await tx.frameworkVersion.findMany({
+ where: { frameworkId: { in: frameworkEditorIds } },
+ orderBy: { publishedAt: 'desc' },
+ select: { id: true, frameworkId: true, manifest: true },
+ });
+ const latestVersionByFrameworkId = new Map();
+ const manifestByFrameworkId = new Map();
+ for (const v of versions) {
+ if (!latestVersionByFrameworkId.has(v.frameworkId)) {
+ latestVersionByFrameworkId.set(v.frameworkId, v.id);
+ manifestByFrameworkId.set(v.frameworkId, v.manifest as unknown as FrameworkManifest);
+ }
+ }
+ const frameworksWithoutVersion = frameworkEditorIds.filter(
+ (fid) => !latestVersionByFrameworkId.has(fid),
+ );
+
+ const requirementToFrameworkId = new Map();
+ const controlsMap = new Map();
+ const policiesMap = new Map();
+ const tasksMap = new Map();
+
+ const relationsByControl = new Map<
+ string,
+ {
+ controlTemplateId: string;
+ requirementTemplateIds: Set;
+ policyTemplateIds: Set;
+ taskTemplateIds: Set;
+ }
+ >();
+ const getOrCreateRelation = (controlTemplateId: string) => {
+ let rel = relationsByControl.get(controlTemplateId);
+ if (!rel) {
+ rel = {
+ controlTemplateId,
+ requirementTemplateIds: new Set(),
+ policyTemplateIds: new Set(),
+ taskTemplateIds: new Set(),
+ };
+ relationsByControl.set(controlTemplateId, rel);
+ }
+ return rel;
+ };
+
+ for (const [frameworkId, manifest] of manifestByFrameworkId) {
+ for (const r of manifest.requirements) {
+ requirementToFrameworkId.set(r.id, frameworkId);
+ }
+ for (const c of manifest.controls) {
+ if (!controlsMap.has(c.id)) {
+ controlsMap.set(c.id, {
+ id: c.id,
+ name: c.name,
+ description: c.description,
+ documentTypes: (c.documentTypes ?? []) as EvidenceFormType[],
+ });
+ }
+ const rel = getOrCreateRelation(c.id);
+ for (const rid of c.requirementIds) rel.requirementTemplateIds.add(rid);
+ for (const pid of c.policyIds) rel.policyTemplateIds.add(pid);
+ for (const tid of c.taskIds) rel.taskTemplateIds.add(tid);
+ }
+ for (const p of manifest.policies) {
+ if (!policiesMap.has(p.id)) {
+ policiesMap.set(p.id, {
+ id: p.id,
+ name: p.name,
+ description: p.description ?? '',
+ content: p.content as Prisma.JsonValue,
+ frequency: p.frequency as unknown as Frequency,
+ department: p.department as unknown as Departments,
+ });
+ }
+ }
+ for (const t of manifest.tasks) {
+ if (!tasksMap.has(t.id)) {
+ tasksMap.set(t.id, {
+ id: t.id,
+ name: t.name,
+ description: t.description,
+ frequency: (t.frequency as unknown as Frequency | null) ?? null,
+ department: (t.department as unknown as Departments | null) ?? null,
+ automationStatus: 'AUTOMATED' as TaskAutomationStatus,
+ });
+ }
+ }
+ }
+
+ // automationStatus isn't in the manifest — resolve from live task templates.
+ const manifestTaskIds = Array.from(tasksMap.keys());
+ if (manifestTaskIds.length > 0) {
+ const liveTasks = await tx.frameworkEditorTaskTemplate.findMany({
+ where: { id: { in: manifestTaskIds } },
+ select: { id: true, automationStatus: true },
+ });
+ for (const lt of liveTasks) {
+ const existing = tasksMap.get(lt.id);
+ if (existing) existing.automationStatus = lt.automationStatus;
+ }
+ }
+
+ // Fallback for frameworks without a published version: live-template reads.
+ if (frameworksWithoutVersion.length > 0) {
+ const fallbackFrameworks = frameworkEditorFrameworks.filter((f) =>
+ frameworksWithoutVersion.includes(f.id),
+ );
+ const fallbackRequirementIds = fallbackFrameworks.flatMap((f) =>
+ f.requirements.map((r) => r.id),
+ );
+ for (const f of fallbackFrameworks) {
+ for (const r of f.requirements) {
+ if (!requirementToFrameworkId.has(r.id)) {
+ requirementToFrameworkId.set(r.id, f.id);
+ }
+ }
+ }
+
+ const liveControls = await tx.frameworkEditorControlTemplate.findMany({
+ where: { requirements: { some: { id: { in: fallbackRequirementIds } } } },
+ select: { id: true, name: true, description: true, documentTypes: true },
+ });
+ for (const lc of liveControls) {
+ if (!controlsMap.has(lc.id)) {
+ controlsMap.set(lc.id, {
+ id: lc.id,
+ name: lc.name,
+ description: lc.description,
+ documentTypes: lc.documentTypes,
+ });
+ }
+ }
+
+ const fallbackControlIds = liveControls.map((c) => c.id);
+ const controlRelationsLive = await tx.frameworkEditorControlTemplate.findMany({
+ where: { id: { in: fallbackControlIds } },
+ select: {
+ id: true,
+ requirements: { where: { id: { in: fallbackRequirementIds } }, select: { id: true } },
+ policyTemplates: { select: { id: true } },
+ taskTemplates: { select: { id: true } },
+ },
+ });
+ for (const cr of controlRelationsLive) {
+ const rel = getOrCreateRelation(cr.id);
+ for (const r of cr.requirements) rel.requirementTemplateIds.add(r.id);
+ for (const p of cr.policyTemplates) rel.policyTemplateIds.add(p.id);
+ for (const t of cr.taskTemplates) rel.taskTemplateIds.add(t.id);
+ }
+
+ const fallbackPolicyIds = controlRelationsLive.flatMap((cr) =>
+ cr.policyTemplates.map((p) => p.id),
+ );
+ if (fallbackPolicyIds.length > 0) {
+ const livePolicies = await tx.frameworkEditorPolicyTemplate.findMany({
+ where: { id: { in: fallbackPolicyIds } },
+ });
+ for (const lp of livePolicies) {
+ if (!policiesMap.has(lp.id)) {
+ policiesMap.set(lp.id, {
+ id: lp.id,
+ name: lp.name,
+ description: lp.description,
+ content: lp.content,
+ frequency: lp.frequency,
+ department: lp.department,
+ });
+ }
+ }
+ }
+
+ const fallbackTaskIds = controlRelationsLive.flatMap((cr) =>
+ cr.taskTemplates.map((t) => t.id),
+ );
+ if (fallbackTaskIds.length > 0) {
+ const liveTasks = await tx.frameworkEditorTaskTemplate.findMany({
+ where: { id: { in: fallbackTaskIds } },
+ });
+ for (const lt of liveTasks) {
+ if (!tasksMap.has(lt.id)) {
+ tasksMap.set(lt.id, {
+ id: lt.id,
+ name: lt.name,
+ description: lt.description,
+ frequency: lt.frequency,
+ department: lt.department,
+ automationStatus: lt.automationStatus,
+ });
+ }
+ }
+ }
+ }
+
+ const groupedRelations = Array.from(relationsByControl.values()).map((rel) => ({
+ controlTemplateId: rel.controlTemplateId,
+ requirementTemplateIds: Array.from(rel.requirementTemplateIds),
+ policyTemplateIds: Array.from(rel.policyTemplateIds),
+ taskTemplateIds: Array.from(rel.taskTemplateIds),
+ }));
+
+ return {
+ controlTemplates: Array.from(controlsMap.values()),
+ policyTemplates: Array.from(policiesMap.values()),
+ taskTemplates: Array.from(tasksMap.values()),
+ groupedRelations,
+ latestVersionByFrameworkId,
+ frameworksWithoutVersion,
+ requirementToFrameworkId,
+ };
+}
diff --git a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
index 0d70a90795..9ae8559985 100644
--- a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
+++ b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
@@ -67,6 +67,14 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
enabled: !!triggerJobId,
});
+ const dismissKey = triggerJobId ? `onboarding-tracker-dismissed:${triggerJobId}` : null;
+ const handleDismiss = useCallback(() => {
+ if (dismissKey && typeof window !== 'undefined') {
+ window.localStorage.setItem(dismissKey, '1');
+ }
+ setIsDismissed(true);
+ }, [dismissKey]);
+
const handleRetry = useCallback(() => {
if (!organizationId) {
return;
@@ -76,7 +84,16 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
useEffect(() => {
setMounted(true);
- }, []);
+ // Always reflect the stored state for THIS triggerJobId. If the key
+ // changes (new onboarding run), this resets isDismissed to false when
+ // no dismissal exists for the new key — otherwise a dismissed prior
+ // run could leave the tracker hidden forever.
+ if (dismissKey && typeof window !== 'undefined') {
+ setIsDismissed(window.localStorage.getItem(dismissKey) === '1');
+ } else {
+ setIsDismissed(false);
+ }
+ }, [dismissKey]);
// Auto-minimize when completed
useEffect(() => {
@@ -302,8 +319,10 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
return null;
}
- // Dismiss completed card
- if (run?.status === 'COMPLETED' && isDismissed) {
+ // Dismissed is a hard hide — stays gone across refreshes via localStorage
+ // keyed by triggerJobId, so onboarding keeps running in the background and
+ // the user doesn't see the tracker again for this run.
+ if (isDismissed) {
return null;
}
@@ -346,15 +365,6 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
- {isCompleted && (
-
- )}
{!isCompleted && (
)}
+
@@ -394,13 +411,22 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
Status Unavailable
Could not retrieve status
-
+
+
+
+
);
}
@@ -424,13 +450,22 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
Setting up your organization
-
+
+
+
+
{/* Step progress - scrollable */}
@@ -841,13 +876,22 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
Setup Complete
-
+
+
+
+
@@ -892,13 +936,22 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
contact support for help.
-
+
+
+
+
-
+
+
+
+
);
}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx
new file mode 100644
index 0000000000..631b8019cd
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx
@@ -0,0 +1,241 @@
+'use client';
+
+import { useFeatureFlag } from '@trycompai/analytics';
+import {
+ Button,
+ PageHeader,
+ PageHeaderDescription,
+ PageLayout,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@trycompai/design-system';
+import { OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@trycompai/ui/dropdown-menu';
+import Link from 'next/link';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { useCallback, useState } from 'react';
+import { useFrameworkInstance } from '@/hooks/use-framework-instance';
+import { usePermissions } from '@/hooks/use-permissions';
+import { getControlStatus } from '@/lib/control-compliance';
+import type { FrameworkUpdateStatus } from '@/types/framework-versioning';
+import { AddCustomRequirementSheet } from './AddCustomRequirementSheet';
+import { FrameworkDeleteDialog } from './FrameworkDeleteDialog';
+import { FrameworkProgress } from './FrameworkProgress';
+import { FrameworkRequirements } from './FrameworkRequirements';
+import { FrameworkTimeline } from './FrameworkTimeline';
+import { FrameworkVersioningSection } from './FrameworkVersioningSection';
+import { LinkRequirementSheet } from './LinkRequirementSheet';
+import { SyncHistorySection } from './SyncHistorySection';
+
+interface FrameworkDetailContentProps {
+ orgId: string;
+ frameworkInstanceId: string;
+ initialFramework: any;
+ initialUpdateStatus?: FrameworkUpdateStatus;
+}
+
+const DEFAULT_TAB = 'requirements';
+
+export function FrameworkDetailContent({
+ orgId,
+ frameworkInstanceId,
+ initialFramework,
+ initialUpdateStatus,
+}: FrameworkDetailContentProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const { hasPermission, permissions } = usePermissions();
+ const versioningEnabled = useFeatureFlag('is-framework-versioning-enabled');
+ const complianceTimelineEnabled = useFeatureFlag('is-timeline-enabled');
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+
+ const { data } = useFrameworkInstance(frameworkInstanceId, {
+ fallbackData: initialFramework,
+ });
+ const framework = data ?? initialFramework;
+ const frameworkInstanceWithControls = {
+ ...framework,
+ controls: framework.controls ?? [],
+ };
+
+ const frameworkName =
+ framework.framework?.name ?? framework.customFramework?.name ?? 'Framework';
+ const frameworkDescription =
+ framework.framework?.description ?? framework.customFramework?.description ?? '';
+
+ const tasks = framework.tasks || [];
+ const evidenceSubmissions = framework.evidenceSubmissions || [];
+ const requirementDefinitions = framework.requirementDefinitions || [];
+
+ // Tab state synced to ?tab=
+ // Progress tab only exists when the compliance timeline flag is on — when
+ // it's off, the lightweight FrameworkProgress renders above the tabs.
+ const tabParam = searchParams.get('tab');
+ const validTabsList: string[] = [];
+ if (complianceTimelineEnabled) validTabsList.push('progress');
+ validTabsList.push('requirements');
+ if (versioningEnabled) validTabsList.push('history');
+ const validTabs = new Set(validTabsList);
+ const activeTab = tabParam && validTabs.has(tabParam) ? tabParam : DEFAULT_TAB;
+
+ const handleTabChange = useCallback(
+ (value: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value === DEFAULT_TAB) params.delete('tab');
+ else params.set('tab', value);
+ const q = params.toString();
+ router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
+ },
+ [pathname, router, searchParams],
+ );
+
+ // Tab label counts
+ const compliancePct = computeCompliancePercent(
+ frameworkInstanceWithControls.controls,
+ tasks,
+ evidenceSubmissions,
+ );
+ const requirementsCount = requirementDefinitions.length;
+
+ const canDeleteFramework = hasPermission('framework', 'delete');
+
+ return (
+
+ },
+ },
+ { label: frameworkName, isCurrent: true },
+ ]}
+ actions={
+ <>
+
+
+ {canDeleteFramework && (
+
+
+
+
+
+ {
+ setDropdownOpen(false);
+ setDeleteDialogOpen(true);
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete Framework
+
+
+
+ )}
+ >
+ }
+ tabs={
+
+ {complianceTimelineEnabled && (
+
+ Progress {compliancePct}%
+
+ )}
+
+ Requirements {requirementsCount}
+
+ {versioningEnabled && History}
+
+ }
+ >
+ {frameworkDescription && (
+ {frameworkDescription}
+ )}
+
+ }
+ >
+
+
+ {!complianceTimelineEnabled && (
+
+ )}
+
+ {complianceTimelineEnabled && (
+
+
+
+ )}
+
+
+
+
+
+ {versioningEnabled && (
+
+
+
+ )}
+
+
+ setDeleteDialogOpen(false)}
+ frameworkInstance={frameworkInstanceWithControls}
+ />
+
+ );
+}
+
+function TabBadge({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function computeCompliancePercent(
+ controls: any[],
+ tasks: any[],
+ evidenceSubmissions: any[],
+): number {
+ const total = controls.length;
+ if (total === 0) return 0;
+ const compliant = controls.filter(
+ (c) =>
+ getControlStatus(c.policies, tasks, c.id, c.controlDocumentTypes, evidenceSubmissions) ===
+ 'completed',
+ ).length;
+ return Math.round((compliant / total) * 100);
+}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkProgress.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkProgress.tsx
new file mode 100644
index 0000000000..bff198f325
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkProgress.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import type { Control, Task } from '@db';
+import { Badge, Text } from '@trycompai/design-system';
+import { getControlStatus } from '@/lib/control-compliance';
+import type { FrameworkInstanceWithControls } from '@/lib/types/framework';
+
+interface EvidenceSubmissionInfo {
+ id: string;
+ formType: string;
+ createdAt: Date | string;
+}
+
+interface Props {
+ framework: FrameworkInstanceWithControls;
+ tasks: (Task & { controls: Control[] })[];
+ evidenceSubmissions: EvidenceSubmissionInfo[];
+}
+
+export function FrameworkProgress({ framework, tasks, evidenceSubmissions }: Props) {
+ const allControls = framework.controls ?? [];
+ const totalControls = allControls.length;
+
+ const compliantControls = allControls.filter(
+ (control) =>
+ getControlStatus(
+ control.policies,
+ tasks,
+ control.id,
+ control.controlDocumentTypes,
+ evidenceSubmissions,
+ ) === 'completed',
+ ).length;
+
+ const percent = totalControls > 0 ? Math.round((compliantControls / totalControls) * 100) : 0;
+ const remaining = totalControls - compliantControls;
+
+ const variant: 'default' | 'secondary' | 'destructive' =
+ percent >= 80 ? 'default' : percent >= 60 ? 'secondary' : 'destructive';
+
+ return (
+
+
+ {percent}% compliant
+
+ {compliantControls} completed
+
+
+ {remaining} remaining
+
+
+ {totalControls} total controls
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkVersioningSection.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkVersioningSection.tsx
new file mode 100644
index 0000000000..6d0df221bb
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkVersioningSection.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { useFeatureFlag } from '@trycompai/analytics';
+import { useParams, useRouter } from 'next/navigation';
+import { useFrameworkUpdateStatus } from '@/hooks/use-framework-update-status';
+import { usePermissions } from '@/hooks/use-permissions';
+import type { FrameworkUpdateStatus } from '@/types/framework-versioning';
+import { UpdateAvailableBanner } from './UpdateAvailableBanner';
+
+interface FrameworkVersioningSectionProps {
+ frameworkInstanceId: string;
+ initialStatus?: FrameworkUpdateStatus;
+ hasActiveAudit: boolean;
+}
+
+export function FrameworkVersioningSection({
+ frameworkInstanceId,
+ initialStatus,
+ hasActiveAudit,
+}: FrameworkVersioningSectionProps) {
+ const enabled = useFeatureFlag('is-framework-versioning-enabled');
+ // Thread the flag into SWR so the update-status request doesn't fire for
+ // orgs that don't have versioning enabled. Without this the request runs
+ // every mount and we just throw the response away.
+ const { data } = useFrameworkUpdateStatus(frameworkInstanceId, {
+ fallbackData: initialStatus,
+ enabled,
+ });
+ const { hasPermission } = usePermissions();
+ const router = useRouter();
+ const { orgId } = useParams<{ orgId: string }>();
+
+ if (!enabled) return null;
+
+ const canUpdate = hasPermission('framework', 'update');
+
+ return (
+ <>
+ {data && (
+
+ router.push(`/${orgId}/frameworks/${frameworkInstanceId}/review-update`)
+ }
+ />
+ )}
+ >
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/RollbackConfirmDialog.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/RollbackConfirmDialog.tsx
new file mode 100644
index 0000000000..533cd1587f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/RollbackConfirmDialog.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Text,
+} from '@trycompai/design-system';
+import type { SyncHistoryItem } from '@/types/framework-versioning';
+
+interface RollbackConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ item: SyncHistoryItem | null;
+ isRollingBack: boolean;
+ onConfirm: () => void;
+}
+
+export function RollbackConfirmDialog({
+ open,
+ onOpenChange,
+ item,
+ isRollingBack,
+ onConfirm,
+}: RollbackConfirmDialogProps) {
+ if (!item) return null;
+
+ return (
+
+
+
+
+ Roll back to v{item.fromVersion.version}?
+
+
+ This will revert the sync from v{item.fromVersion.version} to
+ v{item.toVersion.version}. Items added by that sync will be
+ removed, archived items will be restored, and any content edits
+ you made since the sync will be kept.
+
+
+
+
+ Rollback will be blocked if any task created by the sync has been
+ completed, or if any policy created by the sync has been published.
+
+
+
+ Cancel
+
+ {isRollingBack ? 'Rolling back...' : 'Confirm rollback'}
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/SyncConfirmDialog.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/SyncConfirmDialog.tsx
new file mode 100644
index 0000000000..f434b56df8
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/SyncConfirmDialog.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Stack,
+ Text,
+} from '@trycompai/design-system';
+import type { UpdatePreview } from '@/types/framework-versioning';
+
+interface SyncConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ preview: UpdatePreview;
+ isSyncing: boolean;
+ onConfirm: () => void;
+}
+
+function countChanges(preview: UpdatePreview): {
+ added: number;
+ archived: number;
+ updated: number;
+ linkChanges: number;
+} {
+ const added =
+ preview.controls.added.length +
+ preview.tasks.added.length +
+ preview.policies.added.length +
+ preview.requirements.added.length;
+
+ const archived =
+ preview.controls.archived.length +
+ preview.tasks.archived.length +
+ preview.policies.archived.length +
+ preview.requirements.removed.length;
+
+ const updated =
+ preview.controls.updatedApplied.length +
+ preview.tasks.updatedApplied.length +
+ preview.policies.updatedApplied.length +
+ preview.requirements.updated.length;
+
+ // Edge-level changes (control↔policy/task/requirement/document-type) are
+ // real sync impact too; without this the summary can read "no changes"
+ // even though sync will rewire links.
+ const linkChanges =
+ preview.edges.controlPolicy.added.length +
+ preview.edges.controlPolicy.removed.length +
+ preview.edges.controlTask.added.length +
+ preview.edges.controlTask.removed.length +
+ preview.edges.controlRequirement.added.length +
+ preview.edges.controlRequirement.removed.length +
+ preview.edges.controlDocumentType.added.length +
+ preview.edges.controlDocumentType.removed.length;
+
+ return { added, archived, updated, linkChanges };
+}
+
+export function SyncConfirmDialog({
+ open,
+ onOpenChange,
+ preview,
+ isSyncing,
+ onConfirm,
+}: SyncConfirmDialogProps) {
+ const { added, archived, updated, linkChanges } = countChanges(preview);
+
+ return (
+
+
+
+
+ Sync to v{preview.toVersion.version}?
+
+
+ This will apply the following changes to your framework instance.
+ This action can be rolled back within the rollback window.
+
+
+
+
+ {added > 0 && (
+
+ {added} item
+ {added !== 1 ? 's' : ''} will be added
+
+ )}
+ {archived > 0 && (
+
+ {archived} item
+ {archived !== 1 ? 's' : ''} will be archived
+
+ )}
+ {updated > 0 && (
+
+ {updated} item
+ {updated !== 1 ? 's' : ''} will be updated
+
+ )}
+ {linkChanges > 0 && (
+
+ {linkChanges} link
+ {linkChanges !== 1 ? 's' : ''} will be rewired
+
+ )}
+ {preview.controls.updatedPreserved.length > 0 && (
+
+ {preview.controls.updatedPreserved.length} control edit
+ {preview.controls.updatedPreserved.length !== 1 ? 's' : ''} you
+ made will be preserved
+
+ )}
+
+
+
+ Cancel
+
+ {isSyncing ? 'Syncing...' : 'Confirm sync'}
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/SyncHistorySection.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/SyncHistorySection.tsx
new file mode 100644
index 0000000000..56d7ef672e
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/SyncHistorySection.tsx
@@ -0,0 +1,179 @@
+'use client';
+
+import { Badge, Button, Text } from '@trycompai/design-system';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useFrameworkSyncHistory } from '@/hooks/use-framework-sync-history';
+import { useFrameworkRollback } from '@/hooks/use-framework-rollback';
+import { hasPermission } from '@/lib/permissions';
+import type { UserPermissions } from '@/lib/permissions';
+import type { SyncHistoryItem } from '@/types/framework-versioning';
+import { RollbackConfirmDialog } from './RollbackConfirmDialog';
+
+interface SyncHistorySectionProps {
+ frameworkInstanceId: string;
+ permissions: UserPermissions;
+}
+
+function formatDate(dateStr: string): string {
+ return new Date(dateStr).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+function isWithinRollbackWindow(item: SyncHistoryItem): boolean {
+ if (!item.rollbackExpiresAt) return false;
+ return new Date(item.rollbackExpiresAt) > new Date();
+}
+
+function canRollbackItem(item: SyncHistoryItem): boolean {
+ return (
+ item.kind === 'SYNC' &&
+ !item.rolledBackByOperationId &&
+ isWithinRollbackWindow(item)
+ );
+}
+
+interface HistoryItemRowProps {
+ item: SyncHistoryItem;
+ showRollback: boolean;
+ onRollback: (syncOperationId: string) => void;
+ isRollingBack: boolean;
+}
+
+function HistoryItemRow({
+ item,
+ showRollback,
+ onRollback,
+ isRollingBack,
+}: HistoryItemRowProps) {
+ const isSync = item.kind === 'SYNC';
+ const wasRolledBack = !!item.rolledBackByOperationId;
+ const actorName = item.performedBy?.user?.name
+ ?? item.performedBy?.user?.email
+ ?? null;
+ const actionVerb = isSync ? 'Synced' : 'Rolled back';
+
+ return (
+
+
+
+
+ {isSync ? 'Sync' : 'Rollback'}
+
+
+ v{item.fromVersion.version} → v{item.toVersion.version}
+
+ {wasRolledBack && (
+ Rolled back
+ )}
+
+
+ {actionVerb}{actorName ? ` by ${actorName}` : ''} on {formatDate(item.performedAt)}
+
+ {item.rollbackExpiresAt && isWithinRollbackWindow(item) && !wasRolledBack && (
+
+ Rollback available until {formatDate(item.rollbackExpiresAt)}
+
+ )}
+
+ {showRollback && canRollbackItem(item) && (
+
+
+
+ )}
+
+ );
+}
+
+const INITIAL_VISIBLE = 5;
+
+export function SyncHistorySection({
+ frameworkInstanceId,
+ permissions,
+}: SyncHistorySectionProps) {
+ const { data: history, isLoading } = useFrameworkSyncHistory(frameworkInstanceId);
+ const { rollback, isRollingBack } = useFrameworkRollback(frameworkInstanceId);
+ const [pendingRollback, setPendingRollback] = useState(null);
+ const [showAll, setShowAll] = useState(false);
+
+ const canUpdate = hasPermission(permissions, 'framework', 'update');
+ const items = Array.isArray(history) ? history : [];
+
+ // Only the most recent non-reversed sync can be rolled back. Rolling back
+ // an older sync in the middle of a chain would leave the instance in an
+ // inconsistent state, so we surface the Rollback action only on that row.
+ const latestRollbackableSyncId = items.find(
+ (i) => i.kind === 'SYNC' && !i.rolledBackByOperationId,
+ )?.id ?? null;
+
+ if (isLoading) return null;
+ if (items.length === 0) return null;
+
+ const visibleItems = showAll ? items : items.slice(0, INITIAL_VISIBLE);
+ const hiddenCount = items.length - visibleItems.length;
+
+ const openRollbackDialog = (syncOperationId: string) => {
+ const item = items.find((i) => i.id === syncOperationId);
+ if (!item) return;
+ setPendingRollback(item);
+ };
+
+ const handleConfirmRollback = async () => {
+ if (!pendingRollback) return;
+ try {
+ await rollback(pendingRollback.id);
+ toast.success(`Rolled back to v${pendingRollback.fromVersion.version}`);
+ setPendingRollback(null);
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : 'Failed to roll back framework',
+ );
+ }
+ };
+
+ return (
+
+
+ {visibleItems.map((item) => (
+
+ ))}
+
+ {items.length > INITIAL_VISIBLE && (
+
+
+
+ )}
+
!open && setPendingRollback(null)}
+ item={pendingRollback}
+ isRollingBack={isRollingBack}
+ onConfirm={handleConfirmRollback}
+ />
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/UpdateAvailableBanner.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/UpdateAvailableBanner.tsx
new file mode 100644
index 0000000000..ee0094241d
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/UpdateAvailableBanner.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { Badge, Button, HStack, Stack, Text } from '@trycompai/design-system';
+import type { FrameworkUpdateStatus } from '@/types/framework-versioning';
+
+interface UpdateAvailableBannerProps {
+ status: FrameworkUpdateStatus;
+ canUpdate: boolean;
+ onReview: () => void;
+ hasActiveAudit?: boolean;
+}
+
+export function UpdateAvailableBanner({
+ status,
+ canUpdate,
+ onReview,
+ hasActiveAudit,
+}: UpdateAvailableBannerProps) {
+ if (!status.updateAvailable || !status.latestVersion) return null;
+
+ return (
+
+
+
+ Update available
+
+ v{status.currentVersion?.version ?? '—'} → v
+ {status.latestVersion.version}
+
+
+ {status.latestVersion.releaseNotes && (
+
+ {status.latestVersion.releaseNotes}
+
+ )}
+ {hasActiveAudit && (
+
+ Active audit in progress — syncing may change controls the
+ auditor is reviewing.
+
+ )}
+ {canUpdate && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/__tests__/UpdateAvailableBanner.test.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/__tests__/UpdateAvailableBanner.test.tsx
new file mode 100644
index 0000000000..bff4ccf8fb
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/__tests__/UpdateAvailableBanner.test.tsx
@@ -0,0 +1,127 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { UpdateAvailableBanner } from '../UpdateAvailableBanner';
+import type { FrameworkUpdateStatus } from '@/types/framework-versioning';
+
+const statusWithUpdate: FrameworkUpdateStatus = {
+ updateAvailable: true,
+ currentVersion: { id: 'v1', version: '1.0.0' },
+ latestVersion: {
+ id: 'v2',
+ version: '2.0.0',
+ publishedAt: '2024-01-01T00:00:00Z',
+ releaseNotes: 'New controls added.',
+ },
+};
+
+const statusNoUpdate: FrameworkUpdateStatus = {
+ updateAvailable: false,
+ currentVersion: { id: 'v1', version: '1.0.0' },
+ latestVersion: {
+ id: 'v1',
+ version: '1.0.0',
+ publishedAt: '2024-01-01T00:00:00Z',
+ releaseNotes: null,
+ },
+};
+
+describe('UpdateAvailableBanner', () => {
+ it('returns null when updateAvailable is false', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders when updateAvailable is true', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/Update available/i)).toBeInTheDocument();
+ expect(screen.getByText(/1\.0\.0.*2\.0\.0/i)).toBeInTheDocument();
+ });
+
+ it('renders Review update button when canUpdate is true', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole('button', { name: /review update/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('hides Review update button when canUpdate is false', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByRole('button', { name: /review update/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('fires onReview when Review update button is clicked', () => {
+ const onReview = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /review update/i }));
+ expect(onReview).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows release notes when present', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('New controls added.')).toBeInTheDocument();
+ });
+
+ it('shows active audit warning when hasActiveAudit is true', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/Active audit in progress/i)).toBeInTheDocument();
+ });
+
+ it('does not show active audit warning when hasActiveAudit is false', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByText(/Active audit in progress/i),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx
index 27277617f0..09340afdff 100644
--- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx
@@ -1,10 +1,7 @@
import { serverApi } from '@/lib/api-server';
-import { Breadcrumb, PageHeader, PageLayout } from '@trycompai/design-system';
-import Link from 'next/link';
import { redirect } from 'next/navigation';
-import { FrameworkOverview } from './components/FrameworkOverview';
-import { FrameworkRequirements } from './components/FrameworkRequirements';
-import { FrameworkTimeline } from './components/FrameworkTimeline';
+import { FrameworkDetailContent } from './components/FrameworkDetailContent';
+import type { FrameworkUpdateStatus } from '@/types/framework-versioning';
interface PageProps {
params: Promise<{
@@ -16,45 +13,23 @@ interface PageProps {
export default async function FrameworkPage({ params }: PageProps) {
const { orgId: organizationId, frameworkInstanceId } = await params;
- const frameworkRes = await serverApi.get(
- `/v1/frameworks/${frameworkInstanceId}`,
- );
+ const [frameworkRes, updateStatusRes] = await Promise.all([
+ serverApi.get(`/v1/frameworks/${frameworkInstanceId}`),
+ serverApi.get<{ data: FrameworkUpdateStatus }>(
+ `/v1/frameworks/${frameworkInstanceId}/update-status`,
+ ),
+ ]);
if (!frameworkRes.data) {
redirect(`/${organizationId}/frameworks`);
}
- const framework = frameworkRes.data;
- const frameworkInstanceWithControls = {
- ...framework,
- controls: framework.controls ?? [],
- };
- const frameworkName = framework.framework?.name ?? 'Framework';
-
return (
-
- },
- },
- { label: frameworkName, isCurrent: true },
- ]}
- />
-
-
-
-
+
);
}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx
new file mode 100644
index 0000000000..cbf4d312f2
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx
@@ -0,0 +1,700 @@
+'use client';
+
+import { useFrameworkSync } from '@/hooks/use-framework-sync';
+import { useFrameworkUpdatePreview } from '@/hooks/use-framework-update-preview';
+import { usePermissions } from '@/hooks/use-permissions';
+import type { UpdatePreview } from '@/types/framework-versioning';
+import {
+ Badge,
+ Button,
+ HStack,
+ Heading,
+ PageHeader,
+ PageHeaderDescription,
+ Tabs,
+ TabsList,
+ TabsTrigger,
+ Text,
+} from '@trycompai/design-system';
+import { useRouter } from 'next/navigation';
+import { useMemo, useState } from 'react';
+import { toast } from 'sonner';
+import { SyncConfirmDialog } from '../../components/SyncConfirmDialog';
+
+interface Props {
+ orgId: string;
+ frameworkInstanceId: string;
+ frameworkName: string;
+ initialPreview: UpdatePreview;
+}
+
+type FilterKey = 'all' | 'added' | 'removed' | 'modified';
+
+type ChangeKind = 'added' | 'removed' | 'modified' | 'preserved';
+
+interface ChangeRow {
+ key: string;
+ identifier?: string;
+ name: string;
+ description?: string | null;
+ kind: ChangeKind;
+}
+
+interface ChangeGroup {
+ title: string;
+ kind: ChangeKind;
+ rows: ChangeRow[];
+}
+
+export function ReviewUpdateContent({
+ orgId,
+ frameworkInstanceId,
+ frameworkName,
+ initialPreview,
+}: Props) {
+ const router = useRouter();
+ const { hasPermission } = usePermissions();
+ const { sync, isSyncing } = useFrameworkSync(frameworkInstanceId);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [filter, setFilter] = useState('all');
+
+ const { data } = useFrameworkUpdatePreview(frameworkInstanceId, {
+ fallbackData: initialPreview,
+ });
+ const preview = data ?? initialPreview;
+
+ const canApply = hasPermission('framework', 'update');
+ const frameworkHref = `/${orgId}/frameworks/${frameworkInstanceId}`;
+
+ const groups = useMemo(() => buildGroups(preview), [preview]);
+
+ // Summary counts: link removals/adds fold into the Removed / New totals.
+ const edges = preview.edges ?? {
+ controlPolicy: { added: [], removed: [] },
+ controlTask: { added: [], removed: [] },
+ controlRequirement: { added: [], removed: [] },
+ controlDocumentType: { added: [], removed: [] },
+ };
+ const linksAdded =
+ edges.controlPolicy.added.length +
+ edges.controlTask.added.length +
+ edges.controlRequirement.added.length +
+ (edges.controlDocumentType?.added.length ?? 0);
+ const linksRemoved =
+ edges.controlPolicy.removed.length +
+ edges.controlTask.removed.length +
+ edges.controlRequirement.removed.length +
+ (edges.controlDocumentType?.removed.length ?? 0);
+
+ const entitiesAdded =
+ preview.controls.added.length +
+ preview.policies.added.length +
+ preview.tasks.added.length +
+ preview.requirements.added.length;
+ const entitiesRemoved =
+ preview.controls.archived.length +
+ preview.policies.archived.length +
+ preview.tasks.archived.length +
+ preview.requirements.removed.length;
+ const modifiedCount =
+ preview.controls.updatedApplied.length +
+ preview.policies.updatedApplied.length +
+ preview.tasks.updatedApplied.length +
+ preview.requirements.updated.length;
+ const preservedCount =
+ preview.controls.updatedPreserved.length +
+ preview.tasks.updatedPreserved.length +
+ preview.policies.updatedPreserved.length +
+ preview.policies.draftAddedForPublished.length;
+
+ const addedTotal = entitiesAdded + linksAdded;
+ const removedTotal = entitiesRemoved + linksRemoved;
+ const linksTotal = linksAdded + linksRemoved;
+
+ const totalChanges = addedTotal + removedTotal + modifiedCount + preservedCount;
+
+ const visibleGroups = useMemo(() => {
+ if (filter === 'all') return groups;
+ if (filter === 'added') return groups.filter((g) => g.kind === 'added');
+ if (filter === 'removed') return groups.filter((g) => g.kind === 'removed');
+ return groups.filter((g) => g.kind === 'modified' || g.kind === 'preserved');
+ }, [groups, filter]);
+
+ async function handleApply() {
+ if (!preview.toVersion.id) return;
+ try {
+ await sync(preview.toVersion.id);
+ toast.success(`Synced to v${preview.toVersion.version}`);
+ router.push(frameworkHref);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to apply update');
+ } finally {
+ setConfirmOpen(false);
+ }
+ }
+
+ const showLinkChanges =
+ linksTotal > 0 &&
+ filter !== 'modified' &&
+ (filter === 'all' ||
+ (filter === 'added' && linksAdded > 0) ||
+ (filter === 'removed' && linksRemoved > 0));
+
+ const showEmpty = visibleGroups.length === 0 && !showLinkChanges;
+
+ return (
+
+
+
+
+ Reviewing update from v{preview.fromVersion.version} to v{preview.toVersion.version}
+ {preview.releaseNotes ? ` — ${preview.releaseNotes}` : ''}
+
+
+
+
+
+
+
+
+
+
+
+
+ setFilter(v as FilterKey)}>
+
+
+ All
+
+
+ Added
+
+
+ Removed
+
+
+ Modified
+
+
+
+
+
+
+ {showEmpty ? (
+
+ No changes in this category.
+
+ ) : (
+
+ {visibleGroups.map((group) => (
+
+
+
+ {group.title}
+
+ {group.rows.length}
+
+
+ {group.rows.map((row) => (
+
+ ))}
+
+
+ ))}
+ {showLinkChanges && (
+
+ )}
+
+ )}
+
+
+
+
+
+ Apply will update your framework instance. You can roll back within 14 days.
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function StatCard({
+ label,
+ value,
+ tone,
+}: {
+ label: string;
+ value: number;
+ tone?: 'positive' | 'danger';
+}) {
+ const valueClass =
+ tone === 'positive' && value > 0
+ ? 'text-emerald-600 dark:text-emerald-500'
+ : tone === 'danger' && value > 0
+ ? 'text-destructive'
+ : '';
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
+
+function TabBadge({ count }: { count: number }) {
+ return (
+
+ {count}
+
+ );
+}
+
+function ItemRow({ row }: { row: ChangeRow }) {
+ const badge =
+ row.kind === 'added'
+ ? { label: 'Added', variant: 'default' as const }
+ : row.kind === 'removed'
+ ? { label: 'Removed', variant: 'destructive' as const }
+ : row.kind === 'modified'
+ ? { label: 'Modified', variant: 'secondary' as const }
+ : { label: 'Preserved', variant: 'outline' as const };
+
+ const markerTone =
+ row.kind === 'added'
+ ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400'
+ : row.kind === 'removed'
+ ? 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400'
+ : 'bg-muted text-muted-foreground';
+ const marker = row.kind === 'added' ? '+' : row.kind === 'removed' ? '−' : '~';
+
+ return (
+
+
+
+ {marker}
+
+
+
+ {row.identifier && (
+
+
+ {row.identifier}
+
+
+ )}
+
+ {row.name}
+
+
+ {row.description && (
+
+ {row.description}
+
+ )}
+
+
+
{badge.label}
+
+ );
+}
+
+interface LinkRow {
+ key: string;
+ left: string;
+ arrow: '→';
+ right: string;
+ kind: 'added' | 'removed';
+ label: string;
+}
+
+function LinkChangesBlock({
+ edges,
+ show,
+}: {
+ edges: UpdatePreview['edges'];
+ show: 'both' | 'added' | 'removed';
+}) {
+ const rows: LinkRow[] = [];
+ const wantAdded = show === 'both' || show === 'added';
+ const wantRemoved = show === 'both' || show === 'removed';
+
+ if (wantAdded) {
+ edges.controlRequirement.added.forEach((e, i) =>
+ rows.push({
+ key: `cr-add-${i}`,
+ left: e.controlName,
+ arrow: '→',
+ right: `${e.requirementIdentifier ? `${e.requirementIdentifier} — ` : ''}${e.requirementName}`,
+ kind: 'added',
+ label: 'Requirement linked',
+ }),
+ );
+ edges.controlPolicy.added.forEach((e, i) =>
+ rows.push({
+ key: `cp-add-${i}`,
+ left: e.controlName,
+ arrow: '→',
+ right: e.policyName,
+ kind: 'added',
+ label: 'Policy linked',
+ }),
+ );
+ edges.controlTask.added.forEach((e, i) =>
+ rows.push({
+ key: `ct-add-${i}`,
+ left: e.controlName,
+ arrow: '→',
+ right: e.taskName,
+ kind: 'added',
+ label: 'Task linked',
+ }),
+ );
+ (edges.controlDocumentType?.added ?? []).forEach((e, i) =>
+ rows.push({
+ key: `cd-add-${i}`,
+ left: e.controlName,
+ arrow: '→',
+ right: e.formType.replace(/_/g, ' '),
+ kind: 'added',
+ label: 'Document type linked',
+ }),
+ );
+ }
+
+ if (wantRemoved) {
+ edges.controlRequirement.removed.forEach((e, i) =>
+ rows.push({
+ key: `cr-rem-${i}`,
+ left: e.controlName,
+ arrow: '→',
+ right: `${e.requirementIdentifier ? `${e.requirementIdentifier} — ` : ''}${e.requirementName}`,
+ kind: 'removed',
+ label: 'Requirement unlinked',
+ }),
+ );
+ edges.controlPolicy.removed.forEach((e, i) =>
+ rows.push({
+ key: `cp-rem-${i}`,
+ left: e.controlName,
+ arrow: '→',
+ right: e.policyName,
+ kind: 'removed',
+ label: 'Policy unlinked',
+ }),
+ );
+ edges.controlTask.removed.forEach((e, i) =>
+ rows.push({
+ key: `ct-rem-${i}`,
+ left: e.controlName,
+ arrow: '→',
+ right: e.taskName,
+ kind: 'removed',
+ label: 'Task unlinked',
+ }),
+ );
+ (edges.controlDocumentType?.removed ?? []).forEach((e, i) =>
+ rows.push({
+ key: `cd-rem-${i}`,
+ left: e.controlName,
+ arrow: '→',
+ right: e.formType.replace(/_/g, ' '),
+ kind: 'removed',
+ label: 'Document type unlinked',
+ }),
+ );
+ }
+
+ if (rows.length === 0) return null;
+
+ return (
+
+
+
+ LINK CHANGES
+
+ {rows.length}
+
+
+ {rows.map((row) => (
+
+ ))}
+
+
+ );
+}
+
+function LinkRowItem({ row }: { row: LinkRow }) {
+ const markerTone =
+ row.kind === 'added'
+ ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400'
+ : 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400';
+ const marker = row.kind === 'added' ? '+' : '−';
+ return (
+
+
+
+ {marker}
+
+
+
+
+
+ {row.left}
+
+
+
+ {row.arrow}
+
+
+
+ {row.right}
+
+
+
+
+ {row.label}
+
+
+
+
+ {row.kind === 'added' ? 'Added' : 'Removed'}
+
+
+ );
+}
+
+function buildGroups(preview: UpdatePreview): ChangeGroup[] {
+ const out: ChangeGroup[] = [];
+
+ // Removed
+ if (preview.requirements.removed.length) {
+ out.push({
+ title: 'REMOVED REQUIREMENTS',
+ kind: 'removed',
+ rows: preview.requirements.removed.map((r) => ({
+ key: `req-rem-${r.id}`,
+ identifier: r.identifier,
+ name: r.name,
+ description: r.description,
+ kind: 'removed' as const,
+ })),
+ });
+ }
+ if (preview.controls.archived.length) {
+ out.push({
+ title: 'REMOVED CONTROLS',
+ kind: 'removed',
+ rows: preview.controls.archived.map(({ instanceId, manifest }) => ({
+ key: `ctl-rem-${instanceId}`,
+ name: manifest.name,
+ description: manifest.description,
+ kind: 'removed' as const,
+ })),
+ });
+ }
+ if (preview.policies.archived.length) {
+ out.push({
+ title: 'REMOVED POLICIES',
+ kind: 'removed',
+ rows: preview.policies.archived.map(({ instanceId, manifest }) => ({
+ key: `pol-rem-${instanceId}`,
+ name: manifest.name,
+ description: manifest.description ?? null,
+ kind: 'removed' as const,
+ })),
+ });
+ }
+ if (preview.tasks.archived.length) {
+ out.push({
+ title: 'REMOVED TASKS',
+ kind: 'removed',
+ rows: preview.tasks.archived.map(({ instanceId, manifest }) => ({
+ key: `tsk-rem-${instanceId}`,
+ name: manifest.name,
+ description: manifest.description,
+ kind: 'removed' as const,
+ })),
+ });
+ }
+
+ // Added
+ if (preview.requirements.added.length) {
+ out.push({
+ title: 'NEW REQUIREMENTS',
+ kind: 'added',
+ rows: preview.requirements.added.map((r) => ({
+ key: `req-add-${r.id}`,
+ identifier: r.identifier,
+ name: r.name,
+ description: r.description,
+ kind: 'added' as const,
+ })),
+ });
+ }
+ if (preview.controls.added.length) {
+ out.push({
+ title: 'NEW CONTROLS',
+ kind: 'added',
+ rows: preview.controls.added.map((c) => ({
+ key: `ctl-add-${c.id}`,
+ name: c.name,
+ description: c.description,
+ kind: 'added' as const,
+ })),
+ });
+ }
+ if (preview.policies.added.length) {
+ out.push({
+ title: 'NEW POLICIES',
+ kind: 'added',
+ rows: preview.policies.added.map((p) => ({
+ key: `pol-add-${p.id}`,
+ name: p.name,
+ description: p.description ?? null,
+ kind: 'added' as const,
+ })),
+ });
+ }
+ if (preview.tasks.added.length) {
+ out.push({
+ title: 'NEW TASKS',
+ kind: 'added',
+ rows: preview.tasks.added.map((t) => ({
+ key: `tsk-add-${t.id}`,
+ name: t.name,
+ description: t.description,
+ kind: 'added' as const,
+ })),
+ });
+ }
+
+ // Modified
+ if (preview.requirements.updated.length) {
+ out.push({
+ title: 'MODIFIED REQUIREMENTS',
+ kind: 'modified',
+ rows: preview.requirements.updated.map(({ to }) => ({
+ key: `req-mod-${to.id}`,
+ identifier: to.identifier,
+ name: to.name,
+ description: to.description,
+ kind: 'modified' as const,
+ })),
+ });
+ }
+ if (preview.controls.updatedApplied.length) {
+ out.push({
+ title: 'MODIFIED CONTROLS',
+ kind: 'modified',
+ rows: preview.controls.updatedApplied.map(({ instance, manifestTo }) => ({
+ key: `ctl-mod-${instance.id}`,
+ name: manifestTo.name,
+ description: manifestTo.description,
+ kind: 'modified' as const,
+ })),
+ });
+ }
+ if (preview.policies.updatedApplied.length) {
+ out.push({
+ title: 'MODIFIED POLICIES',
+ kind: 'modified',
+ rows: preview.policies.updatedApplied.map(({ instance, manifestTo }) => ({
+ key: `pol-mod-${instance.id}`,
+ name: manifestTo.name,
+ description: manifestTo.description ?? null,
+ kind: 'modified' as const,
+ })),
+ });
+ }
+ if (preview.tasks.updatedApplied.length) {
+ out.push({
+ title: 'MODIFIED TASKS',
+ kind: 'modified',
+ rows: preview.tasks.updatedApplied.map(({ instance, manifestTo }) => ({
+ key: `tsk-mod-${instance.id}`,
+ name: manifestTo.name,
+ description: manifestTo.description,
+ kind: 'modified' as const,
+ })),
+ });
+ }
+
+ // Preserved
+ const preservedRows: ChangeRow[] = [];
+ for (const { instance } of preview.controls.updatedPreserved) {
+ preservedRows.push({
+ key: `ctl-pres-${instance.id}`,
+ name: instance.name,
+ description: 'Your edits are kept. Template changed underneath.',
+ kind: 'preserved',
+ });
+ }
+ for (const { instance } of preview.tasks.updatedPreserved) {
+ preservedRows.push({
+ key: `tsk-pres-${instance.id}`,
+ name: instance.title,
+ description: 'Your edits are kept. Template changed underneath.',
+ kind: 'preserved',
+ });
+ }
+ for (const { instance } of preview.policies.updatedPreserved) {
+ preservedRows.push({
+ key: `pol-pres-${instance.id}`,
+ name: instance.name,
+ description: 'Your edits are kept. Template changed underneath.',
+ kind: 'preserved',
+ });
+ }
+ for (const { instance } of preview.policies.draftAddedForPublished) {
+ preservedRows.push({
+ key: `pol-draft-${instance.id}`,
+ name: instance.name,
+ description: 'Published — a new draft version will be created with the template update.',
+ kind: 'preserved',
+ });
+ }
+ if (preservedRows.length) {
+ out.push({ title: 'YOUR EDITS PRESERVED', kind: 'preserved', rows: preservedRows });
+ }
+
+ return out;
+}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/page.tsx
new file mode 100644
index 0000000000..71b4ce2e23
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/page.tsx
@@ -0,0 +1,42 @@
+import { serverApi } from '@/lib/api-server';
+import { PageLayout } from '@trycompai/design-system';
+import { redirect } from 'next/navigation';
+import type { UpdatePreview } from '@/types/framework-versioning';
+import { ReviewUpdateContent } from './components/ReviewUpdateContent';
+
+interface PageProps {
+ params: Promise<{
+ orgId: string;
+ frameworkInstanceId: string;
+ }>;
+}
+
+export default async function ReviewUpdatePage({ params }: PageProps) {
+ const { orgId, frameworkInstanceId } = await params;
+
+ const [frameworkRes, previewRes] = await Promise.all([
+ serverApi.get(`/v1/frameworks/${frameworkInstanceId}`),
+ serverApi.get<{ data: UpdatePreview }>(
+ `/v1/frameworks/${frameworkInstanceId}/update-preview`,
+ ),
+ ]);
+
+ // No update available (latest version matches current) → bounce back.
+ if (!frameworkRes.data || !previewRes.data?.data) {
+ redirect(`/${orgId}/frameworks/${frameworkInstanceId}`);
+ }
+
+ const framework = frameworkRes.data;
+ const frameworkName = framework.framework?.name ?? 'Framework';
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/app/src/hooks/use-framework-instance.ts b/apps/app/src/hooks/use-framework-instance.ts
new file mode 100644
index 0000000000..df873f9f0b
--- /dev/null
+++ b/apps/app/src/hooks/use-framework-instance.ts
@@ -0,0 +1,23 @@
+'use client';
+
+import useSWR from 'swr';
+import { apiClient } from '@/lib/api-client';
+
+export function useFrameworkInstance(
+ frameworkInstanceId: string,
+ options?: { fallbackData?: T },
+) {
+ return useSWR(
+ frameworkInstanceId ? `/v1/frameworks/${frameworkInstanceId}` : null,
+ async (url) => {
+ const res = await apiClient.get(url);
+ if (res.error) throw new Error(res.error);
+ return res.data as T;
+ },
+ {
+ fallbackData: options?.fallbackData,
+ revalidateOnMount: !options?.fallbackData,
+ revalidateOnFocus: true,
+ },
+ );
+}
diff --git a/apps/app/src/hooks/use-framework-rollback.ts b/apps/app/src/hooks/use-framework-rollback.ts
new file mode 100644
index 0000000000..9368e92a92
--- /dev/null
+++ b/apps/app/src/hooks/use-framework-rollback.ts
@@ -0,0 +1,35 @@
+'use client';
+
+import { useState } from 'react';
+import { apiClient } from '@/lib/api-client';
+import { mutate } from 'swr';
+
+interface RollbackResult {
+ rollbackOperationId: string;
+}
+
+export function useFrameworkRollback(frameworkInstanceId: string) {
+ const [isRollingBack, setIsRollingBack] = useState(false);
+
+ async function rollback(syncOperationId: string): Promise {
+ setIsRollingBack(true);
+ try {
+ const res = await apiClient.post<{ data: RollbackResult }>(
+ `/v1/frameworks/${frameworkInstanceId}/rollback`,
+ { syncOperationId },
+ );
+ if (res.error) throw new Error(res.error);
+ await Promise.all([
+ mutate(`/v1/frameworks/${frameworkInstanceId}/update-status`),
+ mutate(`/v1/frameworks/${frameworkInstanceId}/update-preview`),
+ mutate(`/v1/frameworks/${frameworkInstanceId}/sync-history`),
+ mutate(`/v1/frameworks/${frameworkInstanceId}`),
+ ]);
+ return res.data?.data as RollbackResult;
+ } finally {
+ setIsRollingBack(false);
+ }
+ }
+
+ return { rollback, isRollingBack };
+}
diff --git a/apps/app/src/hooks/use-framework-sync-history.ts b/apps/app/src/hooks/use-framework-sync-history.ts
new file mode 100644
index 0000000000..565f154476
--- /dev/null
+++ b/apps/app/src/hooks/use-framework-sync-history.ts
@@ -0,0 +1,34 @@
+'use client';
+
+import useSWR from 'swr';
+import { apiClient } from '@/lib/api-client';
+import type { SyncHistoryItem } from '@/types/framework-versioning';
+
+interface UseFrameworkSyncHistoryOptions {
+ fallbackData?: SyncHistoryItem[];
+ enabled?: boolean;
+}
+
+export function useFrameworkSyncHistory(
+ frameworkInstanceId: string,
+ options?: UseFrameworkSyncHistoryOptions,
+) {
+ const key =
+ frameworkInstanceId && options?.enabled !== false
+ ? `/v1/frameworks/${frameworkInstanceId}/sync-history`
+ : null;
+
+ return useSWR(
+ key,
+ async (url: string) => {
+ const res = await apiClient.get<{ data: SyncHistoryItem[] }>(url);
+ if (res.error) throw new Error(res.error);
+ return Array.isArray(res.data?.data) ? res.data.data : [];
+ },
+ {
+ fallbackData: options?.fallbackData,
+ revalidateOnMount: !options?.fallbackData,
+ revalidateOnFocus: false,
+ },
+ );
+}
diff --git a/apps/app/src/hooks/use-framework-sync.ts b/apps/app/src/hooks/use-framework-sync.ts
new file mode 100644
index 0000000000..9ea0d71d40
--- /dev/null
+++ b/apps/app/src/hooks/use-framework-sync.ts
@@ -0,0 +1,41 @@
+'use client';
+
+import { useState } from 'react';
+import { apiClient } from '@/lib/api-client';
+import { mutate } from 'swr';
+
+interface SyncResult {
+ syncOperationId: string;
+}
+
+export function useFrameworkSync(frameworkInstanceId: string) {
+ const [isSyncing, setIsSyncing] = useState(false);
+
+ async function sync(targetVersionId: string): Promise {
+ setIsSyncing(true);
+ try {
+ const res = await apiClient.post<{ data: SyncResult }>(
+ `/v1/frameworks/${frameworkInstanceId}/sync`,
+ { targetVersionId },
+ );
+ if (res.error) throw new Error(res.error);
+ // Clear the preview cache without refetching — after a successful sync
+ // the instance is at the latest version, so /update-preview would 404.
+ await mutate(
+ `/v1/frameworks/${frameworkInstanceId}/update-preview`,
+ undefined,
+ { revalidate: false },
+ );
+ await Promise.all([
+ mutate(`/v1/frameworks/${frameworkInstanceId}/update-status`),
+ mutate(`/v1/frameworks/${frameworkInstanceId}/sync-history`),
+ mutate(`/v1/frameworks/${frameworkInstanceId}`),
+ ]);
+ return res.data?.data as SyncResult;
+ } finally {
+ setIsSyncing(false);
+ }
+ }
+
+ return { sync, isSyncing };
+}
diff --git a/apps/app/src/hooks/use-framework-update-preview.ts b/apps/app/src/hooks/use-framework-update-preview.ts
new file mode 100644
index 0000000000..cac7a09381
--- /dev/null
+++ b/apps/app/src/hooks/use-framework-update-preview.ts
@@ -0,0 +1,34 @@
+'use client';
+
+import useSWR from 'swr';
+import { apiClient } from '@/lib/api-client';
+import type { UpdatePreview } from '@/types/framework-versioning';
+
+interface UseFrameworkUpdatePreviewOptions {
+ fallbackData?: UpdatePreview;
+ enabled?: boolean;
+}
+
+export function useFrameworkUpdatePreview(
+ frameworkInstanceId: string,
+ options?: UseFrameworkUpdatePreviewOptions,
+) {
+ const key =
+ frameworkInstanceId && options?.enabled !== false
+ ? `/v1/frameworks/${frameworkInstanceId}/update-preview`
+ : null;
+
+ return useSWR(
+ key,
+ async (url: string) => {
+ const res = await apiClient.get<{ data: UpdatePreview }>(url);
+ if (res.error) throw new Error(res.error);
+ return res.data?.data as UpdatePreview;
+ },
+ {
+ fallbackData: options?.fallbackData,
+ revalidateOnMount: !options?.fallbackData,
+ revalidateOnFocus: false,
+ },
+ );
+}
diff --git a/apps/app/src/hooks/use-framework-update-status.ts b/apps/app/src/hooks/use-framework-update-status.ts
new file mode 100644
index 0000000000..f34804a4bb
--- /dev/null
+++ b/apps/app/src/hooks/use-framework-update-status.ts
@@ -0,0 +1,34 @@
+'use client';
+
+import useSWR from 'swr';
+import { apiClient } from '@/lib/api-client';
+import type { FrameworkUpdateStatus } from '@/types/framework-versioning';
+
+interface UseFrameworkUpdateStatusOptions {
+ fallbackData?: FrameworkUpdateStatus;
+ enabled?: boolean;
+}
+
+export function useFrameworkUpdateStatus(
+ frameworkInstanceId: string,
+ options?: UseFrameworkUpdateStatusOptions,
+) {
+ const key =
+ frameworkInstanceId && options?.enabled !== false
+ ? `/v1/frameworks/${frameworkInstanceId}/update-status`
+ : null;
+
+ return useSWR(
+ key,
+ async (url: string) => {
+ const res = await apiClient.get<{ data: FrameworkUpdateStatus }>(url);
+ if (res.error) throw new Error(res.error);
+ return res.data?.data as FrameworkUpdateStatus;
+ },
+ {
+ fallbackData: options?.fallbackData,
+ revalidateOnMount: !options?.fallbackData,
+ revalidateOnFocus: true,
+ },
+ );
+}
diff --git a/apps/app/src/types/framework-versioning.ts b/apps/app/src/types/framework-versioning.ts
new file mode 100644
index 0000000000..03e43c2dde
--- /dev/null
+++ b/apps/app/src/types/framework-versioning.ts
@@ -0,0 +1,161 @@
+export interface FrameworkUpdateStatus {
+ currentVersion: { id: string; version: string } | null;
+ latestVersion: {
+ id: string;
+ version: string;
+ publishedAt: string;
+ releaseNotes: string | null;
+ } | null;
+ updateAvailable: boolean;
+}
+
+export interface ManifestRequirement {
+ id: string;
+ identifier: string;
+ name: string;
+ description: string | null;
+}
+
+export interface ManifestControl {
+ id: string;
+ name: string;
+ description: string;
+ requirementIds: string[];
+ policyIds: string[];
+ taskIds: string[];
+}
+
+export interface ManifestPolicy {
+ id: string;
+ name: string;
+ description: string | null;
+ content: unknown;
+ frequency: string | null;
+ department: string | null;
+}
+
+export interface ManifestTask {
+ id: string;
+ name: string;
+ description: string;
+ frequency: string | null;
+ department: string | null;
+}
+
+export interface InstanceControl {
+ id: string;
+ controlTemplateId: string | null;
+ name: string;
+ description: string;
+}
+
+export interface InstanceTask {
+ id: string;
+ taskTemplateId: string | null;
+ title: string;
+ description: string;
+ frequency: string | null;
+ department: string | null;
+}
+
+export interface InstancePolicy {
+ id: string;
+ policyTemplateId: string | null;
+ name: string;
+ description: string | null;
+ content: unknown;
+ frequency: string | null;
+ department: string | null;
+ status: string;
+}
+
+export interface UpdatePreview {
+ fromVersion: { id: string; version: string };
+ toVersion: { id: string; version: string };
+ releaseNotes: string | null;
+ controls: {
+ added: ManifestControl[];
+ archived: Array<{ instanceId: string; manifest: ManifestControl }>;
+ updatedApplied: Array<{
+ instance: InstanceControl;
+ manifestFrom: ManifestControl;
+ manifestTo: ManifestControl;
+ }>;
+ updatedPreserved: Array<{
+ instance: InstanceControl;
+ manifestFrom: ManifestControl;
+ manifestTo: ManifestControl;
+ }>;
+ };
+ tasks: {
+ added: ManifestTask[];
+ archived: Array<{ instanceId: string; manifest: ManifestTask }>;
+ updatedApplied: Array<{
+ instance: InstanceTask;
+ manifestFrom: ManifestTask;
+ manifestTo: ManifestTask;
+ }>;
+ updatedPreserved: Array<{
+ instance: InstanceTask;
+ manifestFrom: ManifestTask;
+ manifestTo: ManifestTask;
+ }>;
+ };
+ policies: {
+ added: ManifestPolicy[];
+ archived: Array<{ instanceId: string; manifest: ManifestPolicy }>;
+ updatedApplied: Array<{
+ instance: InstancePolicy;
+ manifestFrom: ManifestPolicy;
+ manifestTo: ManifestPolicy;
+ }>;
+ updatedPreserved: Array<{
+ instance: InstancePolicy;
+ manifestFrom: ManifestPolicy;
+ manifestTo: ManifestPolicy;
+ }>;
+ draftAddedForPublished: Array<{
+ instance: InstancePolicy;
+ manifestTo: ManifestPolicy;
+ }>;
+ };
+ requirements: {
+ added: ManifestRequirement[];
+ removed: ManifestRequirement[];
+ updated: Array<{ from: ManifestRequirement; to: ManifestRequirement }>;
+ };
+ edges: {
+ controlPolicy: {
+ added: Array<{ controlName: string; policyName: string }>;
+ removed: Array<{ controlName: string; policyName: string }>;
+ };
+ controlTask: {
+ added: Array<{ controlName: string; taskName: string }>;
+ removed: Array<{ controlName: string; taskName: string }>;
+ };
+ controlRequirement: {
+ added: Array<{ controlName: string; requirementIdentifier: string; requirementName: string }>;
+ removed: Array<{ controlName: string; requirementIdentifier: string; requirementName: string }>;
+ };
+ controlDocumentType: {
+ added: Array<{ controlName: string; formType: string }>;
+ removed: Array<{ controlName: string; formType: string }>;
+ };
+ };
+}
+
+export interface SyncHistoryItem {
+ id: string;
+ kind: 'SYNC' | 'ROLLBACK';
+ performedAt: string;
+ performedById: string | null;
+ performedBy: {
+ id: string;
+ user: { id: string; name: string; email: string } | null;
+ } | null;
+ rollbackExpiresAt: string | null;
+ rolledBackByOperationId: string | null;
+ fromVersion: { id: string; version: string };
+ toVersion: { id: string; version: string };
+ summary: unknown;
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx
index b827a18683..3136d4b749 100644
--- a/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx
+++ b/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx
@@ -14,6 +14,7 @@ import { ImportFrameworkDialog } from './components/ImportFrameworkDialog';
export interface FrameworkWithCounts extends Omit {
requirementsCount: number;
controlsCount: number;
+ latestVersion: { id: string; version: string; publishedAt: string } | null;
}
interface FrameworksClientPageProps {
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkTabs.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkTabs.tsx
index cbf0e528b6..253b83a37c 100644
--- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkTabs.tsx
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkTabs.tsx
@@ -14,6 +14,7 @@ export function FrameworkTabs() {
{ name: 'Policies', href: `/frameworks/${frameworkId}/policies`, segment: 'policies' },
{ name: 'Tasks', href: `/frameworks/${frameworkId}/tasks`, segment: 'tasks' },
{ name: 'Documents', href: `/frameworks/${frameworkId}/documents`, segment: 'documents' },
+ { name: 'Versions', href: `/frameworks/${frameworkId}/versions`, segment: 'versions' },
];
const activeValue = segment ?? 'requirements';
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/layout.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/layout.tsx
index 0b6349e8a2..f3198a1456 100644
--- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/layout.tsx
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/layout.tsx
@@ -13,6 +13,11 @@ interface FrameworkSummary {
visible: boolean;
}
+interface PublishedVersion {
+ id: string;
+ version: string;
+}
+
export default async function FrameworkLayout({
children,
params,
@@ -32,6 +37,19 @@ export default async function FrameworkLayout({
notFound();
}
+ // Fetch the latest published version (our new FrameworkVersion table).
+ // Fall back to the catalog version string if no versions have been published.
+ let latestVersion: string = framework.version;
+ try {
+ const versionsRes = await serverApi<{ data: PublishedVersion[] }>(
+ `/framework/${frameworkId}/versions`,
+ );
+ const latest = Array.isArray(versionsRes?.data) ? versionsRes.data[0] : undefined;
+ if (latest?.version) latestVersion = latest.version;
+ } catch {
+ // ignore — endpoint may not exist or no versions yet
+ }
+
return (
{framework.name}
-
v{framework.version}
+
v{latestVersion}
{framework.visible ? 'Visible' : 'Hidden'}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/VersionsClient.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/VersionsClient.tsx
new file mode 100644
index 0000000000..6fa23d19b2
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/VersionsClient.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import { Button } from '@trycompai/ui';
+import { useState } from 'react';
+import { useFrameworkVersions } from './hooks/useFrameworkVersions';
+import { PublishVersionDialog } from './components/PublishVersionDialog';
+import { VersionList } from './components/VersionList';
+
+interface VersionsClientProps {
+ frameworkId: string;
+}
+
+export function VersionsClient({ frameworkId }: VersionsClientProps) {
+ const [publishOpen, setPublishOpen] = useState(false);
+ const { data: versions, isLoading, error, refetch } = useFrameworkVersions(frameworkId);
+
+ const latestVersion = versions?.[0]?.version;
+
+ return (
+
+
+
Published Versions
+
+
+
+
+
+
setPublishOpen(false)}
+ latestVersion={latestVersion}
+ onPublished={refetch}
+ />
+
+ );
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/[versionId]/VersionDetailClient.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/[versionId]/VersionDetailClient.tsx
new file mode 100644
index 0000000000..7081727c8a
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/[versionId]/VersionDetailClient.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import Link from 'next/link';
+import { Button } from '@trycompai/ui';
+import { useFrameworkVersionDiff } from '../hooks/useFrameworkVersionDiff';
+import { VersionDiffView, hasAnyChanges } from '../components/VersionDiffView';
+
+interface VersionDetailClientProps {
+ frameworkId: string;
+ versionId: string;
+}
+
+export function VersionDetailClient({ frameworkId, versionId }: VersionDetailClientProps) {
+ const { data, isLoading, error } = useFrameworkVersionDiff(frameworkId, versionId);
+
+ if (isLoading) {
+ return
Loading version…
;
+ }
+
+ if (error) {
+ return (
+
+ Failed to load version: {error.message}
+
+ );
+ }
+
+ if (!data) {
+ return (
+
Version not found.
+ );
+ }
+
+ const { version, previousVersion, diff, linkChanges } = data;
+ const hasChanges = hasAnyChanges(diff);
+ const comparisonLabel = previousVersion
+ ? `Compared with v${previousVersion.version}`
+ : 'Initial version — comparing against an empty framework';
+
+ return (
+
+
+
+
+
+
+
+
+
v{version.version}
+
+ Published{' '}
+ {new Date(version.publishedAt).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+ {' · '}
+ {comparisonLabel}
+
+ {version.releaseNotes && (
+
{version.releaseNotes}
+ )}
+
+
+
+
+
Changes
+ {!hasChanges && (
+
+ No changes detected between this version and its predecessor.
+
+ )}
+
+ {hasChanges &&
}
+
+
+ );
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/[versionId]/page.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/[versionId]/page.tsx
new file mode 100644
index 0000000000..4108382dac
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/[versionId]/page.tsx
@@ -0,0 +1,20 @@
+import { isAuthorized } from '@/app/lib/utils';
+import { redirect } from 'next/navigation';
+import { VersionDetailClient } from './VersionDetailClient';
+
+export async function generateMetadata() {
+ return { title: 'Framework Version' };
+}
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ frameworkId: string; versionId: string }>;
+}) {
+ const isAllowed = await isAuthorized();
+ if (!isAllowed) redirect('/auth');
+
+ const { frameworkId, versionId } = await params;
+
+ return
;
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/PublishVersionDialog.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/PublishVersionDialog.tsx
new file mode 100644
index 0000000000..3906b973bc
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/PublishVersionDialog.tsx
@@ -0,0 +1,240 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ Input,
+ Textarea,
+} from '@trycompai/ui';
+import { useEffect, useState } from 'react';
+import { useForm, type ControllerRenderProps } from 'react-hook-form';
+import { toast } from 'sonner';
+import { z } from 'zod';
+import { useFrameworkDraftDiff } from '../hooks/useFrameworkDraftDiff';
+import { VersionDiffView, hasAnyChanges } from './VersionDiffView';
+
+function suggestNextVersion(current: string | undefined): string {
+ if (!current) return '1.0.0';
+ const parts = current.split('.').map(Number);
+ const [major, minor] = parts;
+ if (parts.some((n) => Number.isNaN(n))) return '1.0.0';
+ return `${major}.${minor + 1}.0`;
+}
+
+const publishSchema = z.object({
+ version: z
+ .string()
+ .min(1, 'Version is required')
+ .regex(/^\d+\.\d+\.\d+$/, 'Must be MAJOR.MINOR.PATCH format'),
+ releaseNotes: z.string().optional(),
+});
+
+type PublishFormValues = z.infer
;
+
+interface PublishVersionDialogProps {
+ frameworkId: string;
+ open: boolean;
+ onClose: () => void;
+ latestVersion?: string;
+ onPublished: () => void;
+}
+
+export function PublishVersionDialog({
+ frameworkId,
+ open,
+ onClose,
+ latestVersion,
+ onPublished,
+}: PublishVersionDialogProps) {
+ const { data: draftDiff, isLoading: diffLoading } = useFrameworkDraftDiff(frameworkId, {
+ enabled: open,
+ });
+ const [collisionError, setCollisionError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(publishSchema),
+ defaultValues: {
+ version: suggestNextVersion(latestVersion),
+ releaseNotes: '',
+ },
+ });
+
+ useEffect(() => {
+ if (open) {
+ form.reset({
+ version: suggestNextVersion(latestVersion),
+ releaseNotes: '',
+ });
+ setCollisionError(null);
+ }
+ }, [open, latestVersion, form]);
+
+ const hasChanges = draftDiff?.diff ? hasAnyChanges(draftDiff.diff) : false;
+
+ const handlePublish = async (values: PublishFormValues) => {
+ setCollisionError(null);
+ setIsSubmitting(true);
+ try {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'}/v1/framework-editor/framework/${frameworkId}/versions`,
+ {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ version: values.version,
+ releaseNotes: values.releaseNotes || undefined,
+ }),
+ },
+ );
+
+ if (res.status === 409) {
+ setCollisionError(
+ `Version ${values.version} is already published. Choose a different version.`,
+ );
+ return;
+ }
+
+ if (!res.ok) {
+ const text = await res.text();
+ toast.error(text || 'Failed to publish version');
+ return;
+ }
+
+ toast.success(`Version v${values.version} published`);
+ onPublished();
+ handleClose();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to publish version');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleClose = () => {
+ form.reset();
+ setCollisionError(null);
+ onClose();
+ };
+
+ const diff = draftDiff?.diff;
+
+ return (
+
+ );
+}
+
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx
new file mode 100644
index 0000000000..3ca6b4173e
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx
@@ -0,0 +1,319 @@
+'use client';
+
+import type {
+ DiffControl,
+ DiffPolicy,
+ DiffRequirement,
+ DiffTask,
+ DraftDiff,
+} from '../hooks/useFrameworkDraftDiff';
+
+/**
+ * Entity + link changes between two manifests. Identical shape for
+ * publish-time (draft vs latest published) and historical (v_n vs v_n-1)
+ * diffs, so this component powers both the PublishVersionDialog and the
+ * historical version-detail page.
+ */
+export interface VersionDiffViewProps {
+ diff: DraftDiff['diff'];
+ linkChanges: DraftDiff['linkChanges'];
+}
+
+export function hasAnyChanges(diff: DraftDiff['diff']): boolean {
+ const {
+ controls,
+ requirements,
+ policies,
+ tasks,
+ requirementMapEdges,
+ controlPolicyEdges,
+ controlTaskEdges,
+ controlDocumentTypeEdges,
+ } = diff;
+ const docTypeEdges = controlDocumentTypeEdges ?? { added: [], removed: [] };
+ return (
+ controls.added.length > 0 ||
+ controls.removed.length > 0 ||
+ controls.updated.length > 0 ||
+ requirements.added.length > 0 ||
+ requirements.removed.length > 0 ||
+ requirements.updated.length > 0 ||
+ policies.added.length > 0 ||
+ policies.removed.length > 0 ||
+ policies.updated.length > 0 ||
+ tasks.added.length > 0 ||
+ tasks.removed.length > 0 ||
+ tasks.updated.length > 0 ||
+ requirementMapEdges.added.length > 0 ||
+ requirementMapEdges.removed.length > 0 ||
+ controlPolicyEdges.added.length > 0 ||
+ controlPolicyEdges.removed.length > 0 ||
+ controlTaskEdges.added.length > 0 ||
+ controlTaskEdges.removed.length > 0 ||
+ docTypeEdges.added.length > 0 ||
+ docTypeEdges.removed.length > 0
+ );
+}
+
+export function VersionDiffView({ diff, linkChanges }: VersionDiffViewProps) {
+ return (
+ <>
+ (
+
+ {r.identifier}
+ {r.name}
+
+ )}
+ />
+ {c.name}}
+ />
+ {p.name}}
+ />
+ {t.name}}
+ />
+ ({
+ key: `a-${i}`,
+ left: e.controlName,
+ right: e.requirementIdentifier
+ ? `${e.requirementIdentifier} — ${e.requirementName}`
+ : e.requirementName,
+ }))}
+ removed={(linkChanges?.controlRequirement.removed ?? []).map((e, i) => ({
+ key: `r-${i}`,
+ left: e.controlName,
+ right: e.requirementIdentifier
+ ? `${e.requirementIdentifier} — ${e.requirementName}`
+ : e.requirementName,
+ }))}
+ fallbackAdded={diff.requirementMapEdges.added.length}
+ fallbackRemoved={diff.requirementMapEdges.removed.length}
+ />
+ ({
+ key: `a-${i}`,
+ left: e.controlName,
+ right: e.policyName,
+ }))}
+ removed={(linkChanges?.controlPolicy.removed ?? []).map((e, i) => ({
+ key: `r-${i}`,
+ left: e.controlName,
+ right: e.policyName,
+ }))}
+ fallbackAdded={diff.controlPolicyEdges.added.length}
+ fallbackRemoved={diff.controlPolicyEdges.removed.length}
+ />
+ ({
+ key: `a-${i}`,
+ left: e.controlName,
+ right: e.taskName,
+ }))}
+ removed={(linkChanges?.controlTask.removed ?? []).map((e, i) => ({
+ key: `r-${i}`,
+ left: e.controlName,
+ right: e.taskName,
+ }))}
+ fallbackAdded={diff.controlTaskEdges.added.length}
+ fallbackRemoved={diff.controlTaskEdges.removed.length}
+ />
+ ({
+ key: `a-${i}`,
+ left: e.controlName,
+ right: e.formType.replace(/_/g, ' '),
+ }))}
+ removed={(linkChanges?.controlDocumentType.removed ?? []).map((e, i) => ({
+ key: `r-${i}`,
+ left: e.controlName,
+ right: e.formType.replace(/_/g, ' '),
+ }))}
+ fallbackAdded={diff.controlDocumentTypeEdges?.added.length ?? 0}
+ fallbackRemoved={diff.controlDocumentTypeEdges?.removed.length ?? 0}
+ />
+ >
+ );
+}
+
+interface DiffDetailSectionProps {
+ title: string;
+ added: T[];
+ removed: T[];
+ updated: Array<{ id: string; from: T; to: T }>;
+ renderRow: (item: T) => React.ReactNode;
+}
+
+function DiffDetailSection({
+ title,
+ added,
+ removed,
+ updated,
+ renderRow,
+}: DiffDetailSectionProps) {
+ if (added.length === 0 && removed.length === 0 && updated.length === 0) return null;
+ return (
+
+
+ {title}
+
+
+ {added.map((item) => (
+
+ {renderRow(item)}
+
+ ))}
+ {removed.map((item) => (
+
+ {renderRow(item)}
+
+ ))}
+ {updated.map((u) => (
+
+ {renderRow(u.to)}
+
+ ))}
+
+
+ );
+}
+
+function DiffRow({
+ kind,
+ children,
+}: {
+ kind: 'added' | 'removed' | 'modified';
+ children: React.ReactNode;
+}) {
+ const markerClass =
+ kind === 'added'
+ ? 'bg-green-100 text-green-700'
+ : kind === 'removed'
+ ? 'bg-red-100 text-red-700'
+ : 'bg-slate-100 text-slate-700';
+ const marker = kind === 'added' ? '+' : kind === 'removed' ? '−' : '~';
+ const label = kind.charAt(0).toUpperCase() + kind.slice(1);
+ return (
+
+
+
+ {marker}
+
+ {children}
+
+
{label}
+
+ );
+}
+
+interface LinkEntry {
+ key: string;
+ left: string;
+ right: string;
+}
+
+function LinkEdgeSection({
+ title,
+ added,
+ removed,
+ fallbackAdded,
+ fallbackRemoved,
+}: {
+ title: string;
+ added: LinkEntry[];
+ removed: LinkEntry[];
+ fallbackAdded: number;
+ fallbackRemoved: number;
+}) {
+ const totalDetailed = added.length + removed.length;
+ const totalFallback = fallbackAdded + fallbackRemoved;
+ if (totalDetailed === 0 && totalFallback === 0) return null;
+
+ // If the enriched linkChanges payload is missing (older API), just show counts.
+ if (totalDetailed === 0 && totalFallback > 0) {
+ return (
+
+
+ {title}
+
+
+ {fallbackAdded > 0 && (
+
+ {fallbackAdded} link{fallbackAdded !== 1 ? 's' : ''} added
+
+ )}
+ {fallbackRemoved > 0 && (
+
+ {fallbackRemoved} link{fallbackRemoved !== 1 ? 's' : ''} removed
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+ {title}
+
+
+ {added.map((e) => (
+
+ ))}
+ {removed.map((e) => (
+
+ ))}
+
+
+ );
+}
+
+function LinkRow({ kind, entry }: { kind: 'added' | 'removed'; entry: LinkEntry }) {
+ const markerClass =
+ kind === 'added' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
+ const marker = kind === 'added' ? '+' : '−';
+ const label = kind === 'added' ? 'Added' : 'Removed';
+ return (
+
+
+
+ {marker}
+
+
+ {entry.left}
+ →
+ {entry.right}
+
+
+
{label}
+
+ );
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionList.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionList.tsx
new file mode 100644
index 0000000000..eacfa0d4f9
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionList.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@trycompai/ui';
+import { useRouter } from 'next/navigation';
+import type { FrameworkVersionListItem } from '../hooks/useFrameworkVersions';
+
+interface VersionListProps {
+ frameworkId: string;
+ versions: FrameworkVersionListItem[] | undefined;
+ isLoading: boolean;
+ error: Error | null;
+}
+
+export function VersionList({ frameworkId, versions, isLoading, error }: VersionListProps) {
+ const router = useRouter();
+ if (isLoading) {
+ return (
+
+ Loading versions...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Failed to load versions.
+
+ );
+ }
+
+ if (!versions || versions.length === 0) {
+ return (
+
+ No versions published yet.
+
+ );
+ }
+
+ return (
+
+
+
+ Version
+ Published
+ Publisher
+ Release Notes
+
+
+
+ {versions.map((v) => (
+ router.push(`/frameworks/${frameworkId}/versions/${v.id}`)}
+ >
+
+ v{v.version}
+
+
+
+ {new Date(v.publishedAt).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+
+ {v.publishedBy?.name || v.publishedBy?.email || '—'}
+
+
+
+
+ {v.releaseNotes
+ ? v.releaseNotes.length > 80
+ ? `${v.releaseNotes.slice(0, 80)}…`
+ : v.releaseNotes
+ : '—'}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts
new file mode 100644
index 0000000000..abcd875de1
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts
@@ -0,0 +1,113 @@
+'use client';
+
+import { apiClient } from '@/app/lib/api-client';
+import { useCallback, useEffect, useState } from 'react';
+
+export interface DiffControl {
+ id: string;
+ name: string;
+ description?: string;
+}
+
+export interface DiffRequirement {
+ id: string;
+ name: string;
+ identifier: string;
+ description?: string | null;
+}
+
+export interface DiffPolicy {
+ id: string;
+ name: string;
+ description?: string | null;
+}
+
+export interface DiffTask {
+ id: string;
+ name: string;
+ description?: string;
+}
+
+export interface EntityDiffCounts {
+ added: T[];
+ removed: T[];
+ updated: Array<{ id: string; from: T; to: T }>;
+}
+
+export interface EdgeDiffCounts {
+ added: Array<{ controlTemplateId: string; [k: string]: string | undefined }>;
+ removed: Array<{ controlTemplateId: string; [k: string]: string | undefined }>;
+}
+
+export interface DraftDiff {
+ latestVersion: { id: string; version: string } | null;
+ diff: {
+ controls: EntityDiffCounts;
+ requirements: EntityDiffCounts;
+ policies: EntityDiffCounts;
+ tasks: EntityDiffCounts;
+ requirementMapEdges: EdgeDiffCounts;
+ controlPolicyEdges: EdgeDiffCounts;
+ controlTaskEdges: EdgeDiffCounts;
+ controlDocumentTypeEdges?: EdgeDiffCounts;
+ };
+ linkChanges?: {
+ controlRequirement: {
+ added: Array<{
+ controlName: string;
+ requirementName: string;
+ requirementIdentifier: string;
+ }>;
+ removed: Array<{
+ controlName: string;
+ requirementName: string;
+ requirementIdentifier: string;
+ }>;
+ };
+ controlPolicy: {
+ added: Array<{ controlName: string; policyName: string }>;
+ removed: Array<{ controlName: string; policyName: string }>;
+ };
+ controlTask: {
+ added: Array<{ controlName: string; taskName: string }>;
+ removed: Array<{ controlName: string; taskName: string }>;
+ };
+ controlDocumentType: {
+ added: Array<{ controlName: string; formType: string }>;
+ removed: Array<{ controlName: string; formType: string }>;
+ };
+ };
+}
+
+export function useFrameworkDraftDiff(
+ frameworkId: string,
+ options?: { enabled?: boolean },
+) {
+ const [data, setData] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const enabled = options?.enabled !== false;
+
+ const fetchDiff = useCallback(async () => {
+ if (!frameworkId || !enabled) return;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const result = await apiClient<{ data: DraftDiff }>(
+ `/framework/${frameworkId}/draft-diff`,
+ );
+ setData(result?.data);
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Failed to fetch draft diff'));
+ } finally {
+ setIsLoading(false);
+ }
+ }, [frameworkId, enabled]);
+
+ useEffect(() => {
+ void fetchDiff();
+ }, [fetchDiff]);
+
+ return { data, isLoading, error, refetch: fetchDiff };
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkVersionDiff.ts b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkVersionDiff.ts
new file mode 100644
index 0000000000..161f210878
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkVersionDiff.ts
@@ -0,0 +1,57 @@
+'use client';
+
+import { apiClient } from '@/app/lib/api-client';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { DraftDiff } from './useFrameworkDraftDiff';
+
+/**
+ * Diff of a specific published version against the version published
+ * immediately before it (or an empty manifest if it's the first version).
+ * Shape mirrors DraftDiff for the diff/linkChanges portions so the same
+ * VersionDiffView component renders both.
+ */
+export interface VersionDiffResponse {
+ version: {
+ id: string;
+ version: string;
+ publishedAt: string;
+ releaseNotes: string | null;
+ };
+ previousVersion: { id: string; version: string } | null;
+ diff: DraftDiff['diff'];
+ linkChanges: DraftDiff['linkChanges'];
+}
+
+export function useFrameworkVersionDiff(frameworkId: string, versionId: string) {
+ const [data, setData] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ // Monotonic request id — drops older responses when version params change
+ // quickly so a slower fetch can't overwrite a newer result.
+ const latestRequestId = useRef(0);
+
+ const fetchDiff = useCallback(async () => {
+ if (!frameworkId || !versionId) return;
+ const requestId = ++latestRequestId.current;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const result = await apiClient<{ data: VersionDiffResponse }>(
+ `/framework/${frameworkId}/versions/${versionId}/diff`,
+ );
+ if (requestId !== latestRequestId.current) return;
+ setData(result?.data);
+ } catch (err) {
+ if (requestId !== latestRequestId.current) return;
+ setError(err instanceof Error ? err : new Error('Failed to fetch version diff'));
+ } finally {
+ if (requestId === latestRequestId.current) setIsLoading(false);
+ }
+ }, [frameworkId, versionId]);
+
+ useEffect(() => {
+ void fetchDiff();
+ }, [fetchDiff]);
+
+ return { data, isLoading, error, refetch: fetchDiff };
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkVersions.ts b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkVersions.ts
new file mode 100644
index 0000000000..16db90c852
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkVersions.ts
@@ -0,0 +1,48 @@
+'use client';
+
+import { apiClient } from '@/app/lib/api-client';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+export interface FrameworkVersionListItem {
+ id: string;
+ version: string;
+ publishedAt: string;
+ publishedById: string | null;
+ publishedBy: { id: string; name: string; email: string } | null;
+ releaseNotes: string | null;
+}
+
+export function useFrameworkVersions(frameworkId: string) {
+ const [data, setData] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ // Monotonic request id. If a newer fetch has been kicked off by the time an
+ // older one resolves, the older response is dropped — prevents an earlier
+ // refetch from overwriting a newer result with stale data.
+ const latestRequestId = useRef(0);
+
+ const fetchVersions = useCallback(async () => {
+ if (!frameworkId) return;
+ const requestId = ++latestRequestId.current;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const result = await apiClient<{ data: FrameworkVersionListItem[]; count?: number }>(
+ `/framework/${frameworkId}/versions`,
+ );
+ if (requestId !== latestRequestId.current) return;
+ setData(Array.isArray(result?.data) ? result.data : []);
+ } catch (err) {
+ if (requestId !== latestRequestId.current) return;
+ setError(err instanceof Error ? err : new Error('Failed to fetch versions'));
+ } finally {
+ if (requestId === latestRequestId.current) setIsLoading(false);
+ }
+ }, [frameworkId]);
+
+ useEffect(() => {
+ void fetchVersions();
+ }, [fetchVersions]);
+
+ return { data, isLoading, error, refetch: fetchVersions };
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/page.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/page.tsx
new file mode 100644
index 0000000000..ead754d674
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/page.tsx
@@ -0,0 +1,20 @@
+import { isAuthorized } from '@/app/lib/utils';
+import { redirect } from 'next/navigation';
+import { VersionsClient } from './VersionsClient';
+
+export async function generateMetadata() {
+ return { title: 'Framework Versions' };
+}
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ frameworkId: string }>;
+}) {
+ const isAllowed = await isAuthorized();
+ if (!isAllowed) redirect('/auth');
+
+ const { frameworkId } = await params;
+
+ return ;
+}
diff --git a/apps/framework-editor/app/(pages)/frameworks/page.tsx b/apps/framework-editor/app/(pages)/frameworks/page.tsx
index dc83dd56da..2afd672f9e 100644
--- a/apps/framework-editor/app/(pages)/frameworks/page.tsx
+++ b/apps/framework-editor/app/(pages)/frameworks/page.tsx
@@ -7,7 +7,14 @@ export default async function Page() {
const isAllowed = await isAuthorized();
if (!isAllowed) redirect('/auth');
+ // `/framework` now returns latestVersion per framework in a single query,
+ // so we no longer need the N+1 loop that fetched versions per framework.
const frameworks = await serverApi('/framework');
- return ;
+ const enriched = frameworks.map((fw) => ({
+ ...fw,
+ version: fw.latestVersion?.version ?? fw.version,
+ }));
+
+ return ;
}
diff --git a/packages/db/package.json b/packages/db/package.json
index abcf57a5fd..adfa4d79d4 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -43,6 +43,7 @@
"directory": "packages/db"
},
"scripts": {
+ "backfill:framework-versions": "bun src/scripts/backfill-framework-versions.ts",
"build": "rm -rf dist && node scripts/generate-prisma-client-js.js && tsc",
"check-types": "tsc --noEmit",
"db:generate": "node scripts/generate-prisma-client-js.js",
diff --git a/packages/db/prisma/migrations/20260422182751_framework_versioning/migration.sql b/packages/db/prisma/migrations/20260422182751_framework_versioning/migration.sql
new file mode 100644
index 0000000000..8dab033158
--- /dev/null
+++ b/packages/db/prisma/migrations/20260422182751_framework_versioning/migration.sql
@@ -0,0 +1,101 @@
+-- CreateEnum
+CREATE TYPE "FrameworkSyncOperationKind" AS ENUM ('SYNC', 'ROLLBACK');
+
+-- AlterTable
+ALTER TABLE "Control" ADD COLUMN "archivedAt" TIMESTAMP(3);
+
+-- AlterTable
+ALTER TABLE "FrameworkInstance" ADD COLUMN "currentVersionId" TEXT;
+
+-- AlterTable
+ALTER TABLE "Policy" ADD COLUMN "archivedAt" TIMESTAMP(3);
+
+-- AlterTable
+ALTER TABLE "RequirementMap" ADD COLUMN "archivedAt" TIMESTAMP(3);
+
+-- AlterTable
+ALTER TABLE "Task" ADD COLUMN "archivedAt" TIMESTAMP(3);
+
+-- CreateTable
+CREATE TABLE "FrameworkSyncOperation" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fso'::text),
+ "frameworkInstanceId" TEXT NOT NULL,
+ "fromVersionId" TEXT NOT NULL,
+ "toVersionId" TEXT NOT NULL,
+ "kind" "FrameworkSyncOperationKind" NOT NULL,
+ "performedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "performedById" TEXT,
+ "rollbackExpiresAt" TIMESTAMP(3),
+ "rolledBackByOperationId" TEXT,
+ "undoPayload" JSONB NOT NULL,
+ "summary" JSONB NOT NULL,
+
+ CONSTRAINT "FrameworkSyncOperation_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "FrameworkVersion" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fvr'::text),
+ "frameworkId" TEXT NOT NULL,
+ "version" TEXT NOT NULL,
+ "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "publishedById" TEXT,
+ "releaseNotes" TEXT,
+ "manifest" JSONB NOT NULL,
+
+ CONSTRAINT "FrameworkVersion_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "FrameworkSyncOperation_rolledBackByOperationId_key" ON "FrameworkSyncOperation"("rolledBackByOperationId");
+
+-- CreateIndex
+CREATE INDEX "FrameworkSyncOperation_frameworkInstanceId_performedAt_idx" ON "FrameworkSyncOperation"("frameworkInstanceId", "performedAt");
+
+-- CreateIndex
+CREATE INDEX "FrameworkSyncOperation_frameworkInstanceId_kind_idx" ON "FrameworkSyncOperation"("frameworkInstanceId", "kind");
+
+-- CreateIndex
+CREATE INDEX "FrameworkVersion_frameworkId_publishedAt_idx" ON "FrameworkVersion"("frameworkId", "publishedAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "FrameworkVersion_frameworkId_version_key" ON "FrameworkVersion"("frameworkId", "version");
+
+-- CreateIndex
+CREATE INDEX "Control_organizationId_archivedAt_idx" ON "Control"("organizationId", "archivedAt");
+
+-- CreateIndex
+CREATE INDEX "FrameworkInstance_currentVersionId_idx" ON "FrameworkInstance"("currentVersionId");
+
+-- CreateIndex
+CREATE INDEX "Policy_organizationId_archivedAt_idx" ON "Policy"("organizationId", "archivedAt");
+
+-- CreateIndex
+CREATE INDEX "RequirementMap_frameworkInstanceId_archivedAt_idx" ON "RequirementMap"("frameworkInstanceId", "archivedAt");
+
+-- CreateIndex
+CREATE INDEX "Task_organizationId_archivedAt_idx" ON "Task"("organizationId", "archivedAt");
+
+-- AddForeignKey
+ALTER TABLE "FrameworkSyncOperation" ADD CONSTRAINT "FrameworkSyncOperation_frameworkInstanceId_fkey" FOREIGN KEY ("frameworkInstanceId") REFERENCES "FrameworkInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FrameworkSyncOperation" ADD CONSTRAINT "FrameworkSyncOperation_fromVersionId_fkey" FOREIGN KEY ("fromVersionId") REFERENCES "FrameworkVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FrameworkSyncOperation" ADD CONSTRAINT "FrameworkSyncOperation_toVersionId_fkey" FOREIGN KEY ("toVersionId") REFERENCES "FrameworkVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FrameworkSyncOperation" ADD CONSTRAINT "FrameworkSyncOperation_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FrameworkSyncOperation" ADD CONSTRAINT "FrameworkSyncOperation_rolledBackByOperationId_fkey" FOREIGN KEY ("rolledBackByOperationId") REFERENCES "FrameworkSyncOperation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FrameworkVersion" ADD CONSTRAINT "FrameworkVersion_frameworkId_fkey" FOREIGN KEY ("frameworkId") REFERENCES "FrameworkEditorFramework"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FrameworkVersion" ADD CONSTRAINT "FrameworkVersion_publishedById_fkey" FOREIGN KEY ("publishedById") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FrameworkInstance" ADD CONSTRAINT "FrameworkInstance_currentVersionId_fkey" FOREIGN KEY ("currentVersionId") REFERENCES "FrameworkVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20260422183412_framework_version_publisher_to_user/migration.sql b/packages/db/prisma/migrations/20260422183412_framework_version_publisher_to_user/migration.sql
new file mode 100644
index 0000000000..2f97d044d1
--- /dev/null
+++ b/packages/db/prisma/migrations/20260422183412_framework_version_publisher_to_user/migration.sql
@@ -0,0 +1,6 @@
+-- Drop the old FK that points to Member
+ALTER TABLE "FrameworkVersion" DROP CONSTRAINT "FrameworkVersion_publishedById_fkey";
+
+-- Add new FK pointing to User
+ALTER TABLE "FrameworkVersion" ADD CONSTRAINT "FrameworkVersion_publishedById_fkey"
+ FOREIGN KEY ("publishedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20260423121434_backfill_framework_versions/migration.sql b/packages/db/prisma/migrations/20260423121434_backfill_framework_versions/migration.sql
new file mode 100644
index 0000000000..a515985dfb
--- /dev/null
+++ b/packages/db/prisma/migrations/20260423121434_backfill_framework_versions/migration.sql
@@ -0,0 +1,143 @@
+-- Backfill FrameworkVersion v1.0.0 for every FrameworkEditorFramework that
+-- doesn't already have a version, and pin every FrameworkInstance with
+-- currentVersionId = NULL to its framework's v1.0.0. Authoritative, one-shot
+-- data migration for the framework-versioning rollout.
+--
+-- Must stay semantically equivalent to
+-- packages/db/src/scripts/backfill-framework-versions.ts (kept as a dev-only
+-- convenience called from the seed script for post-reset local databases).
+--
+-- Manifest shape is defined in
+-- apps/api/src/frameworks/framework-versioning/manifest.types.ts.
+
+DO $$
+DECLARE
+ f RECORD;
+ v_manifest jsonb;
+BEGIN
+ FOR f IN
+ SELECT fef.id, fef.name, fef.version, fef.description
+ FROM "FrameworkEditorFramework" fef
+ WHERE NOT EXISTS (
+ SELECT 1 FROM "FrameworkVersion" fv WHERE fv."frameworkId" = fef.id
+ )
+ LOOP
+ v_manifest := jsonb_build_object(
+ 'framework', jsonb_build_object(
+ 'id', f.id,
+ 'name', f.name,
+ 'catalogVersion', f.version,
+ 'description', f.description
+ ),
+ 'requirements', COALESCE((
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id', r.id,
+ 'identifier', r.identifier,
+ 'name', r.name,
+ 'description', r.description
+ )
+ ORDER BY r.id
+ )
+ FROM "FrameworkEditorRequirement" r
+ WHERE r."frameworkId" = f.id
+ ), '[]'::jsonb),
+ 'controls', COALESCE((
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id', ct.id,
+ 'name', ct.name,
+ 'description', ct.description,
+ 'requirementIds', COALESCE((
+ SELECT jsonb_agg(r2.id ORDER BY r2.id)
+ FROM "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr
+ JOIN "FrameworkEditorRequirement" r2 ON r2.id = jr."B"
+ WHERE jr."A" = ct.id AND r2."frameworkId" = f.id
+ ), '[]'::jsonb),
+ 'policyIds', COALESCE((
+ SELECT jsonb_agg(pt.id ORDER BY pt.id)
+ FROM "_FrameworkEditorControlTemplateToFrameworkEditorPolicyTemplate" jp
+ JOIN "FrameworkEditorPolicyTemplate" pt ON pt.id = jp."B"
+ WHERE jp."A" = ct.id
+ ), '[]'::jsonb),
+ 'taskIds', COALESCE((
+ SELECT jsonb_agg(tt.id ORDER BY tt.id)
+ FROM "_FrameworkEditorControlTemplateToFrameworkEditorTaskTemplate" jt
+ JOIN "FrameworkEditorTaskTemplate" tt ON tt.id = jt."B"
+ WHERE jt."A" = ct.id
+ ), '[]'::jsonb),
+ 'documentTypes', COALESCE(to_jsonb(ct."documentTypes"::text[]), '[]'::jsonb)
+ )
+ ORDER BY ct.id
+ )
+ FROM "FrameworkEditorControlTemplate" ct
+ WHERE EXISTS (
+ SELECT 1
+ FROM "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr
+ JOIN "FrameworkEditorRequirement" r ON r.id = jr."B"
+ WHERE jr."A" = ct.id AND r."frameworkId" = f.id
+ )
+ ), '[]'::jsonb),
+ 'policies', COALESCE((
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id', dp.id,
+ 'name', dp.name,
+ 'description', dp.description,
+ 'content', dp.content,
+ 'frequency', dp.frequency::text,
+ 'department', dp.department::text
+ )
+ ORDER BY dp.id
+ )
+ FROM (
+ SELECT DISTINCT
+ pt.id, pt.name, pt.description, pt.content, pt.frequency, pt.department
+ FROM "FrameworkEditorPolicyTemplate" pt
+ JOIN "_FrameworkEditorControlTemplateToFrameworkEditorPolicyTemplate" jp
+ ON jp."B" = pt.id
+ JOIN "FrameworkEditorControlTemplate" ct ON ct.id = jp."A"
+ JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr
+ ON jr."A" = ct.id
+ JOIN "FrameworkEditorRequirement" r ON r.id = jr."B"
+ WHERE r."frameworkId" = f.id
+ ) dp
+ ), '[]'::jsonb),
+ 'tasks', COALESCE((
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id', dt.id,
+ 'name', dt.name,
+ 'description', dt.description,
+ 'frequency', dt.frequency::text,
+ 'department', dt.department::text
+ )
+ ORDER BY dt.id
+ )
+ FROM (
+ SELECT DISTINCT
+ tt.id, tt.name, tt.description, tt.frequency, tt.department
+ FROM "FrameworkEditorTaskTemplate" tt
+ JOIN "_FrameworkEditorControlTemplateToFrameworkEditorTaskTemplate" jt
+ ON jt."B" = tt.id
+ JOIN "FrameworkEditorControlTemplate" ct ON ct.id = jt."A"
+ JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr
+ ON jr."A" = ct.id
+ JOIN "FrameworkEditorRequirement" r ON r.id = jr."B"
+ WHERE r."frameworkId" = f.id
+ ) dt
+ ), '[]'::jsonb)
+ );
+
+ INSERT INTO "FrameworkVersion" ("frameworkId", version, "releaseNotes", manifest)
+ VALUES (f.id, '1.0.0', 'Initial version (backfilled).', v_manifest);
+ END LOOP;
+
+ UPDATE "FrameworkInstance" fi
+ SET "currentVersionId" = fv.id
+ FROM "FrameworkVersion" fv
+ WHERE fi."frameworkId" IS NOT NULL
+ AND fi."currentVersionId" IS NULL
+ AND fv."frameworkId" = fi."frameworkId"
+ AND fv.version = '1.0.0';
+END $$;
diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma
index d03e54822b..5e1c09327f 100644
--- a/packages/db/prisma/schema/auth.prisma
+++ b/packages/db/prisma/schema/auth.prisma
@@ -15,19 +15,20 @@ model User {
banExpires DateTime?
isPlatformAdmin Boolean @default(false)
- accounts Account[]
- auditLog AuditLog[]
- integrationResults IntegrationResult[]
- invitations Invitation[]
- members Member[]
- sessions Session[]
- fleetPolicyResults FleetPolicyResult[]
- evidenceSubmissions EvidenceSubmission[] @relation("EvidenceSubmitter")
- evidenceReviews EvidenceSubmission[] @relation("EvidenceReviewer")
- adminFindings Finding[] @relation("AdminFindingCreator")
- timelinePhaseCompletions TimelinePhase[]
- lockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceLockedBy")
- unlockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceUnlockedBy")
+ accounts Account[]
+ auditLog AuditLog[]
+ integrationResults IntegrationResult[]
+ invitations Invitation[]
+ members Member[]
+ sessions Session[]
+ fleetPolicyResults FleetPolicyResult[]
+ evidenceSubmissions EvidenceSubmission[] @relation("EvidenceSubmitter")
+ evidenceReviews EvidenceSubmission[] @relation("EvidenceReviewer")
+ adminFindings Finding[] @relation("AdminFindingCreator")
+ timelinePhaseCompletions TimelinePhase[]
+ lockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceLockedBy")
+ unlockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceUnlockedBy")
+ publishedFrameworkVersions FrameworkVersion[] @relation("FrameworkVersionPublisher")
@@unique([email])
}
@@ -116,25 +117,26 @@ model Member {
employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[]
fleetDmLabelId Int?
- assignedPolicies Policy[] @relation("PolicyAssignee") // Policies where this member is an assignee
- approvedPolicies Policy[] @relation("PolicyApprover") // Policies where this member is an approver
- approvedSOADocuments SOADocument[] @relation("SOADocumentApprover") // SOA documents where this member is an approver
- risks Risk[]
- tasks Task[]
- vendors Vendor[]
- comments Comment[]
- auditLogs AuditLog[]
- reviewedAccessRequests TrustAccessRequest[] @relation("TrustAccessRequestReviewer")
- issuedGrants TrustAccessGrant[] @relation("IssuedGrants")
- revokedGrants TrustAccessGrant[] @relation("RevokedGrants")
- createdTaskItems TaskItem[] @relation("TaskItemCreator")
- updatedTaskItems TaskItem[] @relation("TaskItemUpdater")
- assignedTaskItems TaskItem[] @relation("TaskItemAssignee")
- createdFindings Finding[] @relation("FindingCreatedBy")
- subjectFindings Finding[] @relation("FindingSubject")
- publishedPolicyVersions PolicyVersion[] @relation("PolicyVersionPublisher")
- approvedTasks Task[] @relation("TaskApprover")
- devices Device[]
+ assignedPolicies Policy[] @relation("PolicyAssignee") // Policies where this member is an assignee
+ approvedPolicies Policy[] @relation("PolicyApprover") // Policies where this member is an approver
+ approvedSOADocuments SOADocument[] @relation("SOADocumentApprover") // SOA documents where this member is an approver
+ risks Risk[]
+ tasks Task[]
+ vendors Vendor[]
+ comments Comment[]
+ auditLogs AuditLog[]
+ reviewedAccessRequests TrustAccessRequest[] @relation("TrustAccessRequestReviewer")
+ issuedGrants TrustAccessGrant[] @relation("IssuedGrants")
+ revokedGrants TrustAccessGrant[] @relation("RevokedGrants")
+ createdTaskItems TaskItem[] @relation("TaskItemCreator")
+ updatedTaskItems TaskItem[] @relation("TaskItemUpdater")
+ assignedTaskItems TaskItem[] @relation("TaskItemAssignee")
+ createdFindings Finding[] @relation("FindingCreatedBy")
+ subjectFindings Finding[] @relation("FindingSubject")
+ publishedPolicyVersions PolicyVersion[] @relation("PolicyVersionPublisher")
+ performedFrameworkSyncOperations FrameworkSyncOperation[] @relation("FrameworkSyncOperationPerformer")
+ approvedTasks Task[] @relation("TaskApprover")
+ devices Device[]
}
model Invitation {
diff --git a/packages/db/prisma/schema/control.prisma b/packages/db/prisma/schema/control.prisma
index 11d106e8f5..14672cb972 100644
--- a/packages/db/prisma/schema/control.prisma
+++ b/packages/db/prisma/schema/control.prisma
@@ -8,6 +8,12 @@ model Control {
lastReviewDate DateTime?
nextReviewDate DateTime?
+ // Sync-driven archive (set by FrameworkSyncOperation when the control
+ // template is removed from the framework's latest version and no other
+ // framework instance in the org still references it). Distinct from user
+ // archive (no user archive exists on Control today).
+ archivedAt DateTime?
+
// Relationships
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
@@ -19,4 +25,5 @@ model Control {
controlDocumentTypes ControlDocumentType[]
@@index([organizationId])
+ @@index([organizationId, archivedAt])
}
diff --git a/packages/db/prisma/schema/framework-editor.prisma b/packages/db/prisma/schema/framework-editor.prisma
index 959ea833f7..ee06645f89 100644
--- a/packages/db/prisma/schema/framework-editor.prisma
+++ b/packages/db/prisma/schema/framework-editor.prisma
@@ -23,6 +23,7 @@ model FrameworkEditorFramework {
soaConfigurations SOAFrameworkConfiguration[] // Multiple SOA config versions per framework
soaDocuments SOADocument[] // SOA documents from organizations
timelineTemplates TimelineTemplate[]
+ versions FrameworkVersion[]
// Dates
createdAt DateTime @default(now())
diff --git a/packages/db/prisma/schema/framework-sync-operation.prisma b/packages/db/prisma/schema/framework-sync-operation.prisma
new file mode 100644
index 0000000000..6cc8292997
--- /dev/null
+++ b/packages/db/prisma/schema/framework-sync-operation.prisma
@@ -0,0 +1,35 @@
+enum FrameworkSyncOperationKind {
+ SYNC
+ ROLLBACK
+}
+
+model FrameworkSyncOperation {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('fso'::text)"))
+ frameworkInstanceId String
+ frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade)
+
+ fromVersionId String
+ fromVersion FrameworkVersion @relation("FrameworkSyncOperationFromVersion", fields: [fromVersionId], references: [id])
+ toVersionId String
+ toVersion FrameworkVersion @relation("FrameworkSyncOperationToVersion", fields: [toVersionId], references: [id])
+
+ kind FrameworkSyncOperationKind
+
+ performedAt DateTime @default(now())
+ performedById String?
+ performedBy Member? @relation("FrameworkSyncOperationPerformer", fields: [performedById], references: [id], onDelete: SetNull)
+
+ // Only set when kind = SYNC
+ rollbackExpiresAt DateTime?
+
+ // Self-reference: when this sync was reversed, points at the rollback op.
+ rolledBackByOperationId String? @unique
+ rolledBackByOperation FrameworkSyncOperation? @relation("FrameworkSyncOperationRollback", fields: [rolledBackByOperationId], references: [id])
+ rolledBackOperation FrameworkSyncOperation? @relation("FrameworkSyncOperationRollback")
+
+ undoPayload Json // structured per undo-payload.types.ts
+ summary Json // counts for audit log / UI
+
+ @@index([frameworkInstanceId, performedAt])
+ @@index([frameworkInstanceId, kind])
+}
diff --git a/packages/db/prisma/schema/framework-version.prisma b/packages/db/prisma/schema/framework-version.prisma
new file mode 100644
index 0000000000..9708c3f40c
--- /dev/null
+++ b/packages/db/prisma/schema/framework-version.prisma
@@ -0,0 +1,23 @@
+model FrameworkVersion {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('fvr'::text)"))
+ frameworkId String
+ framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade)
+
+ version String // semver-ish, e.g., "1.0.0", "2.1.0"
+ publishedAt DateTime @default(now())
+ publishedById String?
+ publishedBy User? @relation("FrameworkVersionPublisher", fields: [publishedById], references: [id], onDelete: SetNull)
+
+ releaseNotes String? // markdown
+
+ // Full snapshot of all templates at publish time (see manifest.types.ts).
+ // Immutable once published.
+ manifest Json
+
+ frameworkInstances FrameworkInstance[] @relation("FrameworkInstanceCurrentVersion")
+ syncOperationsFrom FrameworkSyncOperation[] @relation("FrameworkSyncOperationFromVersion")
+ syncOperationsTo FrameworkSyncOperation[] @relation("FrameworkSyncOperationToVersion")
+
+ @@unique([frameworkId, version])
+ @@index([frameworkId, publishedAt])
+}
diff --git a/packages/db/prisma/schema/framework.prisma b/packages/db/prisma/schema/framework.prisma
index 3d26a45749..8b9225245e 100644
--- a/packages/db/prisma/schema/framework.prisma
+++ b/packages/db/prisma/schema/framework.prisma
@@ -12,12 +12,17 @@ model FrameworkInstance {
// CustomFramework in its own org. Enforced at the DB level.
customFramework CustomFramework? @relation(fields: [customFrameworkId, organizationId], references: [id, organizationId], onDelete: Cascade)
+ currentVersionId String?
+ currentVersion FrameworkVersion? @relation("FrameworkInstanceCurrentVersion", fields: [currentVersionId], references: [id], onDelete: Restrict)
+
// Relationships
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
requirementsMapped RequirementMap[]
timelineInstances TimelineInstance[]
+ syncOperations FrameworkSyncOperation[]
@@unique([organizationId, frameworkId])
@@unique([organizationId, customFrameworkId])
@@index([customFrameworkId])
+ @@index([currentVersionId])
}
diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma
index b639973996..945e203c52 100644
--- a/packages/db/prisma/schema/policy.prisma
+++ b/packages/db/prisma/schema/policy.prisma
@@ -34,6 +34,12 @@ model Policy {
lastArchivedAt DateTime?
lastPublishedAt DateTime?
+ // Sync-driven archive. Distinct from `isArchived` (which is user-initiated
+ // archiving of a published policy). UI should hide a policy if EITHER is
+ // set: `WHERE isArchived = false AND archivedAt IS NULL`. Rollback restores
+ // only `archivedAt`, never touches `isArchived`.
+ archivedAt DateTime?
+
// Relationships
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -51,6 +57,7 @@ model Policy {
findings Finding[]
@@index([organizationId])
+ @@index([organizationId, archivedAt])
}
model PolicyVersion {
diff --git a/packages/db/prisma/schema/requirement.prisma b/packages/db/prisma/schema/requirement.prisma
index 0f684b32b3..3bdb57ae1d 100644
--- a/packages/db/prisma/schema/requirement.prisma
+++ b/packages/db/prisma/schema/requirement.prisma
@@ -14,8 +14,13 @@ model RequirementMap {
frameworkInstanceId String
frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade)
+ // Sync-driven archive — edges are framework-scoped, so these always archive
+ // cleanly when the mapping disappears from the framework's latest version.
+ archivedAt DateTime?
+
@@unique([controlId, frameworkInstanceId, requirementId])
@@unique([controlId, frameworkInstanceId, customRequirementId])
@@index([requirementId, frameworkInstanceId])
@@index([customRequirementId, frameworkInstanceId])
+ @@index([frameworkInstanceId, archivedAt])
}
diff --git a/packages/db/prisma/schema/task.prisma b/packages/db/prisma/schema/task.prisma
index d466003f47..1872ea549e 100644
--- a/packages/db/prisma/schema/task.prisma
+++ b/packages/db/prisma/schema/task.prisma
@@ -37,6 +37,11 @@ model Task {
approver Member? @relation("TaskApprover", fields: [approverId], references: [id])
approvedAt DateTime?
previousStatus TaskStatus?
+
+ // Sync-driven archive — see Control.archivedAt.
+ archivedAt DateTime?
+
+ @@index([organizationId, archivedAt])
}
enum TaskStatus {
diff --git a/packages/db/prisma/seed/seed.ts b/packages/db/prisma/seed/seed.ts
index 3d1fc37982..3d0b2c78ed 100644
--- a/packages/db/prisma/seed/seed.ts
+++ b/packages/db/prisma/seed/seed.ts
@@ -193,6 +193,15 @@ async function main() {
try {
await seedJsonFiles('primitives');
await seedJsonFiles('relations');
+ // Build v1.0.0 FrameworkVersion snapshots for any framework without one.
+ // On a fresh `migrate reset`, the backfill data migration runs against empty
+ // tables and is a no-op; seed then creates the framework rows. Without this
+ // call, local onboarding would fail because it reads from FrameworkVersion.
+ const { backfillFrameworkVersions } = await import(
+ '../../src/scripts/backfill-framework-versions'
+ );
+ const result = await backfillFrameworkVersions();
+ console.log('FrameworkVersion backfill:', result);
await prisma.$disconnect();
console.log('Seeding completed successfully for primitives and relations.');
} catch (error: unknown) {
diff --git a/packages/db/scripts/preview-framework-backfill.sql b/packages/db/scripts/preview-framework-backfill.sql
new file mode 100644
index 0000000000..13d038ab86
--- /dev/null
+++ b/packages/db/scripts/preview-framework-backfill.sql
@@ -0,0 +1,105 @@
+-- Dry-run preview for migration 20260423121434_backfill_framework_versions.
+-- Read-only: no INSERT/UPDATE/DELETE. Safe to run against staging or prod
+-- BEFORE the framework-versioning schema migration has been deployed — this
+-- script only reads from framework-editor + framework-instance tables that
+-- already exist, so it works pre-merge as well as post-merge.
+--
+-- Run via:
+-- psql $DATABASE_URL -f preview-framework-backfill.sql
+-- or paste individual sections in a psql session.
+
+\echo '== Section 1: How many FrameworkVersions the migration will create =='
+-- Every FrameworkEditorFramework row gets a v1.0.0 version. On an un-migrated
+-- DB nothing is versioned yet, so this equals total.
+SELECT COUNT(*) AS frameworks_to_version FROM "FrameworkEditorFramework";
+
+\echo ''
+\echo '== Section 2: How many FrameworkInstances the migration will pin =='
+-- Every non-custom-framework instance gets pinned to its framework''s v1.0.0.
+-- Custom-framework instances (frameworkId IS NULL) are skipped.
+SELECT
+ COUNT(*) FILTER (WHERE "frameworkId" IS NOT NULL) AS instances_to_pin,
+ COUNT(*) FILTER (WHERE "frameworkId" IS NULL) AS instances_custom_framework_skipped,
+ COUNT(*) AS instances_total
+FROM "FrameworkInstance";
+
+\echo ''
+\echo '== Section 3: Per-framework manifest preview =='
+-- Eyeball these counts against what you''d expect per framework. SOC 2 / ISO
+-- should have many; a test framework may have few. Zero controls on a real
+-- framework is a red flag.
+SELECT
+ fef.name AS framework_name,
+ fef.version AS catalog_version,
+ (SELECT COUNT(*) FROM "FrameworkEditorRequirement" r WHERE r."frameworkId" = fef.id) AS requirements,
+ (
+ SELECT COUNT(DISTINCT ct.id)
+ FROM "FrameworkEditorControlTemplate" ct
+ WHERE EXISTS (
+ SELECT 1
+ FROM "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr
+ JOIN "FrameworkEditorRequirement" r ON r.id = jr."B"
+ WHERE jr."A" = ct.id AND r."frameworkId" = fef.id
+ )
+ ) AS controls,
+ (
+ SELECT COUNT(DISTINCT pt.id)
+ FROM "FrameworkEditorPolicyTemplate" pt
+ JOIN "_FrameworkEditorControlTemplateToFrameworkEditorPolicyTemplate" jp ON jp."B" = pt.id
+ JOIN "FrameworkEditorControlTemplate" ct ON ct.id = jp."A"
+ JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr ON jr."A" = ct.id
+ JOIN "FrameworkEditorRequirement" r ON r.id = jr."B"
+ WHERE r."frameworkId" = fef.id
+ ) AS policies,
+ (
+ SELECT COUNT(DISTINCT tt.id)
+ FROM "FrameworkEditorTaskTemplate" tt
+ JOIN "_FrameworkEditorControlTemplateToFrameworkEditorTaskTemplate" jt ON jt."B" = tt.id
+ JOIN "FrameworkEditorControlTemplate" ct ON ct.id = jt."A"
+ JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr ON jr."A" = ct.id
+ JOIN "FrameworkEditorRequirement" r ON r.id = jr."B"
+ WHERE r."frameworkId" = fef.id
+ ) AS tasks,
+ (
+ SELECT COUNT(*) FROM "FrameworkInstance" fi WHERE fi."frameworkId" = fef.id
+ ) AS instances_for_this_framework
+FROM "FrameworkEditorFramework" fef
+ORDER BY fef.name;
+
+\echo ''
+\echo '== Section 4: Sanity checks (flag unusual data shapes) =='
+
+\echo '-- Frameworks with zero requirements (manifest would have empty controls/policies/tasks):'
+SELECT fef.id, fef.name
+FROM "FrameworkEditorFramework" fef
+WHERE NOT EXISTS (
+ SELECT 1 FROM "FrameworkEditorRequirement" r WHERE r."frameworkId" = fef.id
+);
+
+\echo ''
+\echo '-- Policy templates with NULL content (would store null in manifest):'
+SELECT COUNT(*) AS policy_templates_with_null_content
+FROM "FrameworkEditorPolicyTemplate"
+WHERE content IS NULL;
+
+\echo ''
+\echo '-- FrameworkInstances with custom frameworks (migration correctly skips these):'
+SELECT COUNT(*) AS custom_framework_instances
+FROM "FrameworkInstance"
+WHERE "frameworkId" IS NULL AND "customFrameworkId" IS NOT NULL;
+
+\echo ''
+\echo '-- Cross-framework control template references (manifest will filter these):'
+-- Checks for control→requirement M:N edges where the requirement belongs to a
+-- different framework than other requirements on the same control. Non-zero
+-- is expected: control templates are intentionally shared across frameworks.
+SELECT COUNT(*) AS cross_framework_control_edges
+FROM "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr1
+JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" jr2
+ ON jr2."A" = jr1."A" AND jr2."B" <> jr1."B"
+JOIN "FrameworkEditorRequirement" r1 ON r1.id = jr1."B"
+JOIN "FrameworkEditorRequirement" r2 ON r2.id = jr2."B"
+WHERE r1."frameworkId" <> r2."frameworkId";
+
+\echo ''
+\echo '== Preview complete =='
diff --git a/packages/db/src/scripts/backfill-framework-versions.spec.ts b/packages/db/src/scripts/backfill-framework-versions.spec.ts
new file mode 100644
index 0000000000..9efd4c52ec
--- /dev/null
+++ b/packages/db/src/scripts/backfill-framework-versions.spec.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect, beforeEach } from 'bun:test';
+import { db } from '../client';
+import { backfillFrameworkVersions } from './backfill-framework-versions';
+
+const dbUrl = process.env.DATABASE_URL ?? '';
+if (
+ dbUrl.includes('prod') ||
+ dbUrl.includes('staging') ||
+ (!dbUrl.includes('test') && !dbUrl.includes('localhost') && !dbUrl.includes('127.0.0.1'))
+) {
+ throw new Error(
+ `Refusing to run destructive tests. DATABASE_URL must target a local/test DB; got: ${dbUrl}`,
+ );
+}
+
+describe('backfillFrameworkVersions', () => {
+ beforeEach(async () => {
+ // Clear FK references before deleting FrameworkVersions
+ await db.frameworkInstance.updateMany({ data: { currentVersionId: null } });
+ await db.frameworkSyncOperation.deleteMany();
+ await db.frameworkVersion.deleteMany();
+ });
+
+ it('creates v1.0.0 for every framework without one', async () => {
+ const framework = await db.frameworkEditorFramework.findFirst();
+ if (!framework) throw new Error('seed data missing');
+
+ const result = await backfillFrameworkVersions();
+
+ expect(result.versionsCreated).toBeGreaterThan(0);
+ const v1 = await db.frameworkVersion.findUnique({
+ where: { frameworkId_version: { frameworkId: framework.id, version: '1.0.0' } },
+ });
+ expect(v1).not.toBeNull();
+ expect(v1!.manifest).toBeTruthy();
+ });
+
+ it('is idempotent — running twice creates no additional versions', async () => {
+ await backfillFrameworkVersions();
+ const after1 = await db.frameworkVersion.count();
+ await backfillFrameworkVersions();
+ const after2 = await db.frameworkVersion.count();
+ expect(after2).toBe(after1);
+ });
+
+ it('backfills FrameworkInstance.currentVersionId', async () => {
+ const instance = await db.frameworkInstance.findFirst({ where: { frameworkId: { not: null } } });
+ if (!instance) throw new Error('no instance to test against');
+ await db.frameworkInstance.update({
+ where: { id: instance.id },
+ data: { currentVersionId: null },
+ });
+
+ await backfillFrameworkVersions();
+
+ const updated = await db.frameworkInstance.findUnique({ where: { id: instance.id } });
+ expect(updated!.currentVersionId).not.toBeNull();
+ });
+});
diff --git a/packages/db/src/scripts/backfill-framework-versions.ts b/packages/db/src/scripts/backfill-framework-versions.ts
new file mode 100644
index 0000000000..50303b7693
--- /dev/null
+++ b/packages/db/src/scripts/backfill-framework-versions.ts
@@ -0,0 +1,180 @@
+import { Prisma } from '@prisma/client';
+import { db } from '../client';
+
+export interface BackfillResult {
+ versionsCreated: number;
+ instancesBackfilled: number;
+}
+
+type FrameworkWithTemplates = Prisma.FrameworkEditorFrameworkGetPayload<{
+ include: {
+ requirements: {
+ include: {
+ controlTemplates: {
+ include: {
+ requirements: true;
+ policyTemplates: true;
+ taskTemplates: true;
+ };
+ };
+ };
+ };
+ };
+}>;
+
+export async function backfillFrameworkVersions(): Promise {
+ const frameworks = await db.frameworkEditorFramework.findMany({
+ include: {
+ requirements: {
+ include: {
+ controlTemplates: {
+ include: {
+ requirements: true,
+ policyTemplates: true,
+ taskTemplates: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ let versionsCreated = 0;
+
+ for (const framework of frameworks) {
+ const manifest = buildManifestFromFramework(framework);
+
+ try {
+ await db.frameworkVersion.create({
+ data: {
+ frameworkId: framework.id,
+ version: '1.0.0',
+ manifest: manifest as unknown as Prisma.InputJsonValue,
+ releaseNotes: 'Initial version (backfilled).',
+ },
+ });
+ versionsCreated += 1;
+ } catch (err: unknown) {
+ if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
+ // Raced with another backfill run — already created. Not counted.
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ // Backfill instances
+ const versions = await db.frameworkVersion.findMany({
+ where: { version: '1.0.0' },
+ select: { id: true, frameworkId: true },
+ });
+ const byFrameworkId = new Map(versions.map((v) => [v.frameworkId, v.id]));
+
+ const toBackfill = await db.frameworkInstance.findMany({
+ where: { currentVersionId: null, frameworkId: { not: null } },
+ select: { id: true, frameworkId: true },
+ });
+
+ let instancesBackfilled = 0;
+ for (const inst of toBackfill) {
+ const versionId = byFrameworkId.get(inst.frameworkId!);
+ if (!versionId) continue;
+ await db.frameworkInstance.update({
+ where: { id: inst.id },
+ data: { currentVersionId: versionId },
+ });
+ instancesBackfilled += 1;
+ }
+
+ return { versionsCreated, instancesBackfilled };
+}
+
+function buildManifestFromFramework(framework: FrameworkWithTemplates) {
+ // Collect all unique control templates across all requirements
+ const controlTemplateMap = new Map<
+ string,
+ FrameworkWithTemplates['requirements'][number]['controlTemplates'][number]
+ >();
+ for (const req of framework.requirements) {
+ for (const ct of req.controlTemplates) {
+ if (!controlTemplateMap.has(ct.id)) {
+ controlTemplateMap.set(ct.id, ct);
+ }
+ }
+ }
+ const controlTemplates = [...controlTemplateMap.values()];
+
+ // Filter each control's requirementIds down to requirements belonging to
+ // this framework — control templates are shared across frameworks via M:N,
+ // so `ct.requirements` can contain IDs for requirements on other frameworks.
+ const ownRequirementIds = new Set(framework.requirements.map((r) => r.id));
+
+ return {
+ framework: {
+ id: framework.id,
+ name: framework.name,
+ catalogVersion: framework.version,
+ description: framework.description,
+ },
+ requirements: framework.requirements.map((r) => ({
+ id: r.id,
+ identifier: r.identifier,
+ name: r.name,
+ description: r.description,
+ })),
+ controls: controlTemplates.map((c) => ({
+ id: c.id,
+ name: c.name,
+ description: c.description,
+ requirementIds: c.requirements
+ .map((r) => r.id)
+ .filter((id) => ownRequirementIds.has(id)),
+ policyIds: c.policyTemplates.map((p) => p.id),
+ taskIds: c.taskTemplates.map((t) => t.id),
+ documentTypes: [...c.documentTypes],
+ })),
+ policies: dedupeById(
+ controlTemplates.flatMap((c) =>
+ c.policyTemplates.map((p) => ({
+ id: p.id,
+ name: p.name,
+ description: p.description,
+ content: p.content,
+ frequency: p.frequency,
+ department: p.department,
+ })),
+ ),
+ ),
+ tasks: dedupeById(
+ controlTemplates.flatMap((c) =>
+ c.taskTemplates.map((t) => ({
+ id: t.id,
+ name: t.name,
+ description: t.description,
+ frequency: t.frequency,
+ department: t.department,
+ })),
+ ),
+ ),
+ };
+}
+
+function dedupeById(items: T[]): T[] {
+ const seen = new Map();
+ for (const item of items) {
+ if (!seen.has(item.id)) seen.set(item.id, item);
+ }
+ return [...seen.values()];
+}
+
+if (require.main === module) {
+ backfillFrameworkVersions()
+ .then((result) => {
+ console.log('Backfill complete:', result);
+ process.exit(0);
+ })
+ .catch((err) => {
+ console.error('Backfill failed:', err);
+ process.exit(1);
+ });
+}
diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json
index 9efcc80ac9..2bbc842438 100644
--- a/packages/docs/openapi.json
+++ b/packages/docs/openapi.json
@@ -12265,16 +12265,7 @@
},
"post": {
"operationId": "ControlTemplateController_create_v1",
- "parameters": [
- {
- "name": "frameworkId",
- "required": true,
- "in": "query",
- "schema": {
- "type": "string"
- }
- }
- ],
+ "parameters": [],
"requestBody": {
"required": true,
"content": {
@@ -13795,6 +13786,20 @@
"type": "string"
}
},
+ {
+ "name": "severity",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "enum": [
+ "low",
+ "medium",
+ "high",
+ "critical"
+ ],
+ "type": "string"
+ }
+ },
{
"name": "area",
"required": false,
@@ -19742,6 +19747,171 @@
]
}
},
+ "/v1/frameworks/{id}/update-status": {
+ "get": {
+ "operationId": "FrameworksController_getUpdateStatus_v1",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "summary": "Get the update status for a framework instance",
+ "tags": [
+ "Frameworks"
+ ]
+ }
+ },
+ "/v1/frameworks/{id}/update-preview": {
+ "get": {
+ "operationId": "FrameworksController_getUpdatePreview_v1",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "summary": "Preview changes from updating a framework instance",
+ "tags": [
+ "Frameworks"
+ ]
+ }
+ },
+ "/v1/frameworks/{id}/sync": {
+ "post": {
+ "operationId": "FrameworksController_syncFramework_v1",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SyncFrameworkDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "summary": "Sync a framework instance to a target version",
+ "tags": [
+ "Frameworks"
+ ]
+ }
+ },
+ "/v1/frameworks/{id}/rollback": {
+ "post": {
+ "operationId": "FrameworksController_rollbackFramework_v1",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RollbackFrameworkDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "summary": "Roll back a framework sync operation",
+ "tags": [
+ "Frameworks"
+ ]
+ }
+ },
+ "/v1/frameworks/{id}/sync-history": {
+ "get": {
+ "operationId": "FrameworksController_getSyncHistory_v1",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "summary": "Get sync history for a framework instance",
+ "tags": [
+ "Frameworks"
+ ]
+ }
+ },
"/v1/audit-logs": {
"get": {
"operationId": "AuditLogController_getAuditLogs_v1",
@@ -25244,6 +25414,14 @@
"controlIds"
]
},
+ "SyncFrameworkDto": {
+ "type": "object",
+ "properties": {}
+ },
+ "RollbackFrameworkDto": {
+ "type": "object",
+ "properties": {}
+ },
"RequirementMappingDto": {
"type": "object",
"properties": {
From 4d340ad3251ff8d804c7000ded6db81953156a63 Mon Sep 17 00:00:00 2001
From: Mariano Fuentes
Date: Thu, 23 Apr 2026 17:38:09 -0400
Subject: [PATCH 2/3] fix(db): exclude *.spec.ts from tsc build (#2651)
packages/db/tsconfig only excluded *.test.ts, but the framework-versioning
backfill spec is named *.spec.ts (and imports from bun:test). tsc picked it
up during the API's Docker image build and failed with TS2307 on bun:test.
Verified locally by running `docker build -f apps/api/Dockerfile.multistage
--target builder` through to completion.
Co-authored-by: Claude Opus 4.7 (1M context)
---
packages/db/tsconfig.json | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json
index d979a7a0d5..c3b17afd15 100644
--- a/packages/db/tsconfig.json
+++ b/packages/db/tsconfig.json
@@ -19,5 +19,11 @@
"declarationMap": true
},
"include": ["src"],
- "exclude": ["node_modules", "dist", "src/generated", "src/**/*.test.ts"]
+ "exclude": [
+ "node_modules",
+ "dist",
+ "src/generated",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts"
+ ]
}
From 3ae8f5ecc817fd6739eab6773c39a2c14bb3efcb Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 23 Apr 2026 18:13:03 -0400
Subject: [PATCH 3/3] chore: fix rollback bug
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(db): exclude *.spec.ts from tsc build
packages/db/tsconfig only excluded *.test.ts, but the framework-versioning
backfill spec is named *.spec.ts (and imports from bun:test). tsc picked it
up during the API's Docker image build and failed with TS2307 on bun:test.
Verified locally by running `docker build -f apps/api/Dockerfile.multistage
--target builder` through to completion.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(frameworks): make M:N link reversal idempotent in sync + rollback
Rollback on staging failed with Prisma P2025: "No 'Control' record was
found for a disconnect on many-to-many relation 'ControlToPolicy'".
Prisma 7's implicit-M:N disconnect is strict — it throws if the edge
isn't there for any reason (sync recorded `connected` for a no-op connect,
a manual edit removed the edge between sync and rollback, etc.). One bad
edge fails the whole transaction, breaking the 100%-reliable-undo
guarantee we promise customers.
Switch both sync-apply and rollback's replayUndo to operate on the
_ControlToPolicy / _ControlToTask junction tables directly:
- INSERT ... ON CONFLICT (A, B) DO NOTHING for connects
- DELETE ... WHERE (A, B) IN (...) for disconnects
Same raw-SQL pattern main's initialize-organization.ts uses for its bulk
edge inserts. Naturally idempotent — tolerant of pre-existing edges on
insert, tolerant of missing edges on delete.
Also adds a minimal @db mock in framework-sync-apply.spec.ts so Prisma.sql
/ Prisma.join runtime accesses don't trigger real PrismaClient init in
tests.
Co-Authored-By: Claude Opus 4.7 (1M context)
---------
Co-authored-by: Mariano
Co-authored-by: Claude Opus 4.7 (1M context)
---
.../framework-rollback.service.ts | 46 +++++++++++++++----
.../framework-sync-apply.spec.ts | 20 ++++++++
.../framework-sync-apply.ts | 44 ++++++++++++++++--
3 files changed, 98 insertions(+), 12 deletions(-)
diff --git a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts
index b940e20e93..4e941f2fb7 100644
--- a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts
+++ b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts
@@ -190,19 +190,49 @@ async function replayUndo(
// Reverse implicit M:N edges: connects become disconnects and vice versa.
// Guard with nullish coalescing so older sync operations written before
// this bucket existed don't crash the rollback.
+ //
+ // Direct raw-SQL on the junction tables is idempotent by design:
+ // - INSERT … ON CONFLICT DO NOTHING tolerates edges that already exist
+ // - DELETE … WHERE (A,B) IN (…) tolerates edges that don't exist
+ // Prisma 7's implicit-M:N `disconnect` is strict and throws P2025 when the
+ // edge is missing for any reason (e.g., sync recorded a `connected` entry
+ // for a connect that was already a no-op, or a manual edit removed the
+ // edge between sync and rollback). Making rollback resilient here keeps
+ // the 100%-reliable-undo guarantee we promise customers.
const cpl = ctx.undo.controlPolicyLinks ?? { connected: [], disconnected: [] };
const ctl = ctx.undo.controlTaskLinks ?? { connected: [], disconnected: [] };
- for (const link of cpl.connected) {
- await tx.control.update({ where: { id: link.controlId }, data: { policies: { disconnect: { id: link.otherId } } } });
+
+ if (cpl.connected.length > 0) {
+ const pairs = Prisma.join(
+ cpl.connected.map(
+ ({ controlId, otherId }) => Prisma.sql`(${controlId}::text, ${otherId}::text)`,
+ ),
+ );
+ await tx.$executeRaw`DELETE FROM "_ControlToPolicy" WHERE ("A", "B") IN (${pairs})`;
}
- for (const link of cpl.disconnected) {
- await tx.control.update({ where: { id: link.controlId }, data: { policies: { connect: { id: link.otherId } } } });
+ if (cpl.disconnected.length > 0) {
+ const rows = Prisma.join(
+ cpl.disconnected.map(
+ ({ controlId, otherId }) => Prisma.sql`(${controlId}::text, ${otherId}::text)`,
+ ),
+ );
+ await tx.$executeRaw`INSERT INTO "_ControlToPolicy" ("A", "B") VALUES ${rows} ON CONFLICT ("A", "B") DO NOTHING`;
}
- for (const link of ctl.connected) {
- await tx.control.update({ where: { id: link.controlId }, data: { tasks: { disconnect: { id: link.otherId } } } });
+ if (ctl.connected.length > 0) {
+ const pairs = Prisma.join(
+ ctl.connected.map(
+ ({ controlId, otherId }) => Prisma.sql`(${controlId}::text, ${otherId}::text)`,
+ ),
+ );
+ await tx.$executeRaw`DELETE FROM "_ControlToTask" WHERE ("A", "B") IN (${pairs})`;
}
- for (const link of ctl.disconnected) {
- await tx.control.update({ where: { id: link.controlId }, data: { tasks: { connect: { id: link.otherId } } } });
+ if (ctl.disconnected.length > 0) {
+ const rows = Prisma.join(
+ ctl.disconnected.map(
+ ({ controlId, otherId }) => Prisma.sql`(${controlId}::text, ${otherId}::text)`,
+ ),
+ );
+ await tx.$executeRaw`INSERT INTO "_ControlToTask" ("A", "B") VALUES ${rows} ON CONFLICT ("A", "B") DO NOTHING`;
}
// Revert framework instance version pointer
diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts
index b38be58084..2e46f09a1e 100644
--- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts
+++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts
@@ -1,3 +1,21 @@
+// Stub @db so importing applySync (which now uses Prisma.sql / Prisma.join at
+// runtime for junction-table raw SQL) doesn't trigger real PrismaClient init.
+jest.mock('@db', () => {
+ // Minimal sql tag + join implementation sufficient for the tests (we don't
+ // actually inspect the SQL output here — $executeRaw is jest.fn'd below).
+ const sql = (strings: TemplateStringsArray, ..._values: unknown[]) => ({
+ __sql: strings.raw.join('?'),
+ });
+ return {
+ Prisma: {
+ sql,
+ join: (items: unknown[]) => ({ __sql: 'join', items }),
+ },
+ Frequency: { monthly: 'monthly', yearly: 'yearly', daily: 'daily', weekly: 'weekly' },
+ Departments: { none: 'none', it: 'it' },
+ };
+});
+
import { applySync } from './framework-sync-apply';
import type { FrameworkManifest } from './manifest.types';
@@ -25,6 +43,8 @@ function mockTx() {
requirementMap: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...data, id: `rm_new_${Math.random()}` })), updateMany: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
frameworkInstance: { findMany: jest.fn().mockResolvedValue([]), update: jest.fn() },
frameworkSyncOperation: { create: jest.fn().mockResolvedValue({ id: 'fso_new' }) },
+ controlDocumentType: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'cdt_new' }), delete: jest.fn() },
+ $executeRaw: jest.fn().mockResolvedValue(0),
} as any;
}
diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts
index 65b6273f41..b75a9e62d4 100644
--- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts
+++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts
@@ -252,34 +252,70 @@ export async function applySync(
}
// --- Control<->Policy / Control<->Task relations (Prisma implicit M:N) ---
+ // Use raw SQL on the junction tables — Prisma 7's implicit-M:N `disconnect`
+ // is strict and throws P2025 if the edge isn't there, which breaks sync in
+ // the (rare) case where manifest/instance state disagrees about whether an
+ // edge exists (e.g., a manual edit or a prior partial sync). Raw INSERT …
+ // ON CONFLICT / DELETE … WHERE IN is naturally idempotent.
+ const cpAdded: Array<{ controlId: string; policyId: string }> = [];
for (const edge of diff.controlPolicyEdges.added) {
const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
const polInst = polByTemplate.get(edge.policyTemplateId);
if (!ctlInst || !polInst) continue;
- await tx.control.update({ where: { id: ctlInst.id }, data: { policies: { connect: { id: polInst.id } } } });
+ cpAdded.push({ controlId: ctlInst.id, policyId: polInst.id });
undo.controlPolicyLinks.connected.push({ controlId: ctlInst.id, otherId: polInst.id });
}
+ if (cpAdded.length > 0) {
+ const rows = Prisma.join(
+ cpAdded.map(({ controlId, policyId }) => Prisma.sql`(${controlId}::text, ${policyId}::text)`),
+ );
+ await tx.$executeRaw`INSERT INTO "_ControlToPolicy" ("A", "B") VALUES ${rows} ON CONFLICT ("A", "B") DO NOTHING`;
+ }
+
+ const cpRemoved: Array<{ controlId: string; policyId: string }> = [];
for (const edge of diff.controlPolicyEdges.removed) {
const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
const polInst = polByTemplate.get(edge.policyTemplateId);
if (!ctlInst || !polInst) continue;
- await tx.control.update({ where: { id: ctlInst.id }, data: { policies: { disconnect: { id: polInst.id } } } });
+ cpRemoved.push({ controlId: ctlInst.id, policyId: polInst.id });
undo.controlPolicyLinks.disconnected.push({ controlId: ctlInst.id, otherId: polInst.id });
}
+ if (cpRemoved.length > 0) {
+ const pairs = Prisma.join(
+ cpRemoved.map(({ controlId, policyId }) => Prisma.sql`(${controlId}::text, ${policyId}::text)`),
+ );
+ await tx.$executeRaw`DELETE FROM "_ControlToPolicy" WHERE ("A", "B") IN (${pairs})`;
+ }
+
+ const ctAdded: Array<{ controlId: string; taskId: string }> = [];
for (const edge of diff.controlTaskEdges.added) {
const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
const tInst = taskByTemplate.get(edge.taskTemplateId);
if (!ctlInst || !tInst) continue;
- await tx.control.update({ where: { id: ctlInst.id }, data: { tasks: { connect: { id: tInst.id } } } });
+ ctAdded.push({ controlId: ctlInst.id, taskId: tInst.id });
undo.controlTaskLinks.connected.push({ controlId: ctlInst.id, otherId: tInst.id });
}
+ if (ctAdded.length > 0) {
+ const rows = Prisma.join(
+ ctAdded.map(({ controlId, taskId }) => Prisma.sql`(${controlId}::text, ${taskId}::text)`),
+ );
+ await tx.$executeRaw`INSERT INTO "_ControlToTask" ("A", "B") VALUES ${rows} ON CONFLICT ("A", "B") DO NOTHING`;
+ }
+
+ const ctRemoved: Array<{ controlId: string; taskId: string }> = [];
for (const edge of diff.controlTaskEdges.removed) {
const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
const tInst = taskByTemplate.get(edge.taskTemplateId);
if (!ctlInst || !tInst) continue;
- await tx.control.update({ where: { id: ctlInst.id }, data: { tasks: { disconnect: { id: tInst.id } } } });
+ ctRemoved.push({ controlId: ctlInst.id, taskId: tInst.id });
undo.controlTaskLinks.disconnected.push({ controlId: ctlInst.id, otherId: tInst.id });
}
+ if (ctRemoved.length > 0) {
+ const pairs = Prisma.join(
+ ctRemoved.map(({ controlId, taskId }) => Prisma.sql`(${controlId}::text, ${taskId}::text)`),
+ );
+ await tx.$executeRaw`DELETE FROM "_ControlToTask" WHERE ("A", "B") IN (${pairs})`;
+ }
// --- Control <-> DocumentType (explicit junction table ControlDocumentType) ---
// formType is an enum; uniqueness is on (controlId, formType). We treat adds