Skip to content

Commit d296e2a

Browse files
carhartlewisclaude
andcommitted
fix(frameworks): address review feedback — tenant-FK hardening + custom-framework parity
- Schema: enforce tenant consistency on CustomFramework FKs with composite (id, organizationId) references. CustomRequirement and FrameworkInstance can no longer point at another org's CustomFramework, even if application code regresses. Migration adds a guard that aborts if any existing row already violates the invariant. - Scoring: use EvidenceSubmission.submittedAt (canonical submission time) instead of createdAt for the "within last 6 months" recency check; update all evidenceSubmission selects/orderBys in frameworks.service to match. - Policies (both admin + org): include customFramework when collecting the AI policy-regeneration context so org-custom frameworks influence the prompt instead of being silently dropped. - Frontend framework list: exclude already-added custom frameworks from the "available to add" list on both overview and frameworks pages. - useControls createControl payload: tighten requirementMappings to a discriminated union so invalid requirementId+customRequirementId combos fail at compile time (matches the backend's exactly-one rule). - Controls controller: validate :formType path param with ParseEnumPipe. - Document-type labels map: type as Record<EvidenceFormType, string> so Prisma enum drift becomes a compile error. - DocumentsTable: disable every unlink button while any unlink is pending so users can't click enabled controls that no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2b8991f commit d296e2a

13 files changed

Lines changed: 158 additions & 49 deletions

File tree

apps/api/src/admin-organizations/admin-policies.controller.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -130,24 +130,41 @@ export class AdminPoliciesController {
130130
) {
131131
const instances = await db.frameworkInstance.findMany({
132132
where: { organizationId: orgId },
133-
include: { framework: true },
133+
include: { framework: true, customFramework: true },
134134
});
135135

136+
const normalized = instances.map((fi) => {
137+
if (fi.framework) {
138+
return {
139+
id: fi.framework.id,
140+
name: fi.framework.name,
141+
version: fi.framework.version,
142+
description: fi.framework.description,
143+
visible: fi.framework.visible,
144+
createdAt: fi.framework.createdAt,
145+
updatedAt: fi.framework.updatedAt,
146+
};
147+
}
148+
if (fi.customFramework) {
149+
return {
150+
id: fi.customFramework.id,
151+
name: fi.customFramework.name,
152+
version: fi.customFramework.version,
153+
description: fi.customFramework.description,
154+
visible: true,
155+
createdAt: fi.customFramework.createdAt,
156+
updatedAt: fi.customFramework.updatedAt,
157+
};
158+
}
159+
return null;
160+
});
136161
const uniqueFrameworks = Array.from(
137162
new Map(
138-
instances
139-
.filter((fi) => fi.framework)
140-
.map((fi) => [fi.framework!.id, fi.framework!]),
163+
normalized
164+
.filter((f): f is NonNullable<typeof f> => f !== null)
165+
.map((f) => [f.id, f]),
141166
).values(),
142-
).map((f) => ({
143-
id: f.id,
144-
name: f.name,
145-
version: f.version,
146-
description: f.description,
147-
visible: f.visible,
148-
createdAt: f.createdAt,
149-
updatedAt: f.updatedAt,
150-
}));
167+
);
151168

152169
const contextEntries = await db.context.findMany({
153170
where: { organizationId: orgId },

apps/api/src/controls/controls.controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Delete,
55
Get,
66
Param,
7+
ParseEnumPipe,
78
Post,
89
Query,
910
UseGuards,
@@ -159,7 +160,8 @@ export class ControlsController {
159160
async unlinkDocumentType(
160161
@OrganizationId() organizationId: string,
161162
@Param('id') id: string,
162-
@Param('formType') formType: EvidenceFormType,
163+
@Param('formType', new ParseEnumPipe(EvidenceFormType))
164+
formType: EvidenceFormType,
163165
) {
164166
return this.controlsService.unlinkDocumentType(
165167
id,

apps/api/src/frameworks/frameworks-scores.helper.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ interface TaskWithControls {
224224

225225
interface EvidenceSubmissionForScoring {
226226
formType: string;
227-
createdAt: Date | string;
227+
submittedAt: Date | string;
228228
}
229229

230230
function isControlCompleted(
@@ -252,14 +252,14 @@ function isControlCompleted(
252252
if (documentTypes.length > 0) {
253253
const sorted = [...evidenceSubmissions].sort(
254254
(a, b) =>
255-
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
255+
new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(),
256256
);
257257
const now = Date.now();
258258
for (const dt of documentTypes) {
259259
const latest = sorted.find((es) => es.formType === dt.formType);
260260
if (
261261
!latest ||
262-
now - new Date(latest.createdAt).getTime() > SIX_MONTHS_MS
262+
now - new Date(latest.submittedAt).getTime() > SIX_MONTHS_MS
263263
) {
264264
documentsComplete = false;
265265
break;

apps/api/src/frameworks/frameworks.service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export class FrameworksService {
121121
}),
122122
db.evidenceSubmission.findMany({
123123
where: { organizationId },
124-
select: { formType: true, createdAt: true },
124+
select: { formType: true, submittedAt: true },
125125
}),
126126
]);
127127

@@ -199,8 +199,8 @@ export class FrameworksService {
199199
organizationId,
200200
formType: { in: Array.from(allFormTypes) },
201201
},
202-
select: { id: true, formType: true, createdAt: true },
203-
orderBy: { createdAt: 'desc' },
202+
select: { id: true, formType: true, submittedAt: true },
203+
orderBy: { submittedAt: 'desc' },
204204
})
205205
: Promise.resolve([]),
206206
]);
@@ -503,8 +503,8 @@ export class FrameworksService {
503503
organizationId,
504504
formType: { in: Array.from(formTypes) },
505505
},
506-
select: { id: true, formType: true, createdAt: true },
507-
orderBy: { createdAt: 'desc' },
506+
select: { id: true, formType: true, submittedAt: true },
507+
orderBy: { submittedAt: 'desc' },
508508
})
509509
: [];
510510

apps/api/src/policies/policies.controller.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -225,24 +225,43 @@ export class PoliciesController {
225225

226226
const instances = await db.frameworkInstance.findMany({
227227
where: { organizationId },
228-
include: { framework: true },
228+
include: { framework: true, customFramework: true },
229229
});
230230

231+
// Normalize platform + org-custom frameworks into a single shape so the AI
232+
// context reflects every framework the org has enabled, not just platform.
233+
const normalized = instances.map((fi) => {
234+
if (fi.framework) {
235+
return {
236+
id: fi.framework.id,
237+
name: fi.framework.name,
238+
version: fi.framework.version,
239+
description: fi.framework.description,
240+
visible: fi.framework.visible,
241+
createdAt: fi.framework.createdAt,
242+
updatedAt: fi.framework.updatedAt,
243+
};
244+
}
245+
if (fi.customFramework) {
246+
return {
247+
id: fi.customFramework.id,
248+
name: fi.customFramework.name,
249+
version: fi.customFramework.version,
250+
description: fi.customFramework.description,
251+
visible: true,
252+
createdAt: fi.customFramework.createdAt,
253+
updatedAt: fi.customFramework.updatedAt,
254+
};
255+
}
256+
return null;
257+
});
231258
const uniqueFrameworks = Array.from(
232259
new Map(
233-
instances
234-
.filter((fi) => fi.framework)
235-
.map((fi) => [fi.framework!.id, fi.framework!]),
260+
normalized
261+
.filter((f): f is NonNullable<typeof f> => f !== null)
262+
.map((f) => [f.id, f]),
236263
).values(),
237-
).map((f) => ({
238-
id: f.id,
239-
name: f.name,
240-
version: f.version,
241-
description: f.description,
242-
visible: f.visible,
243-
createdAt: f.createdAt,
244-
updatedAt: f.updatedAt,
245-
}));
264+
);
246265

247266
const contextEntries = await db.context.findMany({
248267
where: { organizationId },

apps/app/src/app/(app)/[orgId]/controls/hooks/useControls.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ interface ControlsApiResponse {
99
pageCount: number;
1010
}
1111

12+
type RequirementMappingPayload =
13+
| { requirementId: string; customRequirementId?: never; frameworkInstanceId: string }
14+
| { requirementId?: never; customRequirementId: string; frameworkInstanceId: string };
15+
1216
interface CreateControlPayload {
1317
name: string;
1418
description: string;
1519
policyIds?: string[];
1620
taskIds?: string[];
17-
requirementMappings?: {
18-
requirementId?: string;
19-
customRequirementId?: string;
20-
frameworkInstanceId: string;
21-
}[];
21+
requirementMappings?: RequirementMappingPayload[];
2222
documentTypes?: string[];
2323
}
2424

apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export function DocumentsTable({
113113
<Button
114114
size="sm"
115115
variant="ghost"
116-
disabled={pending === row.formType}
116+
disabled={pending !== null}
117117
onClick={(e) => {
118118
e.stopPropagation();
119119
handleUnlink(row.formType);

apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/documentTypeLabels.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import { EvidenceFormType } from '@db';
2+
13
// Prisma `EvidenceFormType` enum values are snake_case at the TS level
24
// (Prisma maps them to kebab-case strings in the DB). The `/documents/[formType]`
35
// URL uses the kebab-case form, so we convert when navigating.
46

5-
export const DOCUMENT_TYPE_LABELS: Record<string, string> = {
7+
// Typed as Record<EvidenceFormType, string> so adding a new form-type to the
8+
// Prisma enum without updating this map becomes a compile error.
9+
export const DOCUMENT_TYPE_LABELS: Record<EvidenceFormType, string> = {
610
meeting: 'Meeting',
711
board_meeting: 'Board Meeting',
812
it_leadership_meeting: 'IT Leadership Meeting',
@@ -17,10 +21,12 @@ export const DOCUMENT_TYPE_LABELS: Record<string, string> = {
1721
tabletop_exercise: 'Tabletop Exercise',
1822
};
1923

20-
export const ALL_DOCUMENT_TYPES: string[] = Object.keys(DOCUMENT_TYPE_LABELS);
24+
export const ALL_DOCUMENT_TYPES = Object.keys(
25+
DOCUMENT_TYPE_LABELS,
26+
) as EvidenceFormType[];
2127

22-
export function getDocumentTypeLabel(formType: string): string {
23-
return DOCUMENT_TYPE_LABELS[formType] ?? formType;
28+
export function getDocumentTypeLabel(formType: EvidenceFormType | string): string {
29+
return DOCUMENT_TYPE_LABELS[formType as EvidenceFormType] ?? formType;
2430
}
2531

2632
export function toDocumentUrlSlug(formType: string): string {

apps/app/src/app/(app)/[orgId]/frameworks/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ export default async function FrameworksPage({ params }: { params: Promise<{ org
2929

3030
const availableToAdd = allFrameworks.filter(
3131
(framework) =>
32-
!frameworksWithControls.some((fc) => fc.framework?.id === framework.id),
32+
!frameworksWithControls.some(
33+
(fc) =>
34+
fc.framework?.id === framework.id ||
35+
fc.customFramework?.id === framework.id,
36+
),
3337
);
3438

3539
return (

apps/app/src/app/(app)/[orgId]/overview/components/FrameworksOverview.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,12 @@ export function FrameworksOverview({
8282
);
8383

8484
const availableFrameworksToAdd = allFrameworks.filter(
85-
(framework) => !frameworksWithControls.some((fc) => fc.framework?.id === framework.id),
85+
(framework) =>
86+
!frameworksWithControls.some(
87+
(fc) =>
88+
fc.framework?.id === framework.id ||
89+
fc.customFramework?.id === framework.id,
90+
),
8691
);
8792

8893
return (

0 commit comments

Comments
 (0)