diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index 7ef08adbc1..c4c30ba173 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -6,6 +6,27 @@ import { import { db, EvidenceFormType, Prisma } from '@db'; import { CreateControlDto } from './dto/create-control.dto'; +// A CustomRequirement is valid for a given FrameworkInstance when its parent +// matches: either it lives on the FI's CustomFramework, or it was attached +// directly to the FI itself (per-instance custom requirement on a platform +// framework). The CustomRequirement schema's CHECK enforces that exactly one +// of customFrameworkId / frameworkInstanceId is set. +function isCustomReqOnInstance( + req: { + customFrameworkId: string | null; + frameworkInstanceId: string | null; + }, + instance: { id: string; customFrameworkId: string | null }, +): boolean { + if (req.customFrameworkId) { + return ( + instance.customFrameworkId !== null && + req.customFrameworkId === instance.customFrameworkId + ); + } + return req.frameworkInstanceId === instance.id; +} + const controlInclude = { policies: { where: { archivedAt: null }, @@ -402,16 +423,24 @@ export class ControlsService { customReqIds.length > 0 ? db.customRequirement.findMany({ where: { id: { in: customReqIds }, organizationId }, - select: { id: true, customFrameworkId: true }, + select: { + id: true, + customFrameworkId: true, + frameworkInstanceId: true, + }, }) - : Promise.resolve<{ id: string; customFrameworkId: string }[]>([]), + : Promise.resolve< + { + id: string; + customFrameworkId: string | null; + frameworkInstanceId: string | null; + }[] + >([]), ]); const platformReqFwById = new Map( platformReqs.map((r) => [r.id, r.frameworkId]), ); - const customReqFwById = new Map( - customReqs.map((r) => [r.id, r.customFrameworkId]), - ); + const customReqById = new Map(customReqs.map((r) => [r.id, r])); for (const m of mappings) { const instance = instanceById.get(m.frameworkInstanceId); @@ -428,8 +457,8 @@ export class ControlsService { ); } } else if (m.customRequirementId) { - const reqFwId = customReqFwById.get(m.customRequirementId); - if (!reqFwId || reqFwId !== instance.customFrameworkId) { + const req = customReqById.get(m.customRequirementId); + if (!req || !isCustomReqOnInstance(req, instance)) { throw new BadRequestException( 'One or more requirement mappings are invalid', ); @@ -544,16 +573,24 @@ export class ControlsService { customReqIds.length > 0 ? db.customRequirement.findMany({ where: { id: { in: customReqIds }, organizationId }, - select: { id: true, customFrameworkId: true }, + select: { + id: true, + customFrameworkId: true, + frameworkInstanceId: true, + }, }) - : Promise.resolve<{ id: string; customFrameworkId: string }[]>([]), + : Promise.resolve< + { + id: string; + customFrameworkId: string | null; + frameworkInstanceId: string | null; + }[] + >([]), ]); const platformReqFwById = new Map( platformReqs.map((r) => [r.id, r.frameworkId]), ); - const customReqFwById = new Map( - customReqs.map((r) => [r.id, r.customFrameworkId]), - ); + const customReqById = new Map(customReqs.map((r) => [r.id, r])); const validMappings = mappings.filter((m) => { const instance = instanceById.get(m.frameworkInstanceId); @@ -563,8 +600,8 @@ export class ControlsService { return Boolean(reqFwId) && reqFwId === instance.frameworkId; } if (m.customRequirementId) { - const reqFwId = customReqFwById.get(m.customRequirementId); - return Boolean(reqFwId) && reqFwId === instance.customFrameworkId; + const req = customReqById.get(m.customRequirementId); + return Boolean(req) && isCustomReqOnInstance(req!, instance); } return false; }); diff --git a/apps/api/src/frameworks/frameworks.controller.ts b/apps/api/src/frameworks/frameworks.controller.ts index ebf55cfa83..fb434bec5a 100644 --- a/apps/api/src/frameworks/frameworks.controller.ts +++ b/apps/api/src/frameworks/frameworks.controller.ts @@ -85,6 +85,15 @@ export class FrameworksController { return this.frameworksService.getScores(organizationId, authContext.userId); } + @Get('update-statuses') + @RequirePermission('framework', 'read') + @ApiOperation({ summary: 'Get update statuses for all framework instances' }) + async getAllUpdateStatuses(@OrganizationId() organizationId: string) { + const data = + await this.frameworksService.getAllUpdateStatuses(organizationId); + return { data, count: data.length }; + } + @Get(':id') @RequirePermission('framework', 'read') @ApiOperation({ summary: 'Get a single framework instance with full detail' }) diff --git a/apps/api/src/frameworks/frameworks.service.spec.ts b/apps/api/src/frameworks/frameworks.service.spec.ts index 5d46c16012..81b4e28e69 100644 --- a/apps/api/src/frameworks/frameworks.service.spec.ts +++ b/apps/api/src/frameworks/frameworks.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundException } from '@nestjs/common'; import { FrameworksService } from './frameworks.service'; +import { TimelinesService } from '../timelines/timelines.service'; jest.mock('@db', () => ({ db: { @@ -17,6 +18,7 @@ jest.mock('@db', () => ({ findMany: jest.fn(), findFirst: jest.fn(), create: jest.fn(), + createManyAndReturn: jest.fn(), }, requirementMap: { findMany: jest.fn(), @@ -29,6 +31,9 @@ jest.mock('@db', () => ({ findMany: jest.fn(), }, }, + // The frameworks-timeline helper imports FindingType (a Prisma enum) at module + // load. Stub it so the spec file can be evaluated without the real client. + FindingType: { soc2: 'soc2', iso27001: 'iso27001' }, })); jest.mock('./frameworks-scores.helper', () => ({ @@ -50,7 +55,10 @@ describe('FrameworksService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [FrameworksService], + providers: [ + FrameworksService, + { provide: TimelinesService, useValue: {} }, + ], }).compile(); service = module.get(FrameworksService); @@ -192,12 +200,13 @@ describe('FrameworksService', () => { expect(result.requirementDefinitions).toHaveLength(1); }); - it('findOne on a platform FI reads only FrameworkEditorRequirement', async () => { + it('findOne on a platform FI merges per-instance custom requirements with platform requirements', async () => { (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ id: 'fi_platform', organizationId: 'org_A', frameworkId: 'frk_soc2', customFrameworkId: null, + currentVersionId: null, framework: { id: 'frk_soc2', name: 'SOC 2' }, customFramework: null, requirementsMapped: [], @@ -207,32 +216,83 @@ describe('FrameworksService', () => { ).mockResolvedValue([ { id: 'frk_rq_1', name: 'CC1', identifier: 'cc1-1', description: '' }, ]); + // Per-instance custom requirement attached directly to fi_platform. + (mockDb.customRequirement.findMany as jest.Mock).mockResolvedValue([ + { + id: 'creq_local', + name: 'Org-local extra', + identifier: 'X1', + description: '', + }, + ]); (mockDb.task.findMany as jest.Mock).mockResolvedValue([]); (mockDb.requirementMap.findMany as jest.Mock).mockResolvedValue([]); (mockDb.evidenceSubmission.findMany as jest.Mock).mockResolvedValue([]); - await service.findOne('fi_platform', 'org_A'); + const result = await service.findOne('fi_platform', 'org_A'); expect(mockDb.frameworkEditorRequirement.findMany).toHaveBeenCalledWith({ where: { frameworkId: 'frk_soc2' }, orderBy: { name: 'asc' }, }); - expect(mockDb.customRequirement.findMany).not.toHaveBeenCalled(); + expect(mockDb.customRequirement.findMany).toHaveBeenCalledWith({ + where: { frameworkInstanceId: 'fi_platform' }, + orderBy: { name: 'asc' }, + }); + const ids = result.requirementDefinitions.map((r: any) => r.id); + expect(ids).toEqual(expect.arrayContaining(['frk_rq_1', 'creq_local'])); + }); + + it('createRequirement on a custom-framework FI hangs the row off the framework', async () => { + (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'fi_custom', + customFrameworkId: 'cfrm_A', + }); + (mockDb.customRequirement.create as jest.Mock).mockResolvedValue({ + id: 'creq_new', + }); + + await service.createRequirement('fi_custom', 'org_A', { + name: 'x', + identifier: 'x', + description: 'x', + }); + + expect(mockDb.customRequirement.create).toHaveBeenCalledWith({ + data: { + name: 'x', + identifier: 'x', + description: 'x', + organizationId: 'org_A', + customFrameworkId: 'cfrm_A', + }, + }); }); - it('createRequirement rejects a platform framework instance', async () => { + it('createRequirement on a platform FI hangs the row off the instance', async () => { (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'fi_platform', customFrameworkId: null, }); + (mockDb.customRequirement.create as jest.Mock).mockResolvedValue({ + id: 'creq_new', + }); - await expect( - service.createRequirement('fi_platform', 'org_A', { + await service.createRequirement('fi_platform', 'org_A', { + name: 'x', + identifier: 'x', + description: 'x', + }); + + expect(mockDb.customRequirement.create).toHaveBeenCalledWith({ + data: { name: 'x', identifier: 'x', description: 'x', - }), - ).rejects.toThrow(/Cannot add custom requirements/); - expect(mockDb.customRequirement.create).not.toHaveBeenCalled(); + organizationId: 'org_A', + frameworkInstanceId: 'fi_platform', + }, + }); }); }); }); diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 553371ad67..a9443e1b6c 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -23,6 +23,10 @@ type RequirementDef = { description: string; frameworkId: string | null; customFrameworkId: string | null; + // Discriminator that survives the per-instance custom case (where both + // frameworkId and customFrameworkId are null on the def). Used by callers + // to decide which RequirementMap FK column to filter on. + kind: 'platform' | 'custom'; }; @Injectable() @@ -43,6 +47,7 @@ export class FrameworksService { } private async loadRequirementDefinitions(fi: { + id?: string; frameworkId: string | null; customFrameworkId: string | null; currentVersionId?: string | null; @@ -59,9 +64,30 @@ export class FrameworksService { description: r.description, frameworkId: null, customFrameworkId: r.customFrameworkId, + kind: 'custom', })); } if (fi.frameworkId) { + // Per-instance custom requirements (org tacked an extra requirement + // onto a platform framework). Always merged in alongside the platform + // requirements, regardless of whether we read from the pinned version + // manifest or the editor table fallback. + const customRows = fi.id + ? await db.customRequirement.findMany({ + where: { frameworkInstanceId: fi.id }, + orderBy: { name: 'asc' }, + }) + : []; + const customDefs: RequirementDef[] = customRows.map((r) => ({ + id: r.id, + name: r.name, + identifier: r.identifier, + description: r.description, + frameworkId: null, + customFrameworkId: null, + kind: 'custom', + })); + // 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. @@ -72,16 +98,20 @@ export class FrameworksService { }); if (version) { const manifest = version.manifest as unknown as FrameworkManifest; - return [...manifest.requirements] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((r) => ({ + const platformDefs: RequirementDef[] = [...manifest.requirements].map( + (r) => ({ id: r.id, name: r.name, identifier: r.identifier, description: r.description ?? '', frameworkId: fi.frameworkId, customFrameworkId: null, - })); + kind: 'platform', + }), + ); + return [...platformDefs, ...customDefs].sort((a, b) => + a.name.localeCompare(b.name), + ); } } // Fallback: instances with no pinned version (shouldn't happen post-backfill). @@ -89,14 +119,18 @@ export class FrameworksService { where: { frameworkId: fi.frameworkId }, orderBy: { name: 'asc' }, }); - return rows.map((r) => ({ + const platformDefs: RequirementDef[] = rows.map((r) => ({ id: r.id, name: r.name, identifier: r.identifier, description: r.description, frameworkId: r.frameworkId, customFrameworkId: null, + kind: 'platform', })); + return [...platformDefs, ...customDefs].sort((a, b) => + a.name.localeCompare(b.name), + ); } return []; } @@ -340,24 +374,25 @@ export class FrameworksService { ) { const fi = await db.frameworkInstance.findUnique({ where: { id: frameworkInstanceId, organizationId }, - select: { customFrameworkId: true }, + select: { id: true, customFrameworkId: true }, }); if (!fi) { throw new NotFoundException('Framework instance not found'); } - if (!fi.customFrameworkId) { - throw new BadRequestException( - 'Cannot add custom requirements to a platform framework', - ); - } + // For an FI backed by a CustomFramework, the requirement attaches to the + // framework so it travels with any future instances. For a platform FI + // (e.g. ISO 27001) there's no per-org framework to hang it off, so it + // attaches to the instance directly. The DB CHECK enforces exactly one. return db.customRequirement.create({ data: { name: input.name, identifier: input.identifier, description: input.description, - customFrameworkId: fi.customFrameworkId, organizationId, + ...(fi.customFrameworkId + ? { customFrameworkId: fi.customFrameworkId } + : { frameworkInstanceId: fi.id }), }, }); } @@ -369,16 +404,11 @@ export class FrameworksService { ) { const fi = await db.frameworkInstance.findUnique({ where: { id: frameworkInstanceId, organizationId }, - select: { customFrameworkId: true }, + select: { id: true, customFrameworkId: true }, }); if (!fi) { throw new NotFoundException('Framework instance not found'); } - if (!fi.customFrameworkId) { - throw new BadRequestException( - 'Cannot link requirements into a platform framework', - ); - } // Sources may come from either the platform editor table or this org's // custom requirements. @@ -397,9 +427,13 @@ export class FrameworksService { throw new BadRequestException('No valid requirements to link'); } + // Identifier dedupe is parent-scoped: a custom-framework instance dedupes + // against the framework, a platform instance dedupes against the instance. const existing = await db.customRequirement.findMany({ where: { - customFrameworkId: fi.customFrameworkId, + ...(fi.customFrameworkId + ? { customFrameworkId: fi.customFrameworkId } + : { frameworkInstanceId: fi.id }), identifier: { in: sources.map((r) => r.identifier) }, }, select: { identifier: true }, @@ -412,13 +446,17 @@ export class FrameworksService { return { count: 0, requirements: [] }; } + const parentFields = fi.customFrameworkId + ? { customFrameworkId: fi.customFrameworkId } + : { frameworkInstanceId: fi.id }; + const created = await db.customRequirement.createManyAndReturn({ data: toCreate.map((r) => ({ name: r.name, identifier: r.identifier, description: r.description, - customFrameworkId: fi.customFrameworkId!, organizationId, + ...parentFields, })), }); @@ -439,17 +477,25 @@ export class FrameworksService { throw new NotFoundException('Framework instance not found'); } + // The requirement may be: + // - a CustomRequirement on this FI's CustomFramework, or + // - a CustomRequirement attached directly to this FI (per-instance), or + // - a platform FrameworkEditorRequirement on this FI's framework. let requirementKind: 'platform' | 'custom'; - if (fi.customFrameworkId) { - const req = await db.customRequirement.findFirst({ - where: { - id: requirementKey, - customFrameworkId: fi.customFrameworkId, - organizationId, - }, - select: { id: true }, - }); - if (!req) throw new NotFoundException('Requirement not found'); + const customReq = await db.customRequirement.findFirst({ + where: { + id: requirementKey, + organizationId, + OR: [ + ...(fi.customFrameworkId + ? [{ customFrameworkId: fi.customFrameworkId }] + : []), + { frameworkInstanceId: fi.id }, + ], + }, + select: { id: true }, + }); + if (customReq) { requirementKind = 'custom'; } else if (fi.frameworkId) { const req = await db.frameworkEditorRequirement.findFirst({ @@ -570,7 +616,7 @@ export class FrameworksService { where: { frameworkInstanceId, archivedAt: null, - ...(fi.customFrameworkId + ...(requirement.kind === 'custom' ? { customRequirementId: requirementKey } : { requirementId: requirementKey }), }, @@ -653,6 +699,68 @@ export class FrameworksService { return { success: true }; } + async getAllUpdateStatuses(organizationId: string) { + const instances = await db.frameworkInstance.findMany({ + where: { organizationId, frameworkId: { not: null } }, + include: { + currentVersion: { select: { id: true, version: true } }, + framework: { select: { id: true, name: true } }, + }, + }); + + if (instances.length === 0) return []; + + const frameworkIds = [ + ...new Set(instances.map((i) => i.frameworkId).filter(Boolean)), + ] as string[]; + + const latestVersions = await Promise.all( + frameworkIds.map((fid) => + db.frameworkVersion.findFirst({ + where: { frameworkId: fid }, + orderBy: { publishedAt: 'desc' }, + select: { + id: true, + version: true, + publishedAt: true, + releaseNotes: true, + frameworkId: true, + }, + }), + ), + ); + + const latestByFramework = new Map( + latestVersions + .filter(Boolean) + .map((v) => [v!.frameworkId, v!]), + ); + + return instances + .map((instance) => { + const latest = latestByFramework.get(instance.frameworkId!) ?? null; + const updateAvailable = + latest !== null && latest.id !== instance.currentVersion?.id; + if (!updateAvailable) return null; + + return { + frameworkInstanceId: instance.id, + frameworkName: instance.framework?.name ?? null, + currentVersion: instance.currentVersion, + latestVersion: latest + ? { + id: latest.id, + version: latest.version, + publishedAt: latest.publishedAt, + releaseNotes: latest.releaseNotes, + } + : null, + updateAvailable, + }; + }) + .filter(Boolean); + } + async getUpdateStatus(params: { organizationId: string; frameworkInstanceId: string; diff --git a/apps/api/src/questionnaire/utils/content-extractor.ts b/apps/api/src/questionnaire/utils/content-extractor.ts index 635e0740a1..201d59b67f 100644 --- a/apps/api/src/questionnaire/utils/content-extractor.ts +++ b/apps/api/src/questionnaire/utils/content-extractor.ts @@ -37,11 +37,13 @@ const questionExtractionSchema = jsonSchema<{ description: 'The answer/response if provided, null if empty', }, }, - required: ['question'], + required: ['question', 'answer'], + additionalProperties: false, }, }, }, required: ['questions'], + additionalProperties: false, }); export interface ContentExtractionLogger { diff --git a/apps/api/src/questionnaire/utils/question-parser.ts b/apps/api/src/questionnaire/utils/question-parser.ts index 05e64eac7e..4108aec160 100644 --- a/apps/api/src/questionnaire/utils/question-parser.ts +++ b/apps/api/src/questionnaire/utils/question-parser.ts @@ -135,11 +135,13 @@ export async function parseChunkQuestionsAndAnswers( 'The answer to the question. Use null if no answer is provided.', }, }, - required: ['question'], + required: ['question', 'answer'], + additionalProperties: false, }, }, }, required: ['questionsAndAnswers'], + additionalProperties: false, }), system: QUESTION_PARSING_SYSTEM_PROMPT, prompt: buildParsingPrompt(chunk, chunkIndex, totalChunks), diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx index 60123bdf25..969a45eff1 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx @@ -37,8 +37,12 @@ export default async function RequirementPage({ params }: PageProps) { const reqData = requirementRes.data; const frameworkName = framework.framework?.name ?? framework.customFramework?.name ?? 'Framework'; - const isCustomFramework = Boolean(framework.customFrameworkId); const requirement = reqData.requirement; + // Whether this specific requirement is custom — NOT whether its framework is. + // A platform framework (e.g. ISO 27001) can carry per-instance custom + // requirements, in which case requirement.kind is 'custom' even though + // framework.customFrameworkId is null. + const isCustomRequirement = requirement.kind === 'custom'; const identifier: string | undefined = requirement.identifier?.trim() || undefined; const title = identifier ?? requirement.name; @@ -64,7 +68,7 @@ export default async function RequirementPage({ params }: PageProps) { rc.control.id, )} @@ -72,7 +76,7 @@ export default async function RequirementPage({ params }: PageProps) { diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx new file mode 100644 index 0000000000..772aecdfa9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useFrameworkUpdateStatuses } from '@/hooks/use-framework-update-statuses'; +import { usePermissions } from '@/hooks/use-permissions'; +import { + Badge, + Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + HStack, + Text, +} from '@trycompai/design-system'; +import { ChevronUp, Upgrade } from '@trycompai/design-system/icons'; +import { useParams, useRouter } from 'next/navigation'; +import { useState } from 'react'; + +export function FrameworkUpdatesBanner() { + const { data: statuses } = useFrameworkUpdateStatuses(); + const { hasPermission } = usePermissions(); + const router = useRouter(); + const { orgId } = useParams<{ orgId: string }>(); + const [open, setOpen] = useState(true); + + const canUpdate = hasPermission('framework', 'update'); + + if (!statuses || statuses.length === 0) return null; + + const count = statuses.length; + + return ( +
+ +
+
+ +
+ +
+ + {count} framework {count === 1 ? 'update' : 'updates'} available + + NEW +
+ + {open ? `Hide ${count}` : `Show ${count}`} + + +
+ + +
+ {statuses.map((status, index) => ( +
+ + + {status.frameworkName ?? 'Framework'} + + + v{status.currentVersion?.version ?? '—'} → v + {status.latestVersion?.version} + + + {canUpdate && ( + + )} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/page.tsx b/apps/app/src/app/(app)/[orgId]/overview/page.tsx index 0073ad9ebd..221477b79d 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/page.tsx @@ -1,6 +1,7 @@ import { serverApi } from '@/lib/api-server'; import type { FrameworkEditorFramework, Policy, Task } from '@db'; import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { FrameworkUpdatesBanner } from './components/FrameworkUpdatesBanner'; import { Overview } from './components/Overview'; import { OverviewTabs } from './components/OverviewTabs'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; @@ -52,8 +53,10 @@ export default async function OverviewPage({ params }: { params: Promise<{ orgId })); return ( - } />}> - + + } />}> + - + + ); } diff --git a/apps/app/src/hooks/use-framework-rollback.ts b/apps/app/src/hooks/use-framework-rollback.ts index 9368e92a92..c6a1e11af8 100644 --- a/apps/app/src/hooks/use-framework-rollback.ts +++ b/apps/app/src/hooks/use-framework-rollback.ts @@ -3,6 +3,7 @@ import { useState } from 'react'; import { apiClient } from '@/lib/api-client'; import { mutate } from 'swr'; +import { FRAMEWORK_UPDATE_STATUSES_KEY } from './use-framework-update-statuses'; interface RollbackResult { rollbackOperationId: string; @@ -24,6 +25,7 @@ export function useFrameworkRollback(frameworkInstanceId: string) { mutate(`/v1/frameworks/${frameworkInstanceId}/update-preview`), mutate(`/v1/frameworks/${frameworkInstanceId}/sync-history`), mutate(`/v1/frameworks/${frameworkInstanceId}`), + mutate(FRAMEWORK_UPDATE_STATUSES_KEY), ]); return res.data?.data as RollbackResult; } finally { diff --git a/apps/app/src/hooks/use-framework-sync.ts b/apps/app/src/hooks/use-framework-sync.ts index 9ea0d71d40..38c6f4d9e7 100644 --- a/apps/app/src/hooks/use-framework-sync.ts +++ b/apps/app/src/hooks/use-framework-sync.ts @@ -3,6 +3,7 @@ import { useState } from 'react'; import { apiClient } from '@/lib/api-client'; import { mutate } from 'swr'; +import { FRAMEWORK_UPDATE_STATUSES_KEY } from './use-framework-update-statuses'; interface SyncResult { syncOperationId: string; @@ -30,6 +31,7 @@ export function useFrameworkSync(frameworkInstanceId: string) { mutate(`/v1/frameworks/${frameworkInstanceId}/update-status`), mutate(`/v1/frameworks/${frameworkInstanceId}/sync-history`), mutate(`/v1/frameworks/${frameworkInstanceId}`), + mutate(FRAMEWORK_UPDATE_STATUSES_KEY), ]); return res.data?.data as SyncResult; } finally { diff --git a/apps/app/src/hooks/use-framework-update-statuses.ts b/apps/app/src/hooks/use-framework-update-statuses.ts new file mode 100644 index 0000000000..ab644c4d2f --- /dev/null +++ b/apps/app/src/hooks/use-framework-update-statuses.ts @@ -0,0 +1,34 @@ +'use client'; + +import useSWR from 'swr'; +import { apiClient } from '@/lib/api-client'; + +export const FRAMEWORK_UPDATE_STATUSES_KEY = '/v1/frameworks/update-statuses'; + +export interface FrameworkUpdateStatusItem { + frameworkInstanceId: string; + frameworkName: string | null; + currentVersion: { id: string; version: string } | null; + latestVersion: { + id: string; + version: string; + publishedAt: string; + releaseNotes: string | null; + } | null; + updateAvailable: boolean; +} + +export function useFrameworkUpdateStatuses() { + return useSWR( + FRAMEWORK_UPDATE_STATUSES_KEY, + async (url: string) => { + const res = await apiClient.get<{ data: FrameworkUpdateStatusItem[] }>(url); + if (res.error) throw new Error(res.error); + return res.data?.data ?? []; + }, + { + revalidateOnMount: true, + revalidateOnFocus: true, + }, + ); +} diff --git a/packages/db/prisma/migrations/20260507140900_custom_requirement_framework_instance/migration.sql b/packages/db/prisma/migrations/20260507140900_custom_requirement_framework_instance/migration.sql new file mode 100644 index 0000000000..58c21c31e1 --- /dev/null +++ b/packages/db/prisma/migrations/20260507140900_custom_requirement_framework_instance/migration.sql @@ -0,0 +1,34 @@ +-- Allow a CustomRequirement to be attached directly to a FrameworkInstance, +-- not just a CustomFramework. Use case: an org wants to tack an extra +-- requirement onto a platform framework like ISO 27001 without authoring a +-- whole new CustomFramework. Exactly one of customFrameworkId / +-- frameworkInstanceId is set, enforced by a CHECK constraint. + +-- 1. Composite-FK target on FrameworkInstance so the new FK can enforce that +-- a per-instance custom requirement only points at an FI in its own org. +ALTER TABLE "FrameworkInstance" + ADD CONSTRAINT "FrameworkInstance_id_organizationId_key" UNIQUE ("id", "organizationId"); + +-- 2. Schema changes on CustomRequirement: relax the existing FK and add the new one. +ALTER TABLE "CustomRequirement" ADD COLUMN "frameworkInstanceId" TEXT; +ALTER TABLE "CustomRequirement" ALTER COLUMN "customFrameworkId" DROP NOT NULL; + +ALTER TABLE "CustomRequirement" + ADD CONSTRAINT "CustomRequirement_frameworkInstanceId_organizationId_fkey" + FOREIGN KEY ("frameworkInstanceId", "organizationId") + REFERENCES "FrameworkInstance"("id", "organizationId") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "CustomRequirement_frameworkInstanceId_idx" + ON "CustomRequirement"("frameworkInstanceId"); + +-- Identifier uniqueness scoped to whichever parent is set. Postgres treats +-- NULLs as distinct in unique indexes by default, so the inactive parent +-- column on each row never collides across rows. +CREATE UNIQUE INDEX "CustomRequirement_frameworkInstanceId_identifier_key" + ON "CustomRequirement"("frameworkInstanceId", "identifier"); + +-- 3. Exactly one of the two parents must be set. +ALTER TABLE "CustomRequirement" + ADD CONSTRAINT "CustomRequirement_one_parent_check" + CHECK (("customFrameworkId" IS NOT NULL)::int + ("frameworkInstanceId" IS NOT NULL)::int = 1); diff --git a/packages/db/prisma/schema/custom-framework.prisma b/packages/db/prisma/schema/custom-framework.prisma index 15eb0d2e5f..fbd7241a7e 100644 --- a/packages/db/prisma/schema/custom-framework.prisma +++ b/packages/db/prisma/schema/custom-framework.prisma @@ -23,18 +23,33 @@ model CustomRequirement { description String identifier String - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - customFrameworkId String + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + // A custom requirement hangs off either a CustomFramework (the org authored + // a whole new framework) or a single FrameworkInstance (the org tacked an + // extra requirement onto a platform framework like ISO 27001). Exactly one + // of these is set — enforced by a DB CHECK constraint. + customFrameworkId String? // Composite FK onto (id, organizationId) so tenant consistency with the // referenced CustomFramework is enforced at the DB level. - customFramework CustomFramework @relation(fields: [customFrameworkId, organizationId], references: [id, organizationId], onDelete: Cascade) + customFramework CustomFramework? @relation(fields: [customFrameworkId, organizationId], references: [id, organizationId], onDelete: Cascade) + + frameworkInstanceId String? + // Composite FK onto (id, organizationId) so a per-instance custom requirement + // can only point at an FI in its own org. + frameworkInstance FrameworkInstance? @relation(fields: [frameworkInstanceId, organizationId], references: [id, organizationId], onDelete: Cascade) requirementMaps RequirementMap[] createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + // Identifier uniqueness scoped to whichever parent is set. Postgres treats + // NULLs as distinct in unique indexes, so the inactive parent column doesn't + // collide across rows. @@unique([customFrameworkId, identifier]) + @@unique([frameworkInstanceId, identifier]) @@index([organizationId]) + @@index([frameworkInstanceId]) } diff --git a/packages/db/prisma/schema/framework.prisma b/packages/db/prisma/schema/framework.prisma index 8b9225245e..c7c5bb1962 100644 --- a/packages/db/prisma/schema/framework.prisma +++ b/packages/db/prisma/schema/framework.prisma @@ -20,7 +20,14 @@ model FrameworkInstance { requirementsMapped RequirementMap[] timelineInstances TimelineInstance[] syncOperations FrameworkSyncOperation[] + // Per-instance custom requirements (used when an org tacks an extra requirement + // onto a platform framework instance). Custom requirements attached to a + // CustomFramework hang off CustomFramework.requirements instead. + customRequirements CustomRequirement[] + // (id, organizationId) is the composite-FK target for CustomRequirement.frameworkInstanceId + // so a per-instance custom requirement can only point at an FI in its own org. + @@unique([id, organizationId]) @@unique([organizationId, frameworkId]) @@unique([organizationId, customFrameworkId]) @@index([customFrameworkId])