From 00db6b88f6351d5e02a1eede5716f41d06fd55ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:47:40 -0400 Subject: [PATCH 1/7] CS-472 Failed to mark video as completed (#3455) * fix(app): grant portal permission automatically when Employee Compliance is enabled * fix(app): update test name in PermissionMatrix * fix(app): assert onObligationsChange isn't called on unrelated permission changes * fix(api): enforce compliance-obligation-implies-portal invariant server-side * fix(api): enforce compliance-obligation-implies-portal invariant server-side --------- Co-authored-by: chasprowebdev Co-authored-by: chasprowebdev <70908289+chasprowebdev@users.noreply.github.com> Co-authored-by: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> --- apps/api/src/roles/roles.service.spec.ts | 264 +++++++++++++++++- apps/api/src/roles/roles.service.ts | 84 +++++- .../components/PermissionMatrix.test.tsx | 75 +++++ .../roles/components/PermissionMatrix.tsx | 15 + 4 files changed, 421 insertions(+), 17 deletions(-) diff --git a/apps/api/src/roles/roles.service.spec.ts b/apps/api/src/roles/roles.service.spec.ts index ee709e6f5f..30d4c7572f 100644 --- a/apps/api/src/roles/roles.service.spec.ts +++ b/apps/api/src/roles/roles.service.spec.ts @@ -28,6 +28,7 @@ jest.mock('@trycompai/auth', () => { apiKey: ['create', 'read', 'delete'], app: ['read'], trust: ['read', 'update'], + portal: ['read', 'update'], }; const BUILT_IN_ROLE_PERMISSIONS: Record> = { @@ -340,6 +341,102 @@ 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 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', () => { @@ -488,6 +585,135 @@ 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: '{}', + }; + + (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.update).not.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: '{}', + }; + + (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue( + existingRole, + ); + (mockDb.organizationRole.update as jest.Mock).mockImplementation( + ({ data }) => ({ + id: roleId, + name: existingRole.name, + ...data, + updatedAt: new Date(), + }), + ); + + 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 }), + }; + + (mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue( + existingRole, + ); + (mockDb.organizationRole.update as jest.Mock).mockImplementation( + ({ data }) => ({ + id: roleId, + name: existingRole.name, + obligations: existingRole.obligations, + ...data, + updatedAt: new Date(), + }), + ); + + 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: '{}', + }; + + (mockDb.organizationRole.findFirst as jest.Mock) + .mockResolvedValueOnce(existingRole) + .mockResolvedValueOnce(null); + (mockDb.organizationRole.update as jest.Mock).mockResolvedValue({ + ...existingRole, + name: 'new-name', + updatedAt: new Date(), + }); + + await service.updateRole(organizationId, roleId, { name: 'new-name' }, [ + 'owner', + ]); + + expect(mockDb.organizationRole.update).toHaveBeenCalledWith({ + where: { id: roleId }, + data: { name: 'new-name' }, + }); + }); }); describe('deleteRole', () => { @@ -746,7 +972,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 +983,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 +994,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 +1022,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 +1045,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 +1168,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..2b688c17ca 100644 --- a/apps/api/src/roles/roles.service.ts +++ b/apps/api/src/roles/roles.service.ts @@ -79,6 +79,32 @@ 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 { + if (!obligations.compliance) return permissions; + + const portalActions = new Set([ + ...(permissions.portal ?? []), + ...statement.portal, + ]); + return { ...permissions, portal: [...portalActions] }; + } + /** * Check if caller has all the permissions they're trying to grant. * Prevents privilege escalation. @@ -206,13 +232,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 +281,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,12 +434,42 @@ export class RolesService { } } - // Validate and check permissions if provided + // Validate permission shape (resource/action names) as submitted. if (dto.permissions) { this.validatePermissions(dto.permissions); + } + + // 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 effectiveObligations = + dto.obligations !== undefined + ? dto.obligations + : parseObligationsField(role.obligations); + const effectivePermissions: Record = + dto.permissions !== undefined + ? dto.permissions + : typeof role.permissions === 'string' + ? (JSON.parse(role.permissions) as Record) + : role.permissions; + permissionsToPersist = this.withCompliancePortalInvariant( + effectivePermissions, + effectiveObligations, + ); + + // Validate against the FINAL permission set that will actually be + // written — not just the submitted `dto.permissions`. This also + // 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. await this.validateNoPrivilegeEscalation( callerRoles, - dto.permissions, + permissionsToPersist, organizationId, ); } @@ -413,8 +479,8 @@ export class RolesService { where: { id: roleId }, 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), 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..da8ab3c718 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,81 @@ 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('removes portal permissions when the compliance obligation is disabled', () => { + const mockOnChange = vi.fn(); + const mockOnObligationsChange = vi.fn(); + render( + , + ); + + fireEvent.click(findComplianceSwitch()); + + expect(mockOnObligationsChange).toHaveBeenCalledWith({}); + expect(mockOnChange).toHaveBeenCalledWith({ control: ['read'] }); + }); + + 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..c1fa106907 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,21 @@ 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 keep it in sync + // with this toggle instead. + if (key === 'compliance') { + const newPermissions = { ...value }; + if (enabled) { + newPermissions.portal = [...statement.portal]; + } else { + delete newPermissions.portal; + } + onChange(newPermissions); + } }; const handleToggleChange = (resourceKey: string, enabled: boolean) => { From 9347d172cf70c621b6223aa99533bbee6adabf75 Mon Sep 17 00:00:00 2001 From: salcompai Date: Wed, 22 Jul 2026 12:38:06 -0400 Subject: [PATCH 2/7] Update README.md Signed-off-by: salcompai --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 55a4cbfdaa..7dd3492f20 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Logo -

Comp AI

+

Comp AI

The open-source compliance platform. From b201b7e2459ff4080ccbb4143688439d7ff29076 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:37:32 -0400 Subject: [PATCH 3/7] feat: isms management review (clause 9.3) (#3481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): add isms management review (clause 9.3) document type Tenth ISMS document type management_review, cloned from the internal audit (9.2) and monitoring (9.1) patterns: - IsmsManagementReview / IsmsReviewInput / IsmsReviewAction models with server-generated MR-YYYY-NN and A-NN references under the per-document lock, an immutable recordedAt, and attendees frozen at selection - ten Inputs (9.3.2) rows seeded per review at creation, idempotent by inputKey; outputs ship with clause-9.3 template text - chair and attendees default to the Roles (5.3) Top Management and SPO holders, deduped for small teams - chair sign-off locks the review (inputs and outputs frozen, sign-off slot correctable); action tracking stays live to closure and open actions carry forward to the next review's input (a) at display and export time, never copied - submit gate assertManagementReviewComplete mirrors the ticket's document validation for complete reviews; agenda-pack reviews never block - reviews/review-inputs/review-actions registers on the generic dispatch, export section builder, narrative (procedure) seeding, and extras threading at both snapshot call sites * feat(app): management review (clause 9.3) page ISMS > Management Review detail page for the tenth document type: - ManagementReviewClient shell with the clause-9.3 submit-gate mirror (management-review-constants.ts) and the editable Procedure card - review cards with meeting date (backdatable), immutable recorded-on, chair dropdown from the Roles (5.3) Top Management holder, attendees as a select-to-add list frozen at selection, and the bracketed suitable/adequate/effective conclusion verdict - Inputs (9.3.2) table as the meeting agenda: per-row edit, immediate Discussed? checkbox, add/remove rows - Outputs (9.3.3): decisions + changes template text and the actions arising table with immediate status tracking and full MR-YYYY-NN-ANN references - open actions from earlier reviews render as a read-only carried- forward table on later reviews - single chair sign-off slot that locks the review in the UI exactly as the server enforces it (action tracking and the sign-off slot itself stay live) - vitest coverage incl. admin vs read-only permissions and the locked state; existing ISMS fixtures gained the new reviews register * fix(isms): harden management review sign-off lock and attendee handling Review-round fixes: - re-read the sign-off state INSIDE the locked transaction for every mutation gated on "unsigned" (review update/delete, input CRUD, action create/delete) — a concurrent chair sign-off could previously commit between the pre-read and the lock and be overwritten - signed reviews now accept only owner / due date / status on their actions; the description is part of the frozen minutes - action rows patch only dirty fields, so an untouched owner belonging to a former member no longer fails unrelated edits - review details and input rows exit edit mode when the lock lands - attendee picker resets to its placeholder after each selection and saves are serialized so replacement PATCHes cannot drop each other - attendee lists are deduped by member before persisting - client attendee parsing requires a non-empty memberId, mirroring the server schema - edited seeded input rows record source=manual (audit-controls precedent) --------- Co-authored-by: Tofik Hasanov Co-authored-by: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> --- apps/api/src/isms/documents/generate.ts | 7 + .../documents/management-review-defaults.ts | 130 ++++++ .../management-review-export-data.spec.ts | 262 ++++++++++++ .../management-review-export-data.ts | 139 +++++++ .../documents/management-review-sections.ts | 209 ++++++++++ .../isms/documents/management-review.spec.ts | 232 +++++++++++ .../src/isms/documents/management-review.ts | 216 ++++++++++ apps/api/src/isms/documents/registry.ts | 15 +- apps/api/src/isms/documents/snapshot.ts | 4 + apps/api/src/isms/documents/types.ts | 60 +++ .../isms-management-review.service.spec.ts | 392 ++++++++++++++++++ .../isms/isms-management-review.service.ts | 298 +++++++++++++ .../isms/isms-registers.controller.spec.ts | 30 ++ .../api/src/isms/isms-registers.controller.ts | 46 +- .../isms/isms-review-action.service.spec.ts | 232 +++++++++++ .../src/isms/isms-review-action.service.ts | 258 ++++++++++++ .../isms/isms-review-input.service.spec.ts | 175 ++++++++ .../api/src/isms/isms-review-input.service.ts | 191 +++++++++ .../api/src/isms/isms-version.service.spec.ts | 1 + apps/api/src/isms/isms-version.service.ts | 6 + apps/api/src/isms/isms.module.ts | 9 + ...isms.service.ensure-setup-fallback.spec.ts | 5 +- .../src/isms/isms.service.lifecycle.spec.ts | 76 ++++ apps/api/src/isms/isms.service.spec.ts | 9 +- apps/api/src/isms/isms.service.ts | 133 ++++-- .../isms/registers/register-registry.spec.ts | 157 +++++++ .../src/isms/registers/register-registry.ts | 132 ++++++ .../api/src/isms/utils/document-types.spec.ts | 12 +- apps/api/src/isms/utils/document-types.ts | 7 + apps/api/src/isms/utils/export-payload.ts | 32 ++ .../api/src/isms/utils/review-participants.ts | 99 +++++ .../src/isms/wizard/isms-profile.service.ts | 1 + .../[orgId]/documents/isms/[type]/page.tsx | 31 ++ .../isms/components/AttendeesField.tsx | 138 ++++++ .../isms/components/CarriedForwardActions.tsx | 89 ++++ .../ContextOfOrganizationClient.test.tsx | 1 + .../InterestedPartiesClient.test.tsx | 1 + .../components/InternalAuditClient.test.tsx | 1 + .../components/IsmsApprovalSection.test.tsx | 1 + .../components/IsmsControlMappings.test.tsx | 1 + .../isms/components/LeadershipClient.test.tsx | 1 + .../ManagementReviewClient.test.tsx | 374 +++++++++++++++++ .../components/ManagementReviewClient.tsx | 192 +++++++++ .../isms/components/MonitoringClient.test.tsx | 1 + .../isms/components/ObjectivesClient.test.tsx | 1 + .../isms/components/ProcedureCard.tsx | 140 +++++++ .../components/RequirementsClient.test.tsx | 1 + .../isms/components/ReviewActionRow.tsx | 320 ++++++++++++++ .../isms/components/ReviewActionsSection.tsx | 229 ++++++++++ .../documents/isms/components/ReviewCard.tsx | 318 ++++++++++++++ .../isms/components/ReviewFields.tsx | 170 ++++++++ .../isms/components/ReviewInputRow.tsx | 261 ++++++++++++ .../isms/components/ReviewInputsTable.tsx | 215 ++++++++++ .../isms/components/ReviewOutputsSection.tsx | 153 +++++++ .../isms/components/ReviewSignoffCard.tsx | 129 ++++++ .../documents/isms/components/ReviewsList.tsx | 99 +++++ .../isms/components/ScopeClient.test.tsx | 1 + .../components/__test-helpers__/dsMocks.tsx | 22 + .../management-review-constants.test.ts | 195 +++++++++ .../components/management-review-constants.ts | 181 ++++++++ .../management-review-schema.test.ts | 119 ++++++ .../components/management-review-schema.ts | 99 +++++ .../documents/isms/hooks/useIsmsDocument.ts | 5 +- .../[orgId]/documents/isms/isms-types.ts | 87 +++- .../migration.sql | 105 +++++ packages/db/prisma/schema/isms.prisma | 157 +++++++ packages/db/prisma/seed/seed.ts | 7 + 67 files changed, 7381 insertions(+), 39 deletions(-) create mode 100644 apps/api/src/isms/documents/management-review-defaults.ts create mode 100644 apps/api/src/isms/documents/management-review-export-data.spec.ts create mode 100644 apps/api/src/isms/documents/management-review-export-data.ts create mode 100644 apps/api/src/isms/documents/management-review-sections.ts create mode 100644 apps/api/src/isms/documents/management-review.spec.ts create mode 100644 apps/api/src/isms/documents/management-review.ts create mode 100644 apps/api/src/isms/isms-management-review.service.spec.ts create mode 100644 apps/api/src/isms/isms-management-review.service.ts create mode 100644 apps/api/src/isms/isms-review-action.service.spec.ts create mode 100644 apps/api/src/isms/isms-review-action.service.ts create mode 100644 apps/api/src/isms/isms-review-input.service.spec.ts create mode 100644 apps/api/src/isms/isms-review-input.service.ts create mode 100644 apps/api/src/isms/utils/review-participants.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AttendeesField.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/CarriedForwardActions.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ManagementReviewClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ManagementReviewClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ProcedureCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewActionRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewActionsSection.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewFields.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewInputRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewInputsTable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewOutputsSection.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewSignoffCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ReviewsList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-constants.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-constants.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-schema.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/management-review-schema.ts create mode 100644 packages/db/prisma/migrations/20260722090000_isms_management_review/migration.sql 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..1c7d546d83 --- /dev/null +++ b/apps/api/src/isms/documents/management-review-defaults.ts @@ -0,0 +1,130 @@ +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.', + whereToFind: + 'Comp AI > ISMS > Documents > Interested Parties Register & 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.', + whereToFind: 'Comp AI > ISMS > Documents > Objectives 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..59ef084277 --- /dev/null +++ b/apps/api/src/isms/documents/management-review.spec.ts @@ -0,0 +1,232 @@ +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([]); + }); + + 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..46d4cd26f9 --- /dev/null +++ b/apps/api/src/isms/documents/management-review.ts @@ -0,0 +1,216 @@ +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). */ +export function parseReviewAttendees(value: unknown): ReviewAttendee[] { + const parsed = reviewAttendeesSchema.safeParse(value); + return parsed.success ? 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..d3c5bb690e 100644 --- a/apps/api/src/isms/isms.service.spec.ts +++ b/apps/api/src/isms/isms.service.spec.ts @@ -261,9 +261,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 +347,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 +449,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..9394d76703 100644 --- a/apps/api/src/isms/isms.service.ts +++ b/apps/api/src/isms/isms.service.ts @@ -16,6 +16,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'; @@ -216,38 +223,53 @@ 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). + // Same 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 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', ); - if (internalAuditDoc) { + const managementReviewDoc = created.find( + (doc) => doc.type === 'management_review', + ); + if (internalAuditDoc || managementReviewDoc) { const organization = await db.organization.findUnique({ where: { id: organizationId }, select: { name: true }, }); - 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: {} } }, - ], - }, - data: { - draftNarrative: { - programme: defaultProgrammeText( - organization?.name ?? 'The organization', - ), + const organizationName = organization?.name ?? 'The organization'; + // "Empty" matches generateNarrative's definition: NULL (the creation + // state) or an empty object — never a populated draft. + const whileNarrativeEmpty = { + OR: [ + { draftNarrative: { equals: Prisma.AnyNull } }, + { draftNarrative: { equals: {} } }, + ], + }; + if (internalAuditDoc) { + await db.ismsDocument.updateMany({ + where: { id: internalAuditDoc.id, ...whileNarrativeEmpty }, + data: { + draftNarrative: { + programme: defaultProgrammeText(organizationName), + }, }, - }, - }); + }); + } + if (managementReviewDoc) { + await db.ismsDocument.updateMany({ + where: { id: managementReviewDoc.id, ...whileNarrativeEmpty }, + data: { + draftNarrative: { + procedure: defaultProcedureText(organizationName), + }, + }, + }); + } } } @@ -292,6 +314,13 @@ export class IsmsService { findings: { orderBy: { position: 'asc' } }, }, }, + reviews: { + orderBy: { position: 'asc' }, + include: { + inputs: { orderBy: { position: 'asc' } }, + actions: { orderBy: { position: 'asc' } }, + }, + }, controlLinks: { select: { id: true, @@ -348,6 +377,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 +626,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..9857ad35ea 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,13 @@ export const EXPORT_DOCUMENT_INCLUDE = { findings: { orderBy: { position: 'asc' } }, }, }, + reviews: { + orderBy: { position: 'asc' }, + include: { + inputs: { orderBy: { position: 'asc' } }, + actions: { orderBy: { position: 'asc' } }, + }, + }, } satisfies Prisma.IsmsDocumentInclude; export type LoadedExportDocument = Prisma.IsmsDocumentGetPayload<{ @@ -138,6 +150,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 +200,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 +245,9 @@ export function buildExportInput({ audits: internalAuditExtras ? mapAudits(document.audits, internalAuditExtras) : undefined, + reviews: managementReviewExtras + ? mapReviews(document.reviews, managementReviewExtras) + : undefined, }; } @@ -241,12 +270,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..8506741862 --- /dev/null +++ b/apps/api/src/isms/utils/review-participants.ts @@ -0,0 +1,99 @@ +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: { + orderBy: { position: '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/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 ? ( +
+ ))} +
+ )} + + {canEdit && availableMembers.length > 0 ? ( +
+ +
+ ) : 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..0f426913a5 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/CarriedForwardActions.tsx @@ -0,0 +1,89 @@ +'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} + + {action.ownerMemberId + ? (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 ? ( + + + + + ) : ( + + ) + ) : undefined; + + return ( + + Procedure + + Rendered verbatim into the generated Clause 9.3 document. + + + } + headerEnd={headerActions} + > + {isEditing ? ( + + ( + <> +