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 ( + !o && handleClose()}> + + + Publish New Version + + Create a new published snapshot of this framework. All organizations tracking this + framework will be able to update to this version. + + + +
+ + ; + }) => ( + + Version + + + + + {collisionError && ( +

{collisionError}

+ )} +
+ )} + /> + + ; + }) => ( + + Release Notes (optional) + +