diff --git a/README.md b/README.md
index 55a4cbfdaa..7dd3492f20 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
Comp AI
+ Comp AI
The open-source compliance platform.
diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts b/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts
index a53ff09b98..0bdd4c215a 100644
--- a/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts
+++ b/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts
@@ -257,7 +257,7 @@ describe('AdminOrganizationsController', () => {
describe('getAuditLogs', () => {
it('should call service with org id and query params', async () => {
- const mockResult = { data: [{ id: 'aud_1' }] };
+ const mockResult = { data: [{ id: 'aud_1' }], total: 1 };
mockService.getAuditLogs.mockResolvedValue(mockResult);
const result = await controller.getAuditLogs('org_1', 'policy', '50');
@@ -266,12 +266,26 @@ describe('AdminOrganizationsController', () => {
orgId: 'org_1',
entityType: 'policy',
take: '50',
+ offset: undefined,
});
expect(result).toEqual(mockResult);
});
+ it('should forward the offset query param', async () => {
+ mockService.getAuditLogs.mockResolvedValue({ data: [], total: 0 });
+
+ await controller.getAuditLogs('org_1', 'policy', '100', '200');
+
+ expect(mockService.getAuditLogs).toHaveBeenCalledWith({
+ orgId: 'org_1',
+ entityType: 'policy',
+ take: '100',
+ offset: '200',
+ });
+ });
+
it('should pass undefined for optional params', async () => {
- mockService.getAuditLogs.mockResolvedValue({ data: [] });
+ mockService.getAuditLogs.mockResolvedValue({ data: [], total: 0 });
await controller.getAuditLogs('org_1');
@@ -279,6 +293,7 @@ describe('AdminOrganizationsController', () => {
orgId: 'org_1',
entityType: undefined,
take: undefined,
+ offset: undefined,
});
});
});
diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts
index 6de51d6b21..1ff7fa0415 100644
--- a/apps/api/src/admin-organizations/admin-organizations.controller.ts
+++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts
@@ -153,14 +153,20 @@ export class AdminOrganizationsController {
@ApiQuery({
name: 'take',
required: false,
- description: 'Number of logs to return (max 100, default 100)',
+ description: 'Number of logs to return (max 200, default 100)',
+ })
+ @ApiQuery({
+ name: 'offset',
+ required: false,
+ description: 'Number of logs to skip (default 0)',
})
async getAuditLogs(
@Param('id') id: string,
@Query('entityType') entityType?: string,
@Query('take') take?: string,
+ @Query('offset') offset?: string,
) {
- return this.service.getAuditLogs({ orgId: id, entityType, take });
+ return this.service.getAuditLogs({ orgId: id, entityType, take, offset });
}
@Get(':id/invitations')
diff --git a/apps/api/src/admin-organizations/admin-organizations.service.spec.ts b/apps/api/src/admin-organizations/admin-organizations.service.spec.ts
index ca2e404039..37fe9598d1 100644
--- a/apps/api/src/admin-organizations/admin-organizations.service.spec.ts
+++ b/apps/api/src/admin-organizations/admin-organizations.service.spec.ts
@@ -22,6 +22,26 @@ jest.mock('@db', () => ({
update: jest.fn(),
updateMany: jest.fn(),
},
+ auditLog: {
+ findMany: jest.fn(),
+ count: jest.fn(),
+ },
+ },
+ AuditLogEntityType: {
+ organization: 'organization',
+ framework: 'framework',
+ requirement: 'requirement',
+ control: 'control',
+ policy: 'policy',
+ task: 'task',
+ people: 'people',
+ risk: 'risk',
+ vendor: 'vendor',
+ tests: 'tests',
+ integration: 'integration',
+ trust: 'trust',
+ finding: 'finding',
+ pentest: 'pentest',
},
}));
@@ -441,4 +461,135 @@ describe('AdminOrganizationsService', () => {
).rejects.toThrow(NotFoundException);
});
});
+
+ describe('getAuditLogs', () => {
+ const mockLogs = [{ id: 'aud_1' }, { id: 'aud_2' }];
+
+ beforeEach(() => {
+ (mockDb.auditLog.findMany as jest.Mock).mockResolvedValue(mockLogs);
+ (mockDb.auditLog.count as jest.Mock).mockResolvedValue(42);
+ });
+
+ it('passes skip/take to findMany and returns { data, total }', async () => {
+ const result = await service.getAuditLogs({
+ orgId: 'org_1',
+ take: '25',
+ offset: '50',
+ });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { organizationId: 'org_1' },
+ take: 25,
+ skip: 50,
+ orderBy: [{ timestamp: 'desc' }, { id: 'desc' }],
+ }),
+ );
+ expect(mockDb.auditLog.count).toHaveBeenCalledWith({
+ where: { organizationId: 'org_1' },
+ });
+ expect(result).toEqual({ data: mockLogs, total: 42 });
+ });
+
+ it('uses the SAME where for count and findMany', async () => {
+ await service.getAuditLogs({ orgId: 'org_1', entityType: 'policy' });
+
+ const findManyWhere = (mockDb.auditLog.findMany as jest.Mock).mock
+ .calls[0][0].where;
+ const countWhere = (mockDb.auditLog.count as jest.Mock).mock.calls[0][0]
+ .where;
+
+ expect(findManyWhere).toEqual(countWhere);
+ });
+
+ it('defaults take to 100 and offset to 0', async () => {
+ await service.getAuditLogs({ orgId: 'org_1' });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 100, skip: 0 }),
+ );
+ });
+
+ it('clamps take to a max of 200', async () => {
+ await service.getAuditLogs({ orgId: 'org_1', take: '1000' });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 200 }),
+ );
+ });
+
+ it('clamps take to a min of 1', async () => {
+ await service.getAuditLogs({ orgId: 'org_1', take: '0' });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 100 }),
+ );
+ });
+
+ it('clamps a negative offset to 0', async () => {
+ await service.getAuditLogs({ orgId: 'org_1', offset: '-10' });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ skip: 0 }),
+ );
+ });
+
+ it('clamps a NaN offset to 0', async () => {
+ await service.getAuditLogs({ orgId: 'org_1', offset: 'abc' });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ skip: 0 }),
+ );
+ });
+
+ it('clamps an oversized offset to the maximum', async () => {
+ await service.getAuditLogs({ orgId: 'org_1', offset: '999999999' });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ skip: 100_000 }),
+ );
+ });
+
+ it('caps the reported total at the reachable maximum', async () => {
+ (mockDb.auditLog.count as jest.Mock).mockResolvedValue(500_000);
+
+ const result = await service.getAuditLogs({ orgId: 'org_1' });
+
+ // Beyond the offset cap the window is unreachable, so total must not
+ // exceed it — otherwise the client pager loops load-more forever.
+ expect(result.total).toBe(100_000);
+ });
+
+ it('builds a single-value entityType filter', async () => {
+ await service.getAuditLogs({ orgId: 'org_1', entityType: 'policy' });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { organizationId: 'org_1', entityType: 'policy' },
+ }),
+ );
+ });
+
+ it('builds an { in: [...] } filter for multiple entityTypes', async () => {
+ await service.getAuditLogs({
+ orgId: 'org_1',
+ entityType: 'policy, task',
+ });
+
+ expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ organizationId: 'org_1',
+ entityType: { in: ['policy', 'task'] },
+ },
+ }),
+ );
+ });
+
+ it('throws BadRequestException for an invalid entityType', async () => {
+ await expect(
+ service.getAuditLogs({ orgId: 'org_1', entityType: 'not_a_type' }),
+ ).rejects.toThrow(BadRequestException);
+ });
+ });
});
diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts
index fc4ca96c50..8663cff7d5 100644
--- a/apps/api/src/admin-organizations/admin-organizations.service.ts
+++ b/apps/api/src/admin-organizations/admin-organizations.service.ts
@@ -8,6 +8,7 @@ import { AuditLogEntityType, db } from '@db';
import { triggerEmail } from '../email/trigger-email';
import { InviteEmail } from '../email/templates/invite-member';
import { UpdateAdminOrganizationDto } from './dto/update-admin-organization.dto';
+import { MAX_AUDIT_LOG_OFFSET } from '../audit/audit-log.pagination';
@Injectable()
export class AdminOrganizationsService {
@@ -423,8 +424,9 @@ export class AdminOrganizationsService {
orgId: string;
entityType?: string;
take?: string;
+ offset?: string;
}) {
- const { orgId, entityType, take } = options;
+ const { orgId, entityType, take, offset } = options;
const where: Record = { organizationId: orgId };
@@ -444,28 +446,40 @@ export class AdminOrganizationsService {
}
const parsedTake = take
- ? Math.min(100, Math.max(1, parseInt(take, 10) || 100))
+ ? Math.min(200, Math.max(1, parseInt(take, 10) || 100))
: 100;
+ const parsedOffset = offset
+ ? Math.min(MAX_AUDIT_LOG_OFFSET, Math.max(0, parseInt(offset, 10) || 0))
+ : 0;
- const logs = await db.auditLog.findMany({
- where,
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- image: true,
- role: true,
+ const [logs, rawTotal] = await Promise.all([
+ db.auditLog.findMany({
+ where,
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ role: true,
+ },
},
+ member: true,
+ organization: true,
},
- member: true,
- organization: true,
- },
- orderBy: { timestamp: 'desc' },
- take: parsedTake,
- });
+ orderBy: [{ timestamp: 'desc' }, { id: 'desc' }],
+ take: parsedTake,
+ skip: parsedOffset,
+ }),
+ db.auditLog.count({ where }),
+ ]);
+
+ // Report only the reachable total (bounded by the offset cap) so the client
+ // pager stops at the accessible boundary instead of looping load-more over
+ // pages past MAX_AUDIT_LOG_OFFSET that can never be filled.
+ const total = Math.min(rawTotal, MAX_AUDIT_LOG_OFFSET);
- return { data: logs };
+ return { data: logs, total };
}
}
diff --git a/apps/api/src/audit/audit-log.controller.spec.ts b/apps/api/src/audit/audit-log.controller.spec.ts
index 17b01821b8..37336244dc 100644
--- a/apps/api/src/audit/audit-log.controller.spec.ts
+++ b/apps/api/src/audit/audit-log.controller.spec.ts
@@ -16,10 +16,12 @@ jest.mock('@trycompai/auth', () => ({
}));
const mockFindMany = jest.fn();
+const mockCount = jest.fn();
jest.mock('@db', () => ({
db: {
auditLog: {
findMany: (...args: unknown[]) => mockFindMany(...args),
+ count: (...args: unknown[]) => mockCount(...args),
},
},
Prisma: {},
@@ -54,17 +56,20 @@ describe('AuditLogController', () => {
controller = module.get(AuditLogController);
jest.clearAllMocks();
+ mockCount.mockResolvedValue(0);
});
describe('getAuditLogs', () => {
- it('should return logs with default take of 50', async () => {
+ it('should return logs with default take of 50 and the total count', async () => {
const mockLogs = [{ id: 'log_1' }, { id: 'log_2' }];
mockFindMany.mockResolvedValue(mockLogs);
+ mockCount.mockResolvedValue(137);
const result = await controller.getAuditLogs('org_1', mockAuthContext);
expect(result).toEqual({
data: mockLogs,
+ total: 137,
authType: 'session',
authenticatedUser: { id: 'usr_1', email: 'user@example.com' },
});
@@ -83,8 +88,108 @@ describe('AuditLogController', () => {
member: true,
organization: true,
},
- orderBy: { timestamp: 'desc' },
+ orderBy: [{ timestamp: 'desc' }, { id: 'desc' }],
take: 50,
+ skip: 0,
+ });
+ expect(mockCount).toHaveBeenCalledWith({
+ where: { organizationId: 'org_1' },
+ });
+ });
+
+ it('should skip by the offset parameter', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ undefined,
+ undefined,
+ '100',
+ '200',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 100, skip: 200 }),
+ );
+ });
+
+ it('should clamp a negative offset to 0', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ '-10',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({ skip: 0 }),
+ );
+ });
+
+ it('should default offset to 0 for invalid values', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ 'invalid',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({ skip: 0 }),
+ );
+ });
+
+ it('should clamp an oversized offset to the maximum', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ '999999999',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({ skip: 100_000 }),
+ );
+ });
+
+ it('should cap the reported total at the reachable maximum', async () => {
+ mockFindMany.mockResolvedValue([]);
+ mockCount.mockResolvedValue(500_000);
+
+ const result = await controller.getAuditLogs('org_1', mockAuthContext);
+
+ // Beyond the offset cap the window is unreachable, so total must not
+ // exceed it — otherwise the client pager loops load-more forever.
+ expect(result.total).toBe(100_000);
+ });
+
+ it('should count with the same filters as the query', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs('org_1', mockAuthContext, 'vendor,task');
+
+ expect(mockCount).toHaveBeenCalledWith({
+ where: {
+ organizationId: 'org_1',
+ entityType: { in: ['vendor', 'task'] },
+ },
});
});
@@ -243,6 +348,7 @@ describe('AuditLogController', () => {
expect(result).toEqual({
data: [],
+ total: 0,
authType: 'api-key',
});
});
diff --git a/apps/api/src/audit/audit-log.controller.ts b/apps/api/src/audit/audit-log.controller.ts
index 0e5fda432b..0b64544d9a 100644
--- a/apps/api/src/audit/audit-log.controller.ts
+++ b/apps/api/src/audit/audit-log.controller.ts
@@ -6,6 +6,7 @@ import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { PermissionGuard } from '../auth/permission.guard';
import { RequirePermission } from '../auth/require-permission.decorator';
import type { AuthContext as AuthContextType } from '../auth/types';
+import { MAX_AUDIT_LOG_OFFSET } from './audit-log.pagination';
@ApiTags('Audit Logs')
@Controller({ path: 'audit-logs', version: '1' })
@@ -35,6 +36,11 @@ export class AuditLogController {
required: false,
description: 'Number of logs to return (max 100, default 50)',
})
+ @ApiQuery({
+ name: 'offset',
+ required: false,
+ description: 'Number of logs to skip (default 0)',
+ })
async getAuditLogs(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@@ -42,6 +48,7 @@ export class AuditLogController {
@Query('entityId') entityId?: string,
@Query('pathContains') pathContains?: string,
@Query('take') take?: string,
+ @Query('offset') offset?: string,
) {
// organizationId comes from auth context (not user input) — ensures tenant isolation
const where: Record = { organizationId };
@@ -71,28 +78,45 @@ export class AuditLogController {
const parsedTake = take
? Math.min(100, Math.max(1, parseInt(take, 10) || 50))
: 50;
+ const parsedOffset = offset
+ ? Math.min(MAX_AUDIT_LOG_OFFSET, Math.max(0, parseInt(offset, 10) || 0))
+ : 0;
- const logs = await db.auditLog.findMany({
- where,
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- image: true,
- role: true,
+ // `total` drives the client pager (how many pages exist / when to stop
+ // fetching); the stable `id` secondary sort keeps offset paging
+ // deterministic when rows share a timestamp.
+ const [logs, rawTotal] = await Promise.all([
+ db.auditLog.findMany({
+ where,
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ role: true,
+ },
},
+ member: true,
+ organization: true,
},
- member: true,
- organization: true,
- },
- orderBy: { timestamp: 'desc' },
- take: parsedTake,
- });
+ orderBy: [{ timestamp: 'desc' }, { id: 'desc' }],
+ take: parsedTake,
+ skip: parsedOffset,
+ }),
+ db.auditLog.count({ where }),
+ ]);
+
+ // Report only the reachable total (bounded by the offset cap). Otherwise the
+ // client pager keeps `hasMore` true past MAX_AUDIT_LOG_OFFSET — where the
+ // server clamps every request to the same page — looping load-more forever
+ // and exposing pages that can never be filled.
+ const total = Math.min(rawTotal, MAX_AUDIT_LOG_OFFSET);
return {
data: logs,
+ total,
authType: authContext.authType,
...(authContext.userId && {
authenticatedUser: {
diff --git a/apps/api/src/audit/audit-log.pagination.ts b/apps/api/src/audit/audit-log.pagination.ts
new file mode 100644
index 0000000000..72ce612fed
--- /dev/null
+++ b/apps/api/src/audit/audit-log.pagination.ts
@@ -0,0 +1,10 @@
+/**
+ * Upper bound for `skip` on audit-log queries. Offset pagination scans and
+ * discards every preceding row, so an unbounded `?offset=` could be used to
+ * force arbitrarily large table scans. 100k is far beyond any realistic UI
+ * paging depth (the pager fetches 100 rows/batch) while capping that abuse.
+ *
+ * Kept dependency-free (no `@db` import) so controllers/services can pull it in
+ * without dragging Prisma enums into their unit-test module graph.
+ */
+export const MAX_AUDIT_LOG_OFFSET = 100_000;
diff --git a/apps/api/src/isms/documents/generate.ts b/apps/api/src/isms/documents/generate.ts
index cfd717f2a8..98d2861920 100644
--- a/apps/api/src/isms/documents/generate.ts
+++ b/apps/api/src/isms/documents/generate.ts
@@ -269,6 +269,13 @@ export async function runDerivation({
await generateNarrative({ tx, documentId, type, data });
return;
}
+ if (type === 'management_review') {
+ // Only the Procedure paragraph is derivable; review instances are
+ // customer-created (their Inputs rows seed at review creation).
+ // Seed-if-empty, so a regenerate never clobbers an edited procedure.
+ await generateNarrative({ tx, documentId, type, data });
+ return;
+ }
if (isNarrativeType(type)) {
await generateNarrative({ tx, documentId, type, data });
return;
diff --git a/apps/api/src/isms/documents/management-review-defaults.ts b/apps/api/src/isms/documents/management-review-defaults.ts
new file mode 100644
index 0000000000..ad6ad30f45
--- /dev/null
+++ b/apps/api/src/isms/documents/management-review-defaults.ts
@@ -0,0 +1,133 @@
+import type { SeedReviewInputDefinition } from './types';
+
+/**
+ * Default text for the Management Review document (clause 9.3, CS-726). Every
+ * field ships with auditor-defensible template text a layperson can accept
+ * unedited; the customer may edit any of it. Kept ASCII-safe for the PDF
+ * renderer (jsPDF standard fonts cannot render characters like ≥).
+ */
+
+/** The Procedure paragraph, rendered verbatim into the generated document. */
+export function defaultProcedureText(organizationName: string): string {
+ return `${organizationName} holds a management review of the ISMS at least annually, and additionally after any major incident or significant change. The review is chaired by top management, considers the inputs required by ISO 27001 Clause 9.3.2, and records the outputs required by Clause 9.3.3. Minutes are retained in Comp AI.`;
+}
+
+/** Default "Decisions on continual improvement" text for a new review (9.3.3). */
+export const DEFAULT_REVIEW_DECISIONS_TEXT =
+ 'Decisions taken at this review to pursue continual improvement of the ISMS are recorded below.';
+
+/** Default "ISMS changes required" text for a new review (9.3.3). */
+export const DEFAULT_REVIEW_CHANGES_TEXT =
+ 'No changes to the ISMS scope, policy, objectives, controls, or resources were agreed at this review, other than those captured in the actions arising.';
+
+/**
+ * The bracketed verdict choices in the conclusion template: "...the ISMS was
+ * found to be [suitable / adequate / effective] and no changes are required
+ * except those recorded in the outputs section below."
+ */
+export const REVIEW_CONCLUSION_VERDICT_TEXT: Record = {
+ suitable: 'suitable',
+ adequate: 'adequate',
+ effective: 'effective',
+};
+
+/** Assemble the rendered conclusion sentence for a chosen verdict. */
+export function reviewConclusionSentence({
+ verdict,
+ meetingDate,
+}: {
+ verdict: string;
+ /** 'YYYY-MM-DD', or null while the meeting date has not been entered. */
+ meetingDate: string | null;
+}): string {
+ const choice = REVIEW_CONCLUSION_VERDICT_TEXT[verdict] ?? verdict;
+ const reviewed = meetingDate
+ ? `The information security management system was reviewed on ${meetingDate}.`
+ : 'The information security management system was reviewed.';
+ return `${reviewed} Overall, the ISMS was found to be ${choice} and no changes are required except those recorded in the outputs section below.`;
+}
+
+/**
+ * The ten default Inputs (9.3.2) rows seeded per review: inputs (a) through
+ * (g), with (d) split into its four sub-inputs. The table is the meeting
+ * agenda — working through it in order covers everything ISO requires.
+ * "Where to find it" entries begin with "Comp AI" to signal the default
+ * location; the customer overwrites the field when evidence lives elsewhere.
+ * Seeding is idempotent by inputKey (seedReviewInputsIfMissing) and never
+ * overwrites customer edits.
+ */
+export const SEED_REVIEW_INPUT_DEFINITIONS: SeedReviewInputDefinition[] = [
+ {
+ inputKey: 'a_prior_actions',
+ inputRef: '(a) Prior actions',
+ whatItCovers: 'Status of actions from previous management reviews.',
+ whereToFind: 'Comp AI > ISMS > Management Review',
+ },
+ {
+ inputKey: 'b_context_changes',
+ inputRef: '(b) Context changes',
+ whatItCovers:
+ 'Changes in external and internal issues relevant to the ISMS.',
+ whereToFind: 'Comp AI > ISMS > Documents > Context of the Organization',
+ },
+ {
+ inputKey: 'c_interested_party_changes',
+ inputRef: '(c) Interested-party changes',
+ whatItCovers: 'Changes in needs and expectations of interested parties.',
+ // Canonical document titles so the agenda reference is followable.
+ whereToFind:
+ 'Comp AI > ISMS > Documents > Interested Parties Register & Interested Parties Requirements',
+ },
+ {
+ inputKey: 'd1_nonconformity_trends',
+ inputRef: '(d.1) Nonconformity trends',
+ whatItCovers: 'Trends in nonconformities and corrective actions.',
+ whereToFind: 'Comp AI > Findings',
+ },
+ {
+ inputKey: 'd2_monitoring_results',
+ inputRef: '(d.2) Monitoring results',
+ whatItCovers: 'Monitoring and measurement results.',
+ whereToFind: 'Comp AI > ISMS > Monitoring',
+ },
+ {
+ inputKey: 'd3_audit_results',
+ inputRef: '(d.3) Audit results',
+ whatItCovers: 'Results of internal audits.',
+ whereToFind: 'Comp AI > ISMS > Internal Audit',
+ },
+ {
+ inputKey: 'd4_objectives_fulfilment',
+ inputRef: '(d.4) Objectives fulfilment',
+ whatItCovers:
+ 'Extent to which information security objectives have been met.',
+ // Canonical document title so the agenda reference is followable.
+ whereToFind:
+ 'Comp AI > ISMS > Documents > Information Security Objectives and Plan',
+ },
+ {
+ inputKey: 'e_interested_party_feedback',
+ inputRef: '(e) Interested-party feedback',
+ whatItCovers:
+ 'Feedback received from interested parties (customers, vendors, regulators).',
+ whereToFind: 'Comp AI > Vendors (reviews) + external channels',
+ },
+ {
+ inputKey: 'f_risk_assessment',
+ inputRef: '(f) Risk assessment',
+ whatItCovers:
+ 'Results of risk assessment and status of the risk-treatment plan.',
+ whereToFind: 'Comp AI > Risks (risk register)',
+ },
+ {
+ inputKey: 'g_improvement_opportunities',
+ inputRef: '(g) Improvement opportunities',
+ whatItCovers:
+ 'Opportunities for continual improvement raised at the meeting or before.',
+ whereToFind: 'Comp AI > Findings (tagged OFI) + ideas raised at this meeting',
+ },
+];
+
+export const SEED_REVIEW_INPUT_KEYS = SEED_REVIEW_INPUT_DEFINITIONS.map(
+ (input) => input.inputKey,
+);
diff --git a/apps/api/src/isms/documents/management-review-export-data.spec.ts b/apps/api/src/isms/documents/management-review-export-data.spec.ts
new file mode 100644
index 0000000000..e352f1b1e0
--- /dev/null
+++ b/apps/api/src/isms/documents/management-review-export-data.spec.ts
@@ -0,0 +1,262 @@
+import {
+ mapReviews,
+ type ReviewWithExportIncludes,
+} from './management-review-export-data';
+import { buildManagementReviewSections } from './management-review-sections';
+import type { DocumentExportInput } from './types';
+
+jest.mock('@db', () => ({ db: {} }));
+
+const EXTRAS = {
+ memberNames: { mem_spo: 'Alex Petrisor', mem_ceo: 'Raoul Plickat' },
+};
+
+function makeReview(
+ overrides: Partial = {},
+): ReviewWithExportIncludes {
+ return {
+ id: 'mr_1',
+ documentId: 'doc_1',
+ reference: 'MR-2026-01',
+ meetingDate: new Date('2026-05-01T00:00:00.000Z'),
+ recordedAt: new Date('2026-05-01T09:30:00.000Z'),
+ chairName: 'Raoul Plickat (CEO)',
+ attendees: [
+ { memberId: 'mem_ceo', name: 'Raoul Plickat' },
+ { memberId: 'mem_spo', name: 'Alex Petrisor' },
+ ],
+ status: 'complete',
+ conclusionVerdict: 'effective',
+ conclusionNotes: null,
+ decisionsText: 'Two improvements agreed.',
+ changesText: 'No changes to the ISMS.',
+ signoffChairName: 'Raoul Plickat',
+ signoffChairDate: new Date('2026-05-01T00:00:00.000Z'),
+ position: 0,
+ createdAt: new Date('2026-05-01T09:30:00.000Z'),
+ updatedAt: new Date('2026-05-01T09:30:00.000Z'),
+ inputs: [],
+ actions: [],
+ ...overrides,
+ } as ReviewWithExportIncludes;
+}
+
+function makeAction(
+ overrides: Partial = {},
+): ReviewWithExportIncludes['actions'][number] {
+ return {
+ id: 'mra_1',
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ reference: 'A01',
+ description: 'Formalise quarterly access review.',
+ ownerMemberId: 'mem_spo',
+ dueDate: new Date('2026-06-30T00:00:00.000Z'),
+ status: 'open',
+ position: 0,
+ createdAt: new Date('2026-05-01T09:30:00.000Z'),
+ updatedAt: new Date('2026-05-01T09:30:00.000Z'),
+ ...overrides,
+ } as ReviewWithExportIncludes['actions'][number];
+}
+
+describe('mapReviews', () => {
+ it('resolves names, labels, dates, and the full action reference', () => {
+ const [row] = mapReviews([makeReview({ actions: [makeAction()] })], EXTRAS);
+
+ expect(row.reference).toBe('MR-2026-01');
+ expect(row.meetingDate).toBe('2026-05-01');
+ expect(row.recordedOn).toBe('2026-05-01');
+ expect(row.attendees).toEqual(['Raoul Plickat', 'Alex Petrisor']);
+ expect(row.status).toBe('Complete');
+ expect(row.conclusion).toContain('was reviewed on 2026-05-01');
+ expect(row.conclusion).toContain('found to be effective');
+ expect(row.actions).toEqual([
+ {
+ reference: 'MR-2026-01-A01',
+ description: 'Formalise quarterly access review.',
+ ownerName: 'Alex Petrisor',
+ dueDate: '2026-06-30',
+ status: 'Open',
+ },
+ ]);
+ });
+
+ it('renders Yes/No for discussed inputs and dashes-free empty notes', () => {
+ const [row] = mapReviews(
+ [
+ makeReview({
+ inputs: [
+ {
+ id: 'mri_1',
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ inputKey: 'a_prior_actions',
+ inputRef: '(a) Prior actions',
+ whatItCovers: 'Status of actions.',
+ whereToFind: 'Comp AI > ISMS > Management Review',
+ discussionNotes: null,
+ discussed: true,
+ source: 'derived',
+ derivedFrom: 'seed:a_prior_actions',
+ position: 0,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ] as ReviewWithExportIncludes['inputs'],
+ }),
+ ],
+ EXTRAS,
+ );
+ expect(row.inputs[0]).toMatchObject({
+ inputRef: '(a) Prior actions',
+ discussionNotes: '',
+ discussed: 'Yes',
+ });
+ });
+
+ it('tolerates a malformed attendees JSON value and a missing owner', () => {
+ const [row] = mapReviews(
+ [
+ makeReview({
+ attendees: 'garbage',
+ actions: [makeAction({ ownerMemberId: 'mem_gone' })],
+ }),
+ ],
+ EXTRAS,
+ );
+ expect(row.attendees).toEqual([]);
+ expect(row.actions[0].ownerName).toBe('Former member');
+ });
+
+ it('carries OPEN actions from earlier reviews into later reviews only', () => {
+ const first = makeReview({
+ id: 'mr_1',
+ reference: 'MR-2025-01',
+ actions: [
+ makeAction({ reference: 'A01', status: 'open' }),
+ makeAction({ id: 'mra_2', reference: 'A02', status: 'closed' }),
+ makeAction({ id: 'mra_3', reference: 'A03', status: 'in_progress' }),
+ ],
+ });
+ const second = makeReview({ id: 'mr_2', reference: 'MR-2026-01' });
+
+ const [firstRow, secondRow] = mapReviews([first, second], EXTRAS);
+
+ expect(firstRow.carriedForward).toEqual([]);
+ expect(secondRow.carriedForward.map((action) => action.reference)).toEqual([
+ 'MR-2025-01-A01',
+ 'MR-2025-01-A03',
+ ]);
+ });
+});
+
+describe('buildManagementReviewSections', () => {
+ const baseInput = {
+ contextIssues: [],
+ interestedParties: [],
+ requirements: [],
+ objectives: [],
+ narrative: { procedure: 'We review annually.' },
+ } as DocumentExportInput;
+
+ it('renders Purpose, Procedure, and the no-reviews empty state', () => {
+ const sections = buildManagementReviewSections({
+ ...baseInput,
+ reviews: [],
+ });
+ expect(sections.map((section) => section.heading)).toEqual([
+ 'Purpose',
+ 'Procedure',
+ 'Reviews',
+ ]);
+ expect(sections[1].paragraphs?.[0].text).toBe('We review annually.');
+ expect(sections[2].emptyText).toBe('No management reviews recorded yet.');
+ });
+
+ it('renders every per-review section in ticket order for a single review', () => {
+ const [reviewRow] = mapReviews(
+ [makeReview({ actions: [makeAction()] })],
+ EXTRAS,
+ );
+ const sections = buildManagementReviewSections({
+ ...baseInput,
+ reviews: [reviewRow],
+ });
+ expect(sections.map((section) => section.heading)).toEqual([
+ 'Purpose',
+ 'Procedure',
+ 'Meeting details',
+ 'Inputs (9.3.2)',
+ 'Decisions on continual improvement',
+ 'ISMS changes required',
+ 'Actions arising',
+ 'Conclusion',
+ 'Sign-off',
+ ]);
+ const signoff = sections[sections.length - 1];
+ expect(signoff.table?.rows).toEqual([
+ ['Chair (Top Management)', 'Raoul Plickat', '2026-05-01'],
+ ]);
+ });
+
+ it('adds the carried-forward section and reference suffixes with several reviews', () => {
+ const rows = mapReviews(
+ [
+ makeReview({
+ id: 'mr_1',
+ reference: 'MR-2025-01',
+ actions: [makeAction()],
+ }),
+ makeReview({ id: 'mr_2', reference: 'MR-2026-01' }),
+ ],
+ EXTRAS,
+ );
+ const sections = buildManagementReviewSections({
+ ...baseInput,
+ reviews: rows,
+ });
+ const headings = sections.map((section) => section.heading);
+ expect(headings).toContain('Meeting details — MR-2025-01');
+ expect(headings).toContain('Actions carried forward — MR-2026-01');
+ expect(headings).not.toContain('Actions carried forward — MR-2025-01');
+ });
+
+ it('falls back to emptyText for a bare review and a missing procedure', () => {
+ const [reviewRow] = mapReviews(
+ [
+ makeReview({
+ conclusionVerdict: null,
+ decisionsText: null,
+ changesText: null,
+ signoffChairName: null,
+ signoffChairDate: null,
+ }),
+ ],
+ EXTRAS,
+ );
+ const sections = buildManagementReviewSections({
+ ...baseInput,
+ narrative: null,
+ reviews: [reviewRow],
+ });
+ const byHeading = new Map(
+ sections.map((section) => [section.heading, section]),
+ );
+ expect(byHeading.get('Procedure')?.emptyText).toBe(
+ 'No review procedure recorded.',
+ );
+ expect(byHeading.get('Inputs (9.3.2)')?.emptyText).toBe(
+ 'No inputs recorded for this review.',
+ );
+ expect(byHeading.get('Actions arising')?.emptyText).toBe(
+ 'No actions arising from this review.',
+ );
+ expect(byHeading.get('Conclusion')?.emptyText).toBe(
+ 'No conclusion recorded yet.',
+ );
+ expect(byHeading.get('Sign-off')?.table?.rows).toEqual([
+ ['Chair (Top Management)', '—', '—'],
+ ]);
+ });
+});
diff --git a/apps/api/src/isms/documents/management-review-export-data.ts b/apps/api/src/isms/documents/management-review-export-data.ts
new file mode 100644
index 0000000000..f08fe2c32f
--- /dev/null
+++ b/apps/api/src/isms/documents/management-review-export-data.ts
@@ -0,0 +1,139 @@
+import { db } from '@db';
+import type { Prisma } from '@db';
+import { reviewConclusionSentence } from './management-review-defaults';
+import { parseReviewAttendees } from './management-review';
+import type { ReviewActionExportRow, ReviewExportRow } from './types';
+
+/**
+ * Extra data the Management Review document (9.3) needs at export time but
+ * that isn't on the review rows: display names for action owners (actions
+ * store a plain memberId, no FK). Attendee and chair names are already frozen
+ * on the review row itself. Resolved once and frozen into the version snapshot
+ * so a historical export re-renders byte-faithfully with the names that were
+ * current at publish.
+ */
+export interface ManagementReviewExtras {
+ /** memberId → display name (name, else email, else a placeholder). */
+ memberNames: Record;
+}
+
+type Client = Prisma.TransactionClient | typeof db;
+
+function memberDisplayName(
+ user: { name: string | null; email: string | null } | null,
+): string {
+ return user?.name?.trim() || user?.email?.trim() || 'Unknown member';
+}
+
+/** Load the Management Review document's export extras for an organization. */
+export async function loadManagementReviewExtras({
+ organizationId,
+ client,
+}: {
+ organizationId: string;
+ client?: Client;
+}): Promise {
+ const prisma = client ?? db;
+
+ const members = await prisma.member.findMany({
+ where: { organizationId },
+ select: { id: true, user: { select: { name: true, email: true } } },
+ });
+
+ const memberNames: Record = {};
+ for (const member of members) {
+ memberNames[member.id] = memberDisplayName(member.user);
+ }
+
+ return { memberNames };
+}
+
+/** The review shape mapReviews consumes (EXPORT_DOCUMENT_INCLUDE's reviews). */
+export type ReviewWithExportIncludes = Prisma.IsmsManagementReviewGetPayload<{
+ include: { inputs: true; actions: true };
+}>;
+
+export const REVIEW_STATUS_LABELS: Record = {
+ planned: 'Planned',
+ in_progress: 'In progress',
+ complete: 'Complete',
+};
+
+export const REVIEW_ACTION_STATUS_LABELS: Record = {
+ open: 'Open',
+ in_progress: 'In progress',
+ closed: 'Closed',
+};
+
+function formatDateYmd(date: Date | null): string | null {
+ return date ? date.toISOString().slice(0, 10) : null;
+}
+
+function mapAction(
+ review: ReviewWithExportIncludes,
+ action: ReviewWithExportIncludes['actions'][number],
+ extras: ManagementReviewExtras,
+): ReviewActionExportRow {
+ return {
+ // Actions store the short per-review sequence ("A01"); the document (and
+ // UI) always show the full reference.
+ reference: `${review.reference}-${action.reference}`,
+ description: action.description,
+ ownerName: action.ownerMemberId
+ ? (extras.memberNames[action.ownerMemberId] ?? 'Former member')
+ : '',
+ dueDate: formatDateYmd(action.dueDate) ?? '',
+ status: REVIEW_ACTION_STATUS_LABELS[action.status] ?? action.status,
+ };
+}
+
+/**
+ * Map review rows (with their inputs and actions) into export rows, resolving
+ * owner names and humanizing enum labels. All reviews render — a planned
+ * review appears with its agenda (the pre-meeting agenda pack), which is the
+ * honest state of the programme. `reviews` must be in position order: each
+ * review's carried-forward list is computed from the reviews BEFORE it (open
+ * actions only), which is how actions reach the next review's input (a)
+ * without ever being copied.
+ */
+export function mapReviews(
+ reviews: ReviewWithExportIncludes[],
+ extras: ManagementReviewExtras,
+): ReviewExportRow[] {
+ return reviews.map((review, index) => ({
+ reference: review.reference,
+ meetingDate: formatDateYmd(review.meetingDate),
+ recordedOn: review.recordedAt.toISOString().slice(0, 10),
+ chairName: review.chairName ?? '',
+ attendees: parseReviewAttendees(review.attendees).map(
+ (attendee) => attendee.name,
+ ),
+ status: REVIEW_STATUS_LABELS[review.status] ?? review.status,
+ conclusion: review.conclusionVerdict
+ ? reviewConclusionSentence({
+ verdict: review.conclusionVerdict,
+ meetingDate: formatDateYmd(review.meetingDate),
+ })
+ : null,
+ conclusionNotes: review.conclusionNotes,
+ decisionsText: review.decisionsText,
+ changesText: review.changesText,
+ signoffChairName: review.signoffChairName ?? '',
+ signoffChairDate: formatDateYmd(review.signoffChairDate) ?? '',
+ inputs: review.inputs.map((input) => ({
+ inputRef: input.inputRef,
+ whatItCovers: input.whatItCovers,
+ whereToFind: input.whereToFind,
+ discussionNotes: input.discussionNotes ?? '',
+ discussed: input.discussed ? 'Yes' : 'No',
+ })),
+ actions: review.actions.map((action) => mapAction(review, action, extras)),
+ carriedForward: reviews
+ .slice(0, index)
+ .flatMap((prior) =>
+ prior.actions
+ .filter((action) => action.status !== 'closed')
+ .map((action) => mapAction(prior, action, extras)),
+ ),
+ }));
+}
diff --git a/apps/api/src/isms/documents/management-review-sections.ts b/apps/api/src/isms/documents/management-review-sections.ts
new file mode 100644
index 0000000000..13e1b4ad12
--- /dev/null
+++ b/apps/api/src/isms/documents/management-review-sections.ts
@@ -0,0 +1,209 @@
+import type { IsmsExportSection } from '../utils/export-shared';
+import type { DocumentExportInput, ReviewExportRow } from './types';
+import { managementReviewNarrativeSchema } from './management-review';
+
+function parseProcedure(narrative: unknown): string | null {
+ const parsed = managementReviewNarrativeSchema.safeParse(narrative);
+ return parsed.success ? parsed.data.procedure : null;
+}
+
+const ACTION_TABLE_HEADERS = [
+ 'Reference',
+ 'Description',
+ 'Owner',
+ 'Due date',
+ 'Status',
+];
+
+function actionRows(actions: ReviewExportRow['actions']): string[][] {
+ return actions.map((action) => [
+ action.reference,
+ action.description,
+ action.ownerName,
+ action.dueDate,
+ action.status,
+ ]);
+}
+
+/**
+ * Per-review sections: meeting details, carried-forward actions, the Inputs
+ * (9.3.2) table, the Outputs (9.3.3) trio, Conclusion, and Sign-off — the
+ * order the CS-726 ticket and reference document specify.
+ */
+function buildReviewSections(
+ review: ReviewExportRow,
+ suffix: string,
+): IsmsExportSection[] {
+ const sections: IsmsExportSection[] = [
+ {
+ heading: `Meeting details${suffix}`,
+ keyValues: [
+ { label: 'Reference', value: review.reference },
+ { label: 'Meeting date', value: review.meetingDate ?? '—' },
+ { label: 'Recorded on', value: review.recordedOn },
+ { label: 'Chair', value: review.chairName || '—' },
+ {
+ label: 'Attendees',
+ value: review.attendees.length > 0 ? review.attendees.join(', ') : '—',
+ },
+ { label: 'Status', value: review.status },
+ ],
+ },
+ ];
+
+ // Input (a) evidence: open actions from previous reviews, carried forward
+ // automatically. Omitted entirely (not emptyText) when there is nothing to
+ // carry — the first review of an ISMS has no predecessors.
+ if (review.carriedForward.length > 0) {
+ sections.push({
+ heading: `Actions carried forward${suffix}`,
+ intro:
+ "Open actions from previous management reviews, carried forward automatically to this review's input (a). Their status reflects the tracking state at the time this document was generated.",
+ table: {
+ headers: ACTION_TABLE_HEADERS,
+ rows: actionRows(review.carriedForward),
+ },
+ });
+ }
+
+ // The renderers show emptyText only when a section has no other content, so
+ // the intro is included only when there are rows to introduce.
+ if (review.inputs.length > 0) {
+ sections.push({
+ heading: `Inputs (9.3.2)${suffix}`,
+ intro:
+ 'Each input required by Clause 9.3.2 was considered at the meeting. The "Where to find it" column names the location the chair and attendees referenced. The "Discussion notes" column captures what was discussed.',
+ table: {
+ headers: [
+ 'Input',
+ 'What it covers',
+ 'Where to find it',
+ 'Discussion notes',
+ 'Discussed?',
+ ],
+ rows: review.inputs.map((input) => [
+ input.inputRef,
+ input.whatItCovers,
+ input.whereToFind,
+ input.discussionNotes,
+ input.discussed,
+ ]),
+ },
+ });
+ } else {
+ sections.push({
+ heading: `Inputs (9.3.2)${suffix}`,
+ emptyText: 'No inputs recorded for this review.',
+ });
+ }
+
+ sections.push(
+ review.decisionsText
+ ? {
+ heading: `Decisions on continual improvement${suffix}`,
+ paragraphs: [{ text: review.decisionsText }],
+ }
+ : {
+ heading: `Decisions on continual improvement${suffix}`,
+ emptyText: 'No decisions recorded.',
+ },
+ review.changesText
+ ? {
+ heading: `ISMS changes required${suffix}`,
+ paragraphs: [{ text: review.changesText }],
+ }
+ : {
+ heading: `ISMS changes required${suffix}`,
+ emptyText: 'No ISMS changes recorded.',
+ },
+ review.actions.length > 0
+ ? {
+ heading: `Actions arising${suffix}`,
+ intro:
+ "Actions arising from this review, tracked to closure in Comp AI. Open actions carry forward automatically to the next review's input (a).",
+ table: {
+ headers: ACTION_TABLE_HEADERS,
+ rows: actionRows(review.actions),
+ },
+ }
+ : {
+ heading: `Actions arising${suffix}`,
+ emptyText: 'No actions arising from this review.',
+ },
+ review.conclusion
+ ? {
+ heading: `Conclusion${suffix}`,
+ paragraphs: [
+ { text: review.conclusion },
+ ...(review.conclusionNotes
+ ? [{ text: review.conclusionNotes }]
+ : []),
+ ],
+ }
+ : {
+ heading: `Conclusion${suffix}`,
+ emptyText: 'No conclusion recorded yet.',
+ },
+ {
+ heading: `Sign-off${suffix}`,
+ table: {
+ headers: ['Role', 'Signatory', 'Date'],
+ rows: [
+ [
+ 'Chair (Top Management)',
+ review.signoffChairName || '—',
+ review.signoffChairDate || '—',
+ ],
+ ],
+ },
+ },
+ );
+
+ return sections;
+}
+
+/**
+ * Build the Management Review Procedure and Minutes document (clause 9.3).
+ * Contents and order follow the CS-726 ticket and reference document: Purpose,
+ * Procedure, then per review its meeting details, carried-forward actions,
+ * Inputs (9.3.2), Outputs (9.3.3), Conclusion, and Sign-off. `reviews` (names
+ * and labels already resolved) is populated by loadManagementReviewExtras at
+ * export-input assembly (see management-review-export-data.ts). With a single
+ * review the headings match the reference document verbatim; with several,
+ * each block carries its reference.
+ */
+export function buildManagementReviewSections(
+ input: DocumentExportInput,
+): IsmsExportSection[] {
+ const reviews = input.reviews ?? [];
+ const procedure = parseProcedure(input.narrative);
+
+ const sections: IsmsExportSection[] = [
+ {
+ heading: 'Purpose',
+ paragraphs: [
+ {
+ text: 'This document records the procedure for management review of the Information Security Management System and the minutes of each management review, in accordance with ISO/IEC 27001:2022, Clause 9.3. It is retained as documented information and made available to the certification body on request.',
+ },
+ ],
+ },
+ procedure
+ ? { heading: 'Procedure', paragraphs: [{ text: procedure }] }
+ : { heading: 'Procedure', emptyText: 'No review procedure recorded.' },
+ ];
+
+ if (reviews.length === 0) {
+ sections.push({
+ heading: 'Reviews',
+ emptyText: 'No management reviews recorded yet.',
+ });
+ return sections;
+ }
+
+ for (const review of reviews) {
+ const suffix = reviews.length > 1 ? ` — ${review.reference}` : '';
+ sections.push(...buildReviewSections(review, suffix));
+ }
+
+ return sections;
+}
diff --git a/apps/api/src/isms/documents/management-review.spec.ts b/apps/api/src/isms/documents/management-review.spec.ts
new file mode 100644
index 0000000000..09ac5342ce
--- /dev/null
+++ b/apps/api/src/isms/documents/management-review.spec.ts
@@ -0,0 +1,239 @@
+import type { Prisma } from '@db';
+import {
+ dedupeReviewAttendees,
+ deriveManagementReviewNarrative,
+ isReviewSigned,
+ parseReviewAttendees,
+ reviewValidationMessages,
+ seedReviewInputsIfMissing,
+} from './management-review';
+import {
+ defaultProcedureText,
+ reviewConclusionSentence,
+ SEED_REVIEW_INPUT_DEFINITIONS,
+} from './management-review-defaults';
+import type { IsmsPlatformData } from './types';
+
+const PLATFORM_DATA = { organizationName: 'Acme Corp' } as IsmsPlatformData;
+
+const READY_REVIEW = {
+ reference: 'MR-2026-01',
+ status: 'complete',
+ hasMeetingDate: true,
+ hasChair: true,
+ attendeeCount: 2,
+ undiscussedInputCount: 0,
+ signed: true,
+};
+
+describe('deriveManagementReviewNarrative', () => {
+ it('seeds the Procedure paragraph with the organization name', () => {
+ expect(deriveManagementReviewNarrative(PLATFORM_DATA)).toEqual({
+ procedure: defaultProcedureText('Acme Corp'),
+ });
+ expect(defaultProcedureText('Acme Corp')).toContain(
+ 'Acme Corp holds a management review of the ISMS at least annually',
+ );
+ });
+});
+
+describe('reviewConclusionSentence', () => {
+ it('renders the chosen verdict and the meeting date', () => {
+ expect(
+ reviewConclusionSentence({ verdict: 'effective', meetingDate: '2026-05-01' }),
+ ).toBe(
+ 'The information security management system was reviewed on 2026-05-01. Overall, the ISMS was found to be effective and no changes are required except those recorded in the outputs section below.',
+ );
+ });
+
+ it('omits the date clause while the meeting date is not set', () => {
+ expect(
+ reviewConclusionSentence({ verdict: 'suitable', meetingDate: null }),
+ ).toContain('was reviewed. Overall, the ISMS was found to be suitable');
+ });
+});
+
+describe('parseReviewAttendees / isReviewSigned', () => {
+ it('parses a stored attendees array and rejects malformed values', () => {
+ expect(
+ parseReviewAttendees([{ memberId: 'mem_1', name: 'Jane' }]),
+ ).toEqual([{ memberId: 'mem_1', name: 'Jane' }]);
+ expect(parseReviewAttendees(null)).toEqual([]);
+ expect(parseReviewAttendees('not-an-array')).toEqual([]);
+ expect(parseReviewAttendees([{ memberId: 'mem_1' }])).toEqual([]);
+ // Legacy rows may still carry duplicates — normalized on read.
+ expect(
+ parseReviewAttendees([
+ { memberId: 'mem_1', name: 'Jane' },
+ { memberId: 'mem_1', name: 'Jane (dup)' },
+ ]),
+ ).toEqual([{ memberId: 'mem_1', name: 'Jane' }]);
+ });
+
+ it('dedupes attendees by member, first occurrence winning', () => {
+ expect(
+ dedupeReviewAttendees([
+ { memberId: 'm1', name: 'Jane' },
+ { memberId: 'm2', name: 'Ada' },
+ { memberId: 'm1', name: 'Jane (dup)' },
+ ]),
+ ).toEqual([
+ { memberId: 'm1', name: 'Jane' },
+ { memberId: 'm2', name: 'Ada' },
+ ]);
+ });
+
+ it('treats a review as signed only when both name and date are set', () => {
+ expect(
+ isReviewSigned({ signoffChairName: 'Jane', signoffChairDate: '2026-05-01' }),
+ ).toBe(true);
+ expect(
+ isReviewSigned({ signoffChairName: ' ', signoffChairDate: '2026-05-01' }),
+ ).toBe(false);
+ expect(
+ isReviewSigned({ signoffChairName: 'Jane', signoffChairDate: null }),
+ ).toBe(false);
+ });
+});
+
+describe('reviewValidationMessages', () => {
+ it('requires the procedure paragraph and at least one review', () => {
+ expect(reviewValidationMessages({ procedure: ' ', reviews: [] })).toEqual([
+ 'The review procedure paragraph must not be empty.',
+ 'At least one management review must be recorded.',
+ ]);
+ });
+
+ it('returns no messages for a signed, fully-discussed complete review', () => {
+ expect(
+ reviewValidationMessages({
+ procedure: 'We review annually.',
+ reviews: [READY_REVIEW],
+ }),
+ ).toEqual([]);
+ });
+
+ it('never blocks on planned / in-progress reviews (agenda packs)', () => {
+ expect(
+ reviewValidationMessages({
+ procedure: 'We review annually.',
+ reviews: [
+ {
+ ...READY_REVIEW,
+ status: 'planned',
+ hasMeetingDate: false,
+ hasChair: false,
+ attendeeCount: 0,
+ undiscussedInputCount: 10,
+ signed: false,
+ },
+ ],
+ }),
+ ).toEqual([]);
+ });
+
+ it('lists every unmet requirement of a complete review', () => {
+ const messages = reviewValidationMessages({
+ procedure: 'We review annually.',
+ reviews: [
+ {
+ ...READY_REVIEW,
+ hasMeetingDate: false,
+ hasChair: false,
+ attendeeCount: 0,
+ undiscussedInputCount: 3,
+ signed: false,
+ },
+ ],
+ });
+ expect(messages).toEqual([
+ 'Review MR-2026-01 is complete but has no meeting date.',
+ 'Review MR-2026-01 is complete but has no chair.',
+ 'Review MR-2026-01 is complete but has no attendees.',
+ 'Review MR-2026-01 has 3 inputs not yet marked as discussed.',
+ 'Review MR-2026-01 is complete but has not been signed by the chair.',
+ ]);
+ });
+
+ it('uses singular phrasing for one undiscussed input', () => {
+ expect(
+ reviewValidationMessages({
+ procedure: 'We review annually.',
+ reviews: [{ ...READY_REVIEW, undiscussedInputCount: 1 }],
+ }),
+ ).toEqual(['Review MR-2026-01 has 1 input not yet marked as discussed.']);
+ });
+});
+
+describe('seedReviewInputsIfMissing', () => {
+ const makeTx = (existing: Array<{ inputKey: string | null; position: number }>) => {
+ const tx = {
+ ismsReviewInput: {
+ findMany: jest.fn().mockResolvedValue(existing),
+ createMany: jest.fn().mockResolvedValue({ count: 0 }),
+ },
+ };
+ return tx;
+ };
+ const asTx = (tx: ReturnType) =>
+ tx as unknown as Prisma.TransactionClient;
+
+ it('seeds all ten rows for a fresh review', async () => {
+ const tx = makeTx([]);
+ await seedReviewInputsIfMissing({
+ tx: asTx(tx),
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ });
+
+ const { data, skipDuplicates } =
+ tx.ismsReviewInput.createMany.mock.calls[0][0];
+ expect(skipDuplicates).toBe(true);
+ expect(data).toHaveLength(SEED_REVIEW_INPUT_DEFINITIONS.length);
+ expect(data.map((row: { inputKey: string }) => row.inputKey)).toEqual(
+ SEED_REVIEW_INPUT_DEFINITIONS.map((input) => input.inputKey),
+ );
+ expect(data[0]).toMatchObject({
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ inputRef: '(a) Prior actions',
+ source: 'derived',
+ derivedFrom: 'seed:a_prior_actions',
+ position: 0,
+ });
+ });
+
+ it('only creates missing seed rows, appending after existing positions', async () => {
+ const tx = makeTx([
+ { inputKey: 'a_prior_actions', position: 0 },
+ { inputKey: null, position: 5 },
+ ]);
+ await seedReviewInputsIfMissing({
+ tx: asTx(tx),
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ });
+
+ const { data } = tx.ismsReviewInput.createMany.mock.calls[0][0];
+ expect(data).toHaveLength(SEED_REVIEW_INPUT_DEFINITIONS.length - 1);
+ expect(
+ data.some((row: { inputKey: string }) => row.inputKey === 'a_prior_actions'),
+ ).toBe(false);
+ expect(data[0].position).toBe(6);
+ });
+
+ it('does nothing when every seed row already exists', async () => {
+ const tx = makeTx(
+ SEED_REVIEW_INPUT_DEFINITIONS.map((input, index) => ({
+ inputKey: input.inputKey,
+ position: index,
+ })),
+ );
+ await seedReviewInputsIfMissing({
+ tx: asTx(tx),
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ });
+ expect(tx.ismsReviewInput.createMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/api/src/isms/documents/management-review.ts b/apps/api/src/isms/documents/management-review.ts
new file mode 100644
index 0000000000..047eefc61c
--- /dev/null
+++ b/apps/api/src/isms/documents/management-review.ts
@@ -0,0 +1,222 @@
+import { z } from 'zod';
+import type { Prisma } from '@db';
+import type { IsmsPlatformData } from './types';
+import {
+ defaultProcedureText,
+ SEED_REVIEW_INPUT_DEFINITIONS,
+} from './management-review-defaults';
+
+type Tx = Prisma.TransactionClient;
+
+/**
+ * Narrative shape persisted on the Management Review document (clause 9.3):
+ * the Procedure paragraph shown at the top of the page and rendered verbatim
+ * into the generated document. The reviews themselves live in their own
+ * registers.
+ */
+export const managementReviewNarrativeSchema = z.object({
+ procedure: z.string().trim().min(1),
+});
+
+export type ManagementReviewNarrative = z.infer<
+ typeof managementReviewNarrativeSchema
+>;
+
+/** Derive the default Procedure paragraph (hardcoded default, editable). */
+export function deriveManagementReviewNarrative(
+ data: IsmsPlatformData,
+): ManagementReviewNarrative {
+ return { procedure: defaultProcedureText(data.organizationName) };
+}
+
+/**
+ * An attendee frozen at selection: the member id plus the display name that
+ * was current when the customer picked them (the minutes must keep the names
+ * of who attended THIS review even if People changes later). Stored as the
+ * review row's `attendees` JSON array.
+ */
+export const reviewAttendeeSchema = z.object({
+ memberId: z.string().min(1),
+ name: z.string().trim().min(1),
+});
+
+export type ReviewAttendee = z.infer;
+
+export const reviewAttendeesSchema = z.array(reviewAttendeeSchema);
+
+/**
+ * Parse a stored attendees JSON value defensively (invalid → empty list,
+ * all-or-nothing like the write schema) and normalize away duplicate members
+ * a legacy row may still carry. The client mirror (parseAttendees in
+ * management-review-constants.ts) applies the same rules so the Submit UI
+ * and the server gate always agree.
+ */
+export function parseReviewAttendees(value: unknown): ReviewAttendee[] {
+ const parsed = reviewAttendeesSchema.safeParse(value);
+ return parsed.success ? dedupeReviewAttendees(parsed.data) : [];
+}
+
+/** A review is signed once the chair's name and date are both captured. */
+export function isReviewSigned(review: {
+ signoffChairName: string | null;
+ signoffChairDate: Date | string | null;
+}): boolean {
+ return Boolean(review.signoffChairName?.trim() && review.signoffChairDate);
+}
+
+/**
+ * Re-read a review's sign-off state INSIDE an open transaction that already
+ * holds the per-document advisory lock. Every mutation gated on "unsigned"
+ * must use this rather than a pre-transaction read: a concurrent chair
+ * sign-off (which takes the same lock) can commit between the pre-read and
+ * the lock acquisition, and only the locked read is authoritative (TOCTOU).
+ */
+export async function loadReviewLockState({
+ tx,
+ reviewId,
+}: {
+ tx: Tx;
+ reviewId: string;
+}): Promise<{ reference: string; signed: boolean }> {
+ const review = await tx.ismsManagementReview.findUniqueOrThrow({
+ where: { id: reviewId },
+ select: {
+ reference: true,
+ signoffChairName: true,
+ signoffChairDate: true,
+ },
+ });
+ return { reference: review.reference, signed: isReviewSigned(review) };
+}
+
+/**
+ * Normalize an attendee list before persisting: at most one entry per member
+ * (first occurrence wins), so generated minutes and attendee counts stay
+ * accurate even if a caller sends duplicates.
+ */
+export function dedupeReviewAttendees(
+ attendees: ReviewAttendee[],
+): ReviewAttendee[] {
+ const seen = new Set();
+ return attendees.filter((attendee) => {
+ if (seen.has(attendee.memberId)) return false;
+ seen.add(attendee.memberId);
+ return true;
+ });
+}
+
+/**
+ * Clause-9.3 completeness check, shared by the submit-for-approval server gate
+ * and the client Submit button (management-review-constants.ts mirrors it).
+ * Requires the Procedure paragraph, at least one review instance (an ISMS with
+ * no management review is the Stage-2 blocker this feature exists to remove),
+ * and — per the ticket's document validation — a meeting date, chair, at least
+ * one attendee, every input discussed, and the chair's signature on every
+ * COMPLETE review. Planned / in-progress reviews render as an agenda pack and
+ * never block. Returns unmet requirements; empty = ready.
+ */
+export function reviewValidationMessages({
+ procedure,
+ reviews,
+}: {
+ procedure: string | null;
+ reviews: Array<{
+ reference: string;
+ status: string;
+ hasMeetingDate: boolean;
+ hasChair: boolean;
+ attendeeCount: number;
+ undiscussedInputCount: number;
+ signed: boolean;
+ }>;
+}): string[] {
+ const messages: string[] = [];
+ if (!procedure?.trim()) {
+ messages.push('The review procedure paragraph must not be empty.');
+ }
+ if (reviews.length === 0) {
+ messages.push('At least one management review must be recorded.');
+ return messages;
+ }
+ for (const review of reviews) {
+ if (review.status !== 'complete') continue;
+ if (!review.hasMeetingDate) {
+ messages.push(
+ `Review ${review.reference} is complete but has no meeting date.`,
+ );
+ }
+ if (!review.hasChair) {
+ messages.push(`Review ${review.reference} is complete but has no chair.`);
+ }
+ if (review.attendeeCount === 0) {
+ messages.push(
+ `Review ${review.reference} is complete but has no attendees.`,
+ );
+ }
+ if (review.undiscussedInputCount > 0) {
+ const count = review.undiscussedInputCount;
+ messages.push(
+ `Review ${review.reference} has ${count} input${count === 1 ? '' : 's'} not yet marked as discussed.`,
+ );
+ }
+ if (!review.signed) {
+ messages.push(
+ `Review ${review.reference} is complete but has not been signed by the chair.`,
+ );
+ }
+ }
+ return messages;
+}
+
+/**
+ * Seed the ten default Inputs (9.3.2) rows for a review, idempotently by
+ * `inputKey`. Only creates seed rows that are missing — it NEVER deletes or
+ * overwrites, so re-running can never clobber the customer's edits, notes, or
+ * discussed ticks (same guarantee as seedAuditControlsIfMissing). Called when
+ * a review instance is created.
+ */
+export async function seedReviewInputsIfMissing({
+ tx,
+ reviewId,
+ documentId,
+}: {
+ tx: Tx;
+ reviewId: string;
+ documentId: string;
+}): Promise {
+ const existing = await tx.ismsReviewInput.findMany({
+ where: { reviewId },
+ select: { inputKey: true, position: true },
+ });
+ const existingKeys = new Set(
+ existing
+ .map((input) => input.inputKey)
+ .filter((key): key is string => !!key),
+ );
+ const missing = SEED_REVIEW_INPUT_DEFINITIONS.filter(
+ (input) => !existingKeys.has(input.inputKey),
+ );
+ if (missing.length === 0) return;
+
+ const maxPosition = existing.reduce(
+ (max, input) => Math.max(max, input.position),
+ -1,
+ );
+
+ await tx.ismsReviewInput.createMany({
+ data: missing.map((input, index) => ({
+ reviewId,
+ documentId,
+ inputKey: input.inputKey,
+ inputRef: input.inputRef,
+ whatItCovers: input.whatItCovers,
+ whereToFind: input.whereToFind,
+ source: 'derived' as const,
+ derivedFrom: `seed:${input.inputKey}`,
+ position: maxPosition + 1 + index,
+ })),
+ // Belt-and-braces with @@unique([reviewId, inputKey]): a concurrent
+ // create racing this seed is absorbed silently.
+ skipDuplicates: true,
+ });
+}
diff --git a/apps/api/src/isms/documents/registry.ts b/apps/api/src/isms/documents/registry.ts
index 4f9d1cb298..16932b2f5b 100644
--- a/apps/api/src/isms/documents/registry.ts
+++ b/apps/api/src/isms/documents/registry.ts
@@ -12,6 +12,11 @@ import {
deriveInternalAuditNarrative,
internalAuditNarrativeSchema,
} from './internal-audit';
+import {
+ deriveManagementReviewNarrative,
+ managementReviewNarrativeSchema,
+} from './management-review';
+import { buildManagementReviewSections } from './management-review-sections';
import {
buildScopeSections,
deriveScopeNarrative,
@@ -41,6 +46,7 @@ const EXPORT_SECTION_BUILDERS: Record<
roles_and_responsibilities: buildRolesSections,
monitoring: buildMonitoringSections,
internal_audit: buildInternalAuditSections,
+ management_review: buildManagementReviewSections,
isms_scope: buildScopeSections,
leadership_commitment: buildLeadershipSections,
};
@@ -57,9 +63,10 @@ export function buildExportSections({
/**
* Zod schema validating the narrative payload for each document type that
- * stores one. Covers the singleton documents plus the Internal Audit document,
- * whose narrative holds only the Programme paragraph (its audits live in their
- * own registers, so it is NOT a narrative type).
+ * stores one. Covers the singleton documents plus the Internal Audit and
+ * Management Review documents, whose narratives hold only the Programme /
+ * Procedure paragraph (their audits/reviews live in their own registers, so
+ * they are NOT narrative types).
*/
export function narrativeSchemaForType(
type: IsmsDocumentType,
@@ -67,6 +74,7 @@ export function narrativeSchemaForType(
if (type === 'isms_scope') return ismsScopeNarrativeSchema;
if (type === 'leadership_commitment') return leadershipNarrativeSchema;
if (type === 'internal_audit') return internalAuditNarrativeSchema;
+ if (type === 'management_review') return managementReviewNarrativeSchema;
return null;
}
@@ -81,6 +89,7 @@ export function deriveNarrativeForType({
if (type === 'isms_scope') return deriveScopeNarrative(data);
if (type === 'leadership_commitment') return deriveLeadershipNarrative(data);
if (type === 'internal_audit') return deriveInternalAuditNarrative(data);
+ if (type === 'management_review') return deriveManagementReviewNarrative(data);
return null;
}
diff --git a/apps/api/src/isms/documents/snapshot.ts b/apps/api/src/isms/documents/snapshot.ts
index 3bb06ebe61..161bc359e5 100644
--- a/apps/api/src/isms/documents/snapshot.ts
+++ b/apps/api/src/isms/documents/snapshot.ts
@@ -97,6 +97,10 @@ const TYPE_DRIFT_SOURCES: Record> = {
// Internal Audit (9.2) renders from its own audits register + the Programme
// narrative (customer-owned once seeded); it never goes platform-drift stale.
internal_audit: [],
+ // Management Review (9.3) renders from its own reviews register + the
+ // Procedure narrative (customer-owned once seeded); same as internal_audit,
+ // it never goes platform-drift stale.
+ management_review: [],
isms_scope: [
'frameworks',
'vendors',
diff --git a/apps/api/src/isms/documents/types.ts b/apps/api/src/isms/documents/types.ts
index 17c9ec653d..e1bfd2d880 100644
--- a/apps/api/src/isms/documents/types.ts
+++ b/apps/api/src/isms/documents/types.ts
@@ -204,6 +204,64 @@ export interface AuditExportRow {
signoffs: AuditSignoffExportRow[];
}
+/** The ten seeded Inputs (9.3.2) rows and their pre-filled text (9.3). */
+export interface SeedReviewInputDefinition {
+ inputKey: string;
+ inputRef: string;
+ whatItCovers: string;
+ whereToFind: string;
+}
+
+/** One Inputs (9.3.2) row, resolved for export (discussed humanized). */
+export interface ReviewInputExportRow {
+ inputRef: string;
+ whatItCovers: string;
+ whereToFind: string;
+ discussionNotes: string;
+ /** "Yes" once the input has been covered at the meeting, else "No". */
+ discussed: string;
+}
+
+/** One action arising, resolved for export (owner name frozen at build time). */
+export interface ReviewActionExportRow {
+ /** Full display reference, e.g. "MR-2026-01-A01". */
+ reference: string;
+ description: string;
+ ownerName: string;
+ dueDate: string;
+ /** Humanized status label ("Open"). */
+ status: string;
+}
+
+/** A review instance, resolved for export: fields + child tables + sign-off. */
+export interface ReviewExportRow {
+ reference: string;
+ meetingDate: string | null;
+ /** Server-set recording date ("Recorded on"), never editable. */
+ recordedOn: string;
+ chairName: string;
+ /** Attendee display names, frozen at selection. */
+ attendees: string[];
+ /** Humanized status label ("In progress"). */
+ status: string;
+ /** Assembled conclusion sentence, or null while no verdict is chosen. */
+ conclusion: string | null;
+ conclusionNotes: string | null;
+ decisionsText: string | null;
+ changesText: string | null;
+ /** Single sign-off slot: the chair signs (empty strings while unsigned). */
+ signoffChairName: string;
+ signoffChairDate: string;
+ inputs: ReviewInputExportRow[];
+ actions: ReviewActionExportRow[];
+ /**
+ * Open actions from previous reviews, carried forward automatically to this
+ * review's input (a). Computed from the earlier review instances at build
+ * time — never copied, so their status keeps tracking to closure.
+ */
+ carriedForward: ReviewActionExportRow[];
+}
+
/**
* The organization profile that fills the narrative parts of the Context of the
* Organization document (clause 4.1) — overview table, mission, intended
@@ -257,4 +315,6 @@ export interface DocumentExportInput {
metrics?: MetricExportRow[];
/** Audit instances with resolved names — only populated for the Internal Audit document (9.2). */
audits?: AuditExportRow[];
+ /** Review instances with resolved names — only populated for the Management Review document (9.3). */
+ reviews?: ReviewExportRow[];
}
diff --git a/apps/api/src/isms/isms-management-review.service.spec.ts b/apps/api/src/isms/isms-management-review.service.spec.ts
new file mode 100644
index 0000000000..459da8fdc7
--- /dev/null
+++ b/apps/api/src/isms/isms-management-review.service.spec.ts
@@ -0,0 +1,392 @@
+import { BadRequestException, NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import { IsmsManagementReviewService } from './isms-management-review.service';
+import {
+ DEFAULT_REVIEW_CHANGES_TEXT,
+ DEFAULT_REVIEW_DECISIONS_TEXT,
+} from './documents/management-review-defaults';
+
+jest.mock('@db', () => {
+ const db = {
+ ismsDocument: {
+ findFirst: jest.fn(),
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ ismsManagementReview: {
+ findFirst: jest.fn(),
+ findUniqueOrThrow: jest.fn(),
+ findMany: jest.fn(),
+ create: jest.fn(),
+ update: jest.fn(),
+ delete: jest.fn(),
+ },
+ ismsReviewInput: {
+ findMany: jest.fn(),
+ createMany: jest.fn(),
+ },
+ ismsRole: {
+ findMany: jest.fn(),
+ },
+ member: {
+ findMany: jest.fn(),
+ count: jest.fn(),
+ },
+ $executeRaw: jest.fn(),
+ $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)),
+ };
+ return { db };
+});
+
+const mockDb = jest.mocked(db);
+const currentYear = new Date().getUTCFullYear();
+
+const TOP_MGMT_ROLE = {
+ roleKey: 'top_management',
+ assignments: [{ memberId: 'mem_ceo' }],
+};
+const SPO_ROLE = { roleKey: 'spo', assignments: [{ memberId: 'mem_spo' }] };
+const MEMBERS = [
+ { id: 'mem_ceo', user: { name: 'Raoul Plickat', email: 'ceo@acme.io' } },
+ { id: 'mem_spo', user: { name: 'Alex Petrisor', email: 'spo@acme.io' } },
+];
+
+describe('IsmsManagementReviewService', () => {
+ let service: IsmsManagementReviewService;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({
+ status: 'draft',
+ });
+ (mockDb.ismsManagementReview.findMany as jest.Mock).mockResolvedValue([]);
+ (mockDb.ismsReviewInput.findMany as jest.Mock).mockResolvedValue([]);
+ (mockDb.ismsReviewInput.createMany as jest.Mock).mockResolvedValue({
+ count: 10,
+ });
+ (mockDb.ismsRole.findMany as jest.Mock).mockResolvedValue([
+ TOP_MGMT_ROLE,
+ SPO_ROLE,
+ ]);
+ (mockDb.member.findMany as jest.Mock).mockResolvedValue(MEMBERS);
+ (mockDb.member.count as jest.Mock).mockResolvedValue(0);
+ service = new IsmsManagementReviewService();
+ });
+
+ describe('create', () => {
+ const args = { documentId: 'doc_1', organizationId: 'org_1', dto: {} };
+
+ beforeEach(() => {
+ (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({
+ id: 'doc_1',
+ });
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ null,
+ );
+ (mockDb.ismsManagementReview.create as jest.Mock).mockResolvedValue({
+ id: 'mr_1',
+ });
+ });
+
+ it('throws NotFoundException when the management-review document is missing', async () => {
+ (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null);
+ await expect(service.create(args)).rejects.toThrow(NotFoundException);
+ expect(mockDb.ismsDocument.findFirst).toHaveBeenCalledWith({
+ where: {
+ id: 'doc_1',
+ organizationId: 'org_1',
+ type: 'management_review',
+ },
+ });
+ });
+
+ it('creates a review with template defaults, Roles-driven participants, and a generated reference', async () => {
+ await service.create(args);
+
+ expect(mockDb.ismsManagementReview.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ documentId: 'doc_1',
+ reference: `MR-${currentYear}-01`,
+ chairName: 'Raoul Plickat',
+ attendees: [
+ { memberId: 'mem_ceo', name: 'Raoul Plickat' },
+ { memberId: 'mem_spo', name: 'Alex Petrisor' },
+ ],
+ decisionsText: DEFAULT_REVIEW_DECISIONS_TEXT,
+ changesText: DEFAULT_REVIEW_CHANGES_TEXT,
+ position: 0,
+ }),
+ });
+ });
+
+ it('dedupes chair + SPO into one attendee when the same person holds both roles', async () => {
+ (mockDb.ismsRole.findMany as jest.Mock).mockResolvedValue([
+ TOP_MGMT_ROLE,
+ { roleKey: 'spo', assignments: [{ memberId: 'mem_ceo' }] },
+ ]);
+
+ await service.create(args);
+
+ const { data } = (mockDb.ismsManagementReview.create as jest.Mock).mock
+ .calls[0][0];
+ expect(data.attendees).toEqual([
+ { memberId: 'mem_ceo', name: 'Raoul Plickat' },
+ ]);
+ });
+
+ it('leaves chair and attendees empty when Roles has no holders yet', async () => {
+ (mockDb.ismsRole.findMany as jest.Mock).mockResolvedValue([]);
+
+ await service.create(args);
+
+ const { data } = (mockDb.ismsManagementReview.create as jest.Mock).mock
+ .calls[0][0];
+ expect(data.chairName).toBeNull();
+ expect(data.attendees).toEqual([]);
+ });
+
+ it('prefers explicit dto values over the Roles defaults', async () => {
+ (mockDb.member.count as jest.Mock).mockResolvedValue(1);
+
+ await service.create({
+ ...args,
+ dto: {
+ chairName: 'External Chair',
+ attendees: [{ memberId: 'mem_spo', name: 'Alex Petrisor' }],
+ },
+ });
+
+ const { data } = (mockDb.ismsManagementReview.create as jest.Mock).mock
+ .calls[0][0];
+ expect(data.chairName).toBe('External Chair');
+ expect(data.attendees).toEqual([
+ { memberId: 'mem_spo', name: 'Alex Petrisor' },
+ ]);
+ });
+
+ it('rejects attendees who are not members of the organization', async () => {
+ (mockDb.member.count as jest.Mock).mockResolvedValue(0);
+
+ await expect(
+ service.create({
+ ...args,
+ dto: { attendees: [{ memberId: 'mem_other_org', name: 'Mallory' }] },
+ }),
+ ).rejects.toThrow(NotFoundException);
+ expect(mockDb.ismsManagementReview.create).not.toHaveBeenCalled();
+ });
+
+ it('seeds the ten default Inputs rows in the same transaction', async () => {
+ await service.create(args);
+
+ const { data } = (mockDb.ismsReviewInput.createMany as jest.Mock).mock
+ .calls[0][0];
+ expect(data).toHaveLength(10);
+ expect(data[0]).toMatchObject({
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ inputKey: 'a_prior_actions',
+ });
+ });
+
+ it('continues the per-year sequence from the highest existing reference', async () => {
+ (mockDb.ismsManagementReview.findMany as jest.Mock).mockResolvedValue([
+ { reference: `MR-${currentYear}-01` },
+ { reference: `MR-${currentYear}-03` },
+ ]);
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue({
+ position: 2,
+ });
+
+ await service.create(args);
+
+ expect(mockDb.ismsManagementReview.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ reference: `MR-${currentYear}-04`,
+ position: 3,
+ }),
+ });
+ });
+ });
+
+ describe('update', () => {
+ const unsignedReview = {
+ id: 'mr_1',
+ documentId: 'doc_1',
+ reference: `MR-${currentYear}-01`,
+ signoffChairName: null,
+ signoffChairDate: null,
+ };
+ const signedReview = {
+ ...unsignedReview,
+ signoffChairName: 'Raoul Plickat',
+ signoffChairDate: new Date('2026-05-01T00:00:00.000Z'),
+ };
+
+ beforeEach(() => {
+ (mockDb.ismsManagementReview.update as jest.Mock).mockResolvedValue({});
+ // The signed-state re-read UNDER the document lock (TOCTOU guard).
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(unsignedReview);
+ });
+
+ it('updates fields on an unsigned review, leaving omitted fields untouched', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ unsignedReview,
+ );
+
+ await service.update({
+ reviewId: 'mr_1',
+ organizationId: 'org_1',
+ dto: { status: 'complete', conclusionVerdict: 'effective' },
+ });
+
+ const { data } = (mockDb.ismsManagementReview.update as jest.Mock).mock
+ .calls[0][0];
+ expect(data.status).toBe('complete');
+ expect(data.conclusionVerdict).toBe('effective');
+ expect(data.chairName).toBeUndefined();
+ expect(data.attendees).toBeUndefined();
+ expect(data.decisionsText).toBeUndefined();
+ });
+
+ it('rejects edits to a signed review (locked minutes)', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ signedReview,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(signedReview);
+
+ await expect(
+ service.update({
+ reviewId: 'mr_1',
+ organizationId: 'org_1',
+ dto: { conclusionNotes: 'rewriting history' },
+ }),
+ ).rejects.toThrow(BadRequestException);
+ expect(mockDb.ismsManagementReview.update).not.toHaveBeenCalled();
+ });
+
+ it('rejects an edit racing a concurrent sign-off (locked re-read wins over the pre-read)', async () => {
+ // Pre-transaction read saw an unsigned review...
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ unsignedReview,
+ );
+ // ...but by the time the advisory lock is held, the chair has signed.
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(signedReview);
+
+ await expect(
+ service.update({
+ reviewId: 'mr_1',
+ organizationId: 'org_1',
+ dto: { conclusionNotes: 'rewriting history' },
+ }),
+ ).rejects.toThrow(BadRequestException);
+ expect(mockDb.ismsManagementReview.update).not.toHaveBeenCalled();
+ });
+
+ it('still allows correcting or clearing the sign-off slot itself when signed', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ signedReview,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(signedReview);
+
+ await service.update({
+ reviewId: 'mr_1',
+ organizationId: 'org_1',
+ dto: { signoffChairName: null, signoffChairDate: null },
+ });
+
+ const { data } = (mockDb.ismsManagementReview.update as jest.Mock).mock
+ .calls[0][0];
+ expect(data.signoffChairName).toBeNull();
+ expect(data.signoffChairDate).toBeNull();
+ });
+
+ it('validates replacement attendees against the organization', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ unsignedReview,
+ );
+ (mockDb.member.count as jest.Mock).mockResolvedValue(0);
+
+ await expect(
+ service.update({
+ reviewId: 'mr_1',
+ organizationId: 'org_1',
+ dto: { attendees: [{ memberId: 'mem_other_org', name: 'Mallory' }] },
+ }),
+ ).rejects.toThrow(NotFoundException);
+ expect(mockDb.ismsManagementReview.update).not.toHaveBeenCalled();
+ });
+
+ it('throws NotFoundException for a review outside the organization', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ null,
+ );
+ await expect(
+ service.update({ reviewId: 'mr_x', organizationId: 'org_1', dto: {} }),
+ ).rejects.toThrow(NotFoundException);
+ expect(mockDb.ismsManagementReview.findFirst).toHaveBeenCalledWith({
+ where: {
+ id: 'mr_x',
+ document: { organizationId: 'org_1', type: 'management_review' },
+ },
+ });
+ });
+ });
+
+ describe('remove', () => {
+ const storedReview = {
+ id: 'mr_1',
+ documentId: 'doc_1',
+ reference: `MR-${currentYear}-01`,
+ signoffChairName: null,
+ signoffChairDate: null,
+ };
+
+ it('deletes an unsigned review (cascading to inputs and actions)', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ storedReview,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(storedReview);
+ (mockDb.ismsManagementReview.delete as jest.Mock).mockResolvedValue({});
+
+ const result = await service.remove({
+ reviewId: 'mr_1',
+ organizationId: 'org_1',
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(mockDb.ismsManagementReview.delete).toHaveBeenCalledWith({
+ where: { id: 'mr_1' },
+ });
+ });
+
+ it('refuses to delete a signed review (locked re-read wins over the pre-read)', async () => {
+ // Pre-read saw unsigned; the locked re-read sees a committed sign-off.
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ storedReview,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue({
+ ...storedReview,
+ signoffChairName: 'Raoul Plickat',
+ signoffChairDate: new Date('2026-05-01T00:00:00.000Z'),
+ });
+
+ await expect(
+ service.remove({ reviewId: 'mr_1', organizationId: 'org_1' }),
+ ).rejects.toThrow(BadRequestException);
+ expect(mockDb.ismsManagementReview.delete).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/api/src/isms/isms-management-review.service.ts b/apps/api/src/isms/isms-management-review.service.ts
new file mode 100644
index 0000000000..af4dbb755d
--- /dev/null
+++ b/apps/api/src/isms/isms-management-review.service.ts
@@ -0,0 +1,298 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { db } from '@db';
+import type { Prisma } from '@db';
+import {
+ dedupeReviewAttendees,
+ loadReviewLockState,
+ seedReviewInputsIfMissing,
+ type ReviewAttendee,
+} from './documents/management-review';
+import {
+ DEFAULT_REVIEW_CHANGES_TEXT,
+ DEFAULT_REVIEW_DECISIONS_TEXT,
+} from './documents/management-review-defaults';
+import { invalidateApprovalIfNeeded } from './utils/approval';
+import { lockDocument } from './utils/document-lock';
+import { parseOptionalDate } from './utils/parse-optional-date';
+import {
+ resolveReviewParticipantDefaults,
+ validateReviewAttendees,
+} from './utils/review-participants';
+import type {
+ CreateReviewInput,
+ UpdateReviewInput,
+} from './registers/register-registry';
+
+/** The only fields editable once a review is signed: the sign-off slot itself
+ * (so a mistaken signature can be corrected or cleared — clearing unlocks). */
+const SIGNED_REVIEW_EDITABLE_FIELDS = new Set([
+ 'signoffChairName',
+ 'signoffChairDate',
+]);
+
+/**
+ * CRUD for the Management Review instances register (clause 9.3, CS-726).
+ * Every review is customer-created; the reference is server-generated
+ * ("MR-YYYY-NN"), the chair and attendees default to the Top Management and
+ * SPO holders from ISMS > Roles (5.3), the outputs ship with template text,
+ * and the ten default Inputs (9.3.2) rows are seeded in the same transaction.
+ * `recordedAt` is server-set and immutable (the honest-backdating guardrail).
+ * Once signed by the chair, a review is locked except its own sign-off slot;
+ * deleting a review cascades to its input rows and actions.
+ */
+@Injectable()
+export class IsmsManagementReviewService {
+ async create({
+ documentId,
+ organizationId,
+ dto,
+ }: {
+ documentId: string;
+ organizationId: string;
+ dto: CreateReviewInput;
+ }) {
+ await this.requireDocument({ documentId, organizationId });
+ const meetingDate = parseOptionalDate(dto.meetingDate);
+ if (dto.attendees) {
+ await validateReviewAttendees({
+ attendees: dto.attendees,
+ organizationId,
+ });
+ }
+
+ return db.$transaction(async (tx) => {
+ // The lock also serializes reference generation, so concurrent creates
+ // can never both compute the same next "MR-YYYY-NN".
+ await lockDocument(tx, documentId);
+ const position =
+ dto.position ?? (await this.nextPosition({ tx, documentId }));
+ await invalidateApprovalIfNeeded({ tx, documentId });
+ // Ticket defaults: chair = the Roles (5.3) Top Management holder,
+ // attendees = Chair + SPO (for a 1-3 person org these may be the same
+ // person — the dedupe accepts that). Explicit dto values win.
+ const defaults = await resolveReviewParticipantDefaults({
+ tx,
+ organizationId,
+ });
+ const attendees: ReviewAttendee[] = dedupeReviewAttendees(
+ dto.attendees ?? defaults.attendees,
+ );
+ const review = await tx.ismsManagementReview.create({
+ data: {
+ documentId,
+ reference: await this.nextReference({ tx, documentId }),
+ meetingDate: meetingDate ?? null,
+ chairName: dto.chairName?.trim() || defaults.chairName,
+ attendees: attendees.map((attendee) => ({ ...attendee })),
+ decisionsText: DEFAULT_REVIEW_DECISIONS_TEXT,
+ changesText: DEFAULT_REVIEW_CHANGES_TEXT,
+ position,
+ },
+ });
+ // The default agenda the customer sees when they first open the review.
+ await seedReviewInputsIfMissing({ tx, reviewId: review.id, documentId });
+ return review;
+ });
+ }
+
+ async update({
+ reviewId,
+ organizationId,
+ dto,
+ }: {
+ reviewId: string;
+ organizationId: string;
+ dto: UpdateReviewInput;
+ }) {
+ const review = await this.requireReview({ reviewId, organizationId });
+ if (dto.attendees) {
+ await validateReviewAttendees({
+ attendees: dto.attendees,
+ organizationId,
+ });
+ }
+ const attendees =
+ dto.attendees === undefined
+ ? undefined
+ : dedupeReviewAttendees(dto.attendees);
+
+ return db.$transaction(async (tx) => {
+ // Same per-document lock submit/approve take, so an edit can't race the
+ // submission's completeness check.
+ await lockDocument(tx, review.documentId);
+ // Signed state re-read UNDER the lock: a concurrent sign-off can commit
+ // after the pre-read, and the locked minutes must win (TOCTOU).
+ const lock = await loadReviewLockState({ tx, reviewId });
+ this.assertEditableWhileSigned({ lock, dto });
+ await invalidateApprovalIfNeeded({ tx, documentId: review.documentId });
+ return tx.ismsManagementReview.update({
+ where: { id: reviewId },
+ data: {
+ meetingDate: parseOptionalDate(dto.meetingDate),
+ chairName: this.optionalText(dto.chairName),
+ attendees:
+ attendees === undefined
+ ? undefined
+ : attendees.map((attendee) => ({ ...attendee })),
+ status: dto.status ?? undefined,
+ conclusionVerdict:
+ dto.conclusionVerdict === undefined
+ ? undefined
+ : dto.conclusionVerdict,
+ conclusionNotes: this.optionalText(dto.conclusionNotes),
+ decisionsText: this.optionalText(dto.decisionsText),
+ changesText: this.optionalText(dto.changesText),
+ signoffChairName: this.optionalText(dto.signoffChairName),
+ signoffChairDate: parseOptionalDate(dto.signoffChairDate),
+ position: dto.position ?? undefined,
+ },
+ });
+ });
+ }
+
+ async remove({
+ reviewId,
+ organizationId,
+ }: {
+ reviewId: string;
+ organizationId: string;
+ }) {
+ const review = await this.requireReview({ reviewId, organizationId });
+ await db.$transaction(async (tx) => {
+ await lockDocument(tx, review.documentId);
+ // Signed state re-read UNDER the lock (see update).
+ const lock = await loadReviewLockState({ tx, reviewId });
+ if (lock.signed) {
+ throw new BadRequestException(
+ `Review ${lock.reference} is signed and locked and cannot be deleted. Clear the chair sign-off first.`,
+ );
+ }
+ await invalidateApprovalIfNeeded({ tx, documentId: review.documentId });
+ // Cascades to the review's input rows and actions.
+ await tx.ismsManagementReview.delete({ where: { id: reviewId } });
+ });
+ return { success: true };
+ }
+
+ /**
+ * A signed review is locked: the minutes are the chair-approved record.
+ * Only its own sign-off slot stays editable, so a signature can be
+ * corrected or cleared (clearing unlocks the review). Action TRACKING is
+ * deliberately not gated here — see IsmsReviewActionService.
+ */
+ private assertEditableWhileSigned({
+ lock,
+ dto,
+ }: {
+ lock: { reference: string; signed: boolean };
+ dto: UpdateReviewInput;
+ }): void {
+ if (!lock.signed) return;
+ const blocked = Object.entries(dto).some(
+ ([key, value]) =>
+ value !== undefined && !SIGNED_REVIEW_EDITABLE_FIELDS.has(key),
+ );
+ if (blocked) {
+ throw new BadRequestException(
+ `Review ${lock.reference} is signed and locked. Clear the chair sign-off to edit it.`,
+ );
+ }
+ }
+
+ /** Three-state text field: undefined = leave, null/empty = clear. */
+ private optionalText(
+ value: string | null | undefined,
+ ): string | null | undefined {
+ if (value === undefined) return undefined;
+ return value?.trim() || null;
+ }
+
+ /**
+ * Next "MR-YYYY-NN" for the document: the year of creation plus a two-digit
+ * per-document, per-year sequence. Max + 1 over the surviving rows, so
+ * deleting an older review never reissues its number (deleting the newest
+ * review frees its number — acceptable, since reviews are freely deletable
+ * drafts until signed/published). Runs under the per-document lock.
+ */
+ private async nextReference({
+ tx,
+ documentId,
+ }: {
+ tx: Prisma.TransactionClient;
+ documentId: string;
+ }): Promise {
+ const year = new Date().getUTCFullYear();
+ const prefix = `MR-${year}-`;
+ const existing = await tx.ismsManagementReview.findMany({
+ where: { documentId, reference: { startsWith: prefix } },
+ select: { reference: true },
+ });
+ const maxSequence = existing.reduce((max, review) => {
+ const sequence = Number.parseInt(
+ review.reference.slice(prefix.length),
+ 10,
+ );
+ return Number.isNaN(sequence) ? max : Math.max(max, sequence);
+ }, 0);
+ return `${prefix}${String(maxSequence + 1).padStart(2, '0')}`;
+ }
+
+ private async nextPosition({
+ tx,
+ documentId,
+ }: {
+ tx: Prisma.TransactionClient;
+ documentId: string;
+ }) {
+ const last = await tx.ismsManagementReview.findFirst({
+ where: { documentId },
+ orderBy: { position: 'desc' },
+ select: { position: true },
+ });
+ return (last?.position ?? -1) + 1;
+ }
+
+ private async requireDocument({
+ documentId,
+ organizationId,
+ }: {
+ documentId: string;
+ organizationId: string;
+ }) {
+ // Scope to the Management Review document type: review rows must never
+ // attach to another ISMS document (they'd be invisible to the Clause 9.3
+ // export).
+ const document = await db.ismsDocument.findFirst({
+ where: { id: documentId, organizationId, type: 'management_review' },
+ });
+ if (!document) {
+ throw new NotFoundException('ISMS management review document not found');
+ }
+ return document;
+ }
+
+ private async requireReview({
+ reviewId,
+ organizationId,
+ }: {
+ reviewId: string;
+ organizationId: string;
+ }) {
+ // Type-scoped like create: a review row can only ever live on THIS org's
+ // Management Review document (belt-and-braces with the creation guard).
+ const review = await db.ismsManagementReview.findFirst({
+ where: {
+ id: reviewId,
+ document: { organizationId, type: 'management_review' },
+ },
+ });
+ if (!review) {
+ throw new NotFoundException('Review not found');
+ }
+ return review;
+ }
+}
diff --git a/apps/api/src/isms/isms-registers.controller.spec.ts b/apps/api/src/isms/isms-registers.controller.spec.ts
index 588f5e0a2e..3873c5a24b 100644
--- a/apps/api/src/isms/isms-registers.controller.spec.ts
+++ b/apps/api/src/isms/isms-registers.controller.spec.ts
@@ -19,6 +19,9 @@ import { IsmsMeasurementService } from './isms-measurement.service';
import { IsmsAuditService } from './isms-audit.service';
import { IsmsAuditControlService } from './isms-audit-control.service';
import { IsmsAuditFindingService } from './isms-audit-finding.service';
+import { IsmsManagementReviewService } from './isms-management-review.service';
+import { IsmsReviewInputService } from './isms-review-input.service';
+import { IsmsReviewActionService } from './isms-review-action.service';
import { IsmsNarrativeService } from './isms-narrative.service';
jest.mock('../auth/auth.server', () => ({
@@ -71,6 +74,15 @@ jest.mock('./isms-audit-control.service', () => ({
jest.mock('./isms-audit-finding.service', () => ({
IsmsAuditFindingService: class {},
}));
+jest.mock('./isms-management-review.service', () => ({
+ IsmsManagementReviewService: class {},
+}));
+jest.mock('./isms-review-input.service', () => ({
+ IsmsReviewInputService: class {},
+}));
+jest.mock('./isms-review-action.service', () => ({
+ IsmsReviewActionService: class {},
+}));
jest.mock('./isms-narrative.service', () => ({
IsmsNarrativeService: class {},
}));
@@ -137,6 +149,21 @@ describe('IsmsRegistersController', () => {
update: jest.fn(),
remove: jest.fn(),
};
+ const reviewService = {
+ create: jest.fn(),
+ update: jest.fn(),
+ remove: jest.fn(),
+ };
+ const reviewInputService = {
+ create: jest.fn(),
+ update: jest.fn(),
+ remove: jest.fn(),
+ };
+ const reviewActionService = {
+ create: jest.fn(),
+ update: jest.fn(),
+ remove: jest.fn(),
+ };
const narrativeService = { save: jest.fn() };
const mockActingUser = { resolve: jest.fn() };
@@ -160,6 +187,9 @@ describe('IsmsRegistersController', () => {
{ provide: IsmsAuditService, useValue: auditService },
{ provide: IsmsAuditControlService, useValue: auditControlService },
{ provide: IsmsAuditFindingService, useValue: auditFindingService },
+ { provide: IsmsManagementReviewService, useValue: reviewService },
+ { provide: IsmsReviewInputService, useValue: reviewInputService },
+ { provide: IsmsReviewActionService, useValue: reviewActionService },
{ provide: IsmsNarrativeService, useValue: narrativeService },
{ provide: ActingUserResolver, useValue: mockActingUser },
],
diff --git a/apps/api/src/isms/isms-registers.controller.ts b/apps/api/src/isms/isms-registers.controller.ts
index 451dfc3daf..15ea595666 100644
--- a/apps/api/src/isms/isms-registers.controller.ts
+++ b/apps/api/src/isms/isms-registers.controller.ts
@@ -36,6 +36,9 @@ import { IsmsMeasurementService } from './isms-measurement.service';
import { IsmsAuditService } from './isms-audit.service';
import { IsmsAuditControlService } from './isms-audit-control.service';
import { IsmsAuditFindingService } from './isms-audit-finding.service';
+import { IsmsManagementReviewService } from './isms-management-review.service';
+import { IsmsReviewInputService } from './isms-review-input.service';
+import { IsmsReviewActionService } from './isms-review-action.service';
import { IsmsNarrativeService } from './isms-narrative.service';
import {
createRegisterRegistry,
@@ -136,9 +139,18 @@ const REGISTER_ROW_BODY = {
auditorName: { type: 'string', nullable: true },
plannedStartDate: { type: 'string', nullable: true },
plannedEndDate: { type: 'string', nullable: true },
+ // Per-register verdict: audits use the first three values, reviews the
+ // last three.
conclusionVerdict: {
type: 'string',
- enum: ['conform', 'substantially_conform', 'not_yet_conform'],
+ enum: [
+ 'conform',
+ 'substantially_conform',
+ 'not_yet_conform',
+ 'suitable',
+ 'adequate',
+ 'effective',
+ ],
nullable: true,
},
conclusionNotes: { type: 'string', nullable: true },
@@ -171,6 +183,32 @@ const REGISTER_ROW_BODY = {
clauseOrControl: { type: 'string', nullable: true },
dueDate: { type: 'string', nullable: true },
closureEvidence: { type: 'string', nullable: true },
+ // Management review register (9.3): reviews + review-inputs + review-actions
+ meetingDate: { type: 'string', nullable: true },
+ chairName: { type: 'string', nullable: true },
+ attendees: {
+ type: 'array',
+ description:
+ 'Attendees frozen at selection (members picked from People)',
+ items: {
+ type: 'object',
+ properties: {
+ memberId: { type: 'string' },
+ name: { type: 'string' },
+ },
+ required: ['memberId', 'name'],
+ },
+ },
+ decisionsText: { type: 'string', nullable: true },
+ changesText: { type: 'string', nullable: true },
+ signoffChairName: { type: 'string', nullable: true },
+ signoffChairDate: { type: 'string', nullable: true },
+ reviewId: { type: 'string' },
+ inputRef: { type: 'string' },
+ // whereToFind is shared with the audit-controls register above.
+ whatItCovers: { type: 'string' },
+ discussionNotes: { type: 'string', nullable: true },
+ discussed: { type: 'boolean' },
position: { type: 'integer', minimum: 0 },
},
},
@@ -242,6 +280,9 @@ export class IsmsRegistersController {
auditService: IsmsAuditService,
auditControlService: IsmsAuditControlService,
auditFindingService: IsmsAuditFindingService,
+ reviewService: IsmsManagementReviewService,
+ reviewInputService: IsmsReviewInputService,
+ reviewActionService: IsmsReviewActionService,
private readonly measurementService: IsmsMeasurementService,
private readonly narrativeService: IsmsNarrativeService,
private readonly actingUser: ActingUserResolver,
@@ -258,6 +299,9 @@ export class IsmsRegistersController {
audits: auditService,
auditControls: auditControlService,
auditFindings: auditFindingService,
+ reviews: reviewService,
+ reviewInputs: reviewInputService,
+ reviewActions: reviewActionService,
});
}
diff --git a/apps/api/src/isms/isms-review-action.service.spec.ts b/apps/api/src/isms/isms-review-action.service.spec.ts
new file mode 100644
index 0000000000..d61e4a11fb
--- /dev/null
+++ b/apps/api/src/isms/isms-review-action.service.spec.ts
@@ -0,0 +1,232 @@
+import { BadRequestException, NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import { IsmsReviewActionService } from './isms-review-action.service';
+
+jest.mock('@db', () => {
+ const db = {
+ ismsDocument: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ ismsManagementReview: {
+ findFirst: jest.fn(),
+ findUniqueOrThrow: jest.fn(),
+ },
+ ismsReviewAction: {
+ findFirst: jest.fn(),
+ findMany: jest.fn(),
+ create: jest.fn(),
+ update: jest.fn(),
+ delete: jest.fn(),
+ },
+ member: {
+ findFirst: jest.fn(),
+ },
+ $executeRaw: jest.fn(),
+ $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)),
+ };
+ return { db };
+});
+
+const mockDb = jest.mocked(db);
+
+const UNSIGNED_REVIEW = {
+ id: 'mr_1',
+ documentId: 'doc_1',
+ reference: 'MR-2026-01',
+ signoffChairName: null,
+ signoffChairDate: null,
+};
+const SIGNED_REVIEW = {
+ ...UNSIGNED_REVIEW,
+ signoffChairName: 'Raoul Plickat',
+ signoffChairDate: new Date('2026-05-01T00:00:00.000Z'),
+};
+
+describe('IsmsReviewActionService', () => {
+ let service: IsmsReviewActionService;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({
+ status: 'draft',
+ });
+ (mockDb.ismsReviewAction.findMany as jest.Mock).mockResolvedValue([]);
+ (mockDb.ismsReviewAction.findFirst as jest.Mock).mockResolvedValue(null);
+ // The signed-state re-read UNDER the document lock (TOCTOU guard).
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(UNSIGNED_REVIEW);
+ service = new IsmsReviewActionService();
+ });
+
+ describe('create', () => {
+ const args = {
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ dto: { reviewId: 'mr_1', description: 'Backfill overdue metrics.' },
+ };
+
+ it('creates an action with a generated per-review reference', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ UNSIGNED_REVIEW,
+ );
+ (mockDb.ismsReviewAction.create as jest.Mock).mockResolvedValue({
+ id: 'mra_1',
+ });
+
+ await service.create(args);
+
+ expect(mockDb.ismsReviewAction.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ reference: 'A01',
+ description: 'Backfill overdue metrics.',
+ status: 'open',
+ position: 0,
+ }),
+ });
+ });
+
+ it('continues the sequence from the highest surviving reference', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ UNSIGNED_REVIEW,
+ );
+ (mockDb.ismsReviewAction.findMany as jest.Mock).mockResolvedValue([
+ { reference: 'A01' },
+ { reference: 'A04' },
+ ]);
+ (mockDb.ismsReviewAction.findFirst as jest.Mock).mockResolvedValue({
+ position: 3,
+ });
+ (mockDb.ismsReviewAction.create as jest.Mock).mockResolvedValue({
+ id: 'mra_5',
+ });
+
+ await service.create(args);
+
+ expect(mockDb.ismsReviewAction.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({ reference: 'A05', position: 4 }),
+ });
+ });
+
+ it('rejects new actions when the locked re-read sees a signed review (racing sign-off)', async () => {
+ // Pre-read saw unsigned; the in-transaction re-read is authoritative.
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ UNSIGNED_REVIEW,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(SIGNED_REVIEW);
+ await expect(service.create(args)).rejects.toThrow(BadRequestException);
+ expect(mockDb.ismsReviewAction.create).not.toHaveBeenCalled();
+ });
+
+ it('requires the owner to be an active member of the organization', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ UNSIGNED_REVIEW,
+ );
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
+
+ await expect(
+ service.create({
+ ...args,
+ dto: { ...args.dto, ownerMemberId: 'mem_gone' },
+ }),
+ ).rejects.toThrow(NotFoundException);
+ expect(mockDb.ismsReviewAction.create).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('update / remove', () => {
+ const storedAction = {
+ id: 'mra_1',
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ };
+
+ it('still updates the tracking trio on a SIGNED review — actions track to closure', async () => {
+ (mockDb.ismsReviewAction.findFirst as jest.Mock).mockResolvedValue(
+ storedAction,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(SIGNED_REVIEW);
+ (mockDb.ismsReviewAction.update as jest.Mock).mockResolvedValue({});
+
+ await service.update({
+ actionId: 'mra_1',
+ organizationId: 'org_1',
+ dto: { status: 'closed' },
+ });
+
+ const { data } = (mockDb.ismsReviewAction.update as jest.Mock).mock
+ .calls[0][0];
+ expect(data.status).toBe('closed');
+ expect(data.description).toBeUndefined();
+ });
+
+ it('rejects a description change on a signed review — the minutes are frozen', async () => {
+ (mockDb.ismsReviewAction.findFirst as jest.Mock).mockResolvedValue(
+ storedAction,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(SIGNED_REVIEW);
+
+ await expect(
+ service.update({
+ actionId: 'mra_1',
+ organizationId: 'org_1',
+ dto: { description: 'rewriting what was agreed' },
+ }),
+ ).rejects.toThrow(BadRequestException);
+ expect(mockDb.ismsReviewAction.update).not.toHaveBeenCalled();
+ });
+
+ it('updates the description on an unsigned review', async () => {
+ (mockDb.ismsReviewAction.findFirst as jest.Mock).mockResolvedValue(
+ storedAction,
+ );
+ (mockDb.ismsReviewAction.update as jest.Mock).mockResolvedValue({});
+
+ await service.update({
+ actionId: 'mra_1',
+ organizationId: 'org_1',
+ dto: { description: 'Sharper wording.' },
+ });
+
+ const { data } = (mockDb.ismsReviewAction.update as jest.Mock).mock
+ .calls[0][0];
+ expect(data.description).toBe('Sharper wording.');
+ });
+
+ it('refuses to delete an action from a signed review', async () => {
+ (mockDb.ismsReviewAction.findFirst as jest.Mock).mockResolvedValue(
+ storedAction,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(SIGNED_REVIEW);
+
+ await expect(
+ service.remove({ actionId: 'mra_1', organizationId: 'org_1' }),
+ ).rejects.toThrow(BadRequestException);
+ expect(mockDb.ismsReviewAction.delete).not.toHaveBeenCalled();
+ });
+
+ it('deletes an action from an unsigned review', async () => {
+ (mockDb.ismsReviewAction.findFirst as jest.Mock).mockResolvedValue(
+ storedAction,
+ );
+ (mockDb.ismsReviewAction.delete as jest.Mock).mockResolvedValue({});
+
+ const result = await service.remove({
+ actionId: 'mra_1',
+ organizationId: 'org_1',
+ });
+ expect(result).toEqual({ success: true });
+ });
+ });
+});
diff --git a/apps/api/src/isms/isms-review-action.service.ts b/apps/api/src/isms/isms-review-action.service.ts
new file mode 100644
index 0000000000..586ef70d7b
--- /dev/null
+++ b/apps/api/src/isms/isms-review-action.service.ts
@@ -0,0 +1,258 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { db } from '@db';
+import type { Prisma } from '@db';
+import { loadReviewLockState } from './documents/management-review';
+import { invalidateApprovalIfNeeded } from './utils/approval';
+import { lockDocument } from './utils/document-lock';
+import { parseOptionalDate } from './utils/parse-optional-date';
+import type {
+ CreateReviewActionInput,
+ UpdateReviewActionInput,
+} from './registers/register-registry';
+
+/** The only action fields editable once its review is signed: the tracking
+ * trio. The description (and ordering) are part of the signed minutes. */
+const SIGNED_ACTION_EDITABLE_FIELDS = new Set([
+ 'ownerMemberId',
+ 'dueDate',
+ 'status',
+]);
+
+/**
+ * CRUD for a review's Actions arising (9.3.3, CS-726). The reference is
+ * server-generated ("A01", per-review sequence — displayed as
+ * "MR-YYYY-NN-A01") and immutable. Open actions carry forward automatically
+ * to the next review's input (a), computed at display/export time. Signing a
+ * review freezes WHAT was agreed (no create/delete, description immutable),
+ * but the tracking fields — owner, due date, status — stay live to closure:
+ * the ticket's "actions still track to closure after that".
+ */
+@Injectable()
+export class IsmsReviewActionService {
+ async create({
+ documentId,
+ organizationId,
+ dto,
+ }: {
+ documentId: string;
+ organizationId: string;
+ dto: CreateReviewActionInput;
+ }) {
+ const review = await this.requireReview({
+ reviewId: dto.reviewId,
+ documentId,
+ organizationId,
+ });
+ const ownerMemberId = await this.resolveMember({
+ memberId: dto.ownerMemberId,
+ organizationId,
+ });
+ const dueDate = parseOptionalDate(dto.dueDate);
+
+ return db.$transaction(async (tx) => {
+ // The lock also serializes reference generation, so concurrent creates
+ // can never both compute the same next "A-NN".
+ await lockDocument(tx, review.documentId);
+ // Signed state re-read UNDER the lock: a concurrent sign-off can commit
+ // after the pre-read, and the locked minutes must win (TOCTOU).
+ const lock = await loadReviewLockState({ tx, reviewId: review.id });
+ if (lock.signed) {
+ throw new BadRequestException(
+ `Review ${lock.reference} is signed and locked. Clear the chair sign-off to add actions.`,
+ );
+ }
+ const position =
+ dto.position ?? (await this.nextPosition({ tx, reviewId: review.id }));
+ await invalidateApprovalIfNeeded({ tx, documentId: review.documentId });
+ return tx.ismsReviewAction.create({
+ data: {
+ reviewId: review.id,
+ documentId: review.documentId,
+ reference: await this.nextReference({ tx, reviewId: review.id }),
+ description: dto.description,
+ ownerMemberId: ownerMemberId ?? null,
+ dueDate: dueDate ?? null,
+ status: dto.status ?? 'open',
+ position,
+ },
+ });
+ });
+ }
+
+ async update({
+ actionId,
+ organizationId,
+ dto,
+ }: {
+ actionId: string;
+ organizationId: string;
+ dto: UpdateReviewActionInput;
+ }) {
+ const action = await this.requireAction({ actionId, organizationId });
+ const ownerMemberId = await this.resolveMember({
+ memberId: dto.ownerMemberId,
+ organizationId,
+ });
+
+ return db.$transaction(async (tx) => {
+ // Same per-document lock submit/approve take, so an edit can't race the
+ // submission's completeness check.
+ await lockDocument(tx, action.documentId);
+ // Once the review is signed, only the tracking trio (owner / due date /
+ // status) stays editable — the description is part of the minutes.
+ // Re-read UNDER the lock so a concurrent sign-off can't be raced.
+ const lock = await loadReviewLockState({ tx, reviewId: action.reviewId });
+ if (lock.signed) {
+ const blocked = Object.entries(dto).some(
+ ([key, value]) =>
+ value !== undefined && !SIGNED_ACTION_EDITABLE_FIELDS.has(key),
+ );
+ if (blocked) {
+ throw new BadRequestException(
+ `Review ${lock.reference} is signed and locked. Only the owner, due date, and status of its actions can change; clear the chair sign-off to edit anything else.`,
+ );
+ }
+ }
+ await invalidateApprovalIfNeeded({ tx, documentId: action.documentId });
+ return tx.ismsReviewAction.update({
+ where: { id: actionId },
+ data: {
+ description: dto.description ?? undefined,
+ ownerMemberId:
+ dto.ownerMemberId === undefined ? undefined : ownerMemberId,
+ dueDate: parseOptionalDate(dto.dueDate),
+ status: dto.status ?? undefined,
+ position: dto.position ?? undefined,
+ },
+ });
+ });
+ }
+
+ async remove({
+ actionId,
+ organizationId,
+ }: {
+ actionId: string;
+ organizationId: string;
+ }) {
+ const action = await this.requireAction({ actionId, organizationId });
+ await db.$transaction(async (tx) => {
+ await lockDocument(tx, action.documentId);
+ // Signed state re-read UNDER the lock (see create).
+ const lock = await loadReviewLockState({ tx, reviewId: action.reviewId });
+ if (lock.signed) {
+ throw new BadRequestException(
+ `Review ${lock.reference} is signed and locked. Clear the chair sign-off to remove actions.`,
+ );
+ }
+ await invalidateApprovalIfNeeded({ tx, documentId: action.documentId });
+ await tx.ismsReviewAction.delete({ where: { id: actionId } });
+ });
+ return { success: true };
+ }
+
+ /**
+ * Next "A-NN" for the review: max + 1 over the surviving rows, so deleting
+ * an older action never reissues its number (deleting the newest frees it —
+ * acceptable: actions are draft rows until the review is signed/published,
+ * and every published version freezes its own actions in the version
+ * contentSnapshot).
+ */
+ private async nextReference({
+ tx,
+ reviewId,
+ }: {
+ tx: Prisma.TransactionClient;
+ reviewId: string;
+ }): Promise {
+ const existing = await tx.ismsReviewAction.findMany({
+ where: { reviewId },
+ select: { reference: true },
+ });
+ const maxSequence = existing.reduce((max, action) => {
+ const match = /^A(\d+)$/.exec(action.reference);
+ return match ? Math.max(max, Number.parseInt(match[1], 10)) : max;
+ }, 0);
+ return `A${String(maxSequence + 1).padStart(2, '0')}`;
+ }
+
+ private async resolveMember({
+ memberId,
+ organizationId,
+ }: {
+ memberId: string | null | undefined;
+ organizationId: string;
+ }): Promise {
+ if (memberId === undefined) return undefined;
+ const trimmed = memberId?.trim();
+ if (!trimmed) return null;
+ // Action owners must be active People members.
+ const member = await db.member.findFirst({
+ where: { id: trimmed, organizationId, deactivated: false },
+ });
+ if (!member) {
+ throw new NotFoundException('Active member not found in organization');
+ }
+ return trimmed;
+ }
+
+ private async nextPosition({
+ tx,
+ reviewId,
+ }: {
+ tx: Prisma.TransactionClient;
+ reviewId: string;
+ }) {
+ const last = await tx.ismsReviewAction.findFirst({
+ where: { reviewId },
+ orderBy: { position: 'desc' },
+ select: { position: true },
+ });
+ return (last?.position ?? -1) + 1;
+ }
+
+ private async requireReview({
+ reviewId,
+ documentId,
+ organizationId,
+ }: {
+ reviewId: string;
+ documentId: string;
+ organizationId: string;
+ }) {
+ const review = await db.ismsManagementReview.findFirst({
+ where: {
+ id: reviewId,
+ documentId,
+ document: { organizationId, type: 'management_review' },
+ },
+ });
+ if (!review) {
+ throw new NotFoundException('Review not found');
+ }
+ return review;
+ }
+
+ private async requireAction({
+ actionId,
+ organizationId,
+ }: {
+ actionId: string;
+ organizationId: string;
+ }) {
+ const action = await db.ismsReviewAction.findFirst({
+ where: {
+ id: actionId,
+ review: { document: { organizationId, type: 'management_review' } },
+ },
+ });
+ if (!action) {
+ throw new NotFoundException('Review action not found');
+ }
+ return action;
+ }
+}
diff --git a/apps/api/src/isms/isms-review-input.service.spec.ts b/apps/api/src/isms/isms-review-input.service.spec.ts
new file mode 100644
index 0000000000..cb75ad0c6e
--- /dev/null
+++ b/apps/api/src/isms/isms-review-input.service.spec.ts
@@ -0,0 +1,175 @@
+import { BadRequestException, NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import { IsmsReviewInputService } from './isms-review-input.service';
+
+jest.mock('@db', () => {
+ const db = {
+ ismsDocument: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ ismsManagementReview: {
+ findFirst: jest.fn(),
+ findUniqueOrThrow: jest.fn(),
+ },
+ ismsReviewInput: {
+ findFirst: jest.fn(),
+ create: jest.fn(),
+ update: jest.fn(),
+ delete: jest.fn(),
+ },
+ $executeRaw: jest.fn(),
+ $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)),
+ };
+ return { db };
+});
+
+const mockDb = jest.mocked(db);
+
+const UNSIGNED_REVIEW = {
+ id: 'mr_1',
+ documentId: 'doc_1',
+ reference: 'MR-2026-01',
+ signoffChairName: null,
+ signoffChairDate: null,
+};
+const SIGNED_REVIEW = {
+ ...UNSIGNED_REVIEW,
+ signoffChairName: 'Raoul Plickat',
+ signoffChairDate: new Date('2026-05-01T00:00:00.000Z'),
+};
+
+describe('IsmsReviewInputService', () => {
+ let service: IsmsReviewInputService;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({
+ status: 'draft',
+ });
+ // The signed-state re-read UNDER the document lock (TOCTOU guard).
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(UNSIGNED_REVIEW);
+ service = new IsmsReviewInputService();
+ });
+
+ describe('create', () => {
+ const args = {
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ dto: { reviewId: 'mr_1', inputRef: '(h) Custom input' },
+ };
+
+ it('creates a manual row on an unsigned review, appending its position', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ UNSIGNED_REVIEW,
+ );
+ (mockDb.ismsReviewInput.findFirst as jest.Mock).mockResolvedValue({
+ position: 9,
+ });
+ (mockDb.ismsReviewInput.create as jest.Mock).mockResolvedValue({
+ id: 'mri_11',
+ });
+
+ await service.create(args);
+
+ expect(mockDb.ismsReviewInput.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ inputRef: '(h) Custom input',
+ discussed: false,
+ source: 'manual',
+ position: 10,
+ }),
+ });
+ });
+
+ it('rejects new rows when the locked re-read sees a signed review (racing sign-off)', async () => {
+ // Pre-read saw unsigned; the in-transaction re-read is authoritative.
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ UNSIGNED_REVIEW,
+ );
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(SIGNED_REVIEW);
+ await expect(service.create(args)).rejects.toThrow(BadRequestException);
+ expect(mockDb.ismsReviewInput.create).not.toHaveBeenCalled();
+ });
+
+ it('throws NotFoundException when the review is not on this document/org', async () => {
+ (mockDb.ismsManagementReview.findFirst as jest.Mock).mockResolvedValue(
+ null,
+ );
+ await expect(service.create(args)).rejects.toThrow(NotFoundException);
+ });
+ });
+
+ describe('update / remove', () => {
+ it('updates notes and the discussed tick, recording the override as manual', async () => {
+ (mockDb.ismsReviewInput.findFirst as jest.Mock).mockResolvedValue({
+ id: 'mri_1',
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ });
+ (mockDb.ismsReviewInput.update as jest.Mock).mockResolvedValue({});
+
+ await service.update({
+ inputId: 'mri_1',
+ organizationId: 'org_1',
+ dto: { discussionNotes: 'Covered in full.', discussed: true },
+ });
+
+ const { data } = (mockDb.ismsReviewInput.update as jest.Mock).mock
+ .calls[0][0];
+ expect(data.discussionNotes).toBe('Covered in full.');
+ expect(data.discussed).toBe(true);
+ expect(data.inputRef).toBeUndefined();
+ // Edited seed rows record the customer override (audit-controls precedent).
+ expect(data.source).toBe('manual');
+ });
+
+ it('rejects edits and deletes on a signed review', async () => {
+ (mockDb.ismsReviewInput.findFirst as jest.Mock).mockResolvedValue({
+ id: 'mri_1',
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ });
+ (
+ mockDb.ismsManagementReview.findUniqueOrThrow as jest.Mock
+ ).mockResolvedValue(SIGNED_REVIEW);
+
+ await expect(
+ service.update({
+ inputId: 'mri_1',
+ organizationId: 'org_1',
+ dto: { discussed: true },
+ }),
+ ).rejects.toThrow(BadRequestException);
+ await expect(
+ service.remove({ inputId: 'mri_1', organizationId: 'org_1' }),
+ ).rejects.toThrow(BadRequestException);
+ expect(mockDb.ismsReviewInput.update).not.toHaveBeenCalled();
+ expect(mockDb.ismsReviewInput.delete).not.toHaveBeenCalled();
+ });
+
+ it('deletes rows (including seeded ones) on an unsigned review', async () => {
+ (mockDb.ismsReviewInput.findFirst as jest.Mock).mockResolvedValue({
+ id: 'mri_1',
+ reviewId: 'mr_1',
+ documentId: 'doc_1',
+ });
+ (mockDb.ismsReviewInput.delete as jest.Mock).mockResolvedValue({});
+
+ const result = await service.remove({
+ inputId: 'mri_1',
+ organizationId: 'org_1',
+ });
+ expect(result).toEqual({ success: true });
+ expect(mockDb.ismsReviewInput.delete).toHaveBeenCalledWith({
+ where: { id: 'mri_1' },
+ });
+ });
+ });
+});
diff --git a/apps/api/src/isms/isms-review-input.service.ts b/apps/api/src/isms/isms-review-input.service.ts
new file mode 100644
index 0000000000..14a8414652
--- /dev/null
+++ b/apps/api/src/isms/isms-review-input.service.ts
@@ -0,0 +1,191 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { db } from '@db';
+import type { Prisma } from '@db';
+import { loadReviewLockState } from './documents/management-review';
+import { invalidateApprovalIfNeeded } from './utils/approval';
+import { lockDocument } from './utils/document-lock';
+import type {
+ CreateReviewInputInput,
+ UpdateReviewInputInput,
+} from './registers/register-registry';
+
+/**
+ * CRUD for a review's Inputs (9.3.2) rows (clause 9.3, CS-726). Ten default
+ * rows are seeded per review at creation (seedReviewInputsIfMissing); the
+ * customer adds discussion notes during the meeting, ticks Discussed?, and may
+ * add, edit, or remove rows freely — until the chair signs, which locks the
+ * review's minutes (the sign-off must be cleared to edit them again).
+ */
+@Injectable()
+export class IsmsReviewInputService {
+ async create({
+ documentId,
+ organizationId,
+ dto,
+ }: {
+ documentId: string;
+ organizationId: string;
+ dto: CreateReviewInputInput;
+ }) {
+ const review = await this.requireReview({
+ reviewId: dto.reviewId,
+ documentId,
+ organizationId,
+ });
+
+ return db.$transaction(async (tx) => {
+ // Same per-document lock submit/approve take, so an edit can't race the
+ // submission's completeness check.
+ await lockDocument(tx, review.documentId);
+ // Signed state re-read UNDER the lock: a concurrent sign-off can commit
+ // after the pre-read, and the locked minutes must win (TOCTOU).
+ await this.assertReviewUnsigned({ tx, reviewId: review.id });
+ const position =
+ dto.position ?? (await this.nextPosition({ tx, reviewId: review.id }));
+ await invalidateApprovalIfNeeded({ tx, documentId: review.documentId });
+ return tx.ismsReviewInput.create({
+ data: {
+ reviewId: review.id,
+ documentId: review.documentId,
+ inputRef: dto.inputRef,
+ whatItCovers: dto.whatItCovers ?? '',
+ whereToFind: dto.whereToFind ?? '',
+ discussionNotes: dto.discussionNotes?.trim() || null,
+ discussed: dto.discussed ?? false,
+ source: 'manual',
+ position,
+ },
+ });
+ });
+ }
+
+ async update({
+ inputId,
+ organizationId,
+ dto,
+ }: {
+ inputId: string;
+ organizationId: string;
+ dto: UpdateReviewInputInput;
+ }) {
+ const input = await this.requireInput({ inputId, organizationId });
+
+ return db.$transaction(async (tx) => {
+ await lockDocument(tx, input.documentId);
+ // Signed state re-read UNDER the lock (see create).
+ await this.assertReviewUnsigned({ tx, reviewId: input.reviewId });
+ await invalidateApprovalIfNeeded({ tx, documentId: input.documentId });
+ return tx.ismsReviewInput.update({
+ where: { id: inputId },
+ data: {
+ inputRef: dto.inputRef ?? undefined,
+ whatItCovers: dto.whatItCovers ?? undefined,
+ whereToFind: dto.whereToFind ?? undefined,
+ discussionNotes:
+ dto.discussionNotes === undefined
+ ? undefined
+ : dto.discussionNotes?.trim() || null,
+ discussed: dto.discussed ?? undefined,
+ position: dto.position ?? undefined,
+ // An edited seed row is the customer's row now — record the
+ // override like every other ISMS register (audit-controls precedent).
+ source: 'manual',
+ },
+ });
+ });
+ }
+
+ async remove({
+ inputId,
+ organizationId,
+ }: {
+ inputId: string;
+ organizationId: string;
+ }) {
+ const input = await this.requireInput({ inputId, organizationId });
+ await db.$transaction(async (tx) => {
+ await lockDocument(tx, input.documentId);
+ // Signed state re-read UNDER the lock (see create).
+ await this.assertReviewUnsigned({ tx, reviewId: input.reviewId });
+ await invalidateApprovalIfNeeded({ tx, documentId: input.documentId });
+ await tx.ismsReviewInput.delete({ where: { id: inputId } });
+ });
+ return { success: true };
+ }
+
+ /** A signed review's Inputs table is part of the chair-approved minutes. */
+ private async assertReviewUnsigned({
+ tx,
+ reviewId,
+ }: {
+ tx: Prisma.TransactionClient;
+ reviewId: string;
+ }): Promise {
+ const lock = await loadReviewLockState({ tx, reviewId });
+ if (lock.signed) {
+ throw new BadRequestException(
+ `Review ${lock.reference} is signed and locked. Clear the chair sign-off to edit its inputs.`,
+ );
+ }
+ }
+
+ private async nextPosition({
+ tx,
+ reviewId,
+ }: {
+ tx: Prisma.TransactionClient;
+ reviewId: string;
+ }) {
+ const last = await tx.ismsReviewInput.findFirst({
+ where: { reviewId },
+ orderBy: { position: 'desc' },
+ select: { position: true },
+ });
+ return (last?.position ?? -1) + 1;
+ }
+
+ private async requireReview({
+ reviewId,
+ documentId,
+ organizationId,
+ }: {
+ reviewId: string;
+ documentId: string;
+ organizationId: string;
+ }) {
+ const review = await db.ismsManagementReview.findFirst({
+ where: {
+ id: reviewId,
+ documentId,
+ document: { organizationId, type: 'management_review' },
+ },
+ });
+ if (!review) {
+ throw new NotFoundException('Review not found');
+ }
+ return review;
+ }
+
+ private async requireInput({
+ inputId,
+ organizationId,
+ }: {
+ inputId: string;
+ organizationId: string;
+ }) {
+ const input = await db.ismsReviewInput.findFirst({
+ where: {
+ id: inputId,
+ review: { document: { organizationId, type: 'management_review' } },
+ },
+ });
+ if (!input) {
+ throw new NotFoundException('Review input not found');
+ }
+ return input;
+ }
+}
diff --git a/apps/api/src/isms/isms-version.service.spec.ts b/apps/api/src/isms/isms-version.service.spec.ts
index c7c4ba2b45..a4f5a7b8f6 100644
--- a/apps/api/src/isms/isms-version.service.spec.ts
+++ b/apps/api/src/isms/isms-version.service.spec.ts
@@ -27,6 +27,7 @@ jest.mock('./utils/export-payload', () => ({
resolveRolesExtras: jest.fn(),
resolveMonitoringExtras: jest.fn(),
resolveInternalAuditExtras: jest.fn(),
+ resolveManagementReviewExtras: jest.fn(),
parseExportSnapshot: jest.fn(() => null),
}));
jest.mock('./utils/export-metadata', () => ({
diff --git a/apps/api/src/isms/isms-version.service.ts b/apps/api/src/isms/isms-version.service.ts
index 22e3a9a1b4..2bd67513aa 100644
--- a/apps/api/src/isms/isms-version.service.ts
+++ b/apps/api/src/isms/isms-version.service.ts
@@ -16,6 +16,7 @@ import {
parseExportSnapshot,
renderSnapshot,
resolveInternalAuditExtras,
+ resolveManagementReviewExtras,
resolveMonitoringExtras,
resolveOrgProfile,
resolveRolesExtras,
@@ -75,12 +76,17 @@ export class IsmsVersionService {
const rolesExtras = await resolveRolesExtras(document, tx);
const monitoringExtras = await resolveMonitoringExtras(document, tx);
const internalAuditExtras = await resolveInternalAuditExtras(document, tx);
+ const managementReviewExtras = await resolveManagementReviewExtras(
+ document,
+ tx,
+ );
const input = buildExportInput({
document,
orgProfile,
rolesExtras,
monitoringExtras,
internalAuditExtras,
+ managementReviewExtras,
});
const metadata = buildExportMetadata({
type: document.type,
diff --git a/apps/api/src/isms/isms.module.ts b/apps/api/src/isms/isms.module.ts
index b82c50a8fe..bcc528f821 100644
--- a/apps/api/src/isms/isms.module.ts
+++ b/apps/api/src/isms/isms.module.ts
@@ -16,6 +16,9 @@ import { IsmsMeasurementService } from './isms-measurement.service';
import { IsmsAuditService } from './isms-audit.service';
import { IsmsAuditControlService } from './isms-audit-control.service';
import { IsmsAuditFindingService } from './isms-audit-finding.service';
+import { IsmsManagementReviewService } from './isms-management-review.service';
+import { IsmsReviewInputService } from './isms-review-input.service';
+import { IsmsReviewActionService } from './isms-review-action.service';
import { IsmsNarrativeService } from './isms-narrative.service';
import { IsmsProfileController } from './wizard/isms-profile.controller';
import { IsmsProfileService } from './wizard/isms-profile.service';
@@ -46,6 +49,9 @@ import { AttachmentsModule } from '../attachments/attachments.module';
IsmsAuditService,
IsmsAuditControlService,
IsmsAuditFindingService,
+ IsmsManagementReviewService,
+ IsmsReviewInputService,
+ IsmsReviewActionService,
IsmsNarrativeService,
IsmsProfileService,
],
@@ -65,6 +71,9 @@ import { AttachmentsModule } from '../attachments/attachments.module';
IsmsAuditService,
IsmsAuditControlService,
IsmsAuditFindingService,
+ IsmsManagementReviewService,
+ IsmsReviewInputService,
+ IsmsReviewActionService,
IsmsNarrativeService,
IsmsProfileService,
],
diff --git a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts
index 1c065ec8a5..56df518310 100644
--- a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts
+++ b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts
@@ -1,5 +1,6 @@
import { db } from '@db';
import { IsmsService } from './isms.service';
+import { ISMS_TYPE_DEFINITIONS } from './utils/document-types';
import type { IsmsVersionService } from './isms-version.service';
jest.mock('@db', () => ({
@@ -72,7 +73,7 @@ describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no template
const result = await service.ensureSetup(dto);
expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1);
- expect(createManyData()).toHaveLength(8);
+ expect(createManyData()).toHaveLength(ISMS_TYPE_DEFINITIONS.length - 1);
// Definition-derived docs carry no templateId.
expect(createManyData()[0].templateId).toBeNull();
expect(result.success).toBe(true);
@@ -96,7 +97,7 @@ describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no template
await service.ensureSetup(dto);
- expect(createManyData()).toHaveLength(9);
+ expect(createManyData()).toHaveLength(ISMS_TYPE_DEFINITIONS.length);
expect(createManyData()[0].requirementId).toBeNull();
});
});
diff --git a/apps/api/src/isms/isms.service.lifecycle.spec.ts b/apps/api/src/isms/isms.service.lifecycle.spec.ts
index 846a3f2efd..afff8c21dd 100644
--- a/apps/api/src/isms/isms.service.lifecycle.spec.ts
+++ b/apps/api/src/isms/isms.service.lifecycle.spec.ts
@@ -13,12 +13,14 @@ jest.mock('@db', () => {
const db = {
ismsDocument: {
findFirst: jest.fn(),
+ findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
member: { findFirst: jest.fn(), findMany: jest.fn() },
ismsRole: { findMany: jest.fn() },
ismsAudit: { findMany: jest.fn() },
+ ismsManagementReview: { findMany: jest.fn() },
// lockDocument runs $executeRaw; submitForApproval wraps validate+update in a tx.
$executeRaw: jest.fn(),
$transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)),
@@ -245,6 +247,80 @@ describe('IsmsService document lifecycle', () => {
await service.submitForApproval(args);
expect(mockDb.ismsDocument.update).toHaveBeenCalled();
});
+
+ it('blocks a Management Review doc with no reviews recorded (clause 9.3)', async () => {
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' });
+ (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({
+ id: 'doc_1',
+ type: 'management_review',
+ });
+ (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({
+ draftNarrative: { procedure: 'We review annually.' },
+ });
+ (mockDb.ismsManagementReview.findMany as jest.Mock).mockResolvedValue([]);
+
+ await expect(service.submitForApproval(args)).rejects.toThrow(
+ BadRequestException,
+ );
+ expect(mockDb.ismsDocument.update).not.toHaveBeenCalled();
+ });
+
+ it('blocks a Management Review doc with an unsigned complete review', async () => {
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' });
+ (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({
+ id: 'doc_1',
+ type: 'management_review',
+ });
+ (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({
+ draftNarrative: { procedure: 'We review annually.' },
+ });
+ (mockDb.ismsManagementReview.findMany as jest.Mock).mockResolvedValue([
+ {
+ reference: 'MR-2026-01',
+ status: 'complete',
+ meetingDate: new Date('2026-05-01T00:00:00.000Z'),
+ chairName: 'Raoul Plickat',
+ attendees: [{ memberId: 'mem_1', name: 'Raoul Plickat' }],
+ signoffChairName: null,
+ signoffChairDate: null,
+ inputs: [{ discussed: true }],
+ },
+ ]);
+
+ await expect(service.submitForApproval(args)).rejects.toThrow(
+ BadRequestException,
+ );
+ });
+
+ it('allows a Management Review doc with a planned review (agenda pack) on record', async () => {
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' });
+ (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({
+ id: 'doc_1',
+ type: 'management_review',
+ });
+ (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({
+ draftNarrative: { procedure: 'We review annually.' },
+ });
+ (mockDb.ismsManagementReview.findMany as jest.Mock).mockResolvedValue([
+ {
+ reference: 'MR-2026-01',
+ status: 'planned',
+ meetingDate: null,
+ chairName: null,
+ attendees: [],
+ signoffChairName: null,
+ signoffChairDate: null,
+ inputs: [{ discussed: false }],
+ },
+ ]);
+ (mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({
+ id: 'doc_1',
+ status: 'needs_review',
+ });
+
+ await service.submitForApproval(args);
+ expect(mockDb.ismsDocument.update).toHaveBeenCalled();
+ });
});
describe('decline', () => {
diff --git a/apps/api/src/isms/isms.service.spec.ts b/apps/api/src/isms/isms.service.spec.ts
index f764be4b56..35acc92c8b 100644
--- a/apps/api/src/isms/isms.service.spec.ts
+++ b/apps/api/src/isms/isms.service.spec.ts
@@ -130,7 +130,16 @@ describe('IsmsService ensureSetup', () => {
(mockDb.ismsDocument.findMany as jest.Mock)
.mockResolvedValueOnce([]) // existing-types probe
.mockResolvedValueOnce([{ id: 'doc_ia', type: 'internal_audit' }]) // created lookup
- .mockResolvedValueOnce([]); // final list
+ .mockResolvedValueOnce([
+ {
+ id: 'doc_ia',
+ type: 'internal_audit',
+ status: 'draft',
+ requirementId: null,
+ currentVersionId: null,
+ draftNarrative: null,
+ },
+ ]); // final list — the seed now works off this, not the created lookup
await service.ensureSetup(dto);
@@ -156,6 +165,70 @@ describe('IsmsService ensureSetup', () => {
});
});
+ it('heals a PRE-EXISTING Management Review doc whose procedure is still empty', async () => {
+ (
+ mockDb.frameworkEditorFramework.findUnique as jest.Mock
+ ).mockResolvedValue({ id: 'fw_1', requirements: [] });
+ mockTemplates.mockResolvedValue([]);
+ (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
+ name: 'Acme Corp',
+ });
+ (mockDb.ismsDocument.findMany as jest.Mock)
+ // Every type already exists, so provisioning creates nothing and the
+ // old created-scoped seed would never have run.
+ .mockResolvedValueOnce([
+ { type: 'context_of_organization' },
+ { type: 'interested_parties_register' },
+ { type: 'interested_parties_requirements' },
+ { type: 'isms_scope' },
+ { type: 'leadership_commitment' },
+ { type: 'roles_and_responsibilities' },
+ { type: 'objectives_plan' },
+ { type: 'monitoring' },
+ { type: 'internal_audit' },
+ { type: 'management_review' },
+ ])
+ .mockResolvedValueOnce([
+ {
+ id: 'doc_mr',
+ type: 'management_review',
+ status: 'draft',
+ requirementId: null,
+ currentVersionId: null,
+ draftNarrative: {}, // blank — e.g. a crash between provision and seed
+ },
+ {
+ id: 'doc_ia',
+ type: 'internal_audit',
+ status: 'draft',
+ requirementId: null,
+ currentVersionId: null,
+ draftNarrative: { programme: 'Customer-edited programme.' },
+ },
+ ]); // final list
+
+ await service.ensureSetup(dto);
+
+ // Only the blank document is healed; the populated one is untouched.
+ expect(mockDb.ismsDocument.updateMany).toHaveBeenCalledTimes(1);
+ expect(mockDb.ismsDocument.updateMany).toHaveBeenCalledWith({
+ where: {
+ id: 'doc_mr',
+ OR: [
+ { draftNarrative: { equals: Prisma.AnyNull } },
+ { draftNarrative: { equals: {} } },
+ ],
+ },
+ data: {
+ draftNarrative: {
+ procedure: expect.stringContaining(
+ 'Acme Corp holds a management review',
+ ),
+ },
+ },
+ });
+ });
+
it('reports overdueMetricCount on the monitoring document row (CS-723)', async () => {
const { addPeriods, periodStartFor } = jest.requireActual<
typeof import('./utils/metric-periods')
@@ -261,9 +334,9 @@ describe('IsmsService ensureSetup', () => {
await service.ensureSetup(dto);
expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1);
- // 1 template-driven + 8 definition fallbacks: a type shipped before its
+ // 1 template-driven + 9 definition fallbacks: a type shipped before its
// template seed re-runs (e.g. monitoring, CS-723) still provisions.
- expect(createManyData()).toHaveLength(9);
+ expect(createManyData()).toHaveLength(10);
expect(createManyData()[0]).toMatchObject({
type: 'context_of_organization',
title: 'Context of the Organization',
@@ -347,9 +420,9 @@ describe('IsmsService ensureSetup', () => {
await service.ensureSetup(dto);
- // objectives (template) + 7 definition fallbacks; the existing
+ // objectives (template) + 8 definition fallbacks; the existing
// context_of_organization is skipped.
- expect(createManyData()).toHaveLength(8);
+ expect(createManyData()).toHaveLength(9);
expect(createManyData()[0].type).toBe('objectives_plan');
expect(
createManyData().map((doc: { type: string }) => doc.type),
@@ -449,6 +522,7 @@ describe('IsmsService ensureSetup', () => {
{ type: 'objectives_plan' },
{ type: 'monitoring' },
{ type: 'internal_audit' },
+ { type: 'management_review' },
])
.mockResolvedValueOnce([]);
diff --git a/apps/api/src/isms/isms.service.ts b/apps/api/src/isms/isms.service.ts
index 357e5dcee7..25cd90a1c0 100644
--- a/apps/api/src/isms/isms.service.ts
+++ b/apps/api/src/isms/isms.service.ts
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { db, Prisma } from '@db';
+import type { IsmsDocumentType } from '@db';
import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto';
import { deriveControlLinks, resolveDocumentPlans } from './utils/ensure-setup-plan';
import { collectPlatformData } from './documents/data-source';
@@ -16,6 +17,13 @@ import {
} from './documents/monitoring';
import { auditValidationMessages } from './documents/internal-audit';
import { defaultProgrammeText } from './documents/internal-audit-defaults';
+import {
+ isReviewSigned,
+ managementReviewNarrativeSchema,
+ parseReviewAttendees,
+ reviewValidationMessages,
+} from './documents/management-review';
+import { defaultProcedureText } from './documents/management-review-defaults';
import { updateDraftSnapshot } from './utils/draft-snapshot';
import { EXPORT_DOCUMENT_INCLUDE } from './utils/export-payload';
import { lockDocument } from './utils/document-lock';
@@ -72,6 +80,15 @@ export class IsmsService {
where: { organizationId, frameworkId },
});
+ // Heal empty Programme (9.2) / Procedure (9.3) paragraphs on EVERY setup,
+ // not just for documents created this call: a document provisioned before
+ // its narrative seed shipped (or a crash between provision and seed) would
+ // otherwise stay blank forever. Guarded per doc on the narrative still
+ // being empty, so a populated draft is never overwritten.
+ if (canWrite) {
+ await this.seedNarrativeDefaultsIfEmpty({ documents, organizationId });
+ }
+
const monitoringDoc = documents.find((doc) => doc.type === 'monitoring');
const overdueMetricCount = monitoringDoc
? await this.countOverdueMetrics(monitoringDoc.id)
@@ -216,36 +233,63 @@ export class IsmsService {
);
}
- // Same first-load guarantee for Internal Audit (9.2): the Programme
- // paragraph opens with its default text. The write is conditional on the
- // narrative still being NULL (its creation state), so it is atomic: under
- // concurrent setup calls — where the "created" lookup can also match a row
- // the other call just created — an early customer edit can never be
- // clobbered (the seed simply matches zero rows).
- const internalAuditDoc = created.find(
- (doc) => doc.type === 'internal_audit',
+ // The Programme (9.2) / Procedure (9.3) narrative seed happens in
+ // ensureSetup (seedNarrativeDefaultsIfEmpty), AFTER the document list is
+ // fetched — so it covers both the documents this call just created and
+ // any pre-existing document whose narrative is still empty.
+ }
+
+ /**
+ * First-load guarantee for Internal Audit (9.2) and Management Review (9.3):
+ * the Programme / Procedure paragraph opens with its default text. Each
+ * write is conditional on the narrative still being empty — NULL (the
+ * creation state) or {}, generateNarrative's definition — so it is atomic:
+ * under concurrent setup calls, an early customer edit can never be
+ * clobbered (the seed simply matches zero rows).
+ */
+ private async seedNarrativeDefaultsIfEmpty({
+ documents,
+ organizationId,
+ }: {
+ documents: Array<{
+ id: string;
+ type: IsmsDocumentType;
+ draftNarrative: Prisma.JsonValue;
+ }>;
+ organizationId: string;
+ }) {
+ const isEmptyNarrative = (narrative: Prisma.JsonValue): boolean =>
+ narrative == null ||
+ (typeof narrative === 'object' &&
+ !Array.isArray(narrative) &&
+ Object.keys(narrative).length === 0);
+
+ const targets = documents.filter(
+ (doc) =>
+ (doc.type === 'internal_audit' || doc.type === 'management_review') &&
+ isEmptyNarrative(doc.draftNarrative),
);
- if (internalAuditDoc) {
- const organization = await db.organization.findUnique({
- where: { id: organizationId },
- select: { name: true },
- });
+ if (targets.length === 0) return;
+
+ const organization = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { name: true },
+ });
+ const organizationName = organization?.name ?? 'The organization';
+ const whileNarrativeEmpty = {
+ OR: [
+ { draftNarrative: { equals: Prisma.AnyNull } },
+ { draftNarrative: { equals: {} } },
+ ],
+ };
+ for (const doc of targets) {
await db.ismsDocument.updateMany({
- where: {
- id: internalAuditDoc.id,
- // "Empty" matches generateNarrative's definition: NULL (the
- // creation state) or an empty object — never a populated draft.
- OR: [
- { draftNarrative: { equals: Prisma.AnyNull } },
- { draftNarrative: { equals: {} } },
- ],
- },
+ where: { id: doc.id, ...whileNarrativeEmpty },
data: {
- draftNarrative: {
- programme: defaultProgrammeText(
- organization?.name ?? 'The organization',
- ),
- },
+ draftNarrative:
+ doc.type === 'internal_audit'
+ ? { programme: defaultProgrammeText(organizationName) }
+ : { procedure: defaultProcedureText(organizationName) },
},
});
}
@@ -292,6 +336,15 @@ export class IsmsService {
findings: { orderBy: { position: 'asc' } },
},
},
+ reviews: {
+ // Same deterministic ordering as EXPORT_DOCUMENT_INCLUDE: the client
+ // computes the carried-forward lists from this order.
+ orderBy: [{ position: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
+ include: {
+ inputs: { orderBy: { position: 'asc' } },
+ actions: { orderBy: { position: 'asc' } },
+ },
+ },
controlLinks: {
select: {
id: true,
@@ -348,6 +401,12 @@ export class IsmsService {
if (document.type === 'internal_audit') {
await this.assertInternalAuditComplete({ tx, documentId });
}
+ // Clause 9.3: the Procedure paragraph plus, on every completed review,
+ // a meeting date, chair, at least one attendee, every input discussed,
+ // and the chair's signature (CS-726).
+ if (document.type === 'management_review') {
+ await this.assertManagementReviewComplete({ tx, documentId });
+ }
return tx.ismsDocument.update({
where: { id: documentId },
@@ -591,6 +650,56 @@ export class IsmsService {
}
}
+ private async assertManagementReviewComplete({
+ tx,
+ documentId,
+ }: {
+ tx: Prisma.TransactionClient;
+ documentId: string;
+ }) {
+ const [document, reviews] = await Promise.all([
+ tx.ismsDocument.findUnique({
+ where: { id: documentId },
+ select: { draftNarrative: true },
+ }),
+ tx.ismsManagementReview.findMany({
+ where: { documentId },
+ select: {
+ reference: true,
+ status: true,
+ meetingDate: true,
+ chairName: true,
+ attendees: true,
+ signoffChairName: true,
+ signoffChairDate: true,
+ inputs: { select: { discussed: true } },
+ },
+ }),
+ ]);
+ const narrative = managementReviewNarrativeSchema.safeParse(
+ document?.draftNarrative,
+ );
+ const messages = reviewValidationMessages({
+ procedure: narrative.success ? narrative.data.procedure : null,
+ reviews: reviews.map((review) => ({
+ reference: review.reference,
+ status: review.status,
+ hasMeetingDate: review.meetingDate != null,
+ hasChair: Boolean(review.chairName?.trim()),
+ attendeeCount: parseReviewAttendees(review.attendees).length,
+ undiscussedInputCount: review.inputs.filter(
+ (input) => !input.discussed,
+ ).length,
+ signed: isReviewSigned(review),
+ })),
+ });
+ if (messages.length > 0) {
+ throw new BadRequestException(
+ `This Clause 9.3 document is not ready to submit. ${messages.join(' ')}`,
+ );
+ }
+ }
+
private async requireDocument({
documentId,
organizationId,
diff --git a/apps/api/src/isms/registers/register-registry.spec.ts b/apps/api/src/isms/registers/register-registry.spec.ts
index 9455b3931a..bdc3c4ee9a 100644
--- a/apps/api/src/isms/registers/register-registry.spec.ts
+++ b/apps/api/src/isms/registers/register-registry.spec.ts
@@ -18,12 +18,18 @@ describe('createRegisterRegistry', () => {
};
const requirements = { create: jest.fn(), update: jest.fn(), remove: jest.fn() };
const objectives = { create: jest.fn(), update: jest.fn(), remove: jest.fn() };
+ const reviews = { create: jest.fn(), update: jest.fn(), remove: jest.fn() };
+ const reviewInputs = { create: jest.fn(), update: jest.fn(), remove: jest.fn() };
+ const reviewActions = { create: jest.fn(), update: jest.fn(), remove: jest.fn() };
const services = {
contextIssues,
interestedParties,
requirements,
objectives,
+ reviews,
+ reviewInputs,
+ reviewActions,
} as unknown as RegisterServices;
const registry = createRegisterRegistry(services);
@@ -246,4 +252,155 @@ describe('createRegisterRegistry', () => {
expect(objectives.create).not.toHaveBeenCalled();
});
});
+
+ describe('reviews (9.3)', () => {
+ it('create dispatches with documentId and parsed dto', async () => {
+ const data = {
+ meetingDate: '2026-05-01',
+ chairName: 'Raoul Plickat',
+ attendees: [{ memberId: 'mem_1', name: 'Raoul Plickat' }],
+ };
+ await registry.reviews.create({
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ data,
+ });
+ expect(reviews.create).toHaveBeenCalledWith({
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ dto: data,
+ });
+ });
+
+ it('update dispatches with reviewId and accepts the review verdict enum', async () => {
+ await registry.reviews.update({
+ rowId: 'mr_1',
+ organizationId: 'org_1',
+ data: { status: 'complete', conclusionVerdict: 'effective' },
+ });
+ expect(reviews.update).toHaveBeenCalledWith({
+ reviewId: 'mr_1',
+ organizationId: 'org_1',
+ dto: { status: 'complete', conclusionVerdict: 'effective' },
+ });
+ });
+
+ it('create rejects an attendee without a name', () => {
+ expect(() =>
+ registry.reviews.create({
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ data: { attendees: [{ memberId: 'mem_1' }] },
+ }),
+ ).toThrow(BadRequestException);
+ expect(reviews.create).not.toHaveBeenCalled();
+ });
+
+ it('update rejects an audit verdict on the reviews register', () => {
+ expect(() =>
+ registry.reviews.update({
+ rowId: 'mr_1',
+ organizationId: 'org_1',
+ data: { conclusionVerdict: 'conform' },
+ }),
+ ).toThrow(BadRequestException);
+ expect(reviews.update).not.toHaveBeenCalled();
+ });
+
+ it('remove dispatches with reviewId', async () => {
+ await registry.reviews.remove({ rowId: 'mr_1', organizationId: 'org_1' });
+ expect(reviews.remove).toHaveBeenCalledWith({
+ reviewId: 'mr_1',
+ organizationId: 'org_1',
+ });
+ });
+ });
+
+ describe('review-inputs (9.3)', () => {
+ it('create requires reviewId and inputRef', () => {
+ expect(() =>
+ registry['review-inputs'].create({
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ data: { whatItCovers: 'w' },
+ }),
+ ).toThrow(BadRequestException);
+ expect(reviewInputs.create).not.toHaveBeenCalled();
+ });
+
+ it('update dispatches with inputId', async () => {
+ await registry['review-inputs'].update({
+ rowId: 'mri_1',
+ organizationId: 'org_1',
+ data: { discussed: true, discussionNotes: 'Covered.' },
+ });
+ expect(reviewInputs.update).toHaveBeenCalledWith({
+ inputId: 'mri_1',
+ organizationId: 'org_1',
+ dto: { discussed: true, discussionNotes: 'Covered.' },
+ });
+ });
+
+ it('remove dispatches with inputId', async () => {
+ await registry['review-inputs'].remove({
+ rowId: 'mri_1',
+ organizationId: 'org_1',
+ });
+ expect(reviewInputs.remove).toHaveBeenCalledWith({
+ inputId: 'mri_1',
+ organizationId: 'org_1',
+ });
+ });
+ });
+
+ describe('review-actions (9.3)', () => {
+ it('create dispatches with documentId and parsed dto', async () => {
+ const data = { reviewId: 'mr_1', description: 'Backfill metrics.' };
+ await registry['review-actions'].create({
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ data,
+ });
+ expect(reviewActions.create).toHaveBeenCalledWith({
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ dto: data,
+ });
+ });
+
+ it('create requires a non-empty description', () => {
+ expect(() =>
+ registry['review-actions'].create({
+ documentId: 'doc_1',
+ organizationId: 'org_1',
+ data: { reviewId: 'mr_1', description: ' ' },
+ }),
+ ).toThrow(BadRequestException);
+ expect(reviewActions.create).not.toHaveBeenCalled();
+ });
+
+ it('update dispatches with actionId', async () => {
+ await registry['review-actions'].update({
+ rowId: 'mra_1',
+ organizationId: 'org_1',
+ data: { status: 'closed' },
+ });
+ expect(reviewActions.update).toHaveBeenCalledWith({
+ actionId: 'mra_1',
+ organizationId: 'org_1',
+ dto: { status: 'closed' },
+ });
+ });
+
+ it('remove dispatches with actionId', async () => {
+ await registry['review-actions'].remove({
+ rowId: 'mra_1',
+ organizationId: 'org_1',
+ });
+ expect(reviewActions.remove).toHaveBeenCalledWith({
+ actionId: 'mra_1',
+ organizationId: 'org_1',
+ });
+ });
+ });
});
diff --git a/apps/api/src/isms/registers/register-registry.ts b/apps/api/src/isms/registers/register-registry.ts
index c653897c49..fc4d953a12 100644
--- a/apps/api/src/isms/registers/register-registry.ts
+++ b/apps/api/src/isms/registers/register-registry.ts
@@ -11,6 +11,10 @@ import type { IsmsMeasurementService } from '../isms-measurement.service';
import type { IsmsAuditService } from '../isms-audit.service';
import type { IsmsAuditControlService } from '../isms-audit-control.service';
import type { IsmsAuditFindingService } from '../isms-audit-finding.service';
+import type { IsmsManagementReviewService } from '../isms-management-review.service';
+import type { IsmsReviewInputService } from '../isms-review-input.service';
+import type { IsmsReviewActionService } from '../isms-review-action.service';
+import { reviewAttendeeSchema } from '../documents/management-review';
/**
* One generic dispatch for every ISMS register row (context issues, interested
@@ -45,6 +49,9 @@ const AUDIT_CONTROL_RESULT = [
] as const;
const AUDIT_FINDING_TYPE = ['nc_major', 'nc_minor', 'ofi', 'observation'] as const;
const AUDIT_FINDING_STATUS = ['open', 'in_progress', 'closed'] as const;
+const REVIEW_STATUS = ['planned', 'in_progress', 'complete'] as const;
+const REVIEW_CONCLUSION_VERDICT = ['suitable', 'adequate', 'effective'] as const;
+const REVIEW_ACTION_STATUS = ['open', 'in_progress', 'closed'] as const;
const schemas = {
contextIssueCreate: z.object({
@@ -265,6 +272,63 @@ const schemas = {
closureEvidence: z.string().nullish(),
position,
}),
+ // reference ("MR-YYYY-NN") and recordedAt are server-generated and immutable;
+ // chair/attendees default to the Roles (5.3) Top Management + SPO holders and
+ // the outputs default to the clause-9.3 template text when omitted.
+ reviewCreate: z.object({
+ meetingDate: z.string().nullish(), // ISO date string
+ chairName: z.string().nullish(),
+ attendees: z.array(reviewAttendeeSchema).optional(),
+ position,
+ }),
+ reviewUpdate: z.object({
+ // Nullish: undefined = leave as-is, null = clear.
+ meetingDate: z.string().nullish(),
+ chairName: z.string().nullish(),
+ attendees: z.array(reviewAttendeeSchema).optional(),
+ status: z.enum(REVIEW_STATUS).optional(),
+ conclusionVerdict: z.enum(REVIEW_CONCLUSION_VERDICT).nullish(),
+ conclusionNotes: z.string().nullish(),
+ decisionsText: z.string().nullish(),
+ changesText: z.string().nullish(),
+ // Single sign-off slot: free-text chair name + ISO date, each clearable.
+ signoffChairName: z.string().nullish(),
+ signoffChairDate: z.string().nullish(),
+ position,
+ }),
+ reviewInputCreate: z.object({
+ reviewId: z.string(),
+ inputRef: z.string().trim().min(1),
+ whatItCovers: z.string().optional(),
+ whereToFind: z.string().optional(),
+ discussionNotes: z.string().nullish(),
+ discussed: z.boolean().optional(),
+ position,
+ }),
+ reviewInputUpdate: z.object({
+ inputRef: z.string().trim().min(1).optional(),
+ whatItCovers: z.string().optional(),
+ whereToFind: z.string().optional(),
+ discussionNotes: z.string().nullish(),
+ discussed: z.boolean().optional(),
+ position,
+ }),
+ // reference is server-generated ("A01", shown as "MR-YYYY-NN-A01") and immutable.
+ reviewActionCreate: z.object({
+ reviewId: z.string(),
+ description: z.string().trim().min(1),
+ ownerMemberId: z.string().nullish(),
+ dueDate: z.string().nullish(), // ISO date string
+ status: z.enum(REVIEW_ACTION_STATUS).optional(),
+ position,
+ }),
+ reviewActionUpdate: z.object({
+ description: z.string().trim().min(1).optional(),
+ ownerMemberId: z.string().nullish(),
+ dueDate: z.string().nullish(),
+ status: z.enum(REVIEW_ACTION_STATUS).optional(),
+ position,
+ }),
} as const;
/** One-save payload for the "Metrics due" / backfill views. */
@@ -319,6 +383,20 @@ export type CreateAuditFindingInput = z.infer<
export type UpdateAuditFindingInput = z.infer<
typeof schemas.auditFindingUpdate
>;
+export type CreateReviewInput = z.infer;
+export type UpdateReviewInput = z.infer;
+export type CreateReviewInputInput = z.infer<
+ typeof schemas.reviewInputCreate
+>;
+export type UpdateReviewInputInput = z.infer<
+ typeof schemas.reviewInputUpdate
+>;
+export type CreateReviewActionInput = z.infer<
+ typeof schemas.reviewActionCreate
+>;
+export type UpdateReviewActionInput = z.infer<
+ typeof schemas.reviewActionUpdate
+>;
export const ISMS_REGISTER_KEYS = [
'context-issues',
@@ -332,6 +410,9 @@ export const ISMS_REGISTER_KEYS = [
'audits',
'audit-controls',
'audit-findings',
+ 'reviews',
+ 'review-inputs',
+ 'review-actions',
] as const;
export type IsmsRegisterKey = (typeof ISMS_REGISTER_KEYS)[number];
@@ -375,6 +456,9 @@ export interface RegisterServices {
audits: IsmsAuditService;
auditControls: IsmsAuditControlService;
auditFindings: IsmsAuditFindingService;
+ reviews: IsmsManagementReviewService;
+ reviewInputs: IsmsReviewInputService;
+ reviewActions: IsmsReviewActionService;
}
/** Build the register → handler map from the injected per-register services. */
@@ -565,6 +649,54 @@ export function createRegisterRegistry(
remove: ({ rowId, organizationId }) =>
services.auditFindings.remove({ findingId: rowId, organizationId }),
},
+ reviews: {
+ create: ({ documentId, organizationId, data }) =>
+ services.reviews.create({
+ documentId,
+ organizationId,
+ dto: parse(schemas.reviewCreate, data),
+ }),
+ update: ({ rowId, organizationId, data }) =>
+ services.reviews.update({
+ reviewId: rowId,
+ organizationId,
+ dto: parse(schemas.reviewUpdate, data),
+ }),
+ remove: ({ rowId, organizationId }) =>
+ services.reviews.remove({ reviewId: rowId, organizationId }),
+ },
+ 'review-inputs': {
+ create: ({ documentId, organizationId, data }) =>
+ services.reviewInputs.create({
+ documentId,
+ organizationId,
+ dto: parse(schemas.reviewInputCreate, data),
+ }),
+ update: ({ rowId, organizationId, data }) =>
+ services.reviewInputs.update({
+ inputId: rowId,
+ organizationId,
+ dto: parse(schemas.reviewInputUpdate, data),
+ }),
+ remove: ({ rowId, organizationId }) =>
+ services.reviewInputs.remove({ inputId: rowId, organizationId }),
+ },
+ 'review-actions': {
+ create: ({ documentId, organizationId, data }) =>
+ services.reviewActions.create({
+ documentId,
+ organizationId,
+ dto: parse(schemas.reviewActionCreate, data),
+ }),
+ update: ({ rowId, organizationId, data }) =>
+ services.reviewActions.update({
+ actionId: rowId,
+ organizationId,
+ dto: parse(schemas.reviewActionUpdate, data),
+ }),
+ remove: ({ rowId, organizationId }) =>
+ services.reviewActions.remove({ actionId: rowId, organizationId }),
+ },
};
}
diff --git a/apps/api/src/isms/utils/document-types.spec.ts b/apps/api/src/isms/utils/document-types.spec.ts
index a6118439a8..06a42ca7d3 100644
--- a/apps/api/src/isms/utils/document-types.spec.ts
+++ b/apps/api/src/isms/utils/document-types.spec.ts
@@ -1,8 +1,8 @@
import { ISMS_TYPE_DEFINITIONS, matchRequirementId } from './document-types';
describe('ISMS_TYPE_DEFINITIONS', () => {
- it('defines all nine foundational document types with clauses', () => {
- expect(ISMS_TYPE_DEFINITIONS).toHaveLength(9);
+ it('defines all ten foundational document types with clauses', () => {
+ expect(ISMS_TYPE_DEFINITIONS).toHaveLength(10);
const types = ISMS_TYPE_DEFINITIONS.map((d) => d.type);
expect(types).toEqual(
expect.arrayContaining([
@@ -15,6 +15,7 @@ describe('ISMS_TYPE_DEFINITIONS', () => {
'objectives_plan',
'monitoring',
'internal_audit',
+ 'management_review',
]),
);
});
@@ -33,6 +34,13 @@ describe('ISMS_TYPE_DEFINITIONS', () => {
expect(internalAudit?.clause).toBe('9.2');
});
+ it('maps management_review to clause 9.3', () => {
+ const managementReview = ISMS_TYPE_DEFINITIONS.find(
+ (d) => d.type === 'management_review',
+ );
+ expect(managementReview?.clause).toBe('9.3');
+ });
+
it('maps 4.2 to both interested-parties documents', () => {
const clause42 = ISMS_TYPE_DEFINITIONS.filter((d) => d.clause === '4.2');
expect(clause42.map((d) => d.type)).toEqual([
diff --git a/apps/api/src/isms/utils/document-types.ts b/apps/api/src/isms/utils/document-types.ts
index 149aebf739..935dd725a2 100644
--- a/apps/api/src/isms/utils/document-types.ts
+++ b/apps/api/src/isms/utils/document-types.ts
@@ -80,6 +80,13 @@ export const ISMS_TYPE_DEFINITIONS: IsmsTypeDefinition[] = [
description:
'The internal audit programme and the plan, controls tested, findings and conclusion of each internal audit of the ISMS (ISO 27001 clause 9.2).',
},
+ {
+ type: 'management_review',
+ clause: '9.3',
+ title: 'Management Review',
+ description:
+ 'The management review procedure and the minutes of each review — inputs considered, outputs, actions arising and chair sign-off (ISO 27001 clause 9.3).',
+ },
];
/**
diff --git a/apps/api/src/isms/utils/export-payload.ts b/apps/api/src/isms/utils/export-payload.ts
index c8e48ad3bb..f5753b1194 100644
--- a/apps/api/src/isms/utils/export-payload.ts
+++ b/apps/api/src/isms/utils/export-payload.ts
@@ -17,6 +17,11 @@ import {
mapAudits,
type InternalAuditExtras,
} from '../documents/internal-audit-export-data';
+import {
+ loadManagementReviewExtras,
+ mapReviews,
+ type ManagementReviewExtras,
+} from '../documents/management-review-export-data';
import type {
DocumentExportInput,
IsmsOrgProfile,
@@ -75,6 +80,16 @@ export const EXPORT_DOCUMENT_INCLUDE = {
findings: { orderBy: { position: 'asc' } },
},
},
+ reviews: {
+ // Deterministic tie-breakers: review order drives which actions carry
+ // forward into which review's input (a), so two reviews sharing a
+ // position must order the same on every read.
+ orderBy: [{ position: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
+ include: {
+ inputs: { orderBy: { position: 'asc' } },
+ actions: { orderBy: { position: 'asc' } },
+ },
+ },
} satisfies Prisma.IsmsDocumentInclude;
export type LoadedExportDocument = Prisma.IsmsDocumentGetPayload<{
@@ -138,6 +153,18 @@ export async function resolveInternalAuditExtras(
});
}
+/** The Management Review document (9.3) resolves action-owner names; other types don't. */
+export async function resolveManagementReviewExtras(
+ document: LoadedExportDocument,
+ client?: Prisma.TransactionClient,
+): Promise {
+ if (document.type !== 'management_review') return undefined;
+ return loadManagementReviewExtras({
+ organizationId: document.organizationId,
+ client,
+ });
+}
+
function formatDateYmd(date: Date | null): string | null {
return date ? date.toISOString().slice(0, 10) : null;
}
@@ -176,12 +203,14 @@ export function buildExportInput({
rolesExtras,
monitoringExtras,
internalAuditExtras,
+ managementReviewExtras,
}: {
document: LoadedExportDocument;
orgProfile?: IsmsOrgProfile;
rolesExtras?: RolesExtras;
monitoringExtras?: MonitoringExtras;
internalAuditExtras?: InternalAuditExtras;
+ managementReviewExtras?: ManagementReviewExtras;
}): DocumentExportInput {
return {
contextIssues: document.contextIssues.map((issue) => ({
@@ -219,6 +248,9 @@ export function buildExportInput({
audits: internalAuditExtras
? mapAudits(document.audits, internalAuditExtras)
: undefined,
+ reviews: managementReviewExtras
+ ? mapReviews(document.reviews, managementReviewExtras)
+ : undefined,
};
}
@@ -241,12 +273,15 @@ export async function buildDraftSnapshot(
const rolesExtras = await resolveRolesExtras(document);
const monitoringExtras = await resolveMonitoringExtras(document);
const internalAuditExtras = await resolveInternalAuditExtras(document);
+ const managementReviewExtras =
+ await resolveManagementReviewExtras(document);
const input = buildExportInput({
document,
orgProfile,
rolesExtras,
monitoringExtras,
internalAuditExtras,
+ managementReviewExtras,
});
const metadata = buildExportMetadata({
type: document.type,
diff --git a/apps/api/src/isms/utils/review-participants.ts b/apps/api/src/isms/utils/review-participants.ts
new file mode 100644
index 0000000000..d1bcffbe1a
--- /dev/null
+++ b/apps/api/src/isms/utils/review-participants.ts
@@ -0,0 +1,101 @@
+import { NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import type { Prisma } from '@db';
+import type { ReviewAttendee } from '../documents/management-review';
+
+type Client = Prisma.TransactionClient | typeof db;
+
+function memberDisplayName(
+ user: { name: string | null; email: string | null } | null,
+): string {
+ return user?.name?.trim() || user?.email?.trim() || 'Unknown member';
+}
+
+/**
+ * Resolve the ticket's participant defaults for a new review from the ISMS >
+ * Roles (5.3) document: chair = the first active Top Management holder,
+ * attendees = Chair + the first active SPO holder, deduped by member (for a
+ * 1-3 person org they may be the same person — one attendee is the honest
+ * default). Names are resolved server-side and frozen at creation; everything
+ * degrades to empty when Roles has no holders yet (same as the monitoring
+ * SPO-fallback precedent, which also tolerates an unassigned Roles document).
+ */
+export async function resolveReviewParticipantDefaults({
+ tx,
+ organizationId,
+}: {
+ tx: Client;
+ organizationId: string;
+}): Promise<{ chairName: string | null; attendees: ReviewAttendee[] }> {
+ const [members, roles] = await Promise.all([
+ tx.member.findMany({
+ where: { organizationId, deactivated: false },
+ select: { id: true, user: { select: { name: true, email: true } } },
+ }),
+ tx.ismsRole.findMany({
+ where: {
+ roleKey: { in: ['top_management', 'spo'] },
+ document: { organizationId, type: 'roles_and_responsibilities' },
+ },
+ select: {
+ roleKey: true,
+ assignments: {
+ // Deterministic tie-breaker: two assignments sharing a position must
+ // resolve the same default chair/SPO on every read.
+ orderBy: [{ position: 'asc' }, { id: 'asc' }],
+ select: { memberId: true },
+ },
+ },
+ }),
+ ]);
+
+ const memberNames = new Map(
+ members.map((member) => [member.id, memberDisplayName(member.user)]),
+ );
+ const firstActiveHolder = (roleKey: string): ReviewAttendee | null => {
+ const role = roles.find((row) => row.roleKey === roleKey);
+ for (const assignment of role?.assignments ?? []) {
+ const name = memberNames.get(assignment.memberId);
+ if (name) return { memberId: assignment.memberId, name };
+ }
+ return null;
+ };
+
+ const chair = firstActiveHolder('top_management');
+ const spo = firstActiveHolder('spo');
+
+ const attendees: ReviewAttendee[] = [];
+ for (const attendee of [chair, spo]) {
+ if (!attendee) continue;
+ if (attendees.some((row) => row.memberId === attendee.memberId)) continue;
+ attendees.push(attendee);
+ }
+
+ return { chairName: chair?.name ?? null, attendees };
+}
+
+/**
+ * Attendees are selected from People, so every memberId must belong to this
+ * organization. Deactivated members stay valid — the minutes are a historical
+ * record, and editing a review after someone leaves must not force removing
+ * them from it. Display names are client-frozen at selection (the CS-724
+ * auditorName precedent: never re-validated against the live roster).
+ */
+export async function validateReviewAttendees({
+ attendees,
+ organizationId,
+}: {
+ attendees: ReviewAttendee[];
+ organizationId: string;
+}): Promise {
+ if (attendees.length === 0) return;
+ const memberIds = [...new Set(attendees.map((row) => row.memberId))];
+ const found = await db.member.count({
+ where: { id: { in: memberIds }, organizationId },
+ });
+ if (found !== memberIds.length) {
+ throw new NotFoundException(
+ 'One or more attendees are not members of this organization',
+ );
+ }
+}
diff --git a/apps/api/src/isms/wizard/isms-profile.service.ts b/apps/api/src/isms/wizard/isms-profile.service.ts
index be66f9dd55..4d81cf4684 100644
--- a/apps/api/src/isms/wizard/isms-profile.service.ts
+++ b/apps/api/src/isms/wizard/isms-profile.service.ts
@@ -35,6 +35,7 @@ const GENERATION_ORDER: Record = {
objectives_plan: 6,
monitoring: 7,
internal_audit: 8,
+ management_review: 9,
};
const GENERATION_ORDER_DEFAULT = Object.keys(GENERATION_ORDER).length;
diff --git a/apps/api/src/roles/dto/create-role.dto.ts b/apps/api/src/roles/dto/create-role.dto.ts
index 3b549f3f86..150c1c535e 100644
--- a/apps/api/src/roles/dto/create-role.dto.ts
+++ b/apps/api/src/roles/dto/create-role.dto.ts
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
import {
IsNotEmpty,
IsObject,
@@ -7,7 +8,9 @@ import {
MaxLength,
MinLength,
Matches,
+ ValidateNested,
} from 'class-validator';
+import { BuiltInObligationsBody } from './update-built-in-obligations.dto';
export class CreateRoleDto {
@ApiProperty({
@@ -44,8 +47,11 @@ export class CreateRoleDto {
'Obligations for the role. Boolean flags for requirements like compliance.',
example: { compliance: true },
required: false,
+ type: BuiltInObligationsBody,
})
- @IsObject()
@IsOptional()
- obligations?: Record;
+ @IsObject()
+ @ValidateNested()
+ @Type(() => BuiltInObligationsBody)
+ obligations?: BuiltInObligationsBody;
}
diff --git a/apps/api/src/roles/dto/role-obligations-validation.dto.spec.ts b/apps/api/src/roles/dto/role-obligations-validation.dto.spec.ts
new file mode 100644
index 0000000000..aecb12d75e
--- /dev/null
+++ b/apps/api/src/roles/dto/role-obligations-validation.dto.spec.ts
@@ -0,0 +1,72 @@
+import { plainToInstance } from 'class-transformer';
+import { validate } from 'class-validator';
+import { CreateRoleDto } from './create-role.dto';
+import { UpdateRoleDto } from './update-role.dto';
+
+// Regression for cubic-dev-ai's review comment on RolesService: obligations
+// is now validated as a real nested object (BuiltInObligationsBody, with
+// `@IsBoolean()` on `compliance`) instead of an unchecked `Record`, so malformed shapes are rejected before ever reaching
+// RolesService.
+//
+// Note on what this DOES NOT catch: the global ValidationPipe runs with
+// `transformOptions.enableImplicitConversion: true` (main.ts), and
+// class-transformer's implicit conversion for a `Boolean`-typed property is
+// a bare `Boolean(value)` cast — which makes any truthy value (including
+// the string "false") convert to `true` *before* `@IsBoolean()` ever runs,
+// so `@IsBoolean()` alone can't reject a merely-truthy non-boolean over
+// HTTP. That's a pre-existing, app-wide characteristic of every
+// `@IsBoolean()` field under this pipe config, not something specific to
+// obligations — out of scope here. What IS specific to this invariant, and
+// what actually matters for the reported bug, is the DB-read path:
+// `RolesService.withCompliancePortalInvariant`'s exact `=== true` check
+// (see roles.service.spec.ts) guards `obligations` read back from stored,
+// unvalidated JSON (`parseObligationsField`), which never goes through
+// class-transformer at all — a stored string "false" stays the literal
+// string "false" there, and the exact check correctly treats it as unset.
+
+function toDto(
+ Dto: new () => T,
+ plain: Record,
+): T {
+ return plainToInstance(Dto, plain, { enableImplicitConversion: true });
+}
+
+describe('CreateRoleDto obligations validation', () => {
+ const base = { name: 'Compliance Lead', permissions: { control: ['read'] } };
+
+ it('accepts a boolean compliance value', async () => {
+ const dto = toDto(CreateRoleDto, {
+ ...base,
+ obligations: { compliance: true },
+ });
+ const errors = await validate(dto);
+ expect(errors.filter((e) => e.property === 'obligations')).toHaveLength(0);
+ });
+
+ it('accepts omitted obligations', async () => {
+ const dto = toDto(CreateRoleDto, base);
+ const errors = await validate(dto);
+ expect(errors.filter((e) => e.property === 'obligations')).toHaveLength(0);
+ });
+
+ it('rejects a non-object obligations value', async () => {
+ const dto = toDto(CreateRoleDto, { ...base, obligations: 'not-an-object' });
+ const errors = await validate(dto);
+ expect(errors.some((e) => e.property === 'obligations')).toBe(true);
+ });
+});
+
+describe('UpdateRoleDto obligations validation', () => {
+ it('accepts a boolean compliance value', async () => {
+ const dto = toDto(UpdateRoleDto, { obligations: { compliance: false } });
+ const errors = await validate(dto);
+ expect(errors.filter((e) => e.property === 'obligations')).toHaveLength(0);
+ });
+
+ it('rejects a non-object obligations value', async () => {
+ const dto = toDto(UpdateRoleDto, { obligations: 'not-an-object' });
+ const errors = await validate(dto);
+ expect(errors.some((e) => e.property === 'obligations')).toBe(true);
+ });
+});
diff --git a/apps/api/src/roles/dto/update-role.dto.ts b/apps/api/src/roles/dto/update-role.dto.ts
index f24b61e251..131bd1b39a 100644
--- a/apps/api/src/roles/dto/update-role.dto.ts
+++ b/apps/api/src/roles/dto/update-role.dto.ts
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
import {
IsObject,
IsOptional,
@@ -6,7 +7,9 @@ import {
MaxLength,
MinLength,
Matches,
+ ValidateNested,
} from 'class-validator';
+import { BuiltInObligationsBody } from './update-built-in-obligations.dto';
export class UpdateRoleDto {
@ApiProperty({
@@ -44,8 +47,11 @@ export class UpdateRoleDto {
description: 'Updated obligations for the role.',
example: { compliance: true },
required: false,
+ type: BuiltInObligationsBody,
})
- @IsObject()
@IsOptional()
- obligations?: Record;
+ @IsObject()
+ @ValidateNested()
+ @Type(() => BuiltInObligationsBody)
+ obligations?: BuiltInObligationsBody;
}
diff --git a/apps/api/src/roles/roles.controller.ts b/apps/api/src/roles/roles.controller.ts
index d28ccd3242..3ae0aeb94b 100644
--- a/apps/api/src/roles/roles.controller.ts
+++ b/apps/api/src/roles/roles.controller.ts
@@ -304,6 +304,10 @@ export class RolesController {
description: 'Forbidden - cannot grant permissions you do not have',
})
@ApiResponse({ status: 404, description: 'Role not found' })
+ @ApiResponse({
+ status: 409,
+ description: 'Role was modified by another request since it was read. Retry the update.',
+ })
async updateRole(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
diff --git a/apps/api/src/roles/roles.service.spec.ts b/apps/api/src/roles/roles.service.spec.ts
index ee709e6f5f..3291acbb7e 100644
--- a/apps/api/src/roles/roles.service.spec.ts
+++ b/apps/api/src/roles/roles.service.spec.ts
@@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
BadRequestException,
+ ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
@@ -28,6 +29,7 @@ jest.mock('@trycompai/auth', () => {
apiKey: ['create', 'read', 'delete'],
app: ['read'],
trust: ['read', 'update'],
+ portal: ['read', 'update'],
};
const BUILT_IN_ROLE_PERMISSIONS: Record> = {
@@ -94,9 +96,11 @@ jest.mock('@db', () => ({
organizationRole: {
findFirst: jest.fn(),
findMany: jest.fn(),
+ findUniqueOrThrow: jest.fn(),
count: jest.fn(),
create: jest.fn(),
update: jest.fn(),
+ updateMany: jest.fn(),
upsert: jest.fn(),
delete: jest.fn(),
},
@@ -340,6 +344,132 @@ describe('RolesService', () => {
service.createRole(organizationId, dto, ['employee', 'auditor']),
).resolves.toBeDefined();
});
+
+ it('grants portal:read/update when the compliance obligation is set, even if not requested', async () => {
+ // Regression: a role created with obligations.compliance=true via any
+ // caller other than the roles settings UI (which syncs this itself in
+ // PermissionMatrix.tsx) would otherwise lack 'portal' entirely and be
+ // unable to reach portal-gated endpoints (e.g. training completions).
+ const dto = {
+ name: 'devops-engineer',
+ permissions: { control: ['read'] },
+ obligations: { compliance: true },
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.organizationRole.count as jest.Mock).mockResolvedValue(0);
+ (mockDb.organizationRole.create as jest.Mock).mockImplementation(
+ ({ data }) => ({
+ id: 'rol_devops',
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }),
+ );
+
+ const result = await service.createRole(organizationId, dto, ['owner']);
+
+ expect(result.permissions.portal).toEqual(
+ expect.arrayContaining(['read', 'update']),
+ );
+ });
+
+ it('does not add portal without the compliance obligation', async () => {
+ const dto = {
+ name: 'read-only-role',
+ permissions: { control: ['read'] },
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.organizationRole.count as jest.Mock).mockResolvedValue(0);
+ (mockDb.organizationRole.create as jest.Mock).mockImplementation(
+ ({ data }) => ({
+ id: 'rol_readonly',
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }),
+ );
+
+ const result = await service.createRole(organizationId, dto, ['owner']);
+
+ expect(result.permissions.portal).toBeUndefined();
+ });
+
+ it('does not treat a malformed non-boolean compliance value as enabled', async () => {
+ // Regression: `withCompliancePortalInvariant` must use an exact
+ // `=== true` check, not a truthy check — a value like the string
+ // "false" is truthy in JS, so a naive `if (obligations.compliance)`
+ // would incorrectly grant portal. This can happen because obligations
+ // is `unknown` JSON read back from the DB in other code paths
+ // (parseObligationsField casts without validating), even though the
+ // DTO layer now validates `compliance` as a real boolean on input.
+ const dto = {
+ name: 'malformed-compliance-role',
+ permissions: { control: ['read'] },
+ obligations: { compliance: 'false' as unknown as boolean },
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.organizationRole.count as jest.Mock).mockResolvedValue(0);
+ (mockDb.organizationRole.create as jest.Mock).mockImplementation(
+ ({ data }) => ({
+ id: 'rol_malformed',
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }),
+ );
+
+ const result = await service.createRole(organizationId, dto, ['owner']);
+
+ expect(result.permissions.portal).toBeUndefined();
+ });
+
+ it('does not duplicate portal actions already requested alongside the compliance obligation', async () => {
+ const dto = {
+ name: 'portal-explicit',
+ permissions: { portal: ['read'] },
+ obligations: { compliance: true },
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.organizationRole.count as jest.Mock).mockResolvedValue(0);
+ (mockDb.organizationRole.create as jest.Mock).mockImplementation(
+ ({ data }) => ({
+ id: 'rol_portal',
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }),
+ );
+
+ const result = await service.createRole(organizationId, dto, ['owner']);
+
+ expect(result.permissions.portal.sort()).toEqual(['read', 'update']);
+ });
+
+ it('rejects creating a compliance-obligated role when the caller lacks portal access (privilege escalation)', async () => {
+ // Regression: the compliance -> portal invariant must be validated,
+ // not just applied — otherwise a caller without 'portal' could grant
+ // it to a role merely by setting obligations.compliance=true.
+ const dto = {
+ name: 'devops-engineer',
+ permissions: { control: ['read'] }, // 'auditor' has this
+ obligations: { compliance: true },
+ };
+
+ // 'auditor' in this mock has control:['read'] but no 'portal' key.
+ await expect(
+ service.createRole(organizationId, dto, ['auditor']),
+ ).rejects.toThrow(ForbiddenException);
+ await expect(
+ service.createRole(organizationId, dto, ['auditor']),
+ ).rejects.toThrow(
+ "Cannot grant 'portal:read' permission - you don't have this permission",
+ );
+ expect(mockDb.organizationRole.create).not.toHaveBeenCalled();
+ });
});
describe('listRoles', () => {
@@ -407,6 +537,14 @@ describe('RolesService', () => {
describe('updateRole', () => {
const organizationId = 'org_123';
const roleId = 'rol_123';
+ const roleVersion = new Date('2026-01-01T00:00:00Z');
+
+ /** Wires the optimistic-concurrency write path to succeed: `updateMany` reports one row matched (the version check passed). */
+ function mockSuccessfulWrite() {
+ (mockDb.organizationRole.updateMany as jest.Mock).mockResolvedValue({
+ count: 1,
+ });
+ }
it('should update role name', async () => {
const existingRole = {
@@ -414,18 +552,14 @@ describe('RolesService', () => {
name: 'old-name',
permissions: JSON.stringify({ control: ['read'] }),
obligations: '{}',
+ updatedAt: roleVersion,
};
(mockDb.organizationRole.findFirst as jest.Mock)
.mockResolvedValueOnce(existingRole) // First call: find role to update
.mockResolvedValueOnce(null); // Second call: check name uniqueness
- (mockDb.organizationRole.update as jest.Mock).mockResolvedValue({
- ...existingRole,
- name: 'new-name',
- obligations: '{}',
- updatedAt: new Date(),
- });
+ mockSuccessfulWrite();
const result = await service.updateRole(
organizationId,
@@ -443,6 +577,7 @@ describe('RolesService', () => {
name: 'old-name',
permissions: JSON.stringify({ control: ['read'] }),
obligations: '{}',
+ updatedAt: roleVersion,
};
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(
@@ -472,6 +607,7 @@ describe('RolesService', () => {
name: 'limited-role',
permissions: JSON.stringify({ task: ['read'] }),
obligations: '{}',
+ updatedAt: roleVersion,
};
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(
@@ -488,6 +624,246 @@ describe('RolesService', () => {
),
).rejects.toThrow(ForbiddenException);
});
+
+ it('rejects an obligations-only update that would grant portal access when the caller lacks it (privilege escalation)', async () => {
+ // Regression (P1): before this check ran against permissionsToPersist,
+ // an obligations-only update never validated privilege escalation at
+ // all, so any caller could grant a role portal access just by
+ // flipping obligations.compliance=true — even a caller without portal
+ // permission themselves.
+ const existingRole = {
+ id: roleId,
+ name: 'devops-engineer',
+ permissions: JSON.stringify({ control: ['read'] }), // 'auditor' has this
+ obligations: '{}',
+ updatedAt: roleVersion,
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(
+ existingRole,
+ );
+
+ // 'auditor' has control:['read'] (matches the existing role) but no
+ // 'portal' key — the newly-merged portal grant must be caught.
+ await expect(
+ service.updateRole(
+ organizationId,
+ roleId,
+ { obligations: { compliance: true } },
+ ['auditor'],
+ ),
+ ).rejects.toThrow(ForbiddenException);
+ expect(mockDb.organizationRole.updateMany).not.toHaveBeenCalled();
+ });
+
+ it('allows an obligations-only update that grants nothing new, even if the role already holds a permission the caller lacks (P2 regression)', async () => {
+ // Regression (P2): re-validating the ENTIRE resulting permission set
+ // (rather than just what this request newly grants) meant an
+ // obligations-only update could fail against a role that already
+ // legitimately held a permission the caller doesn't personally have
+ // — even though this request isn't touching that permission at all.
+ // The role here already has 'secret' and 'portal' (e.g. granted
+ // earlier by an owner); toggling compliance off doesn't strip either
+ // (one-directional by design) and adds nothing new, so an 'auditor'
+ // caller — who has neither 'secret' nor 'portal' — must still be able
+ // to make this update.
+ const existingRole = {
+ id: roleId,
+ name: 'devops-engineer',
+ permissions: JSON.stringify({
+ secret: ['read'],
+ portal: ['read', 'update'],
+ }),
+ obligations: JSON.stringify({ compliance: true }),
+ updatedAt: roleVersion,
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(
+ existingRole,
+ );
+ mockSuccessfulWrite();
+
+ await expect(
+ service.updateRole(
+ organizationId,
+ roleId,
+ { obligations: { compliance: false } },
+ ['auditor'],
+ ),
+ ).resolves.toBeDefined();
+ expect(mockDb.organizationRole.updateMany).toHaveBeenCalled();
+ });
+
+ it('grants portal:read/update when enabling the compliance obligation on an obligations-only update', async () => {
+ // An obligations-only PATCH (no `permissions` in the request) must
+ // still merge portal into the role's EXISTING stored permissions —
+ // otherwise re-saving obligations alone wouldn't fix a role that was
+ // missing portal.
+ const existingRole = {
+ id: roleId,
+ name: 'devops-engineer',
+ permissions: JSON.stringify({ control: ['read'] }),
+ obligations: '{}',
+ updatedAt: roleVersion,
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(
+ existingRole,
+ );
+ mockSuccessfulWrite();
+
+ const result = await service.updateRole(
+ organizationId,
+ roleId,
+ { obligations: { compliance: true } },
+ ['owner'],
+ );
+
+ expect(result.permissions.portal).toEqual(
+ expect.arrayContaining(['read', 'update']),
+ );
+ expect(result.permissions.control).toEqual(['read']);
+ });
+
+ it('grants portal:read/update when updating permissions on a role whose existing obligations already require compliance', async () => {
+ // A permissions-only update (no `obligations` in the request) must
+ // still see the role's EXISTING obligations to know portal applies.
+ const existingRole = {
+ id: roleId,
+ name: 'devops-engineer',
+ permissions: JSON.stringify({ control: ['read'] }),
+ obligations: JSON.stringify({ compliance: true }),
+ updatedAt: roleVersion,
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(
+ existingRole,
+ );
+ mockSuccessfulWrite();
+
+ const result = await service.updateRole(
+ organizationId,
+ roleId,
+ { permissions: { control: ['read', 'update'] } },
+ ['owner'],
+ );
+
+ expect(result.permissions.portal).toEqual(
+ expect.arrayContaining(['read', 'update']),
+ );
+ });
+
+ it('does not touch permissions when neither permissions nor obligations are part of the update', async () => {
+ const existingRole = {
+ id: roleId,
+ name: 'old-name',
+ permissions: JSON.stringify({ control: ['read'] }),
+ obligations: '{}',
+ updatedAt: roleVersion,
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock)
+ .mockResolvedValueOnce(existingRole)
+ .mockResolvedValueOnce(null);
+ mockSuccessfulWrite();
+
+ const result = await service.updateRole(
+ organizationId,
+ roleId,
+ { name: 'new-name' },
+ ['owner'],
+ );
+
+ expect(mockDb.organizationRole.updateMany).toHaveBeenCalledWith({
+ where: { id: roleId, organizationId, updatedAt: roleVersion },
+ data: { name: 'new-name', updatedAt: expect.any(Date) },
+ });
+ // Response is built from the pre-write role snapshot + this request's
+ // own changes, not a second DB read — permissions/obligations are
+ // untouched here, so they must reflect the existing row verbatim.
+ expect(result.permissions).toEqual({ control: ['read'] });
+ expect(result.obligations).toEqual({});
+ });
+
+ it('rejects with a conflict when the role was modified concurrently since it was read', async () => {
+ // Regression: the privilege-escalation check above ran against a
+ // snapshot of the role read at the top of the function. If another
+ // request changed the row in between, writing unconditionally could
+ // silently reintroduce a permission that request just removed,
+ // without ever validating it against THIS request's caller. The
+ // write must be conditioned on the row still matching the exact
+ // version that was validated — `updateMany` reporting 0 rows matched
+ // means it didn't, and the update must be rejected rather than
+ // silently skipped or retried automatically.
+ const existingRole = {
+ id: roleId,
+ name: 'devops-engineer',
+ permissions: JSON.stringify({ control: ['read'] }),
+ obligations: '{}',
+ updatedAt: roleVersion,
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(
+ existingRole,
+ );
+ (mockDb.organizationRole.updateMany as jest.Mock).mockResolvedValue({
+ count: 0,
+ });
+
+ await expect(
+ service.updateRole(
+ organizationId,
+ roleId,
+ { permissions: { control: ['read', 'update'] } },
+ ['owner'],
+ ),
+ ).rejects.toThrow(ConflictException);
+ expect(mockDb.organizationRole.updateMany).toHaveBeenCalledWith({
+ where: { id: roleId, organizationId, updatedAt: roleVersion },
+ data: {
+ permissions: JSON.stringify({ control: ['read', 'update'] }),
+ updatedAt: expect.any(Date),
+ },
+ });
+ });
+
+ it('returns the exact version this request validated and wrote, built without a second DB read', async () => {
+ // Regression: reading the row back after the conditional write
+ // reopens the same race the write itself was guarding against — a
+ // third request could write in the gap between our update and that
+ // read, and this response would then reflect THAT write instead of
+ // the version this request validated and persisted. The response
+ // must come from local data (the pre-write snapshot + this request's
+ // own changes), not a follow-up query.
+ const existingRole = {
+ id: roleId,
+ name: 'devops-engineer',
+ permissions: JSON.stringify({ control: ['read'] }),
+ obligations: '{}',
+ createdAt: new Date('2025-01-01T00:00:00Z'),
+ updatedAt: roleVersion,
+ };
+
+ (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(
+ existingRole,
+ );
+ mockSuccessfulWrite();
+
+ const result = await service.updateRole(
+ organizationId,
+ roleId,
+ { permissions: { control: ['read', 'update'] } },
+ ['owner'],
+ );
+
+ expect(mockDb.organizationRole.findUniqueOrThrow).not.toHaveBeenCalled();
+ expect(result.permissions).toEqual({ control: ['read', 'update'] });
+ expect(result.createdAt).toEqual(existingRole.createdAt);
+ // The written updatedAt must be a freshly captured timestamp, not the
+ // pre-write version this request validated against.
+ expect(result.updatedAt).toBeInstanceOf(Date);
+ expect(result.updatedAt.getTime()).toBeGreaterThan(roleVersion.getTime());
+ });
});
describe('deleteRole', () => {
@@ -746,7 +1122,10 @@ describe('RolesService', () => {
it('returns the hardcoded default when no override row exists', async () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
- const result = await service.getBuiltInObligations(organizationId, 'owner');
+ const result = await service.getBuiltInObligations(
+ organizationId,
+ 'owner',
+ );
expect(result).toEqual({ compliance: true });
});
@@ -754,7 +1133,10 @@ describe('RolesService', () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue({
obligations: JSON.stringify({ compliance: false }),
});
- const result = await service.getBuiltInObligations(organizationId, 'owner');
+ const result = await service.getBuiltInObligations(
+ organizationId,
+ 'owner',
+ );
expect(result).toEqual({ compliance: false });
});
@@ -762,13 +1144,19 @@ describe('RolesService', () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue({
obligations: { compliance: false },
});
- const result = await service.getBuiltInObligations(organizationId, 'owner');
+ const result = await service.getBuiltInObligations(
+ organizationId,
+ 'owner',
+ );
expect(result).toEqual({ compliance: false });
});
it('returns empty for admin (no default compliance, no override)', async () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
- const result = await service.getBuiltInObligations(organizationId, 'admin');
+ const result = await service.getBuiltInObligations(
+ organizationId,
+ 'admin',
+ );
expect(result).toEqual({});
});
@@ -784,7 +1172,10 @@ describe('RolesService', () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue({
obligations: '{}',
});
- const result = await service.getBuiltInObligations(organizationId, 'owner');
+ const result = await service.getBuiltInObligations(
+ organizationId,
+ 'owner',
+ );
expect(result).toEqual({ compliance: true });
});
});
@@ -804,7 +1195,10 @@ describe('RolesService', () => {
{ compliance: false },
);
- expect(result).toEqual({ name: 'owner', obligations: { compliance: false } });
+ expect(result).toEqual({
+ name: 'owner',
+ obligations: { compliance: false },
+ });
expect(mockDb.organizationRole.upsert).toHaveBeenCalledWith({
where: { organizationId_name: { organizationId, name: 'owner' } },
create: expect.objectContaining({
@@ -924,14 +1318,18 @@ describe('RolesService', () => {
const result = await service.listRoles(organizationId);
// Override row must not appear as a custom role
- expect(result.customRoles.map((r) => r.name)).toEqual(['compliance-lead']);
+ expect(result.customRoles.map((r) => r.name)).toEqual([
+ 'compliance-lead',
+ ]);
// Built-in entries carry effective obligations — owner reflects the override
const ownerEntry = result.builtInRoles.find((r) => r.name === 'owner');
expect(ownerEntry?.obligations).toEqual({ compliance: false });
// Other built-ins still show their hardcoded defaults
const adminEntry = result.builtInRoles.find((r) => r.name === 'admin');
expect(adminEntry?.obligations).toEqual({});
- const employeeEntry = result.builtInRoles.find((r) => r.name === 'employee');
+ const employeeEntry = result.builtInRoles.find(
+ (r) => r.name === 'employee',
+ );
expect(employeeEntry?.obligations).toEqual({ compliance: true });
});
diff --git a/apps/api/src/roles/roles.service.ts b/apps/api/src/roles/roles.service.ts
index 12bb421e6f..73404581bb 100644
--- a/apps/api/src/roles/roles.service.ts
+++ b/apps/api/src/roles/roles.service.ts
@@ -1,6 +1,7 @@
import {
Injectable,
BadRequestException,
+ ConflictException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
@@ -79,6 +80,63 @@ export class RolesService {
}
}
+ /**
+ * Enforces the compliance -> portal permission invariant before a role is
+ * persisted: any role whose obligations require compliance (sign policies,
+ * watch training, etc.) must also carry `portal:read/update`. The
+ * custom-role editor UI keeps this in sync as a callback
+ * (PermissionMatrix.tsx's `handleObligationChange`), but that only covers
+ * the one client — a role created or updated any other way (public API,
+ * MCP, a future UI) could set `obligations.compliance` without `portal`
+ * and end up unable to reach portal-gated endpoints (e.g. training video
+ * completions). Normalizing here, right before every write, closes that
+ * gap regardless of caller. One-directional by design: it never strips an
+ * explicitly granted `portal` permission just because compliance is false.
+ */
+ private withCompliancePortalInvariant(
+ permissions: Record,
+ obligations: RoleObligations,
+ ): Record {
+ // Exact-equality check, not a truthy check: `obligations` can come from
+ // parsed, unvalidated DB JSON (parseObligationsField casts without
+ // validating), so a malformed non-boolean value like the string
+ // "false" must not be treated as enabled just because it's truthy.
+ if (obligations.compliance !== true) return permissions;
+
+ const portalActions = new Set([
+ ...(permissions.portal ?? []),
+ ...statement.portal,
+ ]);
+ return { ...permissions, portal: [...portalActions] };
+ }
+
+ /**
+ * Resources/actions present in `after` but not in `before` — what this
+ * request would actually grant that the role didn't already have. Used so
+ * an update only needs privilege-escalation validation for what's newly
+ * granted, not the role's entire resulting permission set: re-checking
+ * everything would fail an obligations-only (or otherwise unrelated)
+ * update against a role that already holds a permission the caller
+ * themselves doesn't have, even though that permission was legitimately
+ * granted earlier and this request isn't touching it.
+ */
+ private diffNewlyGrantedPermissions(
+ before: Record,
+ after: Record,
+ ): Record {
+ const added: Record = {};
+ for (const [resource, actions] of Object.entries(after)) {
+ const beforeActions = before[resource] ?? [];
+ const newActions = actions.filter(
+ (action) => !beforeActions.includes(action),
+ );
+ if (newActions.length > 0) {
+ added[resource] = newActions;
+ }
+ }
+ return added;
+ }
+
/**
* Check if caller has all the permissions they're trying to grant.
* Prevents privilege escalation.
@@ -206,13 +264,23 @@ export class RolesService {
);
}
- // Validate permissions
+ // Validate permission shape (resource/action names) as submitted.
this.validatePermissions(dto.permissions);
- // Check for privilege escalation
+ // Derive the final permission set (including the compliance -> portal
+ // invariant) before checking privilege escalation, so a caller can't
+ // grant themselves/others portal access merely by setting
+ // obligations.compliance=true without explicitly requesting 'portal'.
+ const obligations = dto.obligations || {};
+ const permissions = this.withCompliancePortalInvariant(
+ dto.permissions,
+ obligations,
+ );
+
+ // Check for privilege escalation against the FINAL permission set.
await this.validateNoPrivilegeEscalation(
callerRoles,
- dto.permissions,
+ permissions,
organizationId,
);
@@ -245,8 +313,8 @@ export class RolesService {
const role = await db.organizationRole.create({
data: {
name: dto.name,
- permissions: JSON.stringify(dto.permissions),
- obligations: JSON.stringify(dto.obligations || {}),
+ permissions: JSON.stringify(permissions),
+ obligations: JSON.stringify(obligations),
organizationId,
},
});
@@ -398,44 +466,118 @@ export class RolesService {
}
}
- // Validate and check permissions if provided
+ // Validate permission shape (resource/action names) as submitted.
if (dto.permissions) {
this.validatePermissions(dto.permissions);
- await this.validateNoPrivilegeEscalation(
- callerRoles,
- dto.permissions,
- organizationId,
+ }
+
+ // Re-derive the compliance -> portal invariant whenever either
+ // permissions or obligations change, using the existing row's stored
+ // value for whichever side wasn't part of this request (e.g. an
+ // obligations-only update must still see the role's current
+ // permissions to merge portal into).
+ let permissionsToPersist: Record | undefined;
+ if (dto.permissions !== undefined || dto.obligations !== undefined) {
+ const existingPermissions: Record =
+ typeof role.permissions === 'string'
+ ? (JSON.parse(role.permissions) as Record)
+ : role.permissions;
+ const effectiveObligations =
+ dto.obligations !== undefined
+ ? dto.obligations
+ : parseObligationsField(role.obligations);
+ const effectivePermissions =
+ dto.permissions !== undefined ? dto.permissions : existingPermissions;
+ permissionsToPersist = this.withCompliancePortalInvariant(
+ effectivePermissions,
+ effectiveObligations,
+ );
+
+ // Validate only what this request would newly grant relative to the
+ // role's current permissions — not the entire resulting set. This
+ // still catches the portal grant the invariant above may have just
+ // added on an obligations-only update (no `dto.permissions` at all),
+ // which would otherwise let a caller without portal access grant it
+ // to a role simply by toggling the compliance obligation. But it
+ // must NOT re-validate permissions the role already legitimately
+ // held before this request — otherwise an obligations-only (or
+ // otherwise unrelated) update fails whenever the role holds some
+ // permission the caller doesn't personally have, even though this
+ // request isn't touching it.
+ const newlyGranted = this.diffNewlyGrantedPermissions(
+ existingPermissions,
+ permissionsToPersist,
);
+ if (Object.keys(newlyGranted).length > 0) {
+ await this.validateNoPrivilegeEscalation(
+ callerRoles,
+ newlyGranted,
+ organizationId,
+ );
+ }
}
- // Update the role
- const updated = await db.organizationRole.update({
- where: { id: roleId },
+ // Update the role, guarded by an optimistic-concurrency check on
+ // `updatedAt`. The privilege-escalation validation above ran against
+ // the `role` snapshot read at the top of this function — if another
+ // request changed the row in between (e.g. an owner just revoked a
+ // permission from it), writing unconditionally here could silently
+ // reintroduce that permission on top of the concurrent change, never
+ // validated against the caller who's making THIS request. `updateMany`
+ // (unlike `update`, whose `where` is restricted to unique fields) can
+ // include `updatedAt` in the filter, so the write only applies if the
+ // row still matches the exact version we validated against; `count`
+ // tells us whether it did.
+ //
+ // `writeTimestamp` is set explicitly (overriding the `@updatedAt`
+ // default) rather than left to Prisma/Postgres, so the exact value
+ // persisted is known without reading it back.
+ const writeTimestamp = new Date(
+ Math.max(Date.now(), role.updatedAt.getTime() + 1),
+ );
+ const updateResult = await db.organizationRole.updateMany({
+ where: { id: roleId, organizationId, updatedAt: role.updatedAt },
data: {
...(dto.name && { name: dto.name }),
- ...(dto.permissions && {
- permissions: JSON.stringify(dto.permissions),
+ ...(permissionsToPersist && {
+ permissions: JSON.stringify(permissionsToPersist),
}),
...(dto.obligations !== undefined && {
obligations: JSON.stringify(dto.obligations),
}),
+ updatedAt: writeTimestamp,
},
});
+ if (updateResult.count === 0) {
+ throw new ConflictException(
+ `Role '${role.name}' was modified by another request. Please retry.`,
+ );
+ }
+
+ // Built from what this request validated and just conditionally wrote —
+ // not from a separate read after the fact. A second read here would
+ // reopen the same race: another request could write in the gap between
+ // our conditional update and that read, and this response would then
+ // reflect that other request's state instead of the version this
+ // request actually validated and persisted.
return {
- id: updated.id,
- name: updated.name,
+ id: role.id,
+ name: dto.name ?? role.name,
permissions:
- typeof updated.permissions === 'string'
- ? JSON.parse(updated.permissions)
- : updated.permissions,
+ permissionsToPersist ??
+ (typeof role.permissions === 'string'
+ ? JSON.parse(role.permissions)
+ : role.permissions),
obligations:
- typeof updated.obligations === 'string'
- ? JSON.parse(updated.obligations)
- : updated.obligations || {},
+ dto.obligations !== undefined
+ ? dto.obligations
+ : typeof role.obligations === 'string'
+ ? JSON.parse(role.obligations)
+ : role.obligations || {},
isBuiltIn: false,
- createdAt: updated.createdAt,
- updatedAt: updated.updatedAt,
+ createdAt: role.createdAt,
+ updatedAt: writeTimestamp,
};
}
diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx
index df67801335..2dd234c0a3 100644
--- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx
+++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx
@@ -1,8 +1,8 @@
'use client';
import { RecentAuditLogs } from '@/components/RecentAuditLogs';
-import type { AuditLogWithRelations } from '@/hooks/use-audit-logs';
import { apiClient } from '@/lib/api-client';
+import { useAdminAuditLogs } from '../hooks/use-admin-audit-logs';
import {
AlertDialog,
AlertDialogAction,
@@ -21,7 +21,6 @@ import {
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
-import useSWR from 'swr';
interface AdminOrgDetail {
id: string;
@@ -112,15 +111,8 @@ export function OrganizationDetail({
);
};
- const { data: logs = [], isLoading } = useSWR(
- `/v1/admin/organizations/${org.id}/audit-logs`,
- async (url: string) => {
- const res = await apiClient.get<{ data: AuditLogWithRelations[] }>(url);
- if (res.error || !res.data?.data) return [];
- return res.data.data;
- },
- { revalidateOnFocus: true, refreshInterval: 15_000 },
- );
+ const { logs, total, hasMore, loadMore, isLoadingMore, isLoading } =
+ useAdminAuditLogs(org.id);
return (
@@ -184,7 +176,14 @@ export function OrganizationDetail({
) : (
-
+
)}
{
+ const res = await apiClient.get(
+ `/v1/admin/organizations/${orgId}/audit-logs?take=${take}&offset=${offset}`,
+ );
+ if (res.error || !res.data) {
+ throw new Error(res.error ?? 'Failed to load audit logs');
+ }
+ return res.data;
+ },
+ });
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx
index 213d007f9e..a550eb903a 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx
@@ -11,6 +11,7 @@ import { InterestedPartiesClient } from '../components/InterestedPartiesClient';
import { InternalAuditClient } from '../components/InternalAuditClient';
import type { ApproverOption } from '../components/IsmsApprovalSection';
import { LeadershipClient } from '../components/LeadershipClient';
+import { ManagementReviewClient } from '../components/ManagementReviewClient';
import { MonitoringClient } from '../components/MonitoringClient';
import { ObjectivesClient } from '../components/ObjectivesClient';
import { RequirementsClient } from '../components/RequirementsClient';
@@ -36,6 +37,8 @@ interface IsmsDetailClientProps {
memberOptions: ApproverOption[];
/** Internal Auditor holder(s) from Roles (5.3) — only for the 9.2 document. */
auditorOptions?: string[];
+ /** Top Management holder(s) from Roles (5.3) — only for the 9.3 document. */
+ chairOptions?: string[];
}
const ISMS_DETAIL_CLIENTS: Record<
@@ -49,6 +52,7 @@ const ISMS_DETAIL_CLIENTS: Record<
roles_and_responsibilities: RolesClient,
monitoring: MonitoringClient,
internal_audit: InternalAuditClient,
+ management_review: ManagementReviewClient,
isms_scope: ScopeClient,
leadership_commitment: LeadershipClient,
};
@@ -200,6 +204,32 @@ export default async function IsmsDocumentPage({
}
}
+ // The Management Review chair dropdown is whoever Roles (5.3) says Top
+ // Management is. No defaulting on our part: an empty list means Roles is not
+ // configured yet (the server still resolves creation-time defaults itself).
+ let chairOptions: string[] = [];
+ if (documentType === 'management_review') {
+ const rolesDoc = setupResult.data?.documents?.find(
+ (doc) => doc.type === 'roles_and_responsibilities',
+ );
+ if (rolesDoc) {
+ const rolesResult = await serverApi.get(
+ `/v1/isms/documents/${rolesDoc.id}`,
+ );
+ const topMgmtRole = rolesResult.data?.roles?.find(
+ (role) => role.roleKey === 'top_management',
+ );
+ const memberNames = new Map(memberOptions.map((m) => [m.id, m.name]));
+ chairOptions = [
+ ...new Set(
+ (topMgmtRole?.assignments ?? [])
+ .map((assignment) => memberNames.get(assignment.memberId))
+ .filter((name): name is string => !!name),
+ ),
+ ];
+ }
+ }
+
const DetailClient = ISMS_DETAIL_CLIENTS[documentType];
return (
@@ -213,6 +243,7 @@ export default async function IsmsDocumentPage({
approverOptions={approverOptions}
memberOptions={memberOptions}
auditorOptions={auditorOptions}
+ chairOptions={chairOptions}
/>
);
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/AttendeesField.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AttendeesField.tsx
new file mode 100644
index 0000000000..6259789a9c
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AttendeesField.tsx
@@ -0,0 +1,138 @@
+'use client';
+
+import {
+ Button,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Stack,
+ Text,
+} from '@trycompai/design-system';
+import { Close } from '@trycompai/design-system/icons';
+import { useState } from 'react';
+import type {
+ IsmsManagementReview,
+ IsmsReviewAttendee,
+} from '../isms-types';
+import type { ApproverOption } from './IsmsApprovalSection';
+import { parseAttendees } from './management-review-constants';
+import { IsmsFieldLabel } from './shared';
+
+interface AttendeesFieldProps {
+ review: IsmsManagementReview;
+ canEdit: boolean;
+ memberOptions: ApproverOption[];
+ /** Saves the full replacement list (the register PATCH replaces attendees). */
+ onSave: (attendees: IsmsReviewAttendee[]) => Promise;
+}
+
+/**
+ * The review's attendees: a multi-select from People (Select-to-add plus a
+ * removable list, the RoleAssignments pattern). Defaults to Chair + SPO at
+ * creation — for a 1-3 person org that may be a single person, which is fine.
+ * Names are frozen at selection: a former member stays on the historical
+ * minutes even after leaving People.
+ */
+export function AttendeesField({
+ review,
+ canEdit,
+ memberOptions,
+ onSave,
+}: AttendeesFieldProps) {
+ // Controlled picker value so it reliably returns to its placeholder after
+ // each selection (the RoleAssignments pattern).
+ const [pendingMember, setPendingMember] = useState('');
+ // Each save PATCHes the WHOLE list, so overlapping saves built from a stale
+ // render would drop each other's change — serialize by disabling the
+ // affordances while one is in flight.
+ const [isSaving, setIsSaving] = useState(false);
+
+ const attendees = parseAttendees(review.attendees);
+ const attendingIds = new Set(attendees.map((attendee) => attendee.memberId));
+ const availableMembers = memberOptions.filter(
+ (option) => !attendingIds.has(option.id),
+ );
+
+ const save = async (next: IsmsReviewAttendee[]) => {
+ setIsSaving(true);
+ try {
+ await onSave(next);
+ } catch {
+ // Error already surfaced via toast by the caller.
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleAdd = (memberId: string | null | undefined) => {
+ if (!memberId || isSaving) return;
+ const member = memberOptions.find((option) => option.id === memberId);
+ if (!member) return;
+ // Reset the picker back to its placeholder after selecting.
+ setPendingMember('');
+ void save([...attendees, { memberId: member.id, name: member.name }]);
+ };
+
+ const handleRemove = (memberId: string) => {
+ if (isSaving) return;
+ void save(attendees.filter((attendee) => attendee.memberId !== memberId));
+ };
+
+ return (
+
+
+ {attendees.length === 0 ? (
+
+ No attendees yet — defaults to the Chair and the Security & Privacy Owner from
+ ISMS > Roles.
+
+ ) : (
+
+ {attendees.map((attendee) => (
+
+ {attendee.name}
+ {canEdit ? (
+ handleRemove(attendee.memberId)}
+ disabled={isSaving}
+ iconLeft={ }
+ aria-label={`Remove attendee ${attendee.name}`}
+ />
+ ) : null}
+
+ ))}
+
+ )}
+
+ {canEdit && availableMembers.length > 0 ? (
+
+
+
+
+
+
+ {availableMembers.map((option) => (
+
+ {option.name}
+
+ ))}
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/CarriedForwardActions.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/CarriedForwardActions.tsx
new file mode 100644
index 0000000000..6af4e28457
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/CarriedForwardActions.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import {
+ Badge,
+ Heading,
+ HStack,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Text,
+} from '@trycompai/design-system';
+import { useMemo } from 'react';
+import type { IsmsManagementReview, IsmsReviewAction } from '../isms-types';
+import type { ApproverOption } from './IsmsApprovalSection';
+import {
+ REVIEW_ACTION_STATUS_LABELS,
+ fullActionReference,
+} from './management-review-constants';
+
+interface CarriedForwardActionsProps {
+ entries: Array<{ review: IsmsManagementReview; action: IsmsReviewAction }>;
+ memberOptions: ApproverOption[];
+}
+
+/**
+ * Open actions from previous reviews, carried forward automatically to this
+ * review's input (a) — the customer never has to remember to bring them
+ * across. Read-only here: each action is edited on its own review, and its
+ * live status keeps tracking to closure.
+ */
+export function CarriedForwardActions({
+ entries,
+ memberOptions,
+}: CarriedForwardActionsProps) {
+ const memberNameById = useMemo(() => {
+ const map: Record = {};
+ for (const option of memberOptions) map[option.id] = option.name;
+ return map;
+ }, [memberOptions]);
+
+ return (
+
+
+ Carried forward from previous reviews
+ {String(entries.length)}
+
+
+ Open actions from earlier reviews — discuss them under input (a) and close them on their
+ originating review.
+
+
+
+
+
+ Reference
+ Description
+ Owner
+ Due date
+ Status
+
+
+
+ {entries.map(({ review, action }) => (
+
+
+
+ {fullActionReference(review.reference, action.reference)}
+
+
+ {action.description}
+
+ {/* An empty roster means the people request was unavailable
+ to this user, not that the owner left — stay neutral. */}
+ {action.ownerMemberId
+ ? memberOptions.length === 0
+ ? 'Unknown member'
+ : (memberNameById[action.ownerMemberId] ?? 'Former member')
+ : '—'}
+
+ {action.dueDate?.slice(0, 10) ?? '—'}
+ {REVIEW_ACTION_STATUS_LABELS[action.status]}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx
index 9d28f8efdc..dc0c09fc69 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx
@@ -112,6 +112,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [],
+ reviews: [],
controlLinks: [],
draftNarrative: null,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx
index 949f1f3ae4..529105612e 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx
@@ -135,6 +135,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [],
+ reviews: [],
controlLinks: [],
draftNarrative: null,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/InternalAuditClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/InternalAuditClient.test.tsx
index c873ce6ae6..7823837df9 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/InternalAuditClient.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/InternalAuditClient.test.tsx
@@ -156,6 +156,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [makeAudit()],
+ reviews: [],
controlLinks: [],
draftNarrative: {
programme: 'Acme runs an annual internal audit of the whole ISMS.',
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx
index cb7b580c1d..0bd5e4161b 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx
@@ -107,6 +107,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [],
+ reviews: [],
controlLinks: [],
draftNarrative: null,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx
index dff75458dc..741c1d38ee 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx
@@ -194,6 +194,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [],
+ reviews: [],
controlLinks: LINKS,
draftNarrative: null,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx
index 7a1f422b1a..15f59e0d67 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx
@@ -175,6 +175,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [],
+ reviews: [],
controlLinks: [],
draftNarrative: NARRATIVE,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ManagementReviewClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ManagementReviewClient.test.tsx
new file mode 100644
index 0000000000..1dfe02073f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ManagementReviewClient.test.tsx
@@ -0,0 +1,374 @@
+import { render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ setMockPermissions,
+ ADMIN_PERMISSIONS,
+ AUDITOR_PERMISSIONS,
+ mockHasPermission,
+} from '@/test-utils/mocks/permissions';
+import type {
+ IsmsDocument,
+ IsmsDriftResult,
+ IsmsManagementReview,
+} from '../isms-types';
+import { ismsDesignSystemMock, ismsIconsMock, ismsSharedMock } from './__test-helpers__/dsMocks';
+
+// ─── Mock usePermissions ─────────────────────────────────────
+vi.mock('@/hooks/use-permissions', () => ({
+ usePermissions: () => ({ permissions: {}, hasPermission: mockHasPermission }),
+}));
+
+// ─── Mock the ISMS document hook ─────────────────────────────
+const hookState: {
+ document: IsmsDocument | null;
+ drift: IsmsDriftResult;
+} = {
+ document: null,
+ drift: { isStale: false, changedSources: [] },
+};
+
+const mockCreateRow = vi.fn().mockResolvedValue(undefined);
+const mockUpdateRow = vi.fn().mockResolvedValue(undefined);
+const mockDeleteRow = vi.fn().mockResolvedValue(undefined);
+const mockSaveNarrative = vi.fn().mockResolvedValue(undefined);
+
+vi.mock('../hooks/useIsmsDocument', () => ({
+ useIsmsDocument: () => ({
+ document: hookState.document,
+ isExporting: false,
+ generate: vi.fn().mockResolvedValue(undefined),
+ createRow: mockCreateRow,
+ updateRow: mockUpdateRow,
+ deleteRow: mockDeleteRow,
+ saveNarrative: mockSaveNarrative,
+ submitForApproval: vi.fn().mockResolvedValue(undefined),
+ approve: vi.fn().mockResolvedValue(undefined),
+ decline: vi.fn().mockResolvedValue(undefined),
+ refresh: vi.fn().mockResolvedValue(undefined),
+ handleExport: vi.fn().mockResolvedValue(undefined),
+ }),
+}));
+
+// ─── Mock SWR (drift) + api client ───────────────────────────
+vi.mock('@/lib/api-client', () => ({
+ api: { get: vi.fn().mockResolvedValue({ data: null, error: null }) },
+}));
+
+vi.mock('swr', () => ({
+ default: () => ({ data: hookState.drift, mutate: vi.fn().mockResolvedValue(undefined) }),
+}));
+
+// ─── Mock design system + icons + shared components ──────────
+vi.mock('@trycompai/design-system', () => ismsDesignSystemMock());
+vi.mock('@trycompai/design-system/icons', () => ismsIconsMock());
+vi.mock('./shared', () => ismsSharedMock());
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock('./IsmsControlMappings', () => ({
+ IsmsControlMappings: () =>
,
+}));
+
+vi.mock('./IsmsVersionHistory', () => ({
+ IsmsVersionHistory: () =>
,
+}));
+
+import { ManagementReviewClient } from './ManagementReviewClient';
+
+function makeReview(
+ overrides: Partial = {},
+): IsmsManagementReview {
+ return {
+ id: 'mr_1',
+ reference: 'MR-2026-01',
+ meetingDate: '2026-05-01T00:00:00.000Z',
+ recordedAt: '2026-05-01T09:30:00.000Z',
+ chairName: 'Raoul Plickat (CEO)',
+ attendees: [
+ { memberId: 'm1', name: 'Member One' },
+ { memberId: 'm2', name: 'Approver Two' },
+ ],
+ status: 'complete',
+ conclusionVerdict: 'effective',
+ conclusionNotes: null,
+ decisionsText: 'Two improvements agreed at this review.',
+ changesText: 'No changes to the ISMS were agreed at this review.',
+ signoffChairName: 'Raoul Plickat',
+ signoffChairDate: '2026-05-01T00:00:00.000Z',
+ position: 0,
+ inputs: [
+ {
+ id: 'mri_1',
+ reviewId: 'mr_1',
+ inputKey: 'a_prior_actions',
+ inputRef: '(a) Prior actions',
+ whatItCovers: 'Status of actions from previous management reviews.',
+ whereToFind: 'Comp AI > ISMS > Management Review',
+ discussionNotes: 'First review — no prior actions.',
+ discussed: true,
+ source: 'derived',
+ derivedFrom: 'seed:a_prior_actions',
+ position: 0,
+ },
+ {
+ id: 'mri_2',
+ reviewId: 'mr_1',
+ inputKey: 'f_risk_assessment',
+ inputRef: '(f) Risk assessment',
+ whatItCovers: 'Results of risk assessment and treatment status.',
+ whereToFind: 'Comp AI > Risks (risk register)',
+ discussionNotes: null,
+ discussed: true,
+ source: 'derived',
+ derivedFrom: 'seed:f_risk_assessment',
+ position: 1,
+ },
+ ],
+ actions: [
+ {
+ id: 'mra_1',
+ reviewId: 'mr_1',
+ reference: 'A01',
+ description: 'Formalise a quarterly access review process.',
+ ownerMemberId: 'm2',
+ dueDate: '2026-06-30T00:00:00.000Z',
+ status: 'open',
+ position: 0,
+ },
+ ],
+ ...overrides,
+ };
+}
+
+function makeDocument(overrides: Partial = {}): IsmsDocument {
+ return {
+ id: 'd1',
+ type: 'management_review',
+ status: 'draft',
+ title: 'Management Review',
+ approverId: null,
+ approvedAt: null,
+ declinedAt: null,
+ contextIssues: [],
+ interestedParties: [],
+ interestedPartyRequirements: [],
+ objectives: [],
+ roles: [],
+ metrics: [],
+ audits: [],
+ reviews: [makeReview()],
+ controlLinks: [],
+ draftNarrative: {
+ procedure: 'Acme holds a management review of the ISMS at least annually.',
+ },
+ currentVersionId: null,
+ currentVersion: null,
+ ...overrides,
+ };
+}
+
+const baseProps = {
+ organizationId: 'org-1',
+ documentId: 'd1',
+ fallbackData: null,
+ currentMemberId: 'm1',
+ approverOptions: [{ id: 'm2', name: 'Approver Two' }],
+ memberOptions: [
+ { id: 'm1', name: 'Member One' },
+ { id: 'm2', name: 'Approver Two' },
+ ],
+ chairOptions: ['Raoul Plickat (CEO)'],
+};
+
+describe('ManagementReviewClient', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ hookState.document = makeDocument();
+ hookState.drift = { isStale: false, changedSources: [] };
+ });
+
+ it('renders the procedure paragraph and the review with its details', () => {
+ setMockPermissions(ADMIN_PERMISSIONS);
+ render( );
+
+ expect(
+ screen.getByText('Acme holds a management review of the ISMS at least annually.'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('MR-2026-01')).toBeInTheDocument();
+ expect(screen.getByText('Raoul Plickat (CEO)')).toBeInTheDocument();
+ // The immutable recorded-on date renders with its guardrail caption.
+ expect(
+ screen.getByText(/2026-05-01 — set by the platform, cannot be edited/),
+ ).toBeInTheDocument();
+ // The assembled conclusion sentence renders in the read view.
+ expect(
+ screen.getByText(/found to be effective and no changes are required/),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the Inputs table with the discussed count', () => {
+ setMockPermissions(ADMIN_PERMISSIONS);
+ render( );
+
+ expect(screen.getByText('Inputs (9.3.2)')).toBeInTheDocument();
+ expect(screen.getByText('2 of 2 discussed')).toBeInTheDocument();
+ expect(screen.getByText('(a) Prior actions')).toBeInTheDocument();
+ expect(screen.getByText('Comp AI > Risks (risk register)')).toBeInTheDocument();
+ expect(screen.getByText('First review — no prior actions.')).toBeInTheDocument();
+ });
+
+ it('renders attendees, outputs, and the actions arising with owner and full reference', () => {
+ setMockPermissions(ADMIN_PERMISSIONS);
+ render( );
+
+ expect(screen.getByText('Member One')).toBeInTheDocument();
+ expect(
+ screen.getByText('Two improvements agreed at this review.'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('MR-2026-01-A01')).toBeInTheDocument();
+ expect(
+ screen.getByText('Formalise a quarterly access review process.'),
+ ).toBeInTheDocument();
+ // Owner resolved from memberOptions.
+ expect(screen.getAllByText('Approver Two').length).toBeGreaterThan(0);
+ });
+
+ it('locks a signed review: no detail edits or input adds, sign-off stays editable', () => {
+ setMockPermissions(ADMIN_PERMISSIONS);
+ render( );
+
+ // The fixture review is signed → locked notice, no edit/delete/add-input.
+ expect(
+ screen.getByText(/signed by the chair and locked/),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByLabelText('Edit review MR-2026-01'),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText('Add input row')).not.toBeInTheDocument();
+ expect(screen.queryByText('Add action')).not.toBeInTheDocument();
+ // The sign-off slot stays live so the signature can be corrected/cleared,
+ // and the action status keeps tracking to closure.
+ expect(screen.getByText('Save sign-off')).toBeInTheDocument();
+ expect(screen.getByLabelText('Edit MR-2026-01-A01')).toBeInTheDocument();
+ expect(
+ screen.queryByLabelText('Delete MR-2026-01-A01'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('allows editing an unsigned review for a user with evidence:update', () => {
+ setMockPermissions(ADMIN_PERMISSIONS);
+ hookState.document = makeDocument({
+ reviews: [
+ makeReview({ signoffChairName: null, signoffChairDate: null }),
+ ],
+ });
+ render( );
+
+ expect(screen.getByText('New review')).toBeInTheDocument();
+ expect(screen.getByText('Add input row')).toBeInTheDocument();
+ expect(screen.getByText('Add action')).toBeInTheDocument();
+ expect(screen.getByLabelText('Edit review MR-2026-01')).toBeInTheDocument();
+ expect(screen.getByLabelText('Delete review MR-2026-01')).toBeInTheDocument();
+ expect(mockHasPermission).toHaveBeenCalledWith('evidence', 'update');
+ });
+
+ it('hides mutating controls for a read-only user but keeps export', () => {
+ setMockPermissions(AUDITOR_PERMISSIONS);
+ hookState.document = makeDocument({
+ reviews: [
+ makeReview({ signoffChairName: null, signoffChairDate: null }),
+ ],
+ });
+ render( );
+
+ expect(screen.queryByText('New review')).not.toBeInTheDocument();
+ expect(screen.queryByText('Add input row')).not.toBeInTheDocument();
+ expect(screen.queryByText('Add action')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Edit review MR-2026-01')).not.toBeInTheDocument();
+ expect(screen.queryByText('Save sign-off')).not.toBeInTheDocument();
+ expect(screen.getByText('Export PDF')).toBeInTheDocument();
+ expect(screen.getByText('Export DOCX')).toBeInTheDocument();
+ });
+
+ it('carries open actions forward to the next review', () => {
+ setMockPermissions(ADMIN_PERMISSIONS);
+ hookState.document = makeDocument({
+ reviews: [
+ makeReview(),
+ makeReview({
+ id: 'mr_2',
+ reference: 'MR-2027-01',
+ status: 'planned',
+ signoffChairName: null,
+ signoffChairDate: null,
+ conclusionVerdict: null,
+ inputs: [],
+ actions: [],
+ }),
+ ],
+ });
+ render( );
+
+ expect(
+ screen.getByText('Carried forward from previous reviews'),
+ ).toBeInTheDocument();
+ // The first review's open action appears twice: on its own review and in
+ // the second review's carried-forward table.
+ expect(screen.getAllByText('MR-2026-01-A01')).toHaveLength(2);
+ });
+
+ it('warns when no review is recorded (clause 9.3 gate)', () => {
+ setMockPermissions(ADMIN_PERMISSIONS);
+ hookState.document = makeDocument({ reviews: [] });
+ render( );
+
+ expect(
+ screen.getAllByText(/At least one management review must be recorded\./).length,
+ ).toBeGreaterThan(0);
+ // Empty state invites creating the first review.
+ expect(screen.getByText('No reviews yet')).toBeInTheDocument();
+ });
+
+ it('warns when a completed review is missing its requirements', () => {
+ setMockPermissions(ADMIN_PERMISSIONS);
+ hookState.document = makeDocument({
+ reviews: [
+ makeReview({
+ meetingDate: null,
+ signoffChairName: null,
+ signoffChairDate: null,
+ inputs: [
+ {
+ id: 'mri_1',
+ reviewId: 'mr_1',
+ inputKey: 'a_prior_actions',
+ inputRef: '(a) Prior actions',
+ whatItCovers: 'Status of actions.',
+ whereToFind: 'Comp AI > ISMS > Management Review',
+ discussionNotes: null,
+ discussed: false,
+ source: 'derived',
+ derivedFrom: 'seed:a_prior_actions',
+ position: 0,
+ },
+ ],
+ }),
+ ],
+ });
+ render( );
+
+ expect(
+ screen.getAllByText(/MR-2026-01 is complete but has no meeting date\./).length,
+ ).toBeGreaterThan(0);
+ expect(
+ screen.getAllByText(/MR-2026-01 has 1 input not yet marked as discussed\./)
+ .length,
+ ).toBeGreaterThan(0);
+ expect(
+ screen.getAllByText(/MR-2026-01 is complete but has not been signed by the chair\./)
+ .length,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ManagementReviewClient.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ManagementReviewClient.tsx
new file mode 100644
index 0000000000..5752b92dc1
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ManagementReviewClient.tsx
@@ -0,0 +1,192 @@
+'use client';
+
+import { Stack } from '@trycompai/design-system';
+import { toast } from 'sonner';
+import type {
+ IsmsDocument as IsmsDocumentData,
+ IsmsReviewAttendee,
+} from '../isms-types';
+import type { ApproverOption } from './IsmsApprovalSection';
+import { IsmsDocumentShell } from './IsmsDocumentShell';
+import { ProcedureCard } from './ProcedureCard';
+import type { ReviewHandlers } from './ReviewCard';
+import { ReviewsList } from './ReviewsList';
+import {
+ parseProcedure,
+ reviewValidationMessages,
+} from './management-review-constants';
+import {
+ toActionPayload,
+ toInputPayload,
+ toOutputsPayload,
+ toReviewPayload,
+ toReviewSignoffPayload,
+} from './management-review-schema';
+
+interface ManagementReviewClientProps {
+ organizationId: string;
+ documentId: string;
+ fallbackData: IsmsDocumentData | null;
+ currentMemberId: string | null;
+ approverOptions: ApproverOption[];
+ memberOptions: ApproverOption[];
+ /** Top Management holder(s) from ISMS > Roles (5.3) — the chair dropdown. */
+ chairOptions?: string[];
+}
+
+const REVIEWS = 'reviews' as const;
+const INPUTS = 'review-inputs' as const;
+const ACTIONS = 'review-actions' as const;
+
+async function run(action: Promise, successMessage: string, failMessage: string) {
+ try {
+ await action;
+ toast.success(successMessage);
+ } catch (caught) {
+ toast.error(caught instanceof Error ? caught.message : failMessage);
+ // Re-throw so the calling form/row keeps its state on failure.
+ throw caught;
+ }
+}
+
+export function ManagementReviewClient({
+ memberOptions,
+ chairOptions = [],
+ ...props
+}: ManagementReviewClientProps) {
+ return (
+ {
+ const messages = reviewValidationMessages({
+ procedure: parseProcedure(document.draftNarrative),
+ reviews: Array.isArray(document.reviews) ? document.reviews : [],
+ });
+ return messages.length > 0
+ ? `Complete the review record before submitting: ${messages.join(' ')}`
+ : null;
+ }}
+ >
+ {({ document, canManage, hook }) => {
+ const reviews = Array.isArray(document.reviews) ? document.reviews : [];
+ const validationMessages = reviewValidationMessages({
+ procedure: parseProcedure(document.draftNarrative),
+ reviews,
+ });
+
+ const handlers: ReviewHandlers = {
+ onUpdateReview: (reviewId, values) =>
+ run(
+ hook.updateRow({ register: REVIEWS, id: reviewId, data: toReviewPayload(values) }),
+ 'Review updated',
+ 'Failed to update review',
+ ),
+ onDeleteReview: (reviewId) =>
+ run(
+ hook.deleteRow({ register: REVIEWS, id: reviewId }),
+ 'Review deleted',
+ 'Failed to delete review',
+ ),
+ onSaveAttendees: (reviewId, attendees: IsmsReviewAttendee[]) =>
+ run(
+ hook.updateRow({ register: REVIEWS, id: reviewId, data: { attendees } }),
+ 'Attendees updated',
+ 'Failed to update attendees',
+ ),
+ onSaveOutputs: (reviewId, values) =>
+ run(
+ hook.updateRow({ register: REVIEWS, id: reviewId, data: toOutputsPayload(values) }),
+ 'Outputs saved',
+ 'Failed to save outputs',
+ ),
+ onSaveSignoff: (reviewId, values) =>
+ run(
+ hook.updateRow({
+ register: REVIEWS,
+ id: reviewId,
+ data: toReviewSignoffPayload(values),
+ }),
+ 'Sign-off saved',
+ 'Failed to save sign-off',
+ ),
+ onCreateInput: (reviewId, values) =>
+ run(
+ hook.createRow({ register: INPUTS, data: { reviewId, ...toInputPayload(values) } }),
+ 'Input row added',
+ 'Failed to add input row',
+ ),
+ onUpdateInput: (inputId, payload) =>
+ run(
+ hook.updateRow({ register: INPUTS, id: inputId, data: payload }),
+ 'Input row updated',
+ 'Failed to update input row',
+ ),
+ onDeleteInput: (inputId) =>
+ run(
+ hook.deleteRow({ register: INPUTS, id: inputId }),
+ 'Input row deleted',
+ 'Failed to delete input row',
+ ),
+ onCreateAction: (reviewId, values) =>
+ run(
+ hook.createRow({
+ register: ACTIONS,
+ data: { reviewId, ...toActionPayload(values) },
+ }),
+ 'Action added',
+ 'Failed to add action',
+ ),
+ onUpdateAction: (actionId, payload) =>
+ run(
+ hook.updateRow({ register: ACTIONS, id: actionId, data: payload }),
+ 'Action updated',
+ 'Failed to update action',
+ ),
+ onDeleteAction: (actionId) =>
+ run(
+ hook.deleteRow({ register: ACTIONS, id: actionId }),
+ 'Action deleted',
+ 'Failed to delete action',
+ ),
+ };
+
+ return (
+
+
+ run(
+ hook.saveNarrative({ procedure }),
+ 'Procedure saved',
+ 'Failed to save procedure',
+ )
+ }
+ />
+
+ run(
+ hook.createRow({ register: REVIEWS, data: {} }),
+ 'Review created with the default template',
+ 'Failed to create review',
+ )
+ }
+ {...handlers}
+ />
+
+ );
+ }}
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/MonitoringClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/MonitoringClient.test.tsx
index 25ba5c3645..559b892267 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/MonitoringClient.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/MonitoringClient.test.tsx
@@ -156,6 +156,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [OVERDUE_METRIC, HEALTHY_METRIC],
audits: [],
+ reviews: [],
controlLinks: [],
draftNarrative: null,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx
index a5ab2860e5..1f6528a2c3 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx
@@ -118,6 +118,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [],
+ reviews: [],
controlLinks: [],
draftNarrative: null,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ProcedureCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ProcedureCard.tsx
new file mode 100644
index 0000000000..0d2b423d87
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ProcedureCard.tsx
@@ -0,0 +1,140 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ Button,
+ Field,
+ FieldError,
+ Heading,
+ HStack,
+ Stack,
+ Text,
+ Textarea,
+} from '@trycompai/design-system';
+import { Edit } from '@trycompai/design-system/icons';
+import { useEffect, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { parseProcedure } from './management-review-constants';
+import { IsmsRegisterCard } from './shared';
+
+const procedureSchema = z.object({
+ procedure: z.string().trim().min(1, 'The procedure paragraph is required'),
+});
+
+type ProcedureFormValues = z.infer;
+
+interface ProcedureCardProps {
+ narrative: unknown;
+ canEdit: boolean;
+ onSave: (procedure: string) => Promise;
+}
+
+/**
+ * The review Procedure paragraph (top of the Management Review page). Ships
+ * with a hardcoded default and is rendered verbatim into the generated
+ * clause-9.3 document; the customer edits it in place.
+ */
+export function ProcedureCard({ narrative, canEdit, onSave }: ProcedureCardProps) {
+ const procedure = parseProcedure(narrative);
+ const [isEditing, setIsEditing] = useState(false);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isDirty, isValid, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(procedureSchema),
+ mode: 'onChange',
+ defaultValues: { procedure },
+ });
+
+ useEffect(() => {
+ if (!isEditing) reset({ procedure });
+ }, [procedure, isEditing, reset]);
+
+ const handleSave = handleSubmit(async (values) => {
+ try {
+ await onSave(values.procedure.trim());
+ } catch {
+ return;
+ }
+ setIsEditing(false);
+ });
+
+ const headerActions = canEdit ? (
+ isEditing ? (
+
+ {
+ reset({ procedure });
+ setIsEditing(false);
+ }}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ Save
+
+
+ ) : (
+ setIsEditing(true)}
+ iconLeft={ }
+ aria-label="Edit procedure"
+ >
+ Edit
+
+ )
+ ) : undefined;
+
+ return (
+
+ Procedure
+
+ Rendered verbatim into the generated Clause 9.3 document.
+
+
+ }
+ headerEnd={headerActions}
+ >
+ {isEditing ? (
+
+ (
+ <>
+
+ {fieldState.error?.message}
+ >
+ )}
+ />
+
+ ) : (
+ // whitespace-pre-wrap inherits into the Text span, preserving the
+ // paragraph breaks a multi-line procedure was written with.
+
+ {procedure || 'No procedure recorded yet.'}
+
+ )}
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx
index 897eccbffe..005cd6d258 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx
@@ -116,6 +116,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [],
+ reviews: [],
controlLinks: [],
draftNarrative: null,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewActionRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewActionRow.tsx
new file mode 100644
index 0000000000..0920f507a9
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewActionRow.tsx
@@ -0,0 +1,324 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button,
+ Field,
+ FieldError,
+ Input,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ TableCell,
+ TableRow,
+ Textarea,
+} from '@trycompai/design-system';
+import { Edit, TrashCan } from '@trycompai/design-system/icons';
+import { useEffect, useMemo, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import type { IsmsManagementReview, IsmsReviewAction } from '../isms-types';
+import type { ApproverOption } from './IsmsApprovalSection';
+import {
+ REVIEW_ACTION_STATUSES,
+ REVIEW_ACTION_STATUS_LABELS,
+ fullActionReference,
+} from './management-review-constants';
+import {
+ reviewActionSchema,
+ toActionPayload,
+ type ReviewActionFormValues,
+} from './management-review-schema';
+
+const NO_OWNER = 'no-owner';
+
+interface ReviewActionRowProps {
+ review: IsmsManagementReview;
+ action: IsmsReviewAction;
+ canEdit: boolean;
+ /** Signed review: this row stays editable (tracks to closure) but not deletable. */
+ locked: boolean;
+ memberOptions: ApproverOption[];
+ onUpdateAction: (actionId: string, payload: Record) => Promise;
+ onDeleteAction: (actionId: string) => Promise;
+}
+
+function toFormValues(action: IsmsReviewAction): ReviewActionFormValues {
+ return {
+ description: action.description,
+ ownerMemberId: action.ownerMemberId ?? '',
+ dueDate: action.dueDate?.slice(0, 10) ?? '',
+ status: action.status,
+ };
+}
+
+/**
+ * One action arising. The Status dropdown saves immediately (tracking to
+ * closure is the primary per-row workflow — it stays live even after the
+ * review is signed); the other fields are edited via the row's edit mode.
+ */
+export function ReviewActionRow({
+ review,
+ action,
+ canEdit,
+ locked,
+ memberOptions,
+ onUpdateAction,
+ onDeleteAction,
+}: ReviewActionRowProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [isSavingStatus, setIsSavingStatus] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ const memberNameById = useMemo(() => {
+ const map: Record = {};
+ for (const option of memberOptions) map[option.id] = option.name;
+ return map;
+ }, [memberOptions]);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isDirty, isValid, isSubmitting, dirtyFields },
+ } = useForm({
+ resolver: zodResolver(reviewActionSchema),
+ mode: 'onChange',
+ defaultValues: toFormValues(action),
+ });
+
+ useEffect(() => {
+ if (!isEditing) reset(toFormValues(action));
+ }, [action, isEditing, reset]);
+
+ const reference = fullActionReference(review.reference, action.reference);
+
+ const handleSave = handleSubmit(async (values) => {
+ // Patch only what changed: an untouched owner (possibly a former member)
+ // must not be re-submitted — the server validates PROVIDED owners against
+ // the active roster — and a signed review accepts only tracking fields.
+ const full = toActionPayload(values);
+ const patch = Object.fromEntries(
+ Object.entries(full).filter(
+ ([key]) => dirtyFields[key as keyof ReviewActionFormValues],
+ ),
+ );
+ if (Object.keys(patch).length > 0) {
+ try {
+ await onUpdateAction(action.id, patch);
+ } catch {
+ return;
+ }
+ }
+ setIsEditing(false);
+ });
+
+ const handleStatusChange = async (next: string | null) => {
+ if (!next) return;
+ setIsSavingStatus(true);
+ try {
+ await onUpdateAction(action.id, { status: next });
+ } catch {
+ // Error already surfaced via toast by the caller.
+ } finally {
+ setIsSavingStatus(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ setConfirmOpen(false);
+ setIsDeleting(true);
+ try {
+ await onDeleteAction(action.id);
+ } catch {
+ // Error already surfaced via toast by the caller.
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ if (isEditing) {
+ return (
+
+
+ {reference}
+
+
+
+ (
+ <>
+ {/* Part of the signed minutes — frozen once the chair signs. */}
+
+ {fieldState.error?.message}
+ >
+ )}
+ />
+
+
+
+ (
+ field.onChange(next === NO_OWNER ? '' : next)}
+ >
+
+
+
+
+ Unassigned
+ {memberOptions.map((option) => (
+
+ {option.name}
+
+ ))}
+
+
+ )}
+ />
+
+
+ (
+
+ )}
+ />
+
+ —
+
+
+ {
+ reset(toFormValues(action));
+ setIsEditing(false);
+ }}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ Save
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {reference}
+
+ {action.description}
+
+ {/* An empty roster means the people request was unavailable to this
+ user, not that the owner left — stay neutral. */}
+ {action.ownerMemberId
+ ? memberOptions.length === 0
+ ? 'Unknown member'
+ : (memberNameById[action.ownerMemberId] ?? 'Former member')
+ : '—'}
+
+ {action.dueDate?.slice(0, 10) ?? '—'}
+
+ {canEdit ? (
+ void handleStatusChange(next)}
+ disabled={isSavingStatus}
+ >
+
+
+
+
+ {REVIEW_ACTION_STATUSES.map((status) => (
+
+ {REVIEW_ACTION_STATUS_LABELS[status]}
+
+ ))}
+
+
+ ) : (
+ REVIEW_ACTION_STATUS_LABELS[action.status]
+ )}
+
+ {canEdit ? (
+
+
+ setIsEditing(true)}
+ disabled={isDeleting}
+ iconLeft={ }
+ aria-label={`Edit ${reference}`}
+ />
+ {!locked ? (
+ setConfirmOpen(true)}
+ disabled={isDeleting}
+ loading={isDeleting}
+ iconLeft={ }
+ aria-label={`Delete ${reference}`}
+ />
+ ) : null}
+
+
+
+
+ Delete action {reference}?
+
+ This permanently removes the action from the review's outputs and from
+ every later review's carried-forward list. This cannot be undone.
+
+
+
+ setConfirmOpen(false)}>Cancel
+ void handleDelete()} variant="destructive">
+ Delete
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewActionsSection.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewActionsSection.tsx
new file mode 100644
index 0000000000..e917e855a0
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewActionsSection.tsx
@@ -0,0 +1,229 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ Badge,
+ Button,
+ Field,
+ FieldError,
+ Heading,
+ HStack,
+ Input,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Stack,
+ Table,
+ TableBody,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Text,
+ Textarea,
+} from '@trycompai/design-system';
+import { Add } from '@trycompai/design-system/icons';
+import { Controller, useForm } from 'react-hook-form';
+import type { IsmsManagementReview } from '../isms-types';
+import type { ApproverOption } from './IsmsApprovalSection';
+import { ReviewActionRow } from './ReviewActionRow';
+import {
+ reviewActionSchema,
+ type ReviewActionFormValues,
+} from './management-review-schema';
+import { IsmsAddCard, IsmsFieldLabel } from './shared';
+
+const NO_OWNER = 'no-owner';
+
+interface ReviewActionsSectionProps {
+ review: IsmsManagementReview;
+ canEdit: boolean;
+ /** Signed review: no add/delete (the arising set is frozen), updates stay live. */
+ locked: boolean;
+ memberOptions: ApproverOption[];
+ onCreateAction: (values: ReviewActionFormValues) => Promise;
+ onUpdateAction: (actionId: string, payload: Record) => Promise;
+ onDeleteAction: (actionId: string) => Promise;
+}
+
+const EMPTY_ACTION: ReviewActionFormValues = {
+ description: '',
+ ownerMemberId: '',
+ dueDate: '',
+ status: 'open',
+};
+
+/**
+ * Actions arising (9.3.3): the review's tracked follow-ups. An empty table is
+ * fine — the document renders "No actions arising from this review". Open
+ * actions carry forward to the next review's input (a) automatically. After
+ * the chair signs, the set is frozen but each action still tracks to closure.
+ */
+export function ReviewActionsSection({
+ review,
+ canEdit,
+ locked,
+ memberOptions,
+ onCreateAction,
+ onUpdateAction,
+ onDeleteAction,
+}: ReviewActionsSectionProps) {
+ const actions = review.actions;
+
+ return (
+
+
+ Actions arising
+ {String(actions.length)}
+
+
+ Follow-ups agreed at this review, tracked to closure. Open actions carry forward to the
+ next review's input (a) automatically — no actions is fine.
+
+
+ {actions.length > 0 ? (
+
+
+
+
+ Reference
+ Description
+ Owner
+ Due date
+ Status
+ {canEdit ? : null}
+
+
+
+ {actions.map((action) => (
+
+ ))}
+
+
+
+ ) : null}
+
+ {canEdit && !locked ? (
+
+ {({ close }) => (
+
+ )}
+
+ ) : null}
+
+ );
+}
+
+function AddActionForm({
+ memberOptions,
+ onAdd,
+ onClose,
+}: {
+ memberOptions: ApproverOption[];
+ onAdd: (values: ReviewActionFormValues) => Promise;
+ onClose: () => void;
+}) {
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting },
+ } = useForm({
+ resolver: zodResolver(reviewActionSchema),
+ defaultValues: EMPTY_ACTION,
+ });
+
+ const handleAdd = handleSubmit(async (values) => {
+ try {
+ await onAdd(values);
+ } catch {
+ // Keep the user's input and the form open when the save fails.
+ return;
+ }
+ reset(EMPTY_ACTION);
+ onClose();
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewCard.tsx
new file mode 100644
index 0000000000..a7d0590f4d
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewCard.tsx
@@ -0,0 +1,318 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ Alert,
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Badge,
+ Button,
+ Grid,
+ Heading,
+ HStack,
+ Stack,
+ Text,
+} from '@trycompai/design-system';
+import { Edit, Locked, TrashCan } from '@trycompai/design-system/icons';
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import type {
+ IsmsManagementReview,
+ IsmsReviewAttendee,
+} from '../isms-types';
+import { AttendeesField } from './AttendeesField';
+import { CarriedForwardActions } from './CarriedForwardActions';
+import type { ApproverOption } from './IsmsApprovalSection';
+import { ReviewActionsSection } from './ReviewActionsSection';
+import { ReviewFields } from './ReviewFields';
+import { ReviewInputsTable } from './ReviewInputsTable';
+import { ReviewOutputsSection } from './ReviewOutputsSection';
+import { ReviewSignoffCard } from './ReviewSignoffCard';
+import {
+ REVIEW_STATUS_LABELS,
+ carriedForwardActions,
+ isReviewSigned,
+ reviewConclusionSentence,
+} from './management-review-constants';
+import {
+ reviewDetailsSchema,
+ type ReviewActionFormValues,
+ type ReviewDetailsFormValues,
+ type ReviewInputFormValues,
+ type ReviewOutputsFormValues,
+ type ReviewSignoffFormValues,
+} from './management-review-schema';
+import { IsmsRegisterCard, IsmsRegisterField } from './shared';
+
+/** All mutations a review card (and its child sections) can perform. */
+export interface ReviewHandlers {
+ onUpdateReview: (reviewId: string, values: ReviewDetailsFormValues) => Promise;
+ onDeleteReview: (reviewId: string) => Promise;
+ onSaveAttendees: (reviewId: string, attendees: IsmsReviewAttendee[]) => Promise;
+ onSaveOutputs: (reviewId: string, values: ReviewOutputsFormValues) => Promise;
+ onSaveSignoff: (reviewId: string, values: ReviewSignoffFormValues) => Promise;
+ onCreateInput: (reviewId: string, values: ReviewInputFormValues) => Promise;
+ onUpdateInput: (inputId: string, payload: Record) => Promise;
+ onDeleteInput: (inputId: string) => Promise;
+ onCreateAction: (reviewId: string, values: ReviewActionFormValues) => Promise;
+ onUpdateAction: (actionId: string, payload: Record) => Promise;
+ onDeleteAction: (actionId: string) => Promise;
+}
+
+interface ReviewCardProps extends ReviewHandlers {
+ review: IsmsManagementReview;
+ /** All reviews (position order) — the source of the carry-forward list. */
+ reviews: IsmsManagementReview[];
+ canEdit: boolean;
+ memberOptions: ApproverOption[];
+ chairOptions: string[];
+}
+
+function toFormValues(review: IsmsManagementReview): ReviewDetailsFormValues {
+ return {
+ meetingDate: review.meetingDate?.slice(0, 10) ?? '',
+ chairName: review.chairName ?? '',
+ status: review.status,
+ conclusionVerdict: review.conclusionVerdict ?? '',
+ conclusionNotes: review.conclusionNotes ?? '',
+ };
+}
+
+export function ReviewCard({
+ review,
+ reviews,
+ canEdit,
+ memberOptions,
+ chairOptions,
+ onUpdateReview,
+ onDeleteReview,
+ onSaveAttendees,
+ onSaveOutputs,
+ onSaveSignoff,
+ onCreateInput,
+ onUpdateInput,
+ onDeleteInput,
+ onCreateAction,
+ onUpdateAction,
+ onDeleteAction,
+}: ReviewCardProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ // Signed = locked: the minutes are the chair-approved record. The server
+ // rejects edits too; the UI mirrors it by disabling everything except the
+ // sign-off slot (clearing the signature unlocks) and action tracking.
+ const locked = isReviewSigned(review);
+ const canEditContent = canEdit && !locked;
+ const carried = carriedForwardActions(reviews, review);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isDirty, isValid, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(reviewDetailsSchema),
+ mode: 'onChange',
+ defaultValues: toFormValues(review),
+ });
+
+ useEffect(() => {
+ if (!isEditing) reset(toFormValues(review));
+ }, [review, isEditing, reset]);
+
+ // If the review becomes signed while the details form is open (another
+ // user signs, SWR refreshes), close the form — the server would reject the
+ // save and the lock notice explains why.
+ useEffect(() => {
+ if (!canEditContent && isEditing) setIsEditing(false);
+ }, [canEditContent, isEditing]);
+
+ const handleSave = handleSubmit(async (values) => {
+ try {
+ await onUpdateReview(review.id, values);
+ } catch {
+ return;
+ }
+ setIsEditing(false);
+ });
+
+ const handleDelete = async () => {
+ setConfirmOpen(false);
+ setIsDeleting(true);
+ try {
+ await onDeleteReview(review.id);
+ } catch {
+ // Error already surfaced via toast by the caller.
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const headerActions = canEditContent ? (
+ isEditing ? (
+
+ {
+ reset(toFormValues(review));
+ setIsEditing(false);
+ }}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ Save
+
+
+ ) : (
+
+ setIsEditing(true)}
+ disabled={isDeleting}
+ iconLeft={ }
+ aria-label={`Edit review ${review.reference}`}
+ >
+ Edit details
+
+ setConfirmOpen(true)}
+ disabled={isDeleting}
+ loading={isDeleting}
+ iconLeft={ }
+ aria-label={`Delete review ${review.reference}`}
+ />
+
+ )
+ ) : undefined;
+
+ return (
+
+ {review.reference}
+ {REVIEW_STATUS_LABELS[review.status]}
+ {locked ? Signed : null}
+
+ }
+ headerEnd={headerActions}
+ >
+ {isEditing && canEditContent ? (
+
+ ) : (
+
+ {locked ? (
+ }>
+
+ This review is signed by the chair and locked. Actions still track to closure;
+ clear the sign-off below to edit anything else.
+
+
+ ) : null}
+
+
+
+ {review.meetingDate?.slice(0, 10) ?? 'Not set'}
+
+
+ {`${review.recordedAt.slice(0, 10)} — set by the platform, cannot be edited`}
+
+
+ {review.chairName || 'Not set — assigned in ISMS > Roles'}
+
+
+ {review.conclusionVerdict
+ ? `${reviewConclusionSentence({
+ verdict: review.conclusionVerdict,
+ meetingDate: review.meetingDate?.slice(0, 10) ?? null,
+ })}${review.conclusionNotes ? ` ${review.conclusionNotes}` : ''}`
+ : 'No conclusion recorded yet — pick the verdict in Edit details.'}
+
+
+
+ onSaveAttendees(review.id, attendees)}
+ />
+
+ {carried.length > 0 ? (
+
+ ) : null}
+
+ onCreateInput(review.id, values)}
+ onUpdateInput={onUpdateInput}
+ onDeleteInput={onDeleteInput}
+ />
+
+ onSaveOutputs(review.id, values)}
+ />
+
+ onCreateAction(review.id, values)}
+ onUpdateAction={onUpdateAction}
+ onDeleteAction={onDeleteAction}
+ />
+
+ onSaveSignoff(review.id, values)}
+ />
+
+ )}
+
+
+
+
+ Delete review {review.reference}?
+
+ This permanently removes the review, its inputs, and its actions arising. This
+ cannot be undone.
+
+
+
+ setConfirmOpen(false)}>Cancel
+
+ Delete
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewFields.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewFields.tsx
new file mode 100644
index 0000000000..9b6002ba62
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewFields.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import {
+ Grid,
+ Input,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Stack,
+ Text,
+ Textarea,
+} from '@trycompai/design-system';
+import { Controller, type Control } from 'react-hook-form';
+import {
+ REVIEW_CONCLUSION_VERDICTS,
+ REVIEW_CONCLUSION_VERDICT_LABELS,
+ REVIEW_STATUSES,
+ REVIEW_STATUS_LABELS,
+} from './management-review-constants';
+import type { ReviewDetailsFormValues } from './management-review-schema';
+import { IsmsFieldLabel } from './shared';
+
+const NO_VERDICT = 'no-verdict';
+const NO_CHAIR = 'no-chair';
+
+interface ReviewFieldsProps {
+ control: Control;
+ /** Top Management holder(s) from ISMS > Roles (5.3). */
+ chairOptions: string[];
+}
+
+/**
+ * The chair picker: whoever ISMS > Roles says Top Management is. The current
+ * stored value stays selectable even if Roles has since changed (the review
+ * is a historical record). When Roles has no Top Management holder yet, the
+ * select is disabled with a pointer.
+ */
+function ChairSelect({
+ value,
+ onChange,
+ chairOptions,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ chairOptions: string[];
+}) {
+ const options = [...new Set([...chairOptions, ...(value ? [value] : [])])];
+ if (options.length === 0) {
+ return (
+
+
+
+
+
+
+
+
+ Assign Top Management under ISMS > Roles first.
+
+
+ );
+ }
+ return (
+
+ onChange(!next || next === NO_CHAIR ? '' : next)
+ }
+ >
+
+
+
+
+ {/* Explicit clear: a stale chair must be removable, not only replaceable. */}
+ No chair yet
+ {options.map((option) => (
+
+ {option}
+
+ ))}
+
+
+ );
+}
+
+/** Inline edit fields for a review instance's details + conclusion (clause 9.3). */
+export function ReviewFields({ control, chairOptions }: ReviewFieldsProps) {
+ return (
+
+
+
+ (
+
+ )}
+ />
+
+
+ (
+
+
+
+
+
+ {REVIEW_STATUSES.map((status) => (
+
+ {REVIEW_STATUS_LABELS[status]}
+
+ ))}
+
+
+ )}
+ />
+
+
+
+ (
+
+ )}
+ />
+
+
+ (
+
+ field.onChange(next === NO_VERDICT ? '' : next)
+ }
+ >
+
+
+
+
+ No verdict yet
+ {REVIEW_CONCLUSION_VERDICTS.map((verdict) => (
+
+ {REVIEW_CONCLUSION_VERDICT_LABELS[verdict]}
+
+ ))}
+
+
+ )}
+ />
+
+
+ (
+
+ )}
+ />
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewInputRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewInputRow.tsx
new file mode 100644
index 0000000000..378a8a1322
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewInputRow.tsx
@@ -0,0 +1,261 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button,
+ Checkbox,
+ Field,
+ FieldError,
+ Input,
+ TableCell,
+ TableRow,
+ Textarea,
+} from '@trycompai/design-system';
+import { Edit, TrashCan } from '@trycompai/design-system/icons';
+import { useEffect, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import type { IsmsReviewInput } from '../isms-types';
+import {
+ reviewInputSchema,
+ toInputPayload,
+ type ReviewInputFormValues,
+} from './management-review-schema';
+
+interface ReviewInputRowProps {
+ input: IsmsReviewInput;
+ canEdit: boolean;
+ onUpdateInput: (inputId: string, payload: Record) => Promise;
+ onDeleteInput: (inputId: string) => Promise;
+}
+
+function toFormValues(input: IsmsReviewInput): ReviewInputFormValues {
+ return {
+ inputRef: input.inputRef,
+ whatItCovers: input.whatItCovers,
+ whereToFind: input.whereToFind,
+ discussionNotes: input.discussionNotes ?? '',
+ };
+}
+
+/**
+ * One Inputs (9.3.2) row. The Discussed? checkbox saves immediately (ticking
+ * through the agenda is the primary meeting workflow); the text columns and
+ * notes are edited via the row's edit mode. Seeded rows are editable and
+ * deletable like custom rows — nothing re-seeds an existing review, so
+ * removals stick.
+ */
+export function ReviewInputRow({
+ input,
+ canEdit,
+ onUpdateInput,
+ onDeleteInput,
+}: ReviewInputRowProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [isSavingDiscussed, setIsSavingDiscussed] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isDirty, isValid, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(reviewInputSchema),
+ mode: 'onChange',
+ defaultValues: toFormValues(input),
+ });
+
+ useEffect(() => {
+ if (!isEditing) reset(toFormValues(input));
+ }, [input, isEditing, reset]);
+
+ // If the review becomes signed while this row is being edited, exit edit
+ // mode — the server rejects input edits on a signed review.
+ useEffect(() => {
+ if (!canEdit && isEditing) setIsEditing(false);
+ }, [canEdit, isEditing]);
+
+ const handleSave = handleSubmit(async (values) => {
+ try {
+ await onUpdateInput(input.id, toInputPayload(values));
+ } catch {
+ return;
+ }
+ setIsEditing(false);
+ });
+
+ const handleDiscussedChange = async (next: boolean) => {
+ setIsSavingDiscussed(true);
+ try {
+ await onUpdateInput(input.id, { discussed: next });
+ } catch {
+ // Error already surfaced via toast by the caller.
+ } finally {
+ setIsSavingDiscussed(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ setConfirmOpen(false);
+ setIsDeleting(true);
+ try {
+ await onDeleteInput(input.id);
+ } catch {
+ // Error already surfaced via toast by the caller.
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ if (isEditing && canEdit) {
+ return (
+
+
+
+ (
+ <>
+
+ {fieldState.error?.message}
+ >
+ )}
+ />
+
+
+
+ (
+
+ )}
+ />
+
+
+ (
+
+ )}
+ />
+
+
+ (
+
+ )}
+ />
+
+ —
+
+
+ {
+ reset(toFormValues(input));
+ setIsEditing(false);
+ }}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ Save
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {input.inputRef}
+
+ {input.whatItCovers || '—'}
+ {input.whereToFind || '—'}
+ {input.discussionNotes || '—'}
+
+ {canEdit ? (
+ void handleDiscussedChange(next === true)}
+ disabled={isSavingDiscussed}
+ aria-label={`Discussed: ${input.inputRef}`}
+ />
+ ) : input.discussed ? (
+ 'Yes'
+ ) : (
+ 'No'
+ )}
+
+ {canEdit ? (
+
+
+ setIsEditing(true)}
+ disabled={isDeleting}
+ iconLeft={ }
+ aria-label={`Edit ${input.inputRef}`}
+ />
+ setConfirmOpen(true)}
+ disabled={isDeleting}
+ loading={isDeleting}
+ iconLeft={ }
+ aria-label={`Delete ${input.inputRef}`}
+ />
+
+
+
+
+ Delete this input row?
+
+ The ten defaults are the ISO-mandated inputs — removing one is not recommended
+ without justification. Removed seeded rows are not re-added. This cannot be
+ undone.
+
+
+
+ setConfirmOpen(false)}>Cancel
+ void handleDelete()} variant="destructive">
+ Delete
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewInputsTable.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewInputsTable.tsx
new file mode 100644
index 0000000000..63b2229671
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewInputsTable.tsx
@@ -0,0 +1,215 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ Badge,
+ Button,
+ Field,
+ FieldError,
+ Heading,
+ HStack,
+ Input,
+ Stack,
+ Table,
+ TableBody,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Text,
+ Textarea,
+} from '@trycompai/design-system';
+import { Add } from '@trycompai/design-system/icons';
+import { Controller, useForm } from 'react-hook-form';
+import type { IsmsManagementReview } from '../isms-types';
+import { ReviewInputRow } from './ReviewInputRow';
+import {
+ reviewInputSchema,
+ type ReviewInputFormValues,
+} from './management-review-schema';
+import { IsmsAddCard, IsmsFieldLabel } from './shared';
+
+interface ReviewInputsTableProps {
+ review: IsmsManagementReview;
+ canEdit: boolean;
+ onCreateInput: (values: ReviewInputFormValues) => Promise;
+ onUpdateInput: (inputId: string, payload: Record) => Promise;
+ onDeleteInput: (inputId: string) => Promise;
+}
+
+const EMPTY_INPUT: ReviewInputFormValues = {
+ inputRef: '',
+ whatItCovers: '',
+ whereToFind: '',
+ discussionNotes: '',
+};
+
+/**
+ * The Inputs (9.3.2) table — the heart of the review and the meeting agenda.
+ * Ten default rows are seeded per review (inputs (a)-(g), with (d) split into
+ * four); working through them in order covers everything ISO requires. For
+ * each row: open the "Where to find it" location, discuss it, capture notes,
+ * and tick Discussed?. Rows can be added, edited, or removed freely.
+ */
+export function ReviewInputsTable({
+ review,
+ canEdit,
+ onCreateInput,
+ onUpdateInput,
+ onDeleteInput,
+}: ReviewInputsTableProps) {
+ const inputs = review.inputs;
+ const discussedCount = inputs.filter((input) => input.discussed).length;
+
+ return (
+
+
+ Inputs (9.3.2)
+ {`${discussedCount} of ${inputs.length} discussed`}
+
+
+ This table is the meeting agenda. For each input: open the "Where to find it"
+ location, review the current state before or during the meeting, add discussion notes,
+ and tick Discussed?. The ten defaults are the ISO-mandated set — not recommended to
+ remove any without justification.
+
+
+ {inputs.length === 0 ? (
+
+ No inputs recorded for this review.
+
+ ) : (
+
+
+
+
+ Input
+ What it covers
+ Where to find it
+ Discussion notes
+ Discussed?
+ {canEdit ? : null}
+
+
+
+ {inputs.map((input) => (
+
+ ))}
+
+
+
+ )}
+
+ {canEdit ? (
+
+ {({ close }) => }
+
+ ) : null}
+
+ );
+}
+
+function AddInputForm({
+ onAdd,
+ onClose,
+}: {
+ onAdd: (values: ReviewInputFormValues) => Promise;
+ onClose: () => void;
+}) {
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting },
+ } = useForm({
+ resolver: zodResolver(reviewInputSchema),
+ defaultValues: EMPTY_INPUT,
+ });
+
+ const handleAdd = handleSubmit(async (values) => {
+ try {
+ await onAdd(values);
+ } catch {
+ // Keep the user's input and the form open when the save fails.
+ return;
+ }
+ reset(EMPTY_INPUT);
+ onClose();
+ });
+
+ return (
+
+
+
+ (
+ <>
+
+ {fieldState.error?.message}
+ >
+ )}
+ />
+
+
+
+ (
+
+ )}
+ />
+
+
+ (
+
+ )}
+ />
+
+
+ (
+
+ )}
+ />
+
+
+ }
+ >
+ Add input row
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewOutputsSection.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewOutputsSection.tsx
new file mode 100644
index 0000000000..1c5513addf
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewOutputsSection.tsx
@@ -0,0 +1,160 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ Button,
+ Heading,
+ HStack,
+ Stack,
+ Text,
+ Textarea,
+} from '@trycompai/design-system';
+import { Edit } from '@trycompai/design-system/icons';
+import { useEffect, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import type { IsmsManagementReview } from '../isms-types';
+import {
+ reviewOutputsSchema,
+ type ReviewOutputsFormValues,
+} from './management-review-schema';
+import { IsmsFieldLabel, IsmsRegisterField } from './shared';
+
+interface ReviewOutputsSectionProps {
+ review: IsmsManagementReview;
+ canEdit: boolean;
+ onSave: (values: ReviewOutputsFormValues) => Promise;
+}
+
+function toFormValues(review: IsmsManagementReview): ReviewOutputsFormValues {
+ return {
+ decisionsText: review.decisionsText ?? '',
+ changesText: review.changesText ?? '',
+ };
+}
+
+/**
+ * The Outputs (9.3.3) text pair: decisions on continual improvement and ISMS
+ * changes required. Both ship with template text the customer edits or
+ * overwrites; the actions arising table follows as its own section.
+ */
+export function ReviewOutputsSection({
+ review,
+ canEdit,
+ onSave,
+}: ReviewOutputsSectionProps) {
+ const [isEditing, setIsEditing] = useState(false);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isDirty, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(reviewOutputsSchema),
+ mode: 'onChange',
+ defaultValues: toFormValues(review),
+ });
+
+ useEffect(() => {
+ if (!isEditing) reset(toFormValues(review));
+ }, [review, isEditing, reset]);
+
+ // If the review becomes signed (or the caller loses edit rights) while the
+ // outputs editor is open, close it — otherwise the textareas would linger
+ // with stale local edits and no Save action.
+ useEffect(() => {
+ if (!canEdit && isEditing) setIsEditing(false);
+ }, [canEdit, isEditing]);
+
+ const handleSave = handleSubmit(async (values) => {
+ try {
+ await onSave(values);
+ } catch {
+ return;
+ }
+ setIsEditing(false);
+ });
+
+ return (
+
+
+ Outputs (9.3.3)
+ {canEdit ? (
+ isEditing ? (
+
+ {
+ reset(toFormValues(review));
+ setIsEditing(false);
+ }}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ Save
+
+
+ ) : (
+ setIsEditing(true)}
+ iconLeft={ }
+ aria-label={`Edit outputs for ${review.reference}`}
+ >
+ Edit
+
+ )
+ ) : null}
+
+
+ Replace the template text with what was actually decided, or leave it as-is if nothing
+ changed. Actions arising are recorded in the table below.
+
+
+ {isEditing && canEdit ? (
+
+
+ (
+
+ )}
+ />
+
+
+ (
+
+ )}
+ />
+
+
+ ) : (
+
+
+ {review.decisionsText || '—'}
+
+
+ {review.changesText || '—'}
+
+
+ )}
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewSignoffCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewSignoffCard.tsx
new file mode 100644
index 0000000000..83d0256352
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewSignoffCard.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ Badge,
+ Button,
+ Grid,
+ Heading,
+ HStack,
+ Input,
+ Stack,
+ Text,
+} from '@trycompai/design-system';
+import { useEffect } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import type { IsmsManagementReview } from '../isms-types';
+import { isReviewSigned } from './management-review-constants';
+import {
+ reviewSignoffSchema,
+ type ReviewSignoffFormValues,
+} from './management-review-schema';
+import { IsmsFieldLabel } from './shared';
+
+interface ReviewSignoffCardProps {
+ review: IsmsManagementReview;
+ canEdit: boolean;
+ onSave: (values: ReviewSignoffFormValues) => Promise;
+}
+
+function toFormValues(review: IsmsManagementReview): ReviewSignoffFormValues {
+ return {
+ signoffChairName: review.signoffChairName ?? '',
+ signoffChairDate: review.signoffChairDate?.slice(0, 10) ?? '',
+ };
+}
+
+/**
+ * The review's single sign-off slot: the chair signs (name free text + date).
+ * Once both are set the review is locked — its minutes are the chair-approved
+ * record; clearing the signature here unlocks it. The slot renders as the
+ * Sign-off table in the generated clause-9.3 document.
+ */
+export function ReviewSignoffCard({ review, canEdit, onSave }: ReviewSignoffCardProps) {
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isDirty, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(reviewSignoffSchema),
+ mode: 'onChange',
+ defaultValues: toFormValues(review),
+ });
+
+ // Re-sync only when the PERSISTED sign-off values change (own save landing),
+ // not on every review refresh — a sibling register update must not discard
+ // unsaved sign-off input.
+ const persistedFingerprint = JSON.stringify(toFormValues(review));
+ useEffect(() => {
+ reset(JSON.parse(persistedFingerprint) as ReviewSignoffFormValues);
+ }, [persistedFingerprint, reset]);
+
+ const handleSave = handleSubmit(async (values) => {
+ try {
+ await onSave(values);
+ } catch {
+ // Error already surfaced via toast by the caller; keep the edits.
+ }
+ });
+
+ const signed = isReviewSigned(review);
+
+ return (
+
+
+
+ Sign-off
+ {signed ? 'Signed' : 'Not signed'}
+
+ {canEdit ? (
+
+ Save sign-off
+
+ ) : null}
+
+
+ The chair signs the completed review. Once name and date are both set the review locks
+ (actions still track to closure); clearing them unlocks it.
+
+
+
+ (
+
+ )}
+ />
+
+
+ (
+
+ )}
+ />
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewsList.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewsList.tsx
new file mode 100644
index 0000000000..6385e9af6f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewsList.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { Alert, Button, Stack, Text } from '@trycompai/design-system';
+import { Add, Events, WarningAlt } from '@trycompai/design-system/icons';
+import { useState } from 'react';
+import type { IsmsManagementReview } from '../isms-types';
+import type { ApproverOption } from './IsmsApprovalSection';
+import { ReviewCard, type ReviewHandlers } from './ReviewCard';
+import { IsmsRegisterShell } from './shared';
+
+interface ReviewsListProps extends ReviewHandlers {
+ reviews: IsmsManagementReview[];
+ canEdit: boolean;
+ memberOptions: ApproverOption[];
+ chairOptions: string[];
+ validationMessages: string[];
+ onCreateReview: () => Promise;
+}
+
+/**
+ * The Reviews register (clause 9.3): a list of review instances, each
+ * expanding to its meeting details, Inputs (9.3.2) table, outputs, actions,
+ * and chair sign-off. "New review" creates an instance pre-filled with the
+ * full template — reference, chair and attendees from Roles (5.3), output
+ * text, and the ten default input rows.
+ */
+export function ReviewsList({
+ reviews,
+ canEdit,
+ memberOptions,
+ chairOptions,
+ validationMessages,
+ onCreateReview,
+ ...handlers
+}: ReviewsListProps) {
+ const [isCreating, setIsCreating] = useState(false);
+
+ const handleCreate = async () => {
+ setIsCreating(true);
+ try {
+ await onCreateReview();
+ } catch {
+ // Error already surfaced via toast by the caller.
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ return (
+
+ {validationMessages.length > 0 ? (
+ }>
+
+ Before the Clause 9.3 document can be submitted:{' '}
+ {validationMessages.join(' ')}
+
+
+ ) : null}
+
+
+ void handleCreate()}
+ disabled={isCreating}
+ loading={isCreating}
+ iconLeft={ }
+ >
+ New review
+
+
+ ) : undefined
+ }
+ >
+
+ {reviews.map((review) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ScopeClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ScopeClient.test.tsx
index 3b79ba8370..20c81de67f 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ScopeClient.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ScopeClient.test.tsx
@@ -166,6 +166,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument {
roles: [],
metrics: [],
audits: [],
+ reviews: [],
controlLinks: [],
draftNarrative: NARRATIVE,
currentVersionId: null,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/__test-helpers__/dsMocks.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/__test-helpers__/dsMocks.tsx
index 86137a48b2..1701904d93 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/__test-helpers__/dsMocks.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/__test-helpers__/dsMocks.tsx
@@ -48,6 +48,25 @@ export function ismsDesignSystemMock() {
DialogHeader: ({ children }: { children: ReactNode }) => {children}
,
DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
Input: (props: React.ComponentProps<'input'>) => ,
+ Checkbox: ({
+ checked,
+ onCheckedChange,
+ disabled,
+ 'aria-label': ariaLabel,
+ }: {
+ checked?: boolean;
+ onCheckedChange?: (next: boolean) => void;
+ disabled?: boolean;
+ 'aria-label'?: string;
+ }) => (
+ onCheckedChange?.(event.target.checked)}
+ disabled={disabled}
+ aria-label={ariaLabel}
+ />
+ ),
Section: ({ title, children }: { title?: ReactNode; children: ReactNode }) => (
{title ? {title} : null}
@@ -118,13 +137,16 @@ export function ismsIconsMock() {
Analytics: Icon,
Checkmark: Icon,
ChevronDown: Icon,
+ Close: Icon,
CloseOutline: Icon,
Document: Icon,
Download: Icon,
Edit: Icon,
+ Events: Icon,
Time: Icon,
Flag: Icon,
ListChecked: Icon,
+ Locked: Icon,
MachineLearningModel: Icon,
Renew: Icon,
TrashCan: Icon,
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-constants.test.ts b/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-constants.test.ts
new file mode 100644
index 0000000000..814921766e
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-constants.test.ts
@@ -0,0 +1,210 @@
+import { describe, expect, it } from 'vitest';
+import type { IsmsManagementReview } from '../isms-types';
+import {
+ carriedForwardActions,
+ fullActionReference,
+ isReviewSigned,
+ parseAttendees,
+ parseProcedure,
+ reviewConclusionSentence,
+ reviewValidationMessages,
+} from './management-review-constants';
+
+function makeReview(
+ overrides: Partial = {},
+): IsmsManagementReview {
+ return {
+ id: 'mr_1',
+ reference: 'MR-2026-01',
+ meetingDate: '2026-05-01T00:00:00.000Z',
+ recordedAt: '2026-05-01T09:30:00.000Z',
+ chairName: 'Raoul Plickat',
+ attendees: [{ memberId: 'm1', name: 'Raoul Plickat' }],
+ status: 'complete',
+ conclusionVerdict: 'effective',
+ conclusionNotes: null,
+ decisionsText: null,
+ changesText: null,
+ signoffChairName: 'Raoul Plickat',
+ signoffChairDate: '2026-05-01T00:00:00.000Z',
+ position: 0,
+ inputs: [],
+ actions: [],
+ ...overrides,
+ };
+}
+
+describe('reviewConclusionSentence / fullActionReference', () => {
+ it('assembles the ticket sentence with the verdict and date', () => {
+ expect(
+ reviewConclusionSentence({ verdict: 'suitable', meetingDate: '2026-05-01' }),
+ ).toBe(
+ 'The information security management system was reviewed on 2026-05-01. Overall, the ISMS was found to be suitable and no changes are required except those recorded in the outputs section below.',
+ );
+ expect(
+ reviewConclusionSentence({ verdict: 'adequate', meetingDate: null }),
+ ).toContain('was reviewed. Overall, the ISMS was found to be adequate');
+ });
+
+ it('composes the full action reference', () => {
+ expect(fullActionReference('MR-2026-01', 'A03')).toBe('MR-2026-01-A03');
+ });
+});
+
+describe('parseAttendees / isReviewSigned / parseProcedure', () => {
+ it('is all-or-nothing on malformed entries, mirroring the server parse', () => {
+ // One malformed entry invalidates the WHOLE list — exactly like the API's
+ // parseReviewAttendees — so the Submit UI can never look ready while the
+ // server gate counts zero attendees.
+ expect(
+ parseAttendees([
+ { memberId: 'm1', name: 'Jane' },
+ { memberId: 'm2' },
+ ]),
+ ).toEqual([]);
+ expect(parseAttendees([{ memberId: '', name: 'Ghost' }])).toEqual([]);
+ expect(parseAttendees([{ memberId: 'm3', name: ' ' }])).toEqual([]);
+ expect(parseAttendees(null)).toEqual([]);
+ expect(parseAttendees('nope')).toEqual([]);
+ });
+
+ it('parses valid attendees, trims names, and dedupes by member', () => {
+ expect(
+ parseAttendees([
+ // Trimmed like the server's zod `.trim()` transform, so the UI and
+ // the generated minutes show the same name.
+ { memberId: 'm1', name: ' Jane ' },
+ { memberId: 'm2', name: 'Ada' },
+ { memberId: 'm1', name: 'Jane (dup)' },
+ ]),
+ ).toEqual([
+ { memberId: 'm1', name: 'Jane' },
+ { memberId: 'm2', name: 'Ada' },
+ ]);
+ });
+
+ it('requires both chair name and date for signed', () => {
+ expect(isReviewSigned(makeReview())).toBe(true);
+ expect(isReviewSigned(makeReview({ signoffChairName: ' ' }))).toBe(false);
+ expect(isReviewSigned(makeReview({ signoffChairDate: null }))).toBe(false);
+ });
+
+ it('reads the procedure out of the draft narrative', () => {
+ expect(parseProcedure({ procedure: 'We review annually.' })).toBe(
+ 'We review annually.',
+ );
+ expect(parseProcedure({ programme: 'wrong doc' })).toBe('');
+ expect(parseProcedure(null)).toBe('');
+ });
+});
+
+describe('carriedForwardActions', () => {
+ it('carries only open/in-progress actions from EARLIER reviews', () => {
+ const first = makeReview({
+ id: 'mr_1',
+ reference: 'MR-2025-01',
+ actions: [
+ {
+ id: 'a1',
+ reviewId: 'mr_1',
+ reference: 'A01',
+ description: 'Open action',
+ ownerMemberId: null,
+ dueDate: null,
+ status: 'open',
+ position: 0,
+ },
+ {
+ id: 'a2',
+ reviewId: 'mr_1',
+ reference: 'A02',
+ description: 'Closed action',
+ ownerMemberId: null,
+ dueDate: null,
+ status: 'closed',
+ position: 1,
+ },
+ ],
+ });
+ const second = makeReview({ id: 'mr_2', reference: 'MR-2026-01' });
+
+ expect(carriedForwardActions([first, second], first)).toEqual([]);
+ const carried = carriedForwardActions([first, second], second);
+ expect(carried).toHaveLength(1);
+ expect(carried[0].action.reference).toBe('A01');
+ expect(carried[0].review.reference).toBe('MR-2025-01');
+ });
+});
+
+describe('reviewValidationMessages', () => {
+ it('requires the procedure and at least one review', () => {
+ expect(reviewValidationMessages({ procedure: ' ', reviews: [] })).toEqual([
+ 'The review procedure paragraph must not be empty.',
+ 'At least one management review must be recorded.',
+ ]);
+ });
+
+ it('passes a signed, fully-discussed complete review', () => {
+ expect(
+ reviewValidationMessages({
+ procedure: 'We review annually.',
+ reviews: [makeReview()],
+ }),
+ ).toEqual([]);
+ });
+
+ it('never blocks on planned reviews (agenda packs)', () => {
+ expect(
+ reviewValidationMessages({
+ procedure: 'We review annually.',
+ reviews: [
+ makeReview({
+ status: 'planned',
+ meetingDate: null,
+ chairName: null,
+ attendees: [],
+ signoffChairName: null,
+ signoffChairDate: null,
+ }),
+ ],
+ }),
+ ).toEqual([]);
+ });
+
+ it('lists every unmet requirement of a complete review', () => {
+ const messages = reviewValidationMessages({
+ procedure: 'We review annually.',
+ reviews: [
+ makeReview({
+ meetingDate: null,
+ chairName: null,
+ attendees: [],
+ signoffChairName: null,
+ signoffChairDate: null,
+ inputs: [
+ {
+ id: 'i1',
+ reviewId: 'mr_1',
+ inputKey: null,
+ inputRef: '(a)',
+ whatItCovers: '',
+ whereToFind: '',
+ discussionNotes: null,
+ discussed: false,
+ source: 'manual',
+ derivedFrom: null,
+ position: 0,
+ },
+ ],
+ }),
+ ],
+ });
+ expect(messages).toEqual([
+ 'Review MR-2026-01 is complete but has no meeting date.',
+ 'Review MR-2026-01 is complete but has no chair.',
+ 'Review MR-2026-01 is complete but has no attendees.',
+ 'Review MR-2026-01 has 1 input not yet marked as discussed.',
+ 'Review MR-2026-01 is complete but has not been signed by the chair.',
+ ]);
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-constants.ts b/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-constants.ts
new file mode 100644
index 0000000000..528977012f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-constants.ts
@@ -0,0 +1,196 @@
+import type {
+ IsmsManagementReview,
+ IsmsReviewActionStatus,
+ IsmsReviewAttendee,
+ IsmsReviewConclusionVerdict,
+ IsmsReviewStatus,
+} from '../isms-types';
+
+/**
+ * Shared labels + the clause-9.3 client validation mirror for the Management
+ * Review document (CS-726). The server enforces the same rules in
+ * assertManagementReviewComplete (documents/management-review.ts) — keep in
+ * sync.
+ */
+
+export const REVIEW_STATUSES = ['planned', 'in_progress', 'complete'] as const;
+
+export const REVIEW_STATUS_LABELS: Record = {
+ planned: 'Planned',
+ in_progress: 'In progress',
+ complete: 'Complete',
+};
+
+export const REVIEW_CONCLUSION_VERDICTS = [
+ 'suitable',
+ 'adequate',
+ 'effective',
+] as const;
+
+/** The bracketed choices in the conclusion template, verbatim per the ticket. */
+export const REVIEW_CONCLUSION_VERDICT_LABELS: Record<
+ IsmsReviewConclusionVerdict,
+ string
+> = {
+ suitable: 'Suitable',
+ adequate: 'Adequate',
+ effective: 'Effective',
+};
+
+/** The assembled conclusion sentence shown in the read view + generated doc. */
+export function reviewConclusionSentence({
+ verdict,
+ meetingDate,
+}: {
+ verdict: IsmsReviewConclusionVerdict;
+ meetingDate: string | null;
+}): string {
+ const choice = REVIEW_CONCLUSION_VERDICT_LABELS[verdict].toLowerCase();
+ const reviewed = meetingDate
+ ? `The information security management system was reviewed on ${meetingDate}.`
+ : 'The information security management system was reviewed.';
+ return `${reviewed} Overall, the ISMS was found to be ${choice} and no changes are required except those recorded in the outputs section below.`;
+}
+
+export const REVIEW_ACTION_STATUSES = [
+ 'open',
+ 'in_progress',
+ 'closed',
+] as const;
+
+export const REVIEW_ACTION_STATUS_LABELS: Record<
+ IsmsReviewActionStatus,
+ string
+> = {
+ open: 'Open',
+ in_progress: 'In progress',
+ closed: 'Closed',
+};
+
+/** Full display reference for an action: "MR-YYYY-NN-A01". */
+export function fullActionReference(
+ reviewReference: string,
+ actionReference: string,
+): string {
+ return `${reviewReference}-${actionReference}`;
+}
+
+function isValidAttendee(entry: unknown): entry is IsmsReviewAttendee {
+ return (
+ !!entry &&
+ typeof entry === 'object' &&
+ typeof (entry as { memberId?: unknown }).memberId === 'string' &&
+ (entry as { memberId: string }).memberId.length > 0 &&
+ typeof (entry as { name?: unknown }).name === 'string' &&
+ (entry as { name: string }).name.trim().length > 0
+ );
+}
+
+/**
+ * Parse a review's attendees defensively — the exact mirror of the server's
+ * parseReviewAttendees (documents/management-review.ts): all-or-nothing (one
+ * malformed entry invalidates the list, like the write schema), names trimmed
+ * (zod's `.trim()` transform does this server-side), and deduped by member,
+ * so the UI, the Submit gate, and the generated minutes always agree.
+ */
+export function parseAttendees(value: unknown): IsmsReviewAttendee[] {
+ if (!Array.isArray(value) || !value.every(isValidAttendee)) return [];
+ const seen = new Set();
+ return value
+ .map((attendee) => ({ ...attendee, name: attendee.name.trim() }))
+ .filter((attendee) => {
+ if (seen.has(attendee.memberId)) return false;
+ seen.add(attendee.memberId);
+ return true;
+ });
+}
+
+/** A review is signed (and locked) once the chair's name AND date are set. */
+export function isReviewSigned(
+ review: Pick,
+): boolean {
+ return Boolean(
+ review.signoffChairName?.trim() && review.signoffChairDate,
+ );
+}
+
+/**
+ * Open actions from the reviews BEFORE this one (position order) — the
+ * carry-forward into this review's input (a). Computed, never copied, so
+ * their status keeps tracking to closure.
+ */
+export function carriedForwardActions(
+ reviews: IsmsManagementReview[],
+ review: IsmsManagementReview,
+): Array<{ review: IsmsManagementReview; action: IsmsManagementReview['actions'][number] }> {
+ const index = reviews.findIndex((row) => row.id === review.id);
+ if (index <= 0) return [];
+ return reviews.slice(0, index).flatMap((prior) =>
+ prior.actions
+ .filter((action) => action.status !== 'closed')
+ .map((action) => ({ review: prior, action })),
+ );
+}
+
+/**
+ * Clause-9.3 readiness check — client mirror of the server submit gate
+ * (reviewValidationMessages in apps/api documents/management-review.ts).
+ * Planned / in-progress reviews (agenda packs) never block.
+ */
+export function reviewValidationMessages({
+ procedure,
+ reviews,
+}: {
+ procedure: string;
+ reviews: IsmsManagementReview[];
+}): string[] {
+ const messages: string[] = [];
+ if (!procedure.trim()) {
+ messages.push('The review procedure paragraph must not be empty.');
+ }
+ if (reviews.length === 0) {
+ messages.push('At least one management review must be recorded.');
+ return messages;
+ }
+ for (const review of reviews) {
+ if (review.status !== 'complete') continue;
+ if (!review.meetingDate) {
+ messages.push(
+ `Review ${review.reference} is complete but has no meeting date.`,
+ );
+ }
+ if (!review.chairName?.trim()) {
+ messages.push(`Review ${review.reference} is complete but has no chair.`);
+ }
+ if (parseAttendees(review.attendees).length === 0) {
+ messages.push(
+ `Review ${review.reference} is complete but has no attendees.`,
+ );
+ }
+ const undiscussed = review.inputs.filter((input) => !input.discussed).length;
+ if (undiscussed > 0) {
+ messages.push(
+ `Review ${review.reference} has ${undiscussed} input${undiscussed === 1 ? '' : 's'} not yet marked as discussed.`,
+ );
+ }
+ if (!isReviewSigned(review)) {
+ messages.push(
+ `Review ${review.reference} is complete but has not been signed by the chair.`,
+ );
+ }
+ }
+ return messages;
+}
+
+/** Read the Procedure paragraph out of the document's draft narrative. */
+export function parseProcedure(narrative: unknown): string {
+ if (
+ narrative &&
+ typeof narrative === 'object' &&
+ 'procedure' in narrative &&
+ typeof (narrative as { procedure: unknown }).procedure === 'string'
+ ) {
+ return (narrative as { procedure: string }).procedure;
+ }
+ return '';
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-schema.test.ts b/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-schema.test.ts
new file mode 100644
index 0000000000..4d31c30669
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-schema.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from 'vitest';
+import {
+ reviewActionSchema,
+ reviewDetailsSchema,
+ reviewInputSchema,
+ toActionPayload,
+ toInputPayload,
+ toOutputsPayload,
+ toReviewPayload,
+ toReviewSignoffPayload,
+} from './management-review-schema';
+
+describe('reviewDetailsSchema / toReviewPayload', () => {
+ it('accepts empty optional fields and the empty verdict', () => {
+ const parsed = reviewDetailsSchema.safeParse({
+ meetingDate: '',
+ chairName: '',
+ status: 'planned',
+ conclusionVerdict: '',
+ conclusionNotes: '',
+ });
+ expect(parsed.success).toBe(true);
+ });
+
+ it('rejects an unknown verdict', () => {
+ const parsed = reviewDetailsSchema.safeParse({
+ meetingDate: '',
+ chairName: '',
+ status: 'planned',
+ conclusionVerdict: 'conform', // the 9.2 enum, not the 9.3 one
+ conclusionNotes: '',
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it('maps empty strings to null (the register "clear" contract)', () => {
+ expect(
+ toReviewPayload({
+ meetingDate: '',
+ chairName: '',
+ status: 'complete',
+ conclusionVerdict: 'effective',
+ conclusionNotes: '',
+ }),
+ ).toEqual({
+ meetingDate: null,
+ chairName: null,
+ status: 'complete',
+ conclusionVerdict: 'effective',
+ conclusionNotes: null,
+ });
+ });
+});
+
+describe('input / action / outputs / sign-off payloads', () => {
+ it('requires an input reference', () => {
+ expect(
+ reviewInputSchema.safeParse({
+ inputRef: ' ',
+ whatItCovers: '',
+ whereToFind: '',
+ discussionNotes: '',
+ }).success,
+ ).toBe(false);
+ });
+
+ it('maps input notes empty string to null', () => {
+ expect(
+ toInputPayload({
+ inputRef: '(h) Custom',
+ whatItCovers: 'w',
+ whereToFind: 'x',
+ discussionNotes: '',
+ }),
+ ).toEqual({
+ inputRef: '(h) Custom',
+ whatItCovers: 'w',
+ whereToFind: 'x',
+ discussionNotes: null,
+ });
+ });
+
+ it('requires an action description', () => {
+ expect(
+ reviewActionSchema.safeParse({
+ description: ' ',
+ ownerMemberId: '',
+ dueDate: '',
+ status: 'open',
+ }).success,
+ ).toBe(false);
+ });
+
+ it('maps unset action owner/due date to null', () => {
+ expect(
+ toActionPayload({
+ description: 'Do the thing',
+ ownerMemberId: '',
+ dueDate: '',
+ status: 'open',
+ }),
+ ).toEqual({
+ description: 'Do the thing',
+ ownerMemberId: null,
+ dueDate: null,
+ status: 'open',
+ });
+ });
+
+ it('maps cleared outputs and sign-off fields to null', () => {
+ expect(toOutputsPayload({ decisionsText: '', changesText: 'kept' })).toEqual({
+ decisionsText: null,
+ changesText: 'kept',
+ });
+ expect(
+ toReviewSignoffPayload({ signoffChairName: '', signoffChairDate: '' }),
+ ).toEqual({ signoffChairName: null, signoffChairDate: null });
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-schema.ts b/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-schema.ts
new file mode 100644
index 0000000000..ae7a856002
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-schema.ts
@@ -0,0 +1,99 @@
+import { z } from 'zod';
+import {
+ REVIEW_ACTION_STATUSES,
+ REVIEW_CONCLUSION_VERDICTS,
+ REVIEW_STATUSES,
+} from './management-review-constants';
+
+/**
+ * Canonical zod schemas for the Management Review forms (clause 9.3). Empty
+ * strings stand in for "not set" while editing; the payload mappers convert
+ * them to null for the register API (which treats null as "clear").
+ */
+
+export const reviewDetailsSchema = z.object({
+ meetingDate: z.string(),
+ chairName: z.string(),
+ status: z.enum(REVIEW_STATUSES),
+ conclusionVerdict: z.union([
+ z.enum(REVIEW_CONCLUSION_VERDICTS),
+ z.literal(''),
+ ]),
+ conclusionNotes: z.string(),
+});
+
+export type ReviewDetailsFormValues = z.infer;
+
+export function toReviewPayload(values: ReviewDetailsFormValues) {
+ return {
+ meetingDate: values.meetingDate || null,
+ chairName: values.chairName || null,
+ status: values.status,
+ conclusionVerdict: values.conclusionVerdict || null,
+ conclusionNotes: values.conclusionNotes || null,
+ };
+}
+
+export const reviewOutputsSchema = z.object({
+ decisionsText: z.string(),
+ changesText: z.string(),
+});
+
+export type ReviewOutputsFormValues = z.infer;
+
+export function toOutputsPayload(values: ReviewOutputsFormValues) {
+ return {
+ decisionsText: values.decisionsText || null,
+ changesText: values.changesText || null,
+ };
+}
+
+export const reviewInputSchema = z.object({
+ inputRef: z.string().trim().min(1, 'Input reference is required'),
+ whatItCovers: z.string(),
+ whereToFind: z.string(),
+ discussionNotes: z.string(),
+});
+
+export type ReviewInputFormValues = z.infer;
+
+export function toInputPayload(values: ReviewInputFormValues) {
+ return {
+ inputRef: values.inputRef,
+ whatItCovers: values.whatItCovers,
+ whereToFind: values.whereToFind,
+ discussionNotes: values.discussionNotes || null,
+ };
+}
+
+export const reviewActionSchema = z.object({
+ description: z.string().trim().min(1, 'Description is required'),
+ ownerMemberId: z.string(),
+ dueDate: z.string(),
+ status: z.enum(REVIEW_ACTION_STATUSES),
+});
+
+export type ReviewActionFormValues = z.infer;
+
+export function toActionPayload(values: ReviewActionFormValues) {
+ return {
+ description: values.description,
+ ownerMemberId: values.ownerMemberId || null,
+ dueDate: values.dueDate || null,
+ status: values.status,
+ };
+}
+
+export const reviewSignoffSchema = z.object({
+ signoffChairName: z.string(),
+ signoffChairDate: z.string(),
+});
+
+export type ReviewSignoffFormValues = z.infer;
+
+export function toReviewSignoffPayload(values: ReviewSignoffFormValues) {
+ return {
+ signoffChairName: values.signoffChairName || null,
+ signoffChairDate: values.signoffChairDate || null,
+ };
+}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIsmsDocument.ts b/apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIsmsDocument.ts
index 08ec701bdd..fc68c91a16 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIsmsDocument.ts
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIsmsDocument.ts
@@ -31,7 +31,10 @@ export type IsmsRegister =
| 'measurements'
| 'audits'
| 'audit-controls'
- | 'audit-findings';
+ | 'audit-findings'
+ | 'reviews'
+ | 'review-inputs'
+ | 'review-actions';
interface IssueInput {
kind: IsmsContextIssueKind;
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/isms-types.ts b/apps/app/src/app/(app)/[orgId]/documents/isms/isms-types.ts
index a161305500..4b07fa2658 100644
--- a/apps/app/src/app/(app)/[orgId]/documents/isms/isms-types.ts
+++ b/apps/app/src/app/(app)/[orgId]/documents/isms/isms-types.ts
@@ -13,7 +13,8 @@ export type IsmsDocumentType =
| 'roles_and_responsibilities'
| 'objectives_plan'
| 'monitoring'
- | 'internal_audit';
+ | 'internal_audit'
+ | 'management_review';
export type IsmsDocumentStatus =
| 'draft'
@@ -45,6 +46,9 @@ export type IsmsAuditControlResult =
| 'not_sampled';
export type IsmsAuditFindingType = 'nc_major' | 'nc_minor' | 'ofi' | 'observation';
export type IsmsAuditFindingStatus = 'open' | 'in_progress' | 'closed';
+export type IsmsReviewStatus = 'planned' | 'in_progress' | 'complete';
+export type IsmsReviewConclusionVerdict = 'suitable' | 'adequate' | 'effective';
+export type IsmsReviewActionStatus = 'open' | 'in_progress' | 'closed';
/**
* The ISO 27001 clause 4.1 category taxonomy auditors expect, scoped by kind.
@@ -270,6 +274,72 @@ export interface IsmsInternalAuditNarrative {
programme: string;
}
+/** Narrative shape for the Management Review document: the Procedure paragraph. */
+export interface IsmsManagementReviewNarrative {
+ procedure: string;
+}
+
+/** An attendee frozen at selection (clause 9.3): member id + display name. */
+export interface IsmsReviewAttendee {
+ memberId: string;
+ name: string;
+}
+
+/** One row in a review's Inputs (9.3.2) table — the meeting agenda. */
+export interface IsmsReviewInput {
+ id: string;
+ reviewId: string;
+ /** Stable key for the ten seeded rows; null for custom rows. */
+ inputKey: string | null;
+ inputRef: string;
+ whatItCovers: string;
+ whereToFind: string;
+ discussionNotes: string | null;
+ discussed: boolean;
+ source: IsmsContextSource;
+ derivedFrom: string | null;
+ position: number;
+}
+
+/** An action arising from a management review (9.3.3 outputs). */
+export interface IsmsReviewAction {
+ id: string;
+ reviewId: string;
+ /** Server-generated per-review sequence ("A01"), immutable; displayed as
+ * "MR-YYYY-NN-A01". */
+ reference: string;
+ description: string;
+ ownerMemberId: string | null;
+ dueDate: string | null;
+ status: IsmsReviewActionStatus;
+ position: number;
+}
+
+/** Register: one management review instance (clause 9.3). */
+export interface IsmsManagementReview {
+ id: string;
+ /** Server-generated "MR-YYYY-NN", immutable. */
+ reference: string;
+ /** The date the review was held (backdatable, customer-entered). */
+ meetingDate: string | null;
+ /** Server-set at creation, immutable — the honest-backdating guardrail. */
+ recordedAt: string;
+ chairName: string | null;
+ /** Attendees frozen at selection. Stored as JSON on the API side; consume
+ * via parseAttendees (management-review-constants.ts) to guard stale shapes. */
+ attendees: IsmsReviewAttendee[];
+ status: IsmsReviewStatus;
+ conclusionVerdict: IsmsReviewConclusionVerdict | null;
+ conclusionNotes: string | null;
+ decisionsText: string | null;
+ changesText: string | null;
+ signoffChairName: string | null;
+ signoffChairDate: string | null;
+ position: number;
+ inputs: IsmsReviewInput[];
+ actions: IsmsReviewAction[];
+}
+
/** Narrative shape for the ISMS Scope singleton (clause 4.3). */
export interface IsmsScopeNarrative {
certificateScopeSentence: string;
@@ -362,12 +432,15 @@ export interface IsmsDocument {
roles: IsmsRole[];
metrics: IsmsMetric[];
audits: IsmsAudit[];
+ reviews: IsmsManagementReview[];
controlLinks: IsmsControlLink[];
- /** Working-draft narrative (Scope, Leadership, Internal Audit programme). */
+ /** Working-draft narrative (Scope, Leadership, Internal Audit programme,
+ * Management Review procedure). */
draftNarrative:
| IsmsScopeNarrative
| IsmsLeadershipNarrative
| IsmsInternalAuditNarrative
+ | IsmsManagementReviewNarrative
| Record
| null;
currentVersionId: string | null;
@@ -457,6 +530,14 @@ export const ISMS_TYPE_META: IsmsTypeMeta[] = [
'The internal audit programme and the plan, controls tested, findings and conclusion of each audit.',
detailRouteEnabled: true,
},
+ {
+ type: 'management_review',
+ clause: '9.3',
+ title: 'Management Review',
+ description:
+ 'The management review procedure and the minutes of each review — inputs considered, outputs, actions arising and chair sign-off.',
+ detailRouteEnabled: true,
+ },
];
/** Map a URL slug (e.g. "context-of-organization") to the canonical type. */
@@ -470,6 +551,7 @@ export const ISMS_SLUG_TO_TYPE: Record = {
objectives: 'objectives_plan',
monitoring: 'monitoring',
'internal-audit': 'internal_audit',
+ 'management-review': 'management_review',
};
/** Inverse of ISMS_SLUG_TO_TYPE for fast type -> slug lookup. */
@@ -483,6 +565,7 @@ const ISMS_TYPE_TO_SLUG: Record = {
objectives_plan: 'objectives',
monitoring: 'monitoring',
internal_audit: 'internal-audit',
+ management_review: 'management-review',
};
export function slugToType(slug: string): IsmsDocumentType | undefined {
diff --git a/apps/app/src/app/(app)/[orgId]/settings/roles/components/PermissionMatrix.test.tsx b/apps/app/src/app/(app)/[orgId]/settings/roles/components/PermissionMatrix.test.tsx
index 2d1ae27fc0..d0dce64756 100644
--- a/apps/app/src/app/(app)/[orgId]/settings/roles/components/PermissionMatrix.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/settings/roles/components/PermissionMatrix.test.tsx
@@ -291,6 +291,85 @@ describe('PermissionMatrix', () => {
});
});
+describe('Employee Compliance obligation implies portal access', () => {
+ function findComplianceSwitch(): HTMLElement {
+ const label = screen.getByText('Employee Compliance');
+ const row = label.closest('[class*="flex"]') as HTMLElement;
+ const switchEl = row.querySelector('[role="switch"]');
+ if (!switchEl) throw new Error('Employee Compliance switch not found');
+ return switchEl as HTMLElement;
+ }
+
+ it('adds portal:read/update permissions when the compliance obligation is enabled', () => {
+ const mockOnChange = vi.fn();
+ const mockOnObligationsChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(findComplianceSwitch());
+
+ expect(mockOnObligationsChange).toHaveBeenCalledWith({ compliance: true });
+ expect(mockOnChange).toHaveBeenCalledWith({
+ control: ['read'],
+ portal: ['read', 'update'],
+ });
+ });
+
+ it('does not touch permissions when the compliance obligation is disabled (one-directional implication)', () => {
+ // Disabling compliance must NOT strip 'portal' back off — a role can
+ // hold portal access independently of this obligation (e.g. granted
+ // directly through the API), and the matrix has no separate row for
+ // 'portal' to tell that case apart from "granted via this toggle".
+ const mockOnChange = vi.fn();
+ const mockOnObligationsChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(findComplianceSwitch());
+
+ expect(mockOnObligationsChange).toHaveBeenCalledWith({});
+ expect(mockOnChange).not.toHaveBeenCalled();
+ });
+
+ it('does not inject portal permissions or touch obligations when an unrelated resource permission changes', () => {
+ // Only the 'compliance' obligation toggle should sync portal — a plain
+ // resource-permission change must not go through the obligation branch.
+ const mockOnChange = vi.fn();
+ const mockOnObligationsChange = vi.fn();
+ render(
+ ,
+ );
+
+ const controlsText = screen.getByText('Controls');
+ const controlsRow = controlsText.closest('[class*="grid"]');
+ const radios = controlsRow?.querySelectorAll('[data-slot="radio-group-item"]');
+ if (radios && radios[1]) {
+ fireEvent.click(radios[1]); // No Access -> Read: a real change
+ }
+
+ expect(mockOnChange).toHaveBeenCalledTimes(1);
+ expect(mockOnChange).toHaveBeenCalledWith({ control: ['read'] });
+ expect(mockOnObligationsChange).not.toHaveBeenCalled();
+ });
+});
+
describe('Utility Functions', () => {
describe('getAccessLevel', () => {
it('returns "none" for empty permissions', () => {
diff --git a/apps/app/src/app/(app)/[orgId]/settings/roles/components/PermissionMatrix.tsx b/apps/app/src/app/(app)/[orgId]/settings/roles/components/PermissionMatrix.tsx
index 0b08e9f058..71868c56c0 100644
--- a/apps/app/src/app/(app)/[orgId]/settings/roles/components/PermissionMatrix.tsx
+++ b/apps/app/src/app/(app)/[orgId]/settings/roles/components/PermissionMatrix.tsx
@@ -235,6 +235,20 @@ export function PermissionMatrix({ value, onChange, obligations, onObligationsCh
delete newObligations[key];
}
onObligationsChange(newObligations);
+
+ // The 'compliance' obligation (Employee Compliance) implies portal
+ // self-service access (sign policies, watch training, etc.) — there's
+ // no separate matrix row for the 'portal' resource itself (it's
+ // excluded from RESOURCE_LABELS/RESOURCE_SECTIONS), so grant it here
+ // when the obligation is enabled. This is intentionally one-directional:
+ // disabling the obligation must NOT strip 'portal' back off, since a
+ // role can also hold portal access independently of this obligation
+ // (e.g. granted directly through the API) — the UI has no separate row
+ // for 'portal' to tell those cases apart, so it must never revoke an
+ // access grant it didn't itself create.
+ if (key === 'compliance' && enabled) {
+ onChange({ ...value, portal: [...statement.portal] });
+ }
};
const handleToggleChange = (resourceKey: string, enabled: boolean) => {
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx
index 1fc0f69235..0599c128d5 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx
@@ -7,7 +7,7 @@ import { VendorNewsLoadingPlaceholder } from '@/components/vendor-risk-assessmen
import { parseVendorRiskAssessmentDescription } from '@/components/vendor-risk-assessment/parse-vendor-risk-assessment-description';
import { Comments } from '@/components/comments/Comments';
import { RecentAuditLogs } from '@/components/RecentAuditLogs';
-import { useAuditLogs } from '@/hooks/use-audit-logs';
+import { usePaginatedAuditLogs } from '@/hooks/use-audit-logs';
import { TaskItems } from '@/components/task-items/TaskItems';
import { useTaskItems, useTaskItemActions } from '@/hooks/use-task-items';
import { useVendor, useVendorActions, type VendorResponse } from '@/hooks/use-vendors';
@@ -649,6 +649,17 @@ export function VendorDetailTabs({
function VendorActivitySection({ vendorId, taskItemIds }: { vendorId: string; taskItemIds: string[] }) {
const entityIds = [vendorId, ...taskItemIds].join(',');
const entityTypes = taskItemIds.length > 0 ? 'vendor,task' : 'vendor';
- const { logs } = useAuditLogs({ entityType: entityTypes, entityId: entityIds });
- return ;
+ const { logs, total, hasMore, loadMore, isLoadingMore } = usePaginatedAuditLogs({
+ entityType: entityTypes,
+ entityId: entityIds,
+ });
+ return (
+
+ );
}
diff --git a/apps/app/src/components/RecentAuditLogs.test.tsx b/apps/app/src/components/RecentAuditLogs.test.tsx
new file mode 100644
index 0000000000..2b22ca54a1
--- /dev/null
+++ b/apps/app/src/components/RecentAuditLogs.test.tsx
@@ -0,0 +1,145 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { AuditLogWithRelations } from '@/hooks/use-audit-logs';
+
+// Lightweight stand-ins for the design-system / ui primitives so the pager
+// buttons and rows are queryable in jsdom without pulling the real components.
+vi.mock('@trycompai/ui/avatar', () => ({
+ Avatar: ({ children }: { children: ReactNode }) => {children}
,
+ AvatarImage: () => null,
+ AvatarFallback: ({ children }: { children: ReactNode }) => {children} ,
+}));
+
+vi.mock('@trycompai/design-system', () => ({
+ Badge: ({ children }: { children: ReactNode }) => {children} ,
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ }: {
+ children: ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ }) => (
+
+ {children}
+
+ ),
+ HStack: ({ children }: { children: ReactNode }) => {children}
,
+ Section: ({ title, children }: { title?: string; children: ReactNode }) => (
+
+ ),
+ Spinner: () => loading
,
+ Stack: ({ children }: { children: ReactNode }) => {children}
,
+ Text: ({ children }: { children: ReactNode }) => {children} ,
+}));
+
+// The pager arrows carry only icons — give them distinct text so the buttons
+// have stable accessible names to query by.
+vi.mock('@trycompai/design-system/icons', () => ({
+ ChevronLeft: () => prev ,
+ ChevronRight: () => next ,
+}));
+
+vi.mock('lucide-react', () => ({
+ ActivityIcon: () => activity ,
+ ChevronDownIcon: () => down ,
+ ChevronRightIcon: () => right ,
+}));
+
+// Import AFTER the mocks so the component picks up the stubs.
+import { RecentAuditLogs } from './RecentAuditLogs';
+
+/**
+ * Minimal fixture — RecentAuditLogs only reads id / description / timestamp /
+ * user / data, so we cast the trimmed shape through `unknown` (no `any`).
+ */
+function makeLog(id: string): AuditLogWithRelations {
+ return {
+ id,
+ timestamp: new Date('2026-07-21T12:00:00Z'),
+ description: 'Updated vendor',
+ userId: 'usr_abcdef',
+ memberId: 'mem_1',
+ organizationId: 'org_1',
+ entityId: 'vnd_1',
+ entityType: 'vendor',
+ data: {},
+ user: { id: 'usr_abcdef', name: 'Test User', image: null, role: 'employee' },
+ member: null,
+ organization: {},
+ } as unknown as AuditLogWithRelations;
+}
+
+const makeLogs = (n: number) => Array.from({ length: n }, (_, i) => makeLog(`aud_${i}`));
+
+describe('RecentAuditLogs', () => {
+ it('shows an empty state when there are no logs', () => {
+ render( );
+ expect(
+ screen.getByText('Activity will appear here when changes are made'),
+ ).toBeInTheDocument();
+ });
+
+ it('does not show the empty state while a batch is loading (server mode)', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByText('Activity will appear here when changes are made'),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('paginates a plain logs array (legacy, no server props)', () => {
+ render( );
+
+ // 15 rows on page 1 of 2.
+ expect(screen.getAllByText('Updated vendor')).toHaveLength(15);
+ expect(screen.getByText('1–15 of 20')).toBeInTheDocument();
+
+ const next = screen.getByRole('button', { name: 'next' });
+ expect(next).toBeEnabled();
+ fireEvent.click(next);
+
+ // Remaining 5 rows on page 2; next now disabled at the genuine end.
+ expect(screen.getAllByText('Updated vendor')).toHaveLength(5);
+ expect(screen.getByText('16–20 of 20')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'next' })).toBeDisabled();
+ });
+
+ it('keeps the next arrow enabled from the server total, not the loaded count', () => {
+ // Only 15 rows loaded, but the server says 100 exist. Legacy mode over 15
+ // rows would render no pager (1 page); server mode must expose more pages.
+ render(
+ ,
+ );
+
+ expect(screen.getByText('of 100', { exact: false })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'next' })).toBeEnabled();
+ });
+
+ it('calls onLoadMore when paging past the loaded rows', () => {
+ const onLoadMore = vi.fn();
+ render(
+ ,
+ );
+
+ // The first (full) page is already loaded — no fetch yet.
+ expect(onLoadMore).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole('button', { name: 'next' }));
+ // Paging to row 16+ crosses the loaded window → fetch the next batch.
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onLoadMore in legacy mode', () => {
+ // Sanity: without server props, paging never triggers a fetch.
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'next' }));
+ // No onLoadMore prop exists to call; nothing throws and the page advances.
+ expect(screen.getByText('16–20 of 20')).toBeInTheDocument();
+ });
+});
diff --git a/apps/app/src/components/RecentAuditLogs.tsx b/apps/app/src/components/RecentAuditLogs.tsx
index 3c1ad71983..20b57f181b 100644
--- a/apps/app/src/components/RecentAuditLogs.tsx
+++ b/apps/app/src/components/RecentAuditLogs.tsx
@@ -7,13 +7,14 @@ import {
Button,
HStack,
Section,
+ Spinner,
Stack,
Text,
} from '@trycompai/design-system';
import { ChevronLeft, ChevronRight } from '@trycompai/design-system/icons';
import { formatDistanceToNow } from 'date-fns';
import { ActivityIcon, ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import type { AuditLogWithRelations } from '@/hooks/use-audit-logs';
const LOGS_PER_PAGE = 15;
@@ -130,14 +131,50 @@ function LogRow({ log }: { log: AuditLogWithRelations }) {
interface RecentAuditLogsProps {
logs: AuditLogWithRelations[];
title?: string;
+ /**
+ * Server-pagination (optional). When `total` and `onLoadMore` are provided,
+ * the pager treats `logs` as a growing window into `total` rows: paging past
+ * the loaded set fetches the next batch, and the › arrow stays enabled until
+ * the genuine end (page < ceil(total / LOGS_PER_PAGE)). Omit both for the
+ * legacy client-only pager over the given `logs` array.
+ */
+ total?: number;
+ hasMore?: boolean;
+ onLoadMore?: () => void;
+ isLoadingMore?: boolean;
}
-export function RecentAuditLogs({ logs, title = 'Recent Activity' }: RecentAuditLogsProps) {
+export function RecentAuditLogs({
+ logs,
+ title = 'Recent Activity',
+ total,
+ hasMore = false,
+ onLoadMore,
+ isLoadingMore = false,
+}: RecentAuditLogsProps) {
const [page, setPage] = useState(0);
- const totalPages = Math.ceil(logs.length / LOGS_PER_PAGE);
+
+ const serverPaged = total != null && onLoadMore != null;
+ const totalCount = serverPaged ? total : logs.length;
+ const totalPages = Math.max(1, Math.ceil(totalCount / LOGS_PER_PAGE));
const paged = logs.slice(page * LOGS_PER_PAGE, (page + 1) * LOGS_PER_PAGE);
- if (logs.length === 0) {
+ // Server mode: when the current page reaches past the loaded rows and the
+ // server has more, pull the next batch. Fires on the last loaded page too so
+ // a partial page (100 rows don't divide evenly by 15) fills in before you
+ // cross it — no rows get skipped at the batch boundary.
+ useEffect(() => {
+ if (
+ serverPaged &&
+ hasMore &&
+ !isLoadingMore &&
+ (page + 1) * LOGS_PER_PAGE > logs.length
+ ) {
+ onLoadMore();
+ }
+ }, [serverPaged, hasMore, isLoadingMore, page, logs.length, onLoadMore]);
+
+ if (logs.length === 0 && !isLoadingMore) {
return (
@@ -152,19 +189,32 @@ export function RecentAuditLogs({ logs, title = 'Recent Activity' }: RecentAudit
);
}
+ // The › arrow is disabled only at the genuine last page — of the server total
+ // when known, else of the loaded set.
+ const nextDisabled = page >= totalPages - 1;
+ const waitingForData = paged.length === 0 && isLoadingMore;
+
return (
- {paged.map((log) => (
-
- ))}
+ {waitingForData ? (
+
+
+
+ Loading more activity…
+
+
+ ) : (
+ paged.map((log) =>
)
+ )}
{totalPages > 1 && (
- {page * LOGS_PER_PAGE + 1}–{Math.min((page + 1) * LOGS_PER_PAGE, logs.length)} of {logs.length}
+ {page * LOGS_PER_PAGE + 1}–{Math.min((page + 1) * LOGS_PER_PAGE, totalCount)} of{' '}
+ {totalCount}
setPage((p) => p - 1)} disabled={page === 0}>
@@ -173,7 +223,7 @@ export function RecentAuditLogs({ logs, title = 'Recent Activity' }: RecentAudit
{page + 1}/{totalPages}
- setPage((p) => p + 1)} disabled={page >= totalPages - 1}>
+ setPage((p) => p + 1)} disabled={nextDisabled}>
diff --git a/apps/app/src/components/comments/CommentItem.test.tsx b/apps/app/src/components/comments/CommentItem.test.tsx
new file mode 100644
index 0000000000..b2d8e3ce06
--- /dev/null
+++ b/apps/app/src/components/comments/CommentItem.test.tsx
@@ -0,0 +1,160 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
+}));
+
+const deleteCommentMock = vi.fn();
+vi.mock('@/hooks/use-comments-api', () => ({
+ useCommentActions: () => ({
+ updateComment: vi.fn(),
+ deleteComment: deleteCommentMock,
+ }),
+}));
+
+vi.mock('@/hooks/use-api', () => ({
+ useApi: () => ({ get: vi.fn() }),
+}));
+
+vi.mock('@/hooks/use-mentionable-members', () => ({
+ useMentionableMembers: () => ({ members: [], isLoading: false }),
+}));
+
+// Renders comment content as plain text — the real component mounts a full
+// TipTap editor, which isn't needed to test the delete flow and doesn't
+// play well with jsdom.
+vi.mock('./CommentContentView', () => ({
+ CommentContentView: ({ content }: { content: string }) => {content}
,
+}));
+
+vi.mock('@trycompai/ui/dropdown-menu', () => ({
+ DropdownMenu: ({ children }: any) => {children}
,
+ DropdownMenuTrigger: ({ children }: any) => <>{children}>,
+ DropdownMenuContent: ({ children }: any) => {children}
,
+ DropdownMenuItem: ({ children, onSelect }: any) => (
+ {children}
+ ),
+}));
+
+vi.mock('@trycompai/design-system', () => ({
+ AlertDialog: ({ children, open }: any) =>
+ open ? {children}
: null,
+ AlertDialogContent: ({ children }: any) => {children}
,
+ AlertDialogHeader: ({ children }: any) => {children}
,
+ AlertDialogTitle: ({ children }: any) => {children} ,
+ AlertDialogDescription: ({ children }: any) => {children}
,
+ AlertDialogFooter: ({ children }: any) => {children}
,
+ AlertDialogCancel: ({ children, disabled }: any) => (
+ {children}
+ ),
+ AlertDialogAction: ({ children, onClick, loading }: any) => (
+
+ {children}
+
+ ),
+ Avatar: ({ children }: any) => {children}
,
+ AvatarFallback: ({ children }: any) => {children} ,
+ AvatarImage: () => null,
+}));
+
+import { toast } from 'sonner';
+import { CommentItem } from './CommentItem';
+import type { CommentWithAuthor } from './Comments';
+
+const baseComment: CommentWithAuthor = {
+ id: 'cmt_1',
+ content: 'Hello world',
+ author: {
+ id: 'usr_1',
+ name: 'Jane Doe',
+ email: 'jane@example.com',
+ image: null,
+ deactivated: false,
+ },
+ attachments: [],
+ createdAt: new Date().toISOString(),
+};
+
+async function openDeleteDialog() {
+ fireEvent.click(screen.getByRole('button', { name: /comment options/i }));
+ fireEvent.click(await screen.findByText('Delete'));
+}
+
+// The mocked dropdown menu (unlike the real Radix one) never unmounts its
+// "Delete" item after selection, so once the confirmation dialog opens there
+// are two "Delete" buttons on the page — scope to the dialog to click the
+// confirm action specifically.
+async function confirmDelete() {
+ const dialog = await screen.findByTestId('alert-dialog');
+ fireEvent.click(within(dialog).getByRole('button', { name: /^delete$/i }));
+}
+
+describe('CommentItem delete error handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('shows the server-provided reason when deletion fails', async () => {
+ deleteCommentMock.mockRejectedValue(
+ new Error('You can only delete your own comments'),
+ );
+
+ render(
+ ,
+ );
+
+ await openDeleteDialog();
+ await confirmDelete();
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith(
+ 'You can only delete your own comments',
+ );
+ });
+ });
+
+ it('falls back to a generic message when the error has no message', async () => {
+ deleteCommentMock.mockRejectedValue('not an Error instance');
+
+ render(
+ ,
+ );
+
+ await openDeleteDialog();
+ await confirmDelete();
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith('Failed to delete comment.');
+ });
+ });
+
+ it('shows a success toast and refreshes on successful deletion', async () => {
+ deleteCommentMock.mockResolvedValue({ success: true, status: 204 });
+ const refreshComments = vi.fn();
+
+ render(
+ ,
+ );
+
+ await openDeleteDialog();
+ await confirmDelete();
+
+ await waitFor(() => {
+ expect(toast.success).toHaveBeenCalledWith('Comment deleted successfully.');
+ });
+ expect(refreshComments).toHaveBeenCalled();
+ });
+});
diff --git a/apps/app/src/components/comments/CommentItem.tsx b/apps/app/src/components/comments/CommentItem.tsx
index 7da7b91f55..1384edbcb5 100644
--- a/apps/app/src/components/comments/CommentItem.tsx
+++ b/apps/app/src/components/comments/CommentItem.tsx
@@ -162,7 +162,7 @@ export function CommentItem({ comment, refreshComments, readOnly = false, entity
refreshComments();
setIsDeleteOpen(false);
} catch (error) {
- toast.error('Failed to delete comment.');
+ toast.error(error instanceof Error ? error.message : 'Failed to delete comment.');
console.error('Delete comment error:', error);
} finally {
setIsDeleting(false);
diff --git a/apps/app/src/hooks/use-audit-logs.ts b/apps/app/src/hooks/use-audit-logs.ts
index baedc961d3..3b943ca728 100644
--- a/apps/app/src/hooks/use-audit-logs.ts
+++ b/apps/app/src/hooks/use-audit-logs.ts
@@ -1,6 +1,11 @@
'use client';
import { apiClient } from '@/lib/api-client';
+import {
+ useOffsetAuditLogs,
+ type AuditLogsPage,
+ type OffsetAuditLogsResult,
+} from '@/hooks/use-offset-audit-logs';
import type { AuditLog, Member, Organization, User } from '@db';
import { useEffect, useRef } from 'react';
import useSWR from 'swr';
@@ -67,3 +72,32 @@ export function useAuditLogs({
mutate,
};
}
+
+/**
+ * Offset-paginated variant of {@link useAuditLogs} for entity-scoped views
+ * (vendor, task, …) that need to page past the endpoint's default window.
+ * Accumulates 100-row batches and reports the server total so callers can wire
+ * the `RecentAuditLogs` pager (load-more + smart next arrow).
+ */
+export function usePaginatedAuditLogs({
+ entityType,
+ entityId,
+ pathContains,
+}: {
+ entityType: string;
+ entityId: string;
+ pathContains?: string;
+}): OffsetAuditLogsResult {
+ return useOffsetAuditLogs({
+ cacheKey: ['/v1/audit-logs', entityType, entityId, pathContains ?? ''],
+ fetchPage: async ({ take, offset }) => {
+ let url = `/v1/audit-logs?entityType=${entityType}&entityId=${entityId}&take=${take}&offset=${offset}`;
+ if (pathContains) url += `&pathContains=${encodeURIComponent(pathContains)}`;
+ const res = await apiClient.get(url);
+ if (res.error || !res.data) {
+ throw new Error(res.error ?? 'Failed to load audit logs');
+ }
+ return res.data;
+ },
+ });
+}
diff --git a/apps/app/src/hooks/use-offset-audit-logs.test.tsx b/apps/app/src/hooks/use-offset-audit-logs.test.tsx
new file mode 100644
index 0000000000..4098b974b3
--- /dev/null
+++ b/apps/app/src/hooks/use-offset-audit-logs.test.tsx
@@ -0,0 +1,117 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { SWRConfig } from 'swr';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { AuditLogWithRelations } from '@/hooks/use-audit-logs';
+import { AUDIT_LOG_PAGE_SIZE, useOffsetAuditLogs } from './use-offset-audit-logs';
+
+// Fresh SWR cache per hook render so batches don't leak between tests.
+// Retry is disabled so a rejected batch stays failed deterministically.
+function wrapper({ children }: { children: ReactNode }) {
+ return (
+ new Map(), shouldRetryOnError: false }}>
+ {children}
+
+ );
+}
+
+// RecentAuditLogs / the pager only key off `id`, so a trimmed shape is enough.
+const makeLogs = (ids: string[]) =>
+ ids.map((id) => ({ id })) as unknown as AuditLogWithRelations[];
+
+describe('useOffsetAuditLogs', () => {
+ it('loads the first batch and derives hasMore from the server total', async () => {
+ const fetchPage = vi.fn(async () => ({ data: makeLogs(['a', 'b']), total: 5 }));
+
+ const { result } = renderHook(
+ () => useOffsetAuditLogs({ cacheKey: ['k'], fetchPage }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.logs).toHaveLength(2));
+ expect(result.current.total).toBe(5);
+ expect(result.current.hasMore).toBe(true);
+ expect(fetchPage).toHaveBeenCalledWith({ take: AUDIT_LOG_PAGE_SIZE, offset: 0 });
+ });
+
+ it('appends the next batch (deduped) and stops when the total is reached', async () => {
+ const fetchPage = vi
+ .fn()
+ .mockResolvedValueOnce({ data: makeLogs(['a', 'b']), total: 4 })
+ // 'b' overlaps a shifted window — it must not be duplicated.
+ .mockResolvedValueOnce({ data: makeLogs(['b', 'c', 'd']), total: 4 });
+
+ const { result } = renderHook(
+ () => useOffsetAuditLogs({ cacheKey: ['k'], fetchPage }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.logs).toHaveLength(2));
+
+ act(() => result.current.loadMore());
+
+ await waitFor(() => expect(result.current.logs).toHaveLength(4));
+ expect(result.current.logs.map((l) => l.id)).toEqual(['a', 'b', 'c', 'd']);
+ expect(result.current.hasMore).toBe(false);
+ expect(fetchPage).toHaveBeenLastCalledWith({
+ take: AUDIT_LOG_PAGE_SIZE,
+ offset: AUDIT_LOG_PAGE_SIZE,
+ });
+ });
+
+ it('does not skip a batch when a load-more fetch fails', async () => {
+ const fetchPage = vi
+ .fn()
+ .mockResolvedValueOnce({ data: makeLogs(['a', 'b']), total: 10 }) // offset 0 ok
+ .mockRejectedValueOnce(new Error('boom')); // offset 100 fails
+
+ const { result } = renderHook(
+ () => useOffsetAuditLogs({ cacheKey: ['k'], fetchPage }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.logs).toHaveLength(2));
+
+ // First load-more requests offset 100, which fails.
+ act(() => result.current.loadMore());
+ await waitFor(() =>
+ expect(fetchPage).toHaveBeenCalledWith({
+ take: AUDIT_LOG_PAGE_SIZE,
+ offset: AUDIT_LOG_PAGE_SIZE,
+ }),
+ );
+
+ // A second load-more must NOT jump to offset 200 and skip the failed batch.
+ act(() => result.current.loadMore());
+ await Promise.resolve();
+ expect(fetchPage).not.toHaveBeenCalledWith({
+ take: AUDIT_LOG_PAGE_SIZE,
+ offset: AUDIT_LOG_PAGE_SIZE * 2,
+ });
+ expect(fetchPage).toHaveBeenCalledTimes(2);
+ });
+
+ it('resets the accumulated window when the filter identity changes', async () => {
+ const { result, rerender } = renderHook(
+ ({ key, data, total }: { key: string; data: string[]; total: number }) =>
+ useOffsetAuditLogs({
+ cacheKey: ['k', key],
+ fetchPage: async () => ({ data: makeLogs(data), total }),
+ }),
+ {
+ wrapper,
+ initialProps: { key: 'v1', data: ['a', 'b', 'c'], total: 3 },
+ },
+ );
+
+ await waitFor(() => expect(result.current.logs).toHaveLength(3));
+
+ // Switching filters must replace, not append onto, the prior batch.
+ rerender({ key: 'v2', data: ['x'], total: 1 });
+
+ await waitFor(() => expect(result.current.logs.map((l) => l.id)).toEqual(['x']));
+ expect(result.current.total).toBe(1);
+ expect(result.current.hasMore).toBe(false);
+ });
+});
diff --git a/apps/app/src/hooks/use-offset-audit-logs.ts b/apps/app/src/hooks/use-offset-audit-logs.ts
new file mode 100644
index 0000000000..925ebf7433
--- /dev/null
+++ b/apps/app/src/hooks/use-offset-audit-logs.ts
@@ -0,0 +1,95 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import useSWR from 'swr';
+
+import type { AuditLogWithRelations } from '@/hooks/use-audit-logs';
+
+/** Server batch size — audit logs are paged in chunks of 100. */
+export const AUDIT_LOG_PAGE_SIZE = 100;
+
+/** Shape every paginated audit-log endpoint returns (extra fields are ignored). */
+export interface AuditLogsPage {
+ data: AuditLogWithRelations[];
+ total: number;
+}
+
+export interface OffsetAuditLogsResult {
+ /** All logs loaded so far (batch 0..N), in the server's desc order. */
+ logs: AuditLogWithRelations[];
+ /** Total rows on the server — drives the pager's "of N" and next-arrow. */
+ total: number;
+ /** Whether another server batch exists to load. */
+ hasMore: boolean;
+ /** Fetch the next batch and append it. */
+ loadMore: () => void;
+ /** A subsequent batch (offset > 0) is currently loading. */
+ isLoadingMore: boolean;
+ /** The very first batch is loading (nothing to show yet). */
+ isLoading: boolean;
+}
+
+/**
+ * Generic offset-accumulate pager for audit logs: keeps a growing window into
+ * the server total by fetching successive batches and merging them (de-duped by
+ * id). Callers supply the SWR cache key and a `fetchPage` for their endpoint —
+ * see {@link useAdminAuditLogs} and `usePaginatedAuditLogs`.
+ *
+ * Mirrors the repo's offset + loadMore convention (use-automation-versions.ts).
+ */
+export function useOffsetAuditLogs({
+ cacheKey,
+ fetchPage,
+}: {
+ cacheKey: readonly unknown[];
+ fetchPage: (args: { take: number; offset: number }) => Promise;
+}): OffsetAuditLogsResult {
+ const [logs, setLogs] = useState([]);
+ const [offset, setOffset] = useState(0);
+ const [total, setTotal] = useState(0);
+
+ // Reset the accumulated window whenever the filter identity changes (e.g. a
+ // vendor's task-item ids finish loading), so batches from a prior filter
+ // can't bleed into the new one.
+ const filterKey = JSON.stringify(cacheKey);
+ useEffect(() => {
+ setLogs([]);
+ setOffset(0);
+ setTotal(0);
+ }, [filterKey]);
+
+ const { isLoading, error } = useSWR(
+ [...cacheKey, offset],
+ () => fetchPage({ take: AUDIT_LOG_PAGE_SIZE, offset }),
+ {
+ revalidateOnFocus: false,
+ revalidateOnMount: true,
+ dedupingInterval: 0,
+ onSuccess: (page) => {
+ setTotal(page.total);
+ setLogs((prev) => {
+ if (offset === 0) return page.data;
+ const seen = new Set(prev.map((log) => log.id));
+ return [...prev, ...page.data.filter((log) => !seen.has(log.id))];
+ });
+ },
+ },
+ );
+
+ const loadMore = useCallback(() => {
+ // Only advance once the current batch has settled successfully. While it's
+ // still loading (or errored — SWR retries the same offset), advancing would
+ // skip the in-flight batch entirely, dropping those rows from the pager.
+ if (isLoading || error) return;
+ setOffset((prev) => prev + AUDIT_LOG_PAGE_SIZE);
+ }, [isLoading, error]);
+
+ return {
+ logs,
+ total,
+ hasMore: logs.length < total,
+ loadMore,
+ isLoadingMore: isLoading && offset > 0,
+ isLoading: isLoading && offset === 0 && logs.length === 0,
+ };
+}
diff --git a/packages/db/prisma/migrations/20260722090000_isms_management_review/migration.sql b/packages/db/prisma/migrations/20260722090000_isms_management_review/migration.sql
new file mode 100644
index 0000000000..7fc13ee147
--- /dev/null
+++ b/packages/db/prisma/migrations/20260722090000_isms_management_review/migration.sql
@@ -0,0 +1,105 @@
+-- CreateEnum
+CREATE TYPE "IsmsReviewStatus" AS ENUM ('planned', 'in_progress', 'complete');
+
+-- CreateEnum
+CREATE TYPE "IsmsReviewConclusionVerdict" AS ENUM ('suitable', 'adequate', 'effective');
+
+-- CreateEnum
+CREATE TYPE "IsmsReviewActionStatus" AS ENUM ('open', 'in_progress', 'closed');
+
+-- AlterEnum
+ALTER TYPE "IsmsDocumentType" ADD VALUE 'management_review';
+
+-- CreateTable
+CREATE TABLE "IsmsManagementReview" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('isms_mr'::text),
+ "documentId" TEXT NOT NULL,
+ "reference" TEXT NOT NULL,
+ "meetingDate" DATE,
+ "recordedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "chairName" TEXT,
+ "attendees" JSONB NOT NULL DEFAULT '[]',
+ "status" "IsmsReviewStatus" NOT NULL DEFAULT 'planned',
+ "conclusionVerdict" "IsmsReviewConclusionVerdict",
+ "conclusionNotes" TEXT,
+ "decisionsText" TEXT,
+ "changesText" TEXT,
+ "signoffChairName" TEXT,
+ "signoffChairDate" DATE,
+ "position" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "IsmsManagementReview_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "IsmsReviewInput" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('isms_mri'::text),
+ "reviewId" TEXT NOT NULL,
+ "documentId" TEXT NOT NULL,
+ "inputKey" TEXT,
+ "inputRef" TEXT NOT NULL,
+ "whatItCovers" TEXT NOT NULL,
+ "whereToFind" TEXT NOT NULL,
+ "discussionNotes" TEXT,
+ "discussed" BOOLEAN NOT NULL DEFAULT false,
+ "source" "IsmsContextSource" NOT NULL DEFAULT 'derived',
+ "derivedFrom" TEXT,
+ "position" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "IsmsReviewInput_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "IsmsReviewAction" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('isms_mra'::text),
+ "reviewId" TEXT NOT NULL,
+ "documentId" TEXT NOT NULL,
+ "reference" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "ownerMemberId" TEXT,
+ "dueDate" DATE,
+ "status" "IsmsReviewActionStatus" NOT NULL DEFAULT 'open',
+ "position" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "IsmsReviewAction_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "IsmsManagementReview_documentId_idx" ON "IsmsManagementReview"("documentId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "IsmsManagementReview_documentId_reference_key" ON "IsmsManagementReview"("documentId", "reference");
+
+-- CreateIndex
+CREATE INDEX "IsmsReviewInput_reviewId_idx" ON "IsmsReviewInput"("reviewId");
+
+-- CreateIndex
+CREATE INDEX "IsmsReviewInput_documentId_idx" ON "IsmsReviewInput"("documentId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "IsmsReviewInput_reviewId_inputKey_key" ON "IsmsReviewInput"("reviewId", "inputKey");
+
+-- CreateIndex
+CREATE INDEX "IsmsReviewAction_reviewId_idx" ON "IsmsReviewAction"("reviewId");
+
+-- CreateIndex
+CREATE INDEX "IsmsReviewAction_documentId_idx" ON "IsmsReviewAction"("documentId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "IsmsReviewAction_reviewId_reference_key" ON "IsmsReviewAction"("reviewId", "reference");
+
+-- AddForeignKey
+ALTER TABLE "IsmsManagementReview" ADD CONSTRAINT "IsmsManagementReview_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "IsmsDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "IsmsReviewInput" ADD CONSTRAINT "IsmsReviewInput_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "IsmsManagementReview"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "IsmsReviewAction" ADD CONSTRAINT "IsmsReviewAction_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "IsmsManagementReview"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
diff --git a/packages/db/prisma/schema/isms.prisma b/packages/db/prisma/schema/isms.prisma
index 1be3259a78..ff30af1df2 100644
--- a/packages/db/prisma/schema/isms.prisma
+++ b/packages/db/prisma/schema/isms.prisma
@@ -20,6 +20,7 @@ enum IsmsDocumentType {
objectives_plan // 6.2
monitoring // 9.1
internal_audit // 9.2
+ management_review // 9.3
}
enum IsmsDocumentStatus {
@@ -89,6 +90,7 @@ model IsmsDocument {
roles IsmsRole[] // 5.3 register (governance roles, responsibilities & authorities)
metrics IsmsMetric[] // 9.1 register (monitoring & measurement framework)
audits IsmsAudit[] // 9.2 register (internal audit programme, plan and report)
+ reviews IsmsManagementReview[] // 9.3 register (management review procedure and minutes)
controlLinks IsmsDocumentControlLink[] // CS-437: org controls this document maps to
@@unique([organizationId, frameworkId, type]) // one document per type per framework per org
@@ -656,6 +658,161 @@ model IsmsAuditFinding {
@@index([controlId])
}
+// --- Register: Management Review Procedure & Minutes (clause 9.3) ---
+
+enum IsmsReviewStatus {
+ planned
+ in_progress
+ complete
+}
+
+// The bracketed choice in the review conclusion template: "The information
+// security management system was reviewed on [meeting date]. Overall, the ISMS
+// was found to be [suitable / adequate / effective] and no changes are required
+// except those recorded in the outputs section below."
+enum IsmsReviewConclusionVerdict {
+ suitable
+ adequate
+ effective
+}
+
+enum IsmsReviewActionStatus {
+ open
+ in_progress
+ closed
+}
+
+// One management review instance (clause 9.3). Annual plus ad-hoc cadence by
+// default; every text field ships with auditor-defensible template text the
+// customer can accept or edit. Manual-first: all values are typed in by the
+// customer, with the same schema shape as Monitoring so later auto-population
+// of the Inputs table needs no schema change.
+model IsmsManagementReview {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('isms_mr'::text)"))
+ documentId String
+ document IsmsDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
+
+ // Server-generated "MR-YYYY-NN" (year of creation, per-document sequence).
+ reference String
+
+ // The date the review was actually held (customer-entered; may be backdated).
+ meetingDate DateTime? @db.Date
+
+ // Server-set at creation, never updatable — the same audit-honesty guardrail
+ // as IsmsMeasurement.recordedAt: a backdated meeting still shows its true
+ // recording date ("Recorded on" in the UI and document).
+ recordedAt DateTime @default(now())
+
+ // The chair as a display string, offered from the ISMS > Roles (5.3) Top
+ // Management holder and frozen at selection (auditorName precedent: the
+ // minutes must keep the name that chaired THIS review even if Roles changes).
+ chairName String?
+
+ // Attendees frozen at selection: array of { memberId, name } objects
+ // (memberId a plain id like IsmsObjective.ownerMemberId). Defaults to
+ // Chair + SPO at creation; for 1-3 person orgs these may be the same person.
+ attendees Json @default("[]")
+
+ status IsmsReviewStatus @default(planned)
+
+ // Conclusion: the customer picks the bracketed verdict and may add narrative.
+ conclusionVerdict IsmsReviewConclusionVerdict?
+ conclusionNotes String?
+
+ // Outputs (9.3.3): both ship with default template text seeded at creation.
+ decisionsText String?
+ changesText String?
+
+ // Sign-off: a single signature slot — the chair signs (name free text, same
+ // rationale as chairName). A review is "signed" when both name and date are
+ // set; once signed the instance is locked (actions still track to closure).
+ signoffChairName String?
+ signoffChairDate DateTime? @db.Date
+
+ position Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ inputs IsmsReviewInput[]
+ actions IsmsReviewAction[]
+
+ // References are generated under the per-document lock, so this never fires
+ // in practice; it keeps concurrent creates honest at the database layer.
+ @@unique([documentId, reference])
+ @@index([documentId])
+}
+
+// One row in a review's Inputs (9.3.2) table — the meeting agenda. Ten default
+// rows are seeded per review (seed-if-missing by inputKey, mirroring
+// IsmsAuditControl.controlKey): inputs (a) through (g) with (d) split into its
+// four sub-inputs. The customer adds discussion notes during the meeting and
+// ticks Discussed?; rows may be added, edited, or removed freely.
+model IsmsReviewInput {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('isms_mri'::text)"))
+ reviewId String
+ review IsmsManagementReview @relation(fields: [reviewId], references: [id], onDelete: Cascade)
+
+ // Denormalized document id so input rows can be listed org-scoped and
+ // mutated through the generic register CRUD (mirrors IsmsAuditControl).
+ documentId String
+
+ // Stable key for the ten seeded rows (e.g. 'a_prior_actions'); null for
+ // customer-added rows. Seeding is idempotent on (reviewId, inputKey);
+ // NULLs stay distinct so many custom rows are allowed.
+ inputKey String?
+
+ inputRef String // e.g. "(a) Prior actions" / "(d.2) Monitoring results"
+ whatItCovers String // what the ISO-mandated input covers (pre-filled, editable)
+ // Pre-filled with a "Comp AI > ..." location; free text so the customer can
+ // point at an external location when evidence lives outside Comp AI.
+ whereToFind String
+
+ discussionNotes String? // filled during the meeting — the minutes for this input
+ discussed Boolean @default(false)
+
+ source IsmsContextSource @default(derived)
+ derivedFrom String? // provenance for seeded rows, e.g. "seed:a_prior_actions"
+
+ position Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([reviewId, inputKey])
+ @@index([reviewId])
+ @@index([documentId])
+}
+
+// An action arising from a management review (9.3.3 outputs). Open actions
+// carry forward automatically to the next review's input (a) — computed from
+// prior reviews at display/export time, never copied. Action tracking stays
+// editable after the review is signed (actions track to closure).
+model IsmsReviewAction {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('isms_mra'::text)"))
+ reviewId String
+ review IsmsManagementReview @relation(fields: [reviewId], references: [id], onDelete: Cascade)
+
+ // Denormalized document id for org-scoped generic register CRUD.
+ documentId String
+
+ // Server-generated "A01" (per-review sequence); rendered with the review
+ // reference as "MR-YYYY-NN-A01" in the UI and document.
+ reference String
+
+ description String
+ ownerMemberId String? // responsible member (plain id, like IsmsObjective.ownerMemberId)
+ dueDate DateTime? @db.Date
+
+ status IsmsReviewActionStatus @default(open)
+
+ position Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([reviewId, reference])
+ @@index([reviewId])
+ @@index([documentId])
+}
+
// --- Org-level link: an ISMS document maps to individual controls (CS-437) ---
// Direct document<->control junction (both are org-scoped), mirroring how the
// platform links documents to controls (cf. ControlDocumentType). The app doc
diff --git a/packages/db/prisma/seed/seed.ts b/packages/db/prisma/seed/seed.ts
index 0ebf260c87..83784e142b 100644
--- a/packages/db/prisma/seed/seed.ts
+++ b/packages/db/prisma/seed/seed.ts
@@ -259,6 +259,13 @@ const ISMS_DOCUMENT_TEMPLATES = [
description:
'The internal audit programme and the plan, controls tested, findings and conclusion of each internal audit of the ISMS (ISO 27001 clause 9.2).',
},
+ {
+ documentType: 'management_review',
+ name: 'Management Review',
+ clause: '9.3',
+ description:
+ 'The management review procedure and the minutes of each review — inputs considered, outputs, actions arising and chair sign-off (ISO 27001 clause 9.3).',
+ },
] as const;
async function seedIsmsDocumentTemplates() {
diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json
index 7e398af4b9..0901f38e91 100644
--- a/packages/docs/openapi.json
+++ b/packages/docs/openapi.json
@@ -24833,6 +24833,15 @@
"schema": {
"type": "string"
}
+ },
+ {
+ "name": "offset",
+ "required": false,
+ "in": "query",
+ "description": "Number of logs to skip (default 0)",
+ "schema": {
+ "type": "string"
+ }
}
],
"responses": {