From ddf7b34a7c304ad6bfd1ac25b09e036c4000ab2f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 15 Jun 2026 10:24:53 -0400 Subject: [PATCH 1/5] fix(automation-builder): stop test dialog hanging on "Running your automation" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Integration Builder test poller only treated COMPLETED (with output) and FAILED as terminal Trigger.dev run statuses. Every other terminal status (TIMED_OUT, CRASHED, SYSTEM_FAILURE, EXPIRED, CANCELED) — and a run stuck in the queue — fell through to a 1s re-poll with no ceiling, so the panel spun on "Running your automation" forever. This reproduces even with a no-op script, because the hang is about how the loop handles the END state, not the script. - Treat COMPLETED as success regardless of output payload - Surface a clear, status-specific error for every terminal-but-not-COMPLETED status (and treat unknown statuses as terminal — fail-safe) - Add an absolute polling deadline backstop so the UI can never spin forever - Add regression tests for all terminal states, unknown status, and the cap Co-Authored-By: Claude Opus 4.8 (1M context) --- .../use-task-automation-execution.test.tsx | 190 ++++++++++++++++++ .../hooks/use-task-automation-execution.ts | 153 ++++++++++---- 2 files changed, 302 insertions(+), 41 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation-execution.test.tsx diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation-execution.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation-execution.test.tsx new file mode 100644 index 0000000000..e39fc1a0fd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation-execution.test.tsx @@ -0,0 +1,190 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useTaskAutomationExecution } from './use-task-automation-execution'; + +// --- Mocks ----------------------------------------------------------------- + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_1', taskId: 'task_1', automationId: 'auto_1' }), +})); + +const getAutomationRunStatus = vi.fn(); +vi.mock('../actions/task-automation-actions', () => ({ + getAutomationRunStatus: (runId: string) => getAutomationRunStatus(runId), +})); + +// sanitizeErrorMessage just passes the message through so we can assert on it. +vi.mock('../actions/sanitize-error', () => ({ + sanitizeErrorMessage: vi.fn(async (err: unknown) => { + if (typeof err === 'string') return err; + if (err && typeof err === 'object' && 'message' in err) { + return String((err as { message: unknown }).message); + } + return 'sanitized'; + }), +})); + +vi.mock('../lib/chat-context', () => ({ + useSharedChatContext: () => ({ automationIdRef: { current: 'auto_1' } }), +})); + +const executeScript = vi.fn(); +vi.mock('../lib/task-automation-api', () => ({ + taskAutomationApi: { + execution: { + executeScript: (data: unknown) => executeScript(data), + }, + }, +})); + +// --- Helpers --------------------------------------------------------------- + +const runStatus = (status: string, output?: unknown) => ({ + success: true, + data: { id: 'run_1', status, ...(output !== undefined ? { output } : {}) }, +}); + +const okOutput = { + success: true, + output: { ok: true }, + summary: 'It worked', + evaluationStatus: 'pass' as const, + evaluationReason: 'criteria met', +}; + +beforeEach(() => { + executeScript.mockResolvedValue({ runId: 'run_1' }); + getAutomationRunStatus.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('useTaskAutomationExecution polling', () => { + it('resolves to success when the run COMPLETES with output', async () => { + getAutomationRunStatus.mockResolvedValue(runStatus('COMPLETED', okOutput)); + + const { result } = renderHook(() => useTaskAutomationExecution()); + await act(async () => { + await result.current.execute(); + }); + + await waitFor(() => expect(result.current.isExecuting).toBe(false)); + expect(result.current.error).toBeNull(); + expect(result.current.result).toMatchObject({ + success: true, + data: { ok: true }, + summary: 'It worked', + evaluationStatus: 'pass', + }); + }); + + // Regression: a COMPLETED run whose output is missing used to fall through + // the `COMPLETED && data.output` guard and poll forever. + it('resolves to success when the run COMPLETES without output', async () => { + getAutomationRunStatus.mockResolvedValue(runStatus('COMPLETED')); + + const { result } = renderHook(() => useTaskAutomationExecution()); + await act(async () => { + await result.current.execute(); + }); + + await waitFor(() => expect(result.current.isExecuting).toBe(false)); + expect(result.current.error).toBeNull(); + expect(result.current.result).toMatchObject({ success: true }); + expect(result.current.result?.data).toBeUndefined(); + }); + + it('surfaces an error when the run FAILS', async () => { + getAutomationRunStatus.mockResolvedValue({ + success: true, + data: { id: 'run_1', status: 'FAILED', error: 'boom from script' }, + }); + + const { result } = renderHook(() => useTaskAutomationExecution()); + await act(async () => { + await result.current.execute(); + }); + + await waitFor(() => expect(result.current.isExecuting).toBe(false)); + expect(result.current.result).toBeNull(); + expect(result.current.error?.message).toBe('boom from script'); + }); + + // Regression: these terminal states used to fall into the infinite poll + // (the bug: "Running your automation" forever). + it.each([ + ['TIMED_OUT', /timed out/i], + ['CRASHED', /crashed/i], + ['SYSTEM_FAILURE', /system error/i], + ['CANCELED', /canceled/i], + ['EXPIRED', /expired/i], + ])('stops polling and surfaces an error for terminal status %s', async (status, expected) => { + getAutomationRunStatus.mockResolvedValue(runStatus(status)); + + const { result } = renderHook(() => useTaskAutomationExecution()); + await act(async () => { + await result.current.execute(); + }); + + await waitFor(() => expect(result.current.isExecuting).toBe(false)); + expect(result.current.result).toBeNull(); + expect(result.current.error?.message).toMatch(expected); + }); + + // Regression: an unknown / unmapped status must NOT loop forever. + it('treats an unknown status as terminal instead of polling forever', async () => { + getAutomationRunStatus.mockResolvedValue(runStatus('SOME_FUTURE_STATUS')); + + const { result } = renderHook(() => useTaskAutomationExecution()); + await act(async () => { + await result.current.execute(); + }); + + await waitFor(() => expect(result.current.isExecuting).toBe(false)); + expect(result.current.error?.message).toMatch(/did not finish successfully/i); + }); + + it('keeps polling through in-progress states then resolves on COMPLETED', async () => { + vi.useFakeTimers(); + getAutomationRunStatus + .mockResolvedValueOnce(runStatus('QUEUED')) + .mockResolvedValueOnce(runStatus('EXECUTING')) + .mockResolvedValue(runStatus('COMPLETED', okOutput)); + + const { result } = renderHook(() => useTaskAutomationExecution()); + await act(async () => { + await result.current.execute(); + }); + // Advance through the two 1s poll intervals. + await act(async () => { + await vi.advanceTimersByTimeAsync(2500); + }); + + expect(result.current.isExecuting).toBe(false); + expect(result.current.result).toMatchObject({ success: true, data: { ok: true } }); + expect(getAutomationRunStatus.mock.calls.length).toBeGreaterThanOrEqual(3); + }); + + // Regression: a run stuck in the queue (never reaches a terminal state) must + // not spin forever — the absolute deadline backstop ends it with an error. + it('gives up with a timeout error if the run never leaves an in-progress state', async () => { + vi.useFakeTimers(); + getAutomationRunStatus.mockResolvedValue(runStatus('QUEUED')); + + const { result } = renderHook(() => useTaskAutomationExecution()); + await act(async () => { + await result.current.execute(); + }); + // Advance past the 6-minute backstop. + await act(async () => { + await vi.advanceTimersByTimeAsync(6 * 60 * 1000 + 5000); + }); + + expect(result.current.isExecuting).toBe(false); + expect(result.current.error?.message).toMatch(/taking longer than expected/i); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation-execution.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation-execution.ts index 3f6325d9d1..3a821777d0 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation-execution.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation-execution.ts @@ -43,6 +43,38 @@ interface AutomationRunData { }; } +// Trigger.dev run statuses that mean the run is still in progress. Anything +// NOT in this set is terminal (the run has stopped for good). Keeping a run +// status outside both this set and COMPLETED used to fall through to an +// infinite poll, which is exactly what hung the "Running your automation" +// dialog. Source: @trigger.dev/sdk v4 RunStatus. +const IN_PROGRESS_RUN_STATUSES = new Set([ + 'PENDING_VERSION', + 'QUEUED', + 'DEQUEUED', + 'EXECUTING', + 'WAITING', + 'DELAYED', +]); + +// Friendly messages for terminal run states that are not a clean COMPLETED and +// usually don't carry a script-level error (timeouts, crashes, cancellations). +const TERMINAL_STATUS_MESSAGES: Record = { + TIMED_OUT: + 'The automation took too long and timed out before it could finish. Try simplifying the script or check any external services it calls.', + CRASHED: 'The automation crashed while running. This is usually temporary — please try again.', + SYSTEM_FAILURE: + 'The automation stopped because of a system error. Please try again in a moment.', + CANCELED: 'The automation run was canceled before it finished.', + EXPIRED: 'The automation expired before it could start. Please try again.', +}; + +// Absolute ceiling on how long we keep polling before surfacing a result, so +// the dialog can never spin forever (e.g. a run stuck in the queue that never +// reaches a terminal state). Comfortably above the backend task's 5-minute +// maxDuration. +const POLL_TIMEOUT_MS = 6 * 60 * 1000; + export function useTaskAutomationExecution({ onSuccess, onError, @@ -65,6 +97,37 @@ export function useTaskAutomationExecution({ useEffect(() => { if (!runId || !isExecuting) return; + // Absolute deadline for this run. Stable for the lifetime of the effect — + // it only restarts when a new run begins (runId/isExecuting change). + const pollDeadline = Date.now() + POLL_TIMEOUT_MS; + + const finishWithSuccess = (data: AutomationRunData, sanitizedError?: string) => { + const output = data.output; + const executionResult: TaskAutomationExecutionResult = { + // A COMPLETED run is a successful execution; default to true if the + // wrapper output is somehow missing (e.g. output too large to inline). + success: output?.success ?? true, + data: output?.output, + error: sanitizedError, + logs: [], // Don't expose internal execution logs to users + summary: output?.summary, + evaluationStatus: output?.evaluationStatus, + evaluationReason: output?.evaluationReason, + taskId: data.id, + }; + + setResult(executionResult); + setIsExecuting(false); + onSuccess?.(executionResult); + }; + + const finishWithError = (message: string) => { + const failure = new Error(message); + setError(failure); + setIsExecuting(false); + onError?.(failure); + }; + const pollRunStatus = async () => { try { const res = await getAutomationRunStatus(runId); @@ -73,61 +136,69 @@ export function useTaskAutomationExecution({ } const data = res.data as AutomationRunData; - if (data.status === 'COMPLETED' && data.output) { + // Terminal success. + if (data.status === 'COMPLETED') { // Check both possible error locations: // - data.output.error (direct error) // - data.output.output.error (nested error from user's script returning {ok: false, error: "..."}) - const rawError = data.output.error || data.output.output?.error; + const rawError = data.output?.error || data.output?.output?.error; // Sanitize if there's an error - makes it user-friendly and removes sensitive data const sanitizedError = rawError ? await sanitizeErrorMessage(rawError).catch(() => String(rawError)) : undefined; - const executionResult: TaskAutomationExecutionResult = { - success: data.output.success, - data: data.output.output, - error: sanitizedError, - logs: [], // Don't expose internal execution logs to users - summary: data.output.summary, - evaluationStatus: data.output.evaluationStatus, - evaluationReason: data.output.evaluationReason, - taskId: data.id, - }; - - setResult(executionResult); - setIsExecuting(false); - onSuccess?.(executionResult); - } else if (data.status === 'FAILED') { - // Log raw error for debugging (internal only) - console.error('[Automation Execution] Raw error:', data.error); - - // Use AI to sanitize error message with fallback if AI fails - // Fallback extracts message from error object if available - const rawErr = data.error; - const sanitizedMessage = await sanitizeErrorMessage(rawErr).catch(() => { - if (typeof rawErr === 'string') return rawErr; - if (rawErr && typeof rawErr === 'object' && 'message' in rawErr) return String((rawErr as { message: unknown }).message); - return 'The automation failed to execute'; - }); - const error = new Error(sanitizedMessage); - - setError(error); - setIsExecuting(false); - onError?.(error); - } else { - // Still running, poll again after 1 second + finishWithSuccess(data, sanitizedError); + return; + } + + // Still running — keep polling, but never past the absolute deadline. + // This is the only branch that re-schedules a poll, so a run that gets + // stuck in the queue can no longer spin the dialog forever. + if (IN_PROGRESS_RUN_STATUSES.has(data.status)) { + if (Date.now() >= pollDeadline) { + finishWithError( + 'The automation is taking longer than expected and may still be running in the background. Close this dialog and check back shortly.', + ); + return; + } pollingIntervalRef.current = setTimeout(pollRunStatus, 1000); + return; } + + // Terminal but NOT COMPLETED: FAILED / CANCELED / CRASHED / + // SYSTEM_FAILURE / EXPIRED / TIMED_OUT (or any unknown status we treat + // as terminal). Previously these fell through to an infinite poll — + // surface a clear error instead. + console.error( + `[Automation Execution] Run ended in non-success state "${data.status}":`, + data.error, + ); + + // A script-level failure (FAILED) carries the real error in data.error, + // so sanitize it for the user. Other terminal states rarely do, so fall + // back to a friendly status-specific message. + const rawErr = data.error; + const statusMessage = + TERMINAL_STATUS_MESSAGES[data.status] ?? + 'The automation did not finish successfully. Please try again.'; + const message = rawErr + ? await sanitizeErrorMessage(rawErr).catch(() => { + if (typeof rawErr === 'string') return rawErr; + if (rawErr && typeof rawErr === 'object' && 'message' in rawErr) { + return String((rawErr as { message: unknown }).message); + } + return statusMessage; + }) + : statusMessage; + + finishWithError(message); } catch (err) { // Sanitize with fallback to ensure state cleanup always happens - const sanitizedMessage = await sanitizeErrorMessage(err).catch( - () => (err instanceof Error ? err.message : 'An unexpected error occurred'), + const sanitizedMessage = await sanitizeErrorMessage(err).catch(() => + err instanceof Error ? err.message : 'An unexpected error occurred', ); - const error = new Error(sanitizedMessage); - setError(error); - setIsExecuting(false); - onError?.(error); + finishWithError(sanitizedMessage); } }; From 5bf86d3987ca86ff9529c5637e4b75b36cb7c21e Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 15 Jun 2026 10:35:29 -0400 Subject: [PATCH 2/5] feat(isms): add CS-437 foundational documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(isms): add CS-437 foundational documents design spec Placement (framework-grouped Documents page, ISO 27001 (ISMS) tab), IsmsDocument substrate following the SOA pattern, clause-requirement linkage, per-document data models, PDF reuse + net-new DOCX export, feature-flagged all-or-nothing release, and slice-first build sequence. Co-Authored-By: Claude Opus 4.8 * feat(isms): add IsmsDocument data model + Context register (CS-437) IsmsDocument + IsmsDocumentVersion (versioned narrative, source snapshot for drift, pdf/docx urls, sign-off) and IsmsContextIssue (clause 4.1 register), mirroring the SOADocument pattern. Links the global FrameworkEditorFramework + org + ISO clause requirement. Back-relations on Organization, FrameworkEditorFramework, FrameworkEditorRequirement, and Member. Co-Authored-By: Claude Opus 4.8 * feat(isms): add ISMS documents API module with PDF/DOCX export (CS-437) NestJS isms module (@Controller path:'isms' v1) mirroring SOA: ensure-setup, get document, deterministic Context-of-Organization (4.1) generation from platform data, context-issue CRUD with derived->manual override, sign-off (submit/approve/decline), drift detection vs approved snapshot, and branded PDF + net-new DOCX export (docx@9.7.1). All endpoints gated @RequirePermission('audit', ...) to match SOA. 63 Jest tests. openapi regen. Co-Authored-By: Claude Opus 4.8 * feat(isms): framework-grouped Documents IA + Context of the Organization (4.1) (CS-437) Add framework-grouped Documents tabs with a flag-gated, ISO-27001-conditional "ISO 27001 (ISMS)" tab (IsmsOverview: 6 foundational-doc cards + the moved SOA card). Full Context of the Organization (4.1) detail page: useIsmsDocument hook, generate-from-platform-data, editable internal/external issues register with derived->manual override, drift banner, submit/approve/decline sign-off, and PDF/DOCX export. Mutations gated on audit:update; readers can view + export. isIsmsEnabled defaults off (PostHog) until the full pack ships. Vitest tests. Co-Authored-By: Claude Opus 4.8 * refactor(isms): gate on evidence resource, drop feature flag (CS-437) - Permissions: switch ISMS endpoints from `audit` to the `evidence` resource (reads incl. ensure-setup -> evidence:read; mutations -> evidence:update). `evidence` matches the Documents route gate and is the correct semantic fit: owner/admin author + sign off, auditor read-only (review + export), others excluded. Frontend mutation gating moved to evidence:update. - Drop the isIsmsEnabled runtime flag. The ISO 27001 (ISMS) tab is now purely framework-conditional (visible when ISO 27001 is active); the unmerged branch is the release gate. SOA card now lives solely in the ISMS tab. - Spec updated to match (permissions + merge-gated release). Co-Authored-By: Claude Opus 4.8 * feat(isms): add register tables for Interested Parties, Requirements, Objectives (CS-437) Add IsmsInterestedParty (4.2a), IsmsInterestedPartyRequirement (4.2b/c, linked to a party), and IsmsObjective (6.2) register tables + IsmsObjectiveStatus enum, all reusing the shared IsmsContextSource derived/manual flag. Scope (4.3) and Leadership (5.1) are singletons stored in IsmsDocumentVersion.narrative. Additive migration only. Co-Authored-By: Claude Opus 4.8 * feat(isms): add api for all six foundational documents via type dispatch (CS-437) Refactor generate/drift/export to dispatch by IsmsDocumentType to per-document handler modules (documents/registry.ts). Add deterministic derivation, register CRUD, and branded PDF/DOCX export for: Interested Parties (4.2a), Requirements & Treatment (4.2b/c, linked to parties), Objectives (6.2), plus singleton narrative docs Scope (4.3) and Leadership (5.1) stored zod-validated in version.narrative. Single platform-data source; per-type drift snapshots; manual rows preserved on regenerate. All endpoints @RequirePermission('evidence', ...). 138 Jest tests. Co-Authored-By: Claude Opus 4.8 * feat(isms): detail pages for the 5 remaining foundational documents (CS-437) Generalize useIsmsDocument (generic createRow/updateRow/deleteRow + saveNarrative) and the [type] route dispatch, enable all six overview cards, and add functional detail pages on the proven Context pattern: - Interested Parties Register (4.2a), Requirements & ISMS Treatment (4.2b/c), Information Security Objectives Plan (6.2) - editable registers - ISMS Scope Statement (4.3, certificate sentence first-class) and Leadership Commitment (5.1, clause 5.1 a-h) - narrative forms via saveNarrative Each reuses DriftBanner + IsmsApprovalSection, gates mutations on evidence:update, and offers PDF/DOCX export. Vitest tests per document (admin vs read-only). Co-Authored-By: Claude Opus 4.8 * feat(isms): add IsmsProfile for wizard inputs (CS-438) One profile per org+framework holding the ~12 un-derivable wizard answers as a Zod-validated JSON blob that feeds document generation. Additive migration. Co-Authored-By: Claude Opus 4.8 * feat(isms): document-creation wizard (CS-438) API: IsmsProfile get-or-init/save/complete + generate-all, a shared Zod WizardAnswers schema, computed defaults for pre-population (capabilities from Types of Services, certificate sentence, default objectives/outcomes, cloud split), and threading wizard answers through derivation (insurance/regulators/ contractors/EU-rep -> Interested Parties & Requirements; certificate sentence + cloud split + capabilities -> Scope; deputy SPO -> Leadership; confirmed objectives -> 6.2). Frontend: 6-step wizard (12 questions, confirm-or-edit, pre-filled from defaults) with partial saves per step and complete -> generate-all on finish; "Run setup wizard" entry point on the ISMS overview. RHF+Zod, design-system only. 189 API jest tests; wizard Vitest tests; all ISMS checks green. Co-Authored-By: Claude Opus 4.8 * feat(isms): wire "Run setup wizard" entry point into ISMS overview (CS-438) Adds the evidence:update-gated wizard launch button to IsmsOverview (missed by the prior wizard commit's add path). Co-Authored-By: Claude Opus 4.8 * feat(isms): model ISMS document types as framework-editor parts (CS-437) Add FrameworkEditorIsmsDocumentTemplate (one per doc type, with default clause) and FrameworkEditorIsmsDocumentRequirementLink (framework-scoped template->clause mapping, editable in the Framework Editor), plus IsmsDocument.templateId provenance. Additive migration. Co-Authored-By: Claude Opus 4.8 * feat(isms): map ISMS document types as framework-editor parts (CS-437, Paul A) Seed the 6 ISMS doc templates; add a framework-editor CRUD/mapping API (framework-editor/isms-document-template: list, update, link/unlink a clause requirement per framework, PlatformAdminGuard); rework ensure-setup to be template-driven (requirement = framework-scoped link ?? clause-match ?? seeded defs fallback) with IsmsDocument.templateId provenance; and a Framework Editor "ISMS Documents" tab/page to author the template->requirement mappings. 238 api jest tests; framework-editor + api typecheck clean. Co-Authored-By: Claude Opus 4.8 * feat(isms): add document<->control link junctions (CS-437, Paul) FrameworkEditorControlIsmsDocumentLink (template: control template <-> ISMS doc template, framework-scoped) and IsmsDocumentControlLink (org: document <-> control), mirroring how policies/document-types link to controls. Back-relations on FrameworkEditorFramework/ControlTemplate/IsmsDocumentTemplate, Control, IsmsDocument. Additive migration. Co-Authored-By: Claude Opus 4.8 * feat(isms): link documents to individual controls (CS-437, Paul) Template level (Framework Editor): link/unlink control templates to ISMS doc templates + a "Controls" mapping cell on the ISMS Documents page. Org level: POST/DELETE /v1/isms/documents/:id/controls (like policies), getDocument returns controlLinks, ensure-setup auto-derives a doc's org control links from its template's control mapping. A "Linked controls" section (IsmsControlMappings) on all 6 document detail pages, mirroring PolicyControlMappings. 257 api jest tests; api/framework-editor/app typecheck clean. Co-Authored-By: Claude Opus 4.8 * feat(isms): polish the ISMS area to be design-system-only and auditor-grade (CS-437) Establish a shared ISMS presentation kit (IsmsStatusBadge, IsmsDocumentCard, IsmsPageHeader, IsmsSummaryRow, IsmsEmptyState, IsmsRegisterShell, IsmsSourceBadge, IsmsRowActions) and restyle the whole area with it: overview (summary stats + Section/Grid of document cards + SOA section + Empty state), the 6 detail pages (consistent header + Section composition), register tables (DS Table + Empty states), drift (Alert), control mappings, approval, and the wizard (Progress + Field steps). One status language, no hand-rolled badges. Strictly design-system components only. Tests + typecheck green. Co-Authored-By: Claude Opus 4.8 * fix(isms): resolve 11 QA findings from the live walkthrough (CS-437) Wizard: Select shows the human label via items prop (#8) + controlled-from-first- render value, no console warning (#9); Finish validation jumps to the first invalid step (#6); form re-seeds when the profile loads post-mount (#7). Shared: source badge reads "Manual" not "Edited" (#1); approval section is status-aware — approved/declined/pending states instead of a bare submit (#2); compact empty state (#4); delete confirmation dialog on register rows (#5). Detail pages: content-first ordering across all 6 (#3). API: certificate-sentence whitespace normalized (#11); cloud-split/capabilities feed scope correctly (#10). Plus QA findings doc + openapi regen. Co-Authored-By: Claude Opus 4.8 * feat(isms): restyle register UIs as read-first cards to match the overview (CS-437) Replace the dense always-on-textarea tables (Context internal/external issues, Interested Parties, Requirements, Objectives) with calm read-first cards: source chip + provenance leading, the primary field prominent, secondary fields as muted labelled values, count-badged sections, compact empty states, and an explicit Edit affordance (Save/Cancel) with the delete-confirm preserved. New shared kit: IsmsRegisterCard, IsmsCardActions, IsmsAddCard. Design-system only; register component props/behaviour unchanged. 42 ISMS tests pass. Co-Authored-By: Claude Opus 4.8 * feat(isms): make the issues registers denser (CS-437) Read mode is now a compact two-line row — issue prominent, effect muted beneath, source chip + provenance on the right, hover-reveal actions, tighter padding — instead of an airy card with a labelled effect field. Roomy labelled form kept for editing. Tighter row spacing. Cuts ~half the vertical space per item for long lists. DS-only; behaviour unchanged. Co-Authored-By: Claude Opus 4.8 * feat(isms): refine register source provenance display (CS-437) Replace the loud uppercase AUTO-DERIVED pill + raw "framework:GDPR" key with a quiet muted line + small icon and a humanized source ("GDPR framework", "Vendor register", "Workforce", "Setup wizard"). Manual rows keep a small badge so human overrides still stand out. Shared component, so all four registers benefit. Co-Authored-By: Claude Opus 4.8 * feat(isms): source as a tag pill under the description (CS-437) Drop the misleading auto/ML icon and the disconnected right-floating label; render the humanized source as a plain pill beneath each entry's description (GDPR framework / Vendor register / Workforce / Manual). Reads like a tag on the entry rather than floating metadata. Co-Authored-By: Claude Opus 4.8 * feat(isms): drop redundant "framework" suffix on source pills (CS-437) framework:GDPR -> "GDPR" (not "GDPR framework"); the name alone is clearer. Co-Authored-By: Claude Opus 4.8 * feat(isms): move approval banner to the top of each document (CS-437) The submit-for-approval / approved banner now renders directly under the header, above drift/content/linked-controls, so the sign-off state and action are the first thing you see on a document rather than buried at the bottom. Applied identically across all six detail pages. Co-Authored-By: Claude Opus 4.8 * feat(isms): drop redundant source pill from interested-parties cards (CS-437) The auto-derive provenance pill (WORKFORCE / GDPR / HIPAA) just restated the party name sitting next to it. Removed it; the category badge (CUSTOMER / SUPPLIER / REGULATOR) stays since that's real classification, not a restatement. Made IsmsRegisterCard.header optional so edit mode renders without it (actions stay right-aligned via ml-auto). Co-Authored-By: Claude Opus 4.8 * feat(isms): render context-of-organization as an auditor-ready document (CS-439) Redesign the ISO 27001 Context of the Organization PDF/DOCX export to match the hand-authored reference document, and capture the data it needs. - db: add `category` to IsmsContextIssue (4.1 grouping) + migration - derivation: assign each derived issue an ISO 4.1 category; export the external/internal category taxonomies - export: pull org overview (onboarding Q&A), mission (company description) and intended outcomes (wizard answers / defaults) at export time; build rich metadata (document code, clause, classification, owner, next review, issue date) - builder: buildContextSections now emits the full 7-section structure — purpose, organization overview, mission + intended outcomes, categorised external and internal issue tables (Category / Issue / Effect), linkage, review - renderers: rebuild PDF (jspdf + jspdf-autotable) and DOCX with a cover block, metadata table, numbered sections, real bordered tables, bullet lists and a footer with classification + page numbers; split PDF into its own module - ui: add an ISO 4.1 category picker to the issue add form + edit row, show the category as a pill in read mode - tests: API builder/derivation/service + app picker coverage Co-Authored-By: Claude Opus 4.8 * feat(documents): gate ISMS tab behind a flag, move SOA to general docs (CS-437) Wrap the ISO 27001 (ISMS) tab in the `is-isms-enabled` PostHog flag so it can be privately tested per-org (matching the is-*-enabled convention). The tab now shows only when ISO 27001 is active AND the flag is on; local development falls through so it stays visible without PostHog configured. Move the Statement of Applicability card out of the ISMS tab and back to the top of the general Documents list (gated on ISO 27001 presence), while we privately test the ISMS area. Co-Authored-By: Claude Opus 4.8 * refactor(isms): consolidate register CRUD into 3 generic endpoints (CS-437) The four ISMS registers (context issues, interested parties, requirements, objectives) each had a create/update/delete trio — 13 near-identical endpoints for what is one operation with a different payload. Collapse them into a single register-routed trio and switch updates to PATCH: POST /v1/isms/documents/:id/registers/:register PATCH /v1/isms/registers/:register/:rowId DELETE /v1/isms/registers/:register/:rowId A register registry maps each key to its service + a zod schema (mirroring the old DTOs), validated off req.body to dodge the ValidationPipe nested-JSON issue. Unknown registers 400. The per-register services are unchanged; the frontend hook already called these generically and now points at the new routes. ISMS endpoint surface drops 23 → 14. Co-Authored-By: Claude Opus 4.8 * fix(isms): address cubic review findings on PR #2992 (CS-437) Auth/tenancy: - approve()/decline() now require needs_review status + the assigned approver - requirement create/update validate interestedPartyId belongs to the document - useIsmsWizard SWR key now includes organizationId (no cross-org cache bleed) Data integrity: - narrative edits on an approved doc reset it to draft (require re-approval) - leadership form preserves commitments beyond the canonical a–h rows - useIsmsDocument revalidates from server instead of caching partial responses - register next-position uses max(position)+1 (survives deletes); ensureProfile upsert Correctness/determinism: - drift now detects wizard-answer, vendor-category, department and member changes - objectives 6.2 export includes plan/measurement; approvalLine: declined wins - deterministic context-answer + requirement-link selection - dedupe sector regulators across 4.2b/4.2c - approver gating uses RBAC (evidence:update) not hardcoded role strings - control picker fetches all pages; register forms keep input on failed save API contract: - add @ApiProperty/@ApiPropertyOptional to the @Body-bound ISMS DTOs Co-Authored-By: Claude Opus 4.8 * fix(isms): derive ensure-setup org from session; skip blank objectives (CS-437) Resolves the remaining actionable cubic findings on PR #2992: - ensure-setup no longer accepts organizationId in the request body; it derives the org from the authenticated session via @OrganizationId() (membership- validated, consistent with every other ISMS endpoint), closing the cross-tenant gap. Both callers drop organizationId from the body. - objectives derivation filters blank-objective wizard rows so empty entries never produce blank 6.2 rows (falls through to the standard objectives). Co-Authored-By: Claude Opus 4.8 * fix(isms): resolve cubic re-review follow-ups (CS-437) - drift: isms_scope + leadership_commitment now include wizardAnswers (both derive from wizard inputs) - requirements: normalize empty/whitespace interestedPartyId to null so a blank id can't skip validation or persist as an invalid reference - narrative save: approval-invalidation + version write are now atomic ($transaction) - generate: preserve an existing (user-edited) narrative on regenerate instead of overwriting it - wizard: a saved empty objectives array is respected instead of being overwritten by defaults on reload Co-Authored-By: Claude Opus 4.8 * fix(isms): seed narrative when empty on regenerate (CS-437) Refine the previous override-preserving guard: preserve only a non-empty narrative. An absent or empty ({}) value — e.g. a snapshot-only version — is still seeded with the derived narrative, so narrative documents can't be left permanently empty. Co-Authored-By: Claude Opus 4.8 * refactor(isms): backend red-team remediation — phase 1 (CS-437) - approval integrity: a shared invalidateApprovalIfNeeded() helper now reverts an approved document to draft on ANY content edit — applied (in a transaction) to every register create/update/remove and the narrative save (was narrative-only) - ensure-setup least privilege: derives the caller's write ability and only provisions documents when they have evidence:update; read-only callers list existing docs with zero writes. Creation is idempotent (createMany skipDuplicates) - approve() now re-derives inside its transaction so the approved rows and the snapshot baseline come from one pass - DRY: deleted the 8 register DTO classes (duplicated the zod schemas); services use z.infer types from the registry. Register endpoints document a body via @ApiBody - objectives ownerMemberId is validated against the org (like the approver) - drift: requirements now detects manual Parties-register edits via a stable parties fingerprint; deleted dead diffSnapshots/ContextSourceSnapshot - generateAll collects platform data once and generates in a deterministic order Co-Authored-By: Claude Opus 4.8 * refactor(isms): frontend red-team remediation — phase 2 (CS-437) - de-duplicate the six detail clients: new IsmsDocumentShell (render-prop) owns useIsmsDocument + drift + lifecycle handlers + header/approval/drift/controls; each client drops from ~210 to ~70 lines - drift is now a single reactive useIsmsDrift hook (was copy-pasted useSWR x6); removed the unused getDrift/refresh/mutate surface from useIsmsDocument - IsmsOverview surfaces load error (Alert + retry) and loading (Spinner) instead of a silently zeroed summary - migrate the inline edit rows (issue/party/requirement/objective) to RHF + Zod, sharing one schema per register with the Add forms (project rule: no useState for form values); Save gated on dirty+valid, input preserved on failed save - exportIsmsDocument now goes through apiClient (auth + org header) instead of a hand-rolled fetch Co-Authored-By: Claude Opus 4.8 * test(isms): add coverage flagged by the red-team review — phase 3 (CS-437) API: docx-renderer (real render -> valid ZIP buffer), data-source (collectPlatformData aggregation + order-insensitive parties fingerprint), version-snapshot (update/create branches). App: IsmsApprovalSection approve/decline/submit interactions; a real-api-client useIsmsDocument test asserting the register verb+URL+body (incl. the PATCH /registers/:register/:rowId routes); a mutation-through-to-hook client test. Co-Authored-By: Claude Opus 4.8 * fix(isms): resolve post-merge cubic findings (CS-437) - clients: every create/update/delete handler re-throws after toast so a failed save keeps the row/form open with the user's input (regression from the shell refactor) - route gating: new server layout gates the whole /documents/isms/* segment on the is-isms-enabled flag (+ ISO 27001 active), so wizard/detail pages aren't reachable by direct URL during private testing - wizard: stable row keys in WizardObjectivesEditor (was array index); breadcrumb href matches its link - single-source the objective zod schema (ObjectivesForm imported the shared one) - framework-editor: useIsmsDocumentRows re-syncs local state when templates prop changes - @ApiBody on the isms-profile @Req endpoint - respect an explicitly-emptied wizard objectives / intended-outcomes array instead of reseeding defaults (only fall back when never set) Co-Authored-By: Claude Opus 4.8 * fix(isms): resolve cubic findings — concurrency, constraint, schemas (CS-437) - atomic read+write: narrative latest-version read and register nextPosition now run inside their transactions (no stale-version write / position TOCTOU) - control-link add/remove invalidate approval (revert approved doc to draft), consistent with register/narrative edits - DB: partial unique index enforces at most one isLatest=true version per document - shared zod schemas trim before min(1) (whitespace-only no longer passes); the last three forms (interested-parties, requirements, add-issue) import the shared schema instead of duplicating it - wizard page derives org from the [orgId] route param, not session active org - ScopeClient keys the narrative form on the stable version id (no remount on save) - framework-editor link handlers guard against double-linking Co-Authored-By: Claude Opus 4.8 * fix(isms): serialize register position allocation; gate control-link approval (CS-437) - register creates take a per-document Postgres advisory xact lock before allocating position, so concurrent creates can't read the same max(position) and persist duplicate ordering keys (a moved-read-into-tx alone doesn't serialize under READ COMMITTED). Shared lockDocumentForPositions helper. - control-link add/remove now invalidate approval only when a row actually changes (createMany/deleteMany count > 0), so an idempotent re-link / no-op unlink no longer downgrades an approved document. Co-Authored-By: Claude Opus 4.8 * chore(isms): stop tracking local plan/QA docs These are working notes for the local branch, not product code: - docs/isms-qa-findings.md - docs/specs/2026-05-29-isms-foundational-documents-design.md Kept on disk via a local git exclude; removed from version control so they don't ship in the PR. Co-Authored-By: Claude Opus 4.8 * chore(db): squash ISMS migrations into one (CS-437) The 7 incremental ISMS migrations created during development are replaced by a single consolidated migration. The SQL is the exact concatenation of the originals in timestamp order (additive DDL only — CREATE TYPE/TABLE/ INDEX + ALTER TABLE), so it is equivalent to applying them sequentially and runs safely as one transaction. Placed after the latest migration so it applies last; ISMS tables only reference long-existing tables (organization, framework, control, member), not main's recent department/trust/device migrations, so ordering is safe. Verified by replaying every migration from empty into a scratch DB (migrate deploy) and diffing the result against the schema (empty diff = zero drift). Note: this will desync already-applied local dev DBs; run `bunx prisma migrate reset` in packages/db to rebuild locally. Fresh deploys (and main after merge) apply the single migration cleanly. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- apps/api/package.json | 2 + apps/api/src/app.module.ts | 4 + .../dto/update-isms-document-template.dto.ts | 30 + .../isms-document-template.controller.spec.ts | 102 ++ .../isms-document-template.controller.ts | 107 ++ .../isms-document-template.module.ts | 12 + .../isms-document-template.service.spec.ts | 294 ++++ .../isms-document-template.service.ts | 240 +++ apps/api/src/isms/documents/context.spec.ts | 138 ++ apps/api/src/isms/documents/context.ts | 182 +++ .../src/isms/documents/data-source.spec.ts | 192 +++ apps/api/src/isms/documents/data-source.ts | 147 ++ apps/api/src/isms/documents/generate.spec.ts | 155 ++ apps/api/src/isms/documents/generate.ts | 261 ++++ .../isms/documents/interested-parties.spec.ts | 144 ++ .../src/isms/documents/interested-parties.ts | 190 +++ apps/api/src/isms/documents/leadership.ts | 129 ++ apps/api/src/isms/documents/narrative.spec.ts | 177 +++ .../api/src/isms/documents/objectives.spec.ts | 161 ++ apps/api/src/isms/documents/objectives.ts | 119 ++ .../src/isms/documents/org-profile.spec.ts | 68 + apps/api/src/isms/documents/org-profile.ts | 89 ++ apps/api/src/isms/documents/registry.ts | 72 + .../src/isms/documents/requirements.spec.ts | 88 ++ apps/api/src/isms/documents/requirements.ts | 163 ++ apps/api/src/isms/documents/scope.ts | 144 ++ apps/api/src/isms/documents/snapshot.spec.ts | 218 +++ apps/api/src/isms/documents/snapshot.ts | 222 +++ apps/api/src/isms/documents/types.ts | 123 ++ apps/api/src/isms/documents/wizard-helpers.ts | 9 + .../api/src/isms/dto/ensure-isms-setup.dto.ts | 11 + .../src/isms/dto/export-isms-document.dto.ts | 12 + .../src/isms/dto/link-isms-controls.dto.ts | 14 + .../isms/dto/submit-isms-for-approval.dto.ts | 11 + .../isms/isms-context-issue.service.spec.ts | 158 ++ .../src/isms/isms-context-issue.service.ts | 143 ++ .../api/src/isms/isms-context.service.spec.ts | 308 ++++ apps/api/src/isms/isms-context.service.ts | 205 +++ .../isms-document-control.service.spec.ts | 199 +++ .../src/isms/isms-document-control.service.ts | 97 ++ .../isms-interested-party.service.spec.ts | 148 ++ .../src/isms/isms-interested-party.service.ts | 140 ++ .../src/isms/isms-narrative.service.spec.ts | 153 ++ apps/api/src/isms/isms-narrative.service.ts | 78 + .../src/isms/isms-objective.service.spec.ts | 211 +++ apps/api/src/isms/isms-objective.service.ts | 198 +++ .../isms/isms-registers.controller.spec.ts | 264 ++++ .../api/src/isms/isms-registers.controller.ts | 203 +++ .../src/isms/isms-requirement.service.spec.ts | 224 +++ apps/api/src/isms/isms-requirement.service.ts | 194 +++ .../isms/isms.controller.permissions.spec.ts | 73 + apps/api/src/isms/isms.controller.spec.ts | 344 +++++ apps/api/src/isms/isms.controller.ts | 250 ++++ apps/api/src/isms/isms.module.ts | 46 + ...isms.service.ensure-setup-fallback.spec.ts | 101 ++ .../src/isms/isms.service.lifecycle.spec.ts | 280 ++++ apps/api/src/isms/isms.service.spec.ts | 305 ++++ apps/api/src/isms/isms.service.ts | 298 ++++ .../isms/registers/register-registry.spec.ts | 223 +++ .../src/isms/registers/register-registry.ts | 210 +++ apps/api/src/isms/utils/approval.ts | 30 + .../src/isms/utils/context-derivation.spec.ts | 125 ++ apps/api/src/isms/utils/context-derivation.ts | 181 +++ apps/api/src/isms/utils/document-lock.ts | 15 + .../api/src/isms/utils/document-types.spec.ts | 74 + apps/api/src/isms/utils/document-types.ts | 87 ++ apps/api/src/isms/utils/docx-renderer.spec.ts | 82 + apps/api/src/isms/utils/docx-renderer.ts | 298 ++++ apps/api/src/isms/utils/ensure-setup-plan.ts | 95 ++ .../src/isms/utils/export-generator.spec.ts | 117 ++ apps/api/src/isms/utils/export-generator.ts | 50 + apps/api/src/isms/utils/export-metadata.ts | 83 ++ apps/api/src/isms/utils/export-shared.ts | 161 ++ apps/api/src/isms/utils/pdf-renderer.ts | 269 ++++ .../src/isms/utils/version-snapshot.spec.ts | 78 + apps/api/src/isms/utils/version-snapshot.ts | 45 + .../src/isms/wizard/dto/generate-all.dto.ts | 12 + .../wizard/isms-profile.controller.spec.ts | 143 ++ .../isms/wizard/isms-profile.controller.ts | 127 ++ .../isms/wizard/isms-profile.service.spec.ts | 288 ++++ .../src/isms/wizard/isms-profile.service.ts | 221 +++ .../api/src/isms/wizard/merge-answers.spec.ts | 48 + apps/api/src/isms/wizard/merge-answers.ts | 41 + .../src/isms/wizard/wizard-defaults.spec.ts | 110 ++ apps/api/src/isms/wizard/wizard-defaults.ts | 113 ++ .../api/src/isms/wizard/wizard-schema.spec.ts | 100 ++ apps/api/src/isms/wizard/wizard-schema.ts | 114 ++ .../components/CompanyOverviewCards.tsx | 35 +- .../components/DocumentsPageTabs.tsx | 59 +- .../documents/components/SOAOverviewCard.tsx | 136 +- .../components/isms/IsmsOverview.test.tsx | 159 ++ .../components/isms/IsmsOverview.tsx | 189 +++ .../[orgId]/documents/isms/[type]/page.tsx | 168 +++ .../isms/components/AddIssueForm.tsx | 131 ++ .../ContextOfOrganizationClient.test.tsx | 191 +++ .../ContextOfOrganizationClient.tsx | 83 ++ .../documents/isms/components/DriftBanner.tsx | 56 + .../InterestedPartiesClient.test.tsx | 310 ++++ .../components/InterestedPartiesClient.tsx | 89 ++ .../isms/components/InterestedPartiesForm.tsx | 107 ++ .../components/InterestedPartiesRow.test.tsx | 134 ++ .../isms/components/InterestedPartiesRow.tsx | 178 +++ .../components/InterestedPartiesTable.tsx | 61 + .../components/IsmsApprovalSection.test.tsx | 274 ++++ .../isms/components/IsmsApprovalSection.tsx | 197 +++ .../components/IsmsControlMappings.test.tsx | 260 ++++ .../isms/components/IsmsControlMappings.tsx | 262 ++++ .../isms/components/IsmsDocumentShell.tsx | 249 ++++ .../isms/components/IssueRow.test.tsx | 126 ++ .../documents/isms/components/IssueRow.tsx | 189 +++ .../isms/components/IssuesRegister.tsx | 109 ++ .../isms/components/LeadershipClient.test.tsx | 250 ++++ .../isms/components/LeadershipClient.tsx | 66 + .../components/LeadershipCommitmentRow.tsx | 57 + .../isms/components/LeadershipForm.tsx | 123 ++ .../isms/components/ObjectivesClient.test.tsx | 187 +++ .../isms/components/ObjectivesClient.tsx | 91 ++ .../isms/components/ObjectivesForm.tsx | 84 ++ .../isms/components/ObjectivesFormFields.tsx | 149 ++ .../isms/components/ObjectivesRow.test.tsx | 133 ++ .../isms/components/ObjectivesRow.tsx | 177 +++ .../isms/components/ObjectivesRowEditor.tsx | 139 ++ .../isms/components/ObjectivesTable.tsx | 53 + .../components/RequirementsClient.test.tsx | 183 +++ .../isms/components/RequirementsClient.tsx | 91 ++ .../isms/components/RequirementsForm.tsx | 122 ++ .../isms/components/RequirementsRow.test.tsx | 128 ++ .../isms/components/RequirementsRow.tsx | 191 +++ .../isms/components/RequirementsTable.tsx | 49 + .../isms/components/ScopeClient.test.tsx | 244 +++ .../documents/isms/components/ScopeClient.tsx | 80 + .../documents/isms/components/ScopeForm.tsx | 222 +++ .../isms/components/ScopeStringList.tsx | 106 ++ .../components/__test-helpers__/dsMocks.tsx | 234 +++ .../interested-party-schema.test.ts | 37 + .../components/interested-party-schema.ts | 14 + .../isms/components/issue-schema.test.ts | 37 + .../documents/isms/components/issue-schema.ts | 14 + .../isms/components/leadership-schema.test.ts | 49 + .../isms/components/leadership-schema.ts | 108 ++ .../isms/components/objective-schema.test.ts | 36 + .../isms/components/objective-schema.ts | 19 + .../isms/components/objectives-status.ts | 12 + .../components/requirement-schema.test.ts | 35 + .../isms/components/requirement-schema.ts | 16 + .../isms/components/shared/IsmsAddCard.tsx | 54 + .../components/shared/IsmsCardActions.tsx | 143 ++ .../components/shared/IsmsDocumentCard.tsx | 61 + .../isms/components/shared/IsmsEmptyState.tsx | 70 + .../isms/components/shared/IsmsPageHeader.tsx | 68 + .../components/shared/IsmsRegisterCard.tsx | 78 + .../components/shared/IsmsRegisterShell.tsx | 64 + .../components/shared/IsmsRowActions.test.tsx | 88 ++ .../isms/components/shared/IsmsRowActions.tsx | 91 ++ .../components/shared/IsmsSourceBadge.tsx | 45 + .../components/shared/IsmsStatusBadge.tsx | 114 ++ .../isms/components/shared/IsmsSummaryRow.tsx | 57 + .../documents/isms/components/shared/index.ts | 24 + .../isms/hooks/exportIsmsDocument.test.ts | 99 ++ .../isms/hooks/exportIsmsDocument.ts | 53 + .../isms/hooks/useIsmsDocument.test.ts | 169 +++ .../documents/isms/hooks/useIsmsDocument.ts | 263 ++++ .../documents/isms/hooks/useIsmsDrift.ts | 33 + .../documents/isms/hooks/useIsmsWizard.ts | 116 ++ .../isms/hooks/useIso27001FrameworkId.ts | 41 + .../[orgId]/documents/isms/isms-types.ts | 265 ++++ .../[orgId]/documents/isms/layout.test.tsx | 109 ++ .../(app)/[orgId]/documents/isms/layout.tsx | 62 + .../isms/wizard/WizardCheckboxList.tsx | 64 + .../isms/wizard/WizardClient.test.tsx | 260 ++++ .../documents/isms/wizard/WizardClient.tsx | 243 +++ .../isms/wizard/WizardEditableList.tsx | 102 ++ .../documents/isms/wizard/WizardField.tsx | 27 + .../wizard/WizardObjectivesEditor.test.tsx | 102 ++ .../isms/wizard/WizardObjectivesEditor.tsx | 132 ++ .../documents/isms/wizard/WizardProgress.tsx | 83 ++ .../isms/wizard/WizardRegulatorSelect.tsx | 141 ++ .../isms/wizard/WizardStepCertificate.tsx | 67 + .../isms/wizard/WizardStepCommitments.tsx | 112 ++ .../isms/wizard/WizardStepContent.tsx | 53 + .../isms/wizard/WizardStepLeadership.tsx | 141 ++ .../isms/wizard/WizardStepOutcomes.tsx | 62 + .../isms/wizard/WizardStepPrivacy.tsx | 69 + .../documents/isms/wizard/WizardStepScope.tsx | 122 ++ .../[orgId]/documents/isms/wizard/page.tsx | 72 + .../isms/wizard/wizard-form-defaults.ts | 76 + .../documents/isms/wizard/wizard-steps.ts | 64 + .../documents/isms/wizard/wizard-types.ts | 141 ++ .../src/app/(app)/[orgId]/documents/page.tsx | 7 +- .../[frameworkId]/FrameworkTabs.tsx | 5 + .../[frameworkId]/isms-documents/page.tsx | 22 + .../isms-documents/IsmsControlsCell.tsx | 213 +++ .../IsmsDocumentsClientPage.tsx | 193 +++ .../isms-documents/IsmsRequirementsCell.tsx | 223 +++ .../app/(pages)/isms-documents/types.ts | 50 + .../isms-documents/useIsmsDocumentRows.ts | 166 +++ bun.lock | 18 + .../migration.sql | 362 +++++ packages/db/prisma/schema/auth.prisma | 1 + packages/db/prisma/schema/control.prisma | 1 + .../db/prisma/schema/framework-editor.prisma | 67 + packages/db/prisma/schema/isms.prisma | 270 ++++ packages/db/prisma/schema/organization.prisma | 2 + packages/db/prisma/seed/seed.ts | 77 + packages/docs/openapi.json | 1328 ++++++++++++++++- 205 files changed, 26630 insertions(+), 153 deletions(-) create mode 100644 apps/api/src/framework-editor/isms-document-template/dto/update-isms-document-template.dto.ts create mode 100644 apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.spec.ts create mode 100644 apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.ts create mode 100644 apps/api/src/framework-editor/isms-document-template/isms-document-template.module.ts create mode 100644 apps/api/src/framework-editor/isms-document-template/isms-document-template.service.spec.ts create mode 100644 apps/api/src/framework-editor/isms-document-template/isms-document-template.service.ts create mode 100644 apps/api/src/isms/documents/context.spec.ts create mode 100644 apps/api/src/isms/documents/context.ts create mode 100644 apps/api/src/isms/documents/data-source.spec.ts create mode 100644 apps/api/src/isms/documents/data-source.ts create mode 100644 apps/api/src/isms/documents/generate.spec.ts create mode 100644 apps/api/src/isms/documents/generate.ts create mode 100644 apps/api/src/isms/documents/interested-parties.spec.ts create mode 100644 apps/api/src/isms/documents/interested-parties.ts create mode 100644 apps/api/src/isms/documents/leadership.ts create mode 100644 apps/api/src/isms/documents/narrative.spec.ts create mode 100644 apps/api/src/isms/documents/objectives.spec.ts create mode 100644 apps/api/src/isms/documents/objectives.ts create mode 100644 apps/api/src/isms/documents/org-profile.spec.ts create mode 100644 apps/api/src/isms/documents/org-profile.ts create mode 100644 apps/api/src/isms/documents/registry.ts create mode 100644 apps/api/src/isms/documents/requirements.spec.ts create mode 100644 apps/api/src/isms/documents/requirements.ts create mode 100644 apps/api/src/isms/documents/scope.ts create mode 100644 apps/api/src/isms/documents/snapshot.spec.ts create mode 100644 apps/api/src/isms/documents/snapshot.ts create mode 100644 apps/api/src/isms/documents/types.ts create mode 100644 apps/api/src/isms/documents/wizard-helpers.ts create mode 100644 apps/api/src/isms/dto/ensure-isms-setup.dto.ts create mode 100644 apps/api/src/isms/dto/export-isms-document.dto.ts create mode 100644 apps/api/src/isms/dto/link-isms-controls.dto.ts create mode 100644 apps/api/src/isms/dto/submit-isms-for-approval.dto.ts create mode 100644 apps/api/src/isms/isms-context-issue.service.spec.ts create mode 100644 apps/api/src/isms/isms-context-issue.service.ts create mode 100644 apps/api/src/isms/isms-context.service.spec.ts create mode 100644 apps/api/src/isms/isms-context.service.ts create mode 100644 apps/api/src/isms/isms-document-control.service.spec.ts create mode 100644 apps/api/src/isms/isms-document-control.service.ts create mode 100644 apps/api/src/isms/isms-interested-party.service.spec.ts create mode 100644 apps/api/src/isms/isms-interested-party.service.ts create mode 100644 apps/api/src/isms/isms-narrative.service.spec.ts create mode 100644 apps/api/src/isms/isms-narrative.service.ts create mode 100644 apps/api/src/isms/isms-objective.service.spec.ts create mode 100644 apps/api/src/isms/isms-objective.service.ts create mode 100644 apps/api/src/isms/isms-registers.controller.spec.ts create mode 100644 apps/api/src/isms/isms-registers.controller.ts create mode 100644 apps/api/src/isms/isms-requirement.service.spec.ts create mode 100644 apps/api/src/isms/isms-requirement.service.ts create mode 100644 apps/api/src/isms/isms.controller.permissions.spec.ts create mode 100644 apps/api/src/isms/isms.controller.spec.ts create mode 100644 apps/api/src/isms/isms.controller.ts create mode 100644 apps/api/src/isms/isms.module.ts create mode 100644 apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts create mode 100644 apps/api/src/isms/isms.service.lifecycle.spec.ts create mode 100644 apps/api/src/isms/isms.service.spec.ts create mode 100644 apps/api/src/isms/isms.service.ts create mode 100644 apps/api/src/isms/registers/register-registry.spec.ts create mode 100644 apps/api/src/isms/registers/register-registry.ts create mode 100644 apps/api/src/isms/utils/approval.ts create mode 100644 apps/api/src/isms/utils/context-derivation.spec.ts create mode 100644 apps/api/src/isms/utils/context-derivation.ts create mode 100644 apps/api/src/isms/utils/document-lock.ts create mode 100644 apps/api/src/isms/utils/document-types.spec.ts create mode 100644 apps/api/src/isms/utils/document-types.ts create mode 100644 apps/api/src/isms/utils/docx-renderer.spec.ts create mode 100644 apps/api/src/isms/utils/docx-renderer.ts create mode 100644 apps/api/src/isms/utils/ensure-setup-plan.ts create mode 100644 apps/api/src/isms/utils/export-generator.spec.ts create mode 100644 apps/api/src/isms/utils/export-generator.ts create mode 100644 apps/api/src/isms/utils/export-metadata.ts create mode 100644 apps/api/src/isms/utils/export-shared.ts create mode 100644 apps/api/src/isms/utils/pdf-renderer.ts create mode 100644 apps/api/src/isms/utils/version-snapshot.spec.ts create mode 100644 apps/api/src/isms/utils/version-snapshot.ts create mode 100644 apps/api/src/isms/wizard/dto/generate-all.dto.ts create mode 100644 apps/api/src/isms/wizard/isms-profile.controller.spec.ts create mode 100644 apps/api/src/isms/wizard/isms-profile.controller.ts create mode 100644 apps/api/src/isms/wizard/isms-profile.service.spec.ts create mode 100644 apps/api/src/isms/wizard/isms-profile.service.ts create mode 100644 apps/api/src/isms/wizard/merge-answers.spec.ts create mode 100644 apps/api/src/isms/wizard/merge-answers.ts create mode 100644 apps/api/src/isms/wizard/wizard-defaults.spec.ts create mode 100644 apps/api/src/isms/wizard/wizard-defaults.ts create mode 100644 apps/api/src/isms/wizard/wizard-schema.spec.ts create mode 100644 apps/api/src/isms/wizard/wizard-schema.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AddIssueForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/DriftBanner.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesRow.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesTable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsDocumentShell.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/IssueRow.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/IssueRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/IssuesRegister.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipCommitmentRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesFormFields.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesRow.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesRowEditor.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesTable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsRow.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsTable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ScopeClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ScopeClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ScopeForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ScopeStringList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/__test-helpers__/dsMocks.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/interested-party-schema.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/interested-party-schema.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/issue-schema.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/issue-schema.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/leadership-schema.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/leadership-schema.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/objective-schema.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/objective-schema.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/objectives-status.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/requirement-schema.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/requirement-schema.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsAddCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsCardActions.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsDocumentCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsEmptyState.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsPageHeader.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsRegisterCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsRegisterShell.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsRowActions.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsRowActions.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSourceBadge.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsStatusBadge.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSummaryRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/index.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/hooks/exportIsmsDocument.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/hooks/exportIsmsDocument.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIsmsDocument.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIsmsDocument.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIsmsDrift.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIsmsWizard.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/hooks/useIso27001FrameworkId.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/isms-types.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/layout.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/layout.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardCheckboxList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardEditableList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardField.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardObjectivesEditor.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardObjectivesEditor.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardProgress.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardRegulatorSelect.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardStepCertificate.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardStepCommitments.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardStepContent.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardStepLeadership.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardStepOutcomes.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardStepPrivacy.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/WizardStepScope.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/wizard-form-defaults.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/wizard-steps.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/wizard/wizard-types.ts create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/isms-documents/page.tsx create mode 100644 apps/framework-editor/app/(pages)/isms-documents/IsmsControlsCell.tsx create mode 100644 apps/framework-editor/app/(pages)/isms-documents/IsmsDocumentsClientPage.tsx create mode 100644 apps/framework-editor/app/(pages)/isms-documents/IsmsRequirementsCell.tsx create mode 100644 apps/framework-editor/app/(pages)/isms-documents/types.ts create mode 100644 apps/framework-editor/app/(pages)/isms-documents/useIsmsDocumentRows.ts create mode 100644 packages/db/prisma/migrations/20260611000000_isms_foundational_documents/migration.sql create mode 100644 packages/db/prisma/schema/isms.prisma diff --git a/apps/api/package.json b/apps/api/package.json index 77a03743c8..22808299a6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -95,6 +95,7 @@ "better-auth": "^1.4.22", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "docx": "^9.7.1", "dotenv": "^17.2.3", "esbuild": "^0.27.1", "exceljs": "^4.4.0", @@ -102,6 +103,7 @@ "helmet": "^8.1.0", "jose": "^6.0.12", "jspdf": "^4.2.0", + "jspdf-autotable": "^5.0.8", "mammoth": "^1.8.0", "nanoid": "^5.1.6", "pdf-lib": "^1.17.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9582e978b3..cfbfbff011 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -24,6 +24,7 @@ import { VendorsModule } from './vendors/vendors.module'; import { ContextModule } from './context/context.module'; import { TrustPortalModule } from './trust-portal/trust-portal.module'; import { ControlTemplateModule } from './framework-editor/control-template/control-template.module'; +import { IsmsDocumentTemplateModule } from './framework-editor/isms-document-template/isms-document-template.module'; import { FrameworkEditorFrameworkModule } from './framework-editor/framework/framework.module'; import { PolicyTemplateModule } from './framework-editor/policy-template/policy-template.module'; import { RequirementModule } from './framework-editor/requirement/requirement.module'; @@ -34,6 +35,7 @@ import { QuestionnaireModule } from './questionnaire/questionnaire.module'; import { VectorStoreModule } from './vector-store/vector-store.module'; import { KnowledgeBaseModule } from './knowledge-base/knowledge-base.module'; import { SOAModule } from './soa/soa.module'; +import { IsmsModule } from './isms/isms.module'; import { IntegrationPlatformModule } from './integration-platform/integration-platform.module'; import { CloudSecurityModule } from './cloud-security/cloud-security.module'; import { BrowserbaseModule } from './browserbase/browserbase.module'; @@ -95,6 +97,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- HealthModule, TrustPortalModule, ControlTemplateModule, + IsmsDocumentTemplateModule, FrameworkEditorFrameworkModule, PolicyTemplateModule, RequirementModule, @@ -105,6 +108,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- VectorStoreModule, KnowledgeBaseModule, SOAModule, + IsmsModule, IntegrationPlatformModule, CloudSecurityModule, BrowserbaseModule, diff --git a/apps/api/src/framework-editor/isms-document-template/dto/update-isms-document-template.dto.ts b/apps/api/src/framework-editor/isms-document-template/dto/update-isms-document-template.dto.ts new file mode 100644 index 0000000000..6d059e3850 --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/dto/update-isms-document-template.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class UpdateIsmsDocumentTemplateDto { + @ApiPropertyOptional({ example: 'Context of the Organization' }) + @IsString() + @IsOptional() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional({ + example: 'Internal and external issues relevant to the ISMS.', + }) + @IsString() + @IsOptional() + @MaxLength(5000) + description?: string; + + @ApiPropertyOptional({ example: '4.1' }) + @IsString() + @IsOptional() + @MaxLength(32) + clause?: string; + + @ApiPropertyOptional({ example: 0 }) + @IsInt() + @IsOptional() + @Min(0) + sortOrder?: number; +} diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.spec.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.spec.ts new file mode 100644 index 0000000000..3c018760f3 --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.spec.ts @@ -0,0 +1,102 @@ +jest.mock('@db', () => ({ db: {} })); + +import { Test, TestingModule } from '@nestjs/testing'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { IsmsDocumentTemplateController } from './isms-document-template.controller'; +import { IsmsDocumentTemplateService } from './isms-document-template.service'; + +jest.mock('../../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class MockPlatformAdminGuard {}, +})); + +describe('IsmsDocumentTemplateController', () => { + let controller: IsmsDocumentTemplateController; + + const mockService = { + findAll: jest.fn(), + update: jest.fn(), + linkRequirement: jest.fn(), + unlinkRequirement: jest.fn(), + linkControlTemplate: jest.fn(), + unlinkControlTemplate: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IsmsDocumentTemplateController], + providers: [ + { provide: IsmsDocumentTemplateService, useValue: mockService }, + ], + }) + .overrideGuard(PlatformAdminGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(IsmsDocumentTemplateController); + jest.clearAllMocks(); + }); + + it('passes the frameworkId filter to findAll', async () => { + mockService.findAll.mockResolvedValue([]); + + await controller.findAll('fw_1'); + + expect(mockService.findAll).toHaveBeenCalledWith('fw_1'); + }); + + it('passes id and dto to update', async () => { + mockService.update.mockResolvedValue({ id: 'tpl_ctx' }); + + await controller.update('tpl_ctx', { name: 'New' }); + + expect(mockService.update).toHaveBeenCalledWith('tpl_ctx', { name: 'New' }); + }); + + it('maps params + query to linkRequirement', async () => { + mockService.linkRequirement.mockResolvedValue({ message: 'linked' }); + + await controller.linkRequirement('tpl_ctx', 'req_41', 'fw_1'); + + expect(mockService.linkRequirement).toHaveBeenCalledWith({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }); + }); + + it('maps params + query to unlinkRequirement', async () => { + mockService.unlinkRequirement.mockResolvedValue({ message: 'unlinked' }); + + await controller.unlinkRequirement('tpl_ctx', 'req_41', 'fw_1'); + + expect(mockService.unlinkRequirement).toHaveBeenCalledWith({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }); + }); + + it('maps params + query to linkControlTemplate', async () => { + mockService.linkControlTemplate.mockResolvedValue({ message: 'linked' }); + + await controller.linkControlTemplate('tpl_ctx', 'ct_1', 'fw_1'); + + expect(mockService.linkControlTemplate).toHaveBeenCalledWith({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_1', + }); + }); + + it('maps params + query to unlinkControlTemplate', async () => { + mockService.unlinkControlTemplate.mockResolvedValue({ message: 'unlinked' }); + + await controller.unlinkControlTemplate('tpl_ctx', 'ct_1', 'fw_1'); + + expect(mockService.unlinkControlTemplate).toHaveBeenCalledWith({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_1', + }); + }); +}); diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.ts new file mode 100644 index 0000000000..61a6488d41 --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { UpdateIsmsDocumentTemplateDto } from './dto/update-isms-document-template.dto'; +import { IsmsDocumentTemplateService } from './isms-document-template.service'; + +@ApiTags('Framework Editor ISMS Document Templates') +@Controller({ path: 'framework-editor/isms-document-template', version: '1' }) +@UseGuards(PlatformAdminGuard) +export class IsmsDocumentTemplateController { + constructor(private readonly service: IsmsDocumentTemplateService) {} + + @Get() + @ApiOperation({ summary: 'List ISMS document templates' }) + async findAll(@Query('frameworkId') frameworkId?: string) { + return this.service.findAll(frameworkId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an ISMS document template' }) + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async update( + @Param('id') id: string, + @Body() dto: UpdateIsmsDocumentTemplateDto, + ) { + return this.service.update(id, dto); + } + + @Post(':id/requirements/:requirementId') + @ApiOperation({ + summary: 'Link a requirement to an ISMS document template for a framework', + }) + async linkRequirement( + @Param('id') id: string, + @Param('requirementId') requirementId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.linkRequirement({ + templateId: id, + requirementId, + frameworkId, + }); + } + + @Delete(':id/requirements/:requirementId') + @ApiOperation({ + summary: + 'Unlink a requirement from an ISMS document template for a framework', + }) + async unlinkRequirement( + @Param('id') id: string, + @Param('requirementId') requirementId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.unlinkRequirement({ + templateId: id, + requirementId, + frameworkId, + }); + } + + @Post(':id/controls/:controlTemplateId') + @ApiOperation({ + summary: + 'Link a control template to an ISMS document template for a framework', + }) + async linkControlTemplate( + @Param('id') id: string, + @Param('controlTemplateId') controlTemplateId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.linkControlTemplate({ + templateId: id, + controlTemplateId, + frameworkId, + }); + } + + @Delete(':id/controls/:controlTemplateId') + @ApiOperation({ + summary: + 'Unlink a control template from an ISMS document template for a framework', + }) + async unlinkControlTemplate( + @Param('id') id: string, + @Param('controlTemplateId') controlTemplateId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.unlinkControlTemplate({ + templateId: id, + controlTemplateId, + frameworkId, + }); + } +} diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.module.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.module.ts new file mode 100644 index 0000000000..52e09b13f3 --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { IsmsDocumentTemplateController } from './isms-document-template.controller'; +import { IsmsDocumentTemplateService } from './isms-document-template.service'; + +@Module({ + imports: [AuthModule], + controllers: [IsmsDocumentTemplateController], + providers: [IsmsDocumentTemplateService], + exports: [IsmsDocumentTemplateService], +}) +export class IsmsDocumentTemplateModule {} diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.spec.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.spec.ts new file mode 100644 index 0000000000..1d59b4fb9c --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.spec.ts @@ -0,0 +1,294 @@ +jest.mock('@db', () => { + const dbMock = { + frameworkEditorIsmsDocumentTemplate: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + frameworkEditorIsmsDocumentRequirementLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + frameworkEditorControlIsmsDocumentLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + frameworkEditorControlTemplate: { + findUnique: jest.fn(), + }, + frameworkEditorFramework: { + findUnique: jest.fn(), + }, + frameworkEditorRequirement: { + findUnique: jest.fn(), + }, + }; + + return { db: dbMock }; +}); + +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsDocumentTemplateService } from './isms-document-template.service'; + +const mockDb = db as jest.Mocked; + +describe('IsmsDocumentTemplateService', () => { + let service: IsmsDocumentTemplateService; + + beforeEach(() => { + service = new IsmsDocumentTemplateService(); + jest.clearAllMocks(); + ( + mockDb.frameworkEditorIsmsDocumentTemplate.findUnique as jest.Mock + ).mockResolvedValue({ id: 'tpl_ctx' }); + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'fw_1', + }); + ( + mockDb.frameworkEditorRequirement.findUnique as jest.Mock + ).mockResolvedValue({ frameworkId: 'fw_1' }); + ( + mockDb.frameworkEditorIsmsDocumentRequirementLink.createMany as jest.Mock + ).mockResolvedValue({ count: 1 }); + ( + mockDb.frameworkEditorIsmsDocumentRequirementLink.deleteMany as jest.Mock + ).mockResolvedValue({ count: 1 }); + ( + mockDb.frameworkEditorControlTemplate.findUnique as jest.Mock + ).mockResolvedValue({ id: 'ct_1' }); + ( + mockDb.frameworkEditorControlIsmsDocumentLink.createMany as jest.Mock + ).mockResolvedValue({ count: 1 }); + ( + mockDb.frameworkEditorControlIsmsDocumentLink.deleteMany as jest.Mock + ).mockResolvedValue({ count: 1 }); + }); + + describe('findAll', () => { + it('orders by sortOrder and includes all requirement links when no framework filter', async () => { + ( + mockDb.frameworkEditorIsmsDocumentTemplate.findMany as jest.Mock + ).mockResolvedValue([{ id: 'tpl_ctx', requirementLinks: [] }]); + + const result = await service.findAll(); + + expect(result).toEqual([{ id: 'tpl_ctx', requirementLinks: [] }]); + const callArgs = ( + mockDb.frameworkEditorIsmsDocumentTemplate.findMany as jest.Mock + ).mock.calls[0][0]; + expect(callArgs.orderBy).toEqual({ sortOrder: 'asc' }); + expect(callArgs.include.requirementLinks.where).toBeUndefined(); + expect(callArgs.include.controlLinks.where).toBeUndefined(); + }); + + it('scopes requirement and control links to the given framework', async () => { + ( + mockDb.frameworkEditorIsmsDocumentTemplate.findMany as jest.Mock + ).mockResolvedValue([]); + + await service.findAll('fw_1'); + + const callArgs = ( + mockDb.frameworkEditorIsmsDocumentTemplate.findMany as jest.Mock + ).mock.calls[0][0]; + expect(callArgs.include.requirementLinks.where).toEqual({ + frameworkId: 'fw_1', + }); + expect(callArgs.include.controlLinks.where).toEqual({ + frameworkId: 'fw_1', + }); + expect(callArgs.include.controlLinks.select.controlTemplate).toEqual({ + select: { id: true, name: true }, + }); + }); + }); + + describe('update', () => { + it('throws NotFoundException when the template does not exist', async () => { + ( + mockDb.frameworkEditorIsmsDocumentTemplate.findUnique as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.update('tpl_missing', { name: 'x' }), + ).rejects.toThrow(NotFoundException); + }); + + it('persists only the provided fields', async () => { + ( + mockDb.frameworkEditorIsmsDocumentTemplate.update as jest.Mock + ).mockResolvedValue({ id: 'tpl_ctx', name: 'New name' }); + + await service.update('tpl_ctx', { name: 'New name', sortOrder: 3 }); + + const updateArgs = ( + mockDb.frameworkEditorIsmsDocumentTemplate.update as jest.Mock + ).mock.calls[0][0]; + expect(updateArgs).toEqual({ + where: { id: 'tpl_ctx' }, + data: { name: 'New name', sortOrder: 3 }, + }); + }); + }); + + describe('linkRequirement', () => { + it('requires a frameworkId', async () => { + await expect( + service.linkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when the framework is missing', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.linkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_missing', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('rejects a requirement from another framework', async () => { + ( + mockDb.frameworkEditorRequirement.findUnique as jest.Mock + ).mockResolvedValue({ frameworkId: 'fw_other' }); + + await expect( + service.linkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('creates the framework-scoped link idempotently', async () => { + await service.linkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }); + + expect( + mockDb.frameworkEditorIsmsDocumentRequirementLink.createMany, + ).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'fw_1', + ismsDocumentTemplateId: 'tpl_ctx', + requirementId: 'req_41', + }, + ], + skipDuplicates: true, + }); + }); + }); + + describe('unlinkRequirement', () => { + it('deletes the framework-scoped link', async () => { + await service.unlinkRequirement({ + templateId: 'tpl_ctx', + requirementId: 'req_41', + frameworkId: 'fw_1', + }); + + expect( + mockDb.frameworkEditorIsmsDocumentRequirementLink.deleteMany, + ).toHaveBeenCalledWith({ + where: { + frameworkId: 'fw_1', + ismsDocumentTemplateId: 'tpl_ctx', + requirementId: 'req_41', + }, + }); + }); + }); + + describe('linkControlTemplate', () => { + it('requires a frameworkId', async () => { + await expect( + service.linkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when the framework is missing', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.linkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_missing', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when the control template is missing', async () => { + ( + mockDb.frameworkEditorControlTemplate.findUnique as jest.Mock + ).mockResolvedValue(null); + + await expect( + service.linkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_missing', + frameworkId: 'fw_1', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('creates the framework-scoped link idempotently', async () => { + await service.linkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_1', + }); + + expect( + mockDb.frameworkEditorControlIsmsDocumentLink.createMany, + ).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'fw_1', + ismsDocumentTemplateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + }, + ], + skipDuplicates: true, + }); + }); + }); + + describe('unlinkControlTemplate', () => { + it('deletes the framework-scoped link', async () => { + await service.unlinkControlTemplate({ + templateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + frameworkId: 'fw_1', + }); + + expect( + mockDb.frameworkEditorControlIsmsDocumentLink.deleteMany, + ).toHaveBeenCalledWith({ + where: { + frameworkId: 'fw_1', + ismsDocumentTemplateId: 'tpl_ctx', + controlTemplateId: 'ct_1', + }, + }); + }); + }); +}); diff --git a/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.ts b/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.ts new file mode 100644 index 0000000000..883c6fb39e --- /dev/null +++ b/apps/api/src/framework-editor/isms-document-template/isms-document-template.service.ts @@ -0,0 +1,240 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { UpdateIsmsDocumentTemplateDto } from './dto/update-isms-document-template.dto'; + +/** + * CRUD + framework-scoped requirement mapping for the ISMS foundational + * document templates (CS-437). Mirrors ControlTemplateService: the 6 templates + * are enum-fixed (seeded), so this exposes list + update + requirement + * link/unlink rather than create/delete. + */ +@Injectable() +export class IsmsDocumentTemplateService { + private readonly logger = new Logger(IsmsDocumentTemplateService.name); + + async findAll(frameworkId?: string) { + return db.frameworkEditorIsmsDocumentTemplate.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { + requirementLinks: { + ...(frameworkId ? { where: { frameworkId } } : {}), + select: { + id: true, + frameworkId: true, + requirementId: true, + requirement: { + select: { + id: true, + name: true, + identifier: true, + framework: { select: { id: true, name: true } }, + }, + }, + }, + }, + controlLinks: { + ...(frameworkId ? { where: { frameworkId } } : {}), + select: { + id: true, + frameworkId: true, + controlTemplateId: true, + controlTemplate: { select: { id: true, name: true } }, + }, + }, + }, + }); + } + + async update(id: string, dto: UpdateIsmsDocumentTemplateDto) { + await this.requireTemplate(id); + const updated = await db.frameworkEditorIsmsDocumentTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.clause !== undefined && { clause: dto.clause }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }, + }); + this.logger.log(`Updated ISMS document template: ${updated.name} (${id})`); + return updated; + } + + async linkRequirement({ + templateId, + requirementId, + frameworkId, + }: { + templateId: string; + requirementId: string; + frameworkId?: string; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }); + await this.ensureRequirement({ + requirementId, + frameworkId: scopedFrameworkId, + }); + await db.frameworkEditorIsmsDocumentRequirementLink.createMany({ + data: [ + { + frameworkId: scopedFrameworkId, + ismsDocumentTemplateId: templateId, + requirementId, + }, + ], + skipDuplicates: true, + }); + return { message: 'Requirement linked' }; + } + + async unlinkRequirement({ + templateId, + requirementId, + frameworkId, + }: { + templateId: string; + requirementId: string; + frameworkId?: string; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }); + await db.frameworkEditorIsmsDocumentRequirementLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + ismsDocumentTemplateId: templateId, + requirementId, + }, + }); + return { message: 'Requirement unlinked' }; + } + + async linkControlTemplate({ + templateId, + controlTemplateId, + frameworkId, + }: { + templateId: string; + controlTemplateId: string; + frameworkId?: string; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }); + await this.ensureControlTemplate(controlTemplateId); + await db.frameworkEditorControlIsmsDocumentLink.createMany({ + data: [ + { + frameworkId: scopedFrameworkId, + ismsDocumentTemplateId: templateId, + controlTemplateId, + }, + ], + skipDuplicates: true, + }); + return { message: 'Control template linked' }; + } + + async unlinkControlTemplate({ + templateId, + controlTemplateId, + frameworkId, + }: { + templateId: string; + controlTemplateId: string; + frameworkId?: string; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }); + await db.frameworkEditorControlIsmsDocumentLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + ismsDocumentTemplateId: templateId, + controlTemplateId, + }, + }); + return { message: 'Control template unlinked' }; + } + + private async requireTemplate(id: string) { + const template = await db.frameworkEditorIsmsDocumentTemplate.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!template) { + throw new NotFoundException(`ISMS document template ${id} not found`); + } + return template; + } + + private async ensureFrameworkScopedTemplate({ + templateId, + frameworkId, + }: { + templateId: string; + frameworkId?: string; + }): Promise { + const scopedFrameworkId = await this.ensureFramework(frameworkId); + await this.requireTemplate(templateId); + return scopedFrameworkId; + } + + private async ensureFramework(frameworkId?: string): Promise { + if (!frameworkId) { + throw new BadRequestException( + 'frameworkId is required to map a requirement', + ); + } + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { id: true }, + }); + if (!framework) throw new NotFoundException('Framework not found'); + return frameworkId; + } + + private async ensureRequirement({ + requirementId, + frameworkId, + }: { + requirementId: string; + frameworkId: string; + }): Promise { + const requirement = await db.frameworkEditorRequirement.findUnique({ + where: { id: requirementId }, + select: { frameworkId: true }, + }); + if (!requirement) { + throw new NotFoundException(`Requirement ${requirementId} not found`); + } + if (requirement.frameworkId !== frameworkId) { + throw new BadRequestException( + 'Requirement does not belong to the given framework', + ); + } + } + + private async ensureControlTemplate(controlTemplateId: string): Promise { + const control = await db.frameworkEditorControlTemplate.findUnique({ + where: { id: controlTemplateId }, + select: { id: true }, + }); + if (!control) { + throw new NotFoundException( + `Control template ${controlTemplateId} not found`, + ); + } + } +} diff --git a/apps/api/src/isms/documents/context.spec.ts b/apps/api/src/isms/documents/context.spec.ts new file mode 100644 index 0000000000..d1ce7c3f4c --- /dev/null +++ b/apps/api/src/isms/documents/context.spec.ts @@ -0,0 +1,138 @@ +import { buildContextSections } from './context'; +import type { DocumentExportInput } from './types'; + +const input: DocumentExportInput = { + contextIssues: [ + // Deliberately out of category order to prove the section sorts them. + { + kind: 'external', + category: 'Technological', + description: 'Reliance on cloud vendors', + effect: 'Extends the ISMS boundary', + }, + { + kind: 'external', + category: 'Regulatory & Legal', + description: 'ISO 27001 obligations', + effect: 'Shapes ISMS objectives and audit scope', + }, + { + kind: 'internal', + category: 'Capabilities & Resources', + description: 'Managed endpoints', + effect: 'Drives device-management controls', + }, + { + kind: 'internal', + category: 'Governance & Structure', + description: 'A workforce of 12 members', + effect: 'Determines awareness and access needs', + }, + ], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + orgProfile: { + overview: [ + { label: 'Legal entity', value: 'Acme Inc' }, + { label: 'Website', value: 'https://acme.io' }, + { label: 'Industry', value: 'SaaS' }, + ], + mission: 'We build secure compliance tooling.', + intendedOutcomes: [ + 'Protect the confidentiality, integrity and availability of information.', + 'Meet legal and contractual obligations.', + ], + }, +}; + +describe('buildContextSections', () => { + it('returns the full 7-section structure with numbered headings', () => { + const sections = buildContextSections(input); + expect(sections.map((s) => s.heading)).toEqual([ + '1. Purpose', + '2. Organization overview', + '3. Mission and intended outcomes of the ISMS', + '4. External issues (4.1)', + '5. Internal issues (4.1)', + '6. Linkage to the ISMS', + '7. Review', + ]); + }); + + it('uses the legal entity name in the purpose narrative', () => { + const sections = buildContextSections(input); + expect(sections[0].paragraphs?.[0].text).toContain('Acme Inc'); + }); + + it('renders the organization overview as key/value rows', () => { + const overview = buildContextSections(input)[1]; + expect(overview.keyValues).toEqual(input.orgProfile?.overview); + }); + + it('renders the mission and intended-outcome bullets in section 3', () => { + const mission = buildContextSections(input)[2]; + expect(mission.paragraphs?.some((p) => p.text === '3.1 Mission')).toBe(true); + expect( + mission.paragraphs?.some( + (p) => p.text === 'We build secure compliance tooling.', + ), + ).toBe(true); + expect(mission.bullets).toEqual(input.orgProfile?.intendedOutcomes); + }); + + it('builds the external issues table with the categorised headers', () => { + const external = buildContextSections(input)[3]; + expect(external.table?.headers).toEqual([ + 'Category', + 'External issue', + 'Effect on the ability to achieve ISMS objectives', + ]); + }); + + it('sorts external issue rows by category and includes the category value', () => { + const external = buildContextSections(input)[3]; + expect(external.table?.rows).toEqual([ + [ + 'Regulatory & Legal', + 'ISO 27001 obligations', + 'Shapes ISMS objectives and audit scope', + ], + [ + 'Technological', + 'Reliance on cloud vendors', + 'Extends the ISMS boundary', + ], + ]); + }); + + it('builds the internal issues table sorted by category', () => { + const internal = buildContextSections(input)[4]; + expect(internal.table?.headers).toEqual([ + 'Category', + 'Internal issue', + 'Effect on the ability to achieve ISMS objectives', + ]); + expect(internal.table?.rows.map((row) => row[0])).toEqual([ + 'Governance & Structure', + 'Capabilities & Resources', + ]); + }); + + it('provides linkage bullets in section 6', () => { + const linkage = buildContextSections(input)[5]; + expect(linkage.bullets?.length).toBeGreaterThan(0); + expect(linkage.bullets?.some((b) => b.includes('Clause 4.2'))).toBe(true); + }); + + it('falls back gracefully when no org profile is supplied', () => { + const sections = buildContextSections({ + ...input, + orgProfile: undefined, + }); + expect(sections).toHaveLength(7); + expect(sections[1].keyValues).toEqual([]); + expect(sections[0].paragraphs?.[0].text).toContain('the organization'); + }); +}); diff --git a/apps/api/src/isms/documents/context.ts b/apps/api/src/isms/documents/context.ts new file mode 100644 index 0000000000..a451917479 --- /dev/null +++ b/apps/api/src/isms/documents/context.ts @@ -0,0 +1,182 @@ +import { + deriveContextIssues, + EXTERNAL_ISSUE_CATEGORIES, + INTERNAL_ISSUE_CATEGORIES, + type ContextDerivationInput, +} from '../utils/context-derivation'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +/** Adapt the shared platform data to the 4.1 derivation input. */ +function toContextInput(data: IsmsPlatformData): ContextDerivationInput { + return { + frameworkNames: data.frameworkNames, + vendorCount: data.vendorCount, + subProcessorCount: data.subProcessorCount, + vendorsByCategory: data.vendorsByCategory, + memberCount: data.memberCount, + membersByDepartment: data.membersByDepartment, + deviceCount: data.deviceCount, + }; +} + +/** Re-export the 4.1 derivation under the per-document module. */ +export function deriveContextOfOrganization(data: IsmsPlatformData) { + return deriveContextIssues(toContextInput(data)); +} + +const STANDARD = 'ISO/IEC 27001:2022'; + +type ContextIssue = DocumentExportInput['contextIssues'][number]; + +function orgNameFrom(input: DocumentExportInput): string { + const entry = input.orgProfile?.overview.find( + (row) => row.label === 'Legal entity', + ); + return entry?.value || 'the organization'; +} + +function purposeSection(orgName: string): IsmsExportSection { + return { + heading: '1. Purpose', + paragraphs: [ + { + text: `This document determines the external and internal issues relevant to the purpose of ${orgName} that affect its ability to achieve the intended outcomes of its Information Security Management System (ISMS), in accordance with ${STANDARD}, Clause 4.1.`, + }, + { + text: "For each issue, the effect on the organization's ability to achieve the objectives of its ISMS is stated explicitly. The document is reviewed at least annually and whenever a material change occurs (strategy, technology stack, regulatory environment, key personnel, or significant incidents).", + }, + ], + }; +} + +function overviewSection(input: DocumentExportInput): IsmsExportSection { + return { + heading: '2. Organization overview', + keyValues: input.orgProfile?.overview ?? [], + emptyText: 'Organization details have not been captured yet.', + }; +} + +function missionSection(input: DocumentExportInput): IsmsExportSection { + const profile = input.orgProfile; + const mission = profile?.mission ?? null; + const intendedOutcomes = profile?.intendedOutcomes ?? []; + + const paragraphs: IsmsExportSection['paragraphs'] = []; + if (mission) { + paragraphs.push({ text: '3.1 Mission', bold: true }); + paragraphs.push({ text: mission }); + } + if (intendedOutcomes.length > 0) { + paragraphs.push({ text: '3.2 Intended outcomes of the ISMS', bold: true }); + } + + return { + heading: '3. Mission and intended outcomes of the ISMS', + paragraphs, + bullets: intendedOutcomes.length > 0 ? intendedOutcomes : undefined, + emptyText: 'No mission or intended outcomes recorded.', + }; +} + +function issuesSection({ + heading, + intro, + issueColumnLabel, + categories, + issues, +}: { + heading: string; + intro: string; + issueColumnLabel: string; + categories: readonly string[]; + issues: ContextIssue[]; +}): IsmsExportSection { + const order = new Map(categories.map((category, index) => [category, index])); + const sorted = [...issues].sort( + (a, b) => + (order.get(a.category ?? '') ?? categories.length) - + (order.get(b.category ?? '') ?? categories.length), + ); + + return { + heading, + intro, + emptyText: 'No issues recorded.', + table: { + headers: [ + 'Category', + issueColumnLabel, + 'Effect on the ability to achieve ISMS objectives', + ], + rows: sorted.map((issue) => [ + issue.category ?? '—', + issue.description, + issue.effect, + ]), + }, + }; +} + +function linkageSection(): IsmsExportSection { + return { + heading: '6. Linkage to the ISMS', + intro: 'The issues above feed directly into:', + bullets: [ + 'Clause 4.2 — Interested parties and their requirements.', + 'Clause 4.3 — ISMS scope, including interfaces and dependencies with cloud providers and other sub-processors.', + 'Clause 5 — Leadership, policy, and roles & responsibilities.', + 'Clause 6.1 — Information security risk assessment and risk treatment.', + 'Clause 9.3 — Management review inputs (changes to external and internal issues).', + ], + }; +} + +function reviewSection(): IsmsExportSection { + return { + heading: '7. Review', + paragraphs: [ + { + text: 'Owner: the Security & Privacy Owner. The document is reviewed at least annually and on material change. Outputs feed the Management Review per Clause 9.3.', + }, + ], + }; +} + +/** + * Build the Context of the Organization (clause 4.1) export as the full + * auditor-ready document: purpose, organization overview, mission & intended + * outcomes, the categorised external/internal issue tables (each with the + * "effect on ISMS objectives" column), linkage to the ISMS, and review. + */ +export function buildContextSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const external = input.contextIssues.filter((i) => i.kind === 'external'); + const internal = input.contextIssues.filter((i) => i.kind === 'internal'); + + return [ + purposeSection(orgNameFrom(input)), + overviewSection(input), + missionSection(input), + issuesSection({ + heading: '4. External issues (4.1)', + intro: + 'External issues are organised under the categories of regulatory & legal, market & economic, technological, and social & cultural. For each, the effect on the ability to achieve the ISMS objectives is stated.', + issueColumnLabel: 'External issue', + categories: EXTERNAL_ISSUE_CATEGORIES, + issues: external, + }), + issuesSection({ + heading: '5. Internal issues (4.1)', + intro: + 'Internal issues are organised under the categories of governance & structure, strategy & objectives, capabilities & resources, and culture & values.', + issueColumnLabel: 'Internal issue', + categories: INTERNAL_ISSUE_CATEGORIES, + issues: internal, + }), + linkageSection(), + reviewSection(), + ]; +} diff --git a/apps/api/src/isms/documents/data-source.spec.ts b/apps/api/src/isms/documents/data-source.spec.ts new file mode 100644 index 0000000000..279dd5420d --- /dev/null +++ b/apps/api/src/isms/documents/data-source.spec.ts @@ -0,0 +1,192 @@ +// Mock @db before importing the unit under test so collectPlatformData reads +// from these fakes. We only stub the table methods the function actually calls. +const mockDb = { + organization: { findUnique: jest.fn() }, + frameworkInstance: { findMany: jest.fn() }, + vendor: { findMany: jest.fn() }, + member: { count: jest.fn(), groupBy: jest.fn() }, + device: { count: jest.fn() }, + risk: { findMany: jest.fn() }, + employeeTrainingVideoCompletion: { count: jest.fn() }, + frameworkEditorFramework: { findUnique: jest.fn() }, + ismsProfile: { findUnique: jest.fn() }, + ismsInterestedParty: { findMany: jest.fn() }, +}; + +jest.mock('@db', () => ({ db: mockDb })); + +import { collectPlatformData } from './data-source'; + +type VendorRow = { name: string; category: string; isSubProcessor: boolean }; +type RiskRow = { residualLikelihood: string; residualImpact: string }; +type PartyRow = { id: string; name: string; category: string }; + +const ARGS = { organizationId: 'org_1', frameworkId: 'fw_1' }; + +function seedDb({ + vendors = [], + membersGrouped = [], + risks = [], + parties = [], + memberCount = 0, + deviceCount = 0, + trainingCount = 0, +}: { + vendors?: VendorRow[]; + membersGrouped?: Array<{ department: string; _count: { _all: number } }>; + risks?: RiskRow[]; + parties?: PartyRow[]; + memberCount?: number; + deviceCount?: number; + trainingCount?: number; +}) { + mockDb.organization.findUnique.mockResolvedValue({ name: ' Acme Corp ' }); + mockDb.frameworkInstance.findMany.mockResolvedValue([ + { framework: { name: 'SOC 2' } }, + ]); + mockDb.vendor.findMany.mockResolvedValue(vendors); + mockDb.member.count.mockResolvedValue(memberCount); + mockDb.member.groupBy.mockResolvedValue(membersGrouped); + mockDb.device.count.mockResolvedValue(deviceCount); + mockDb.risk.findMany.mockResolvedValue(risks); + mockDb.employeeTrainingVideoCompletion.count.mockResolvedValue(trainingCount); + mockDb.frameworkEditorFramework.findUnique.mockResolvedValue({ + name: 'ISO 27001', + }); + mockDb.ismsProfile.findUnique.mockResolvedValue({ answers: {} }); + mockDb.ismsInterestedParty.findMany.mockResolvedValue(parties); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('collectPlatformData', () => { + it('counts only high-likelihood AND high-impact risks as high risk', async () => { + seedDb({ + risks: [ + { residualLikelihood: 'likely', residualImpact: 'major' }, // high+high -> counts + { residualLikelihood: 'very_likely', residualImpact: 'severe' }, // counts + { residualLikelihood: 'very_likely', residualImpact: 'minor' }, // high likelihood only + { residualLikelihood: 'unlikely', residualImpact: 'severe' }, // high impact only + { residualLikelihood: 'unlikely', residualImpact: 'minor' }, // neither + ], + }); + + const data = await collectPlatformData(ARGS); + + expect(data.riskCount).toBe(5); + expect(data.highRiskCount).toBe(2); + }); + + it('groups vendors by category and tracks sub-processors and infra vendors', async () => { + seedDb({ + vendors: [ + { name: 'AWS', category: 'cloud', isSubProcessor: true }, + { name: 'GCP', category: 'infrastructure', isSubProcessor: false }, + { name: 'Stripe', category: 'software_as_a_service', isSubProcessor: true }, + { name: 'Acme HR', category: 'hr', isSubProcessor: false }, + ], + }); + + const data = await collectPlatformData(ARGS); + + expect(data.vendorCount).toBe(4); + expect(data.vendorsByCategory).toEqual({ + cloud: 1, + infrastructure: 1, + software_as_a_service: 1, + hr: 1, + }); + // sub-processors are AWS + Stripe, returned sorted. + expect(data.subProcessorCount).toBe(2); + expect(data.subProcessorNames).toEqual(['AWS', 'Stripe']); + // infra/cloud categories only (not the hr vendor), sorted. + expect(data.infraVendorNames).toEqual(['AWS', 'GCP', 'Stripe']); + }); + + it('groups members by department from the groupBy aggregation', async () => { + seedDb({ + memberCount: 5, + membersGrouped: [ + { department: 'it', _count: { _all: 3 } }, + { department: 'hr', _count: { _all: 2 } }, + ], + }); + + const data = await collectPlatformData(ARGS); + + expect(data.memberCount).toBe(5); + expect(data.membersByDepartment).toEqual({ it: 3, hr: 2 }); + }); + + it('merges framework instance names with the requested framework, sorted', async () => { + seedDb({}); + + const data = await collectPlatformData(ARGS); + + // SOC 2 (instance) + ISO 27001 (ownFramework), de-duped and sorted. + expect(data.frameworkNames).toEqual(['ISO 27001', 'SOC 2']); + }); + + it('trims the organization name and flags the training program', async () => { + seedDb({ trainingCount: 4 }); + + const data = await collectPlatformData(ARGS); + + expect(data.organizationName).toBe('Acme Corp'); + expect(data.hasTrainingProgram).toBe(true); + }); + + it('falls back to a default org name and no training when empty', async () => { + seedDb({ trainingCount: 0 }); + mockDb.organization.findUnique.mockResolvedValue({ name: ' ' }); + + const data = await collectPlatformData(ARGS); + + expect(data.organizationName).toBe('The organization'); + expect(data.hasTrainingProgram).toBe(false); + }); + + it('produces an order-insensitive parties fingerprint', async () => { + const partiesA: PartyRow[] = [ + { id: 'p1', name: 'Customers', category: 'external' }, + { id: 'p2', name: 'Regulators', category: 'external' }, + ]; + const partiesReordered: PartyRow[] = [partiesA[1], partiesA[0]]; + + seedDb({ parties: partiesA }); + const a = await collectPlatformData(ARGS); + + seedDb({ parties: partiesReordered }); + const b = await collectPlatformData(ARGS); + + expect(a.partiesFingerprint).toEqual(b.partiesFingerprint); + expect(a.partiesFingerprint).not.toBe(''); + }); + + it('changes the fingerprint when a party is edited', async () => { + const original: PartyRow[] = [ + { id: 'p1', name: 'Customers', category: 'external' }, + ]; + const edited: PartyRow[] = [ + { id: 'p1', name: 'Customers (key accounts)', category: 'external' }, + ]; + + seedDb({ parties: original }); + const before = await collectPlatformData(ARGS); + + seedDb({ parties: edited }); + const after = await collectPlatformData(ARGS); + + expect(after.partiesFingerprint).not.toBe(before.partiesFingerprint); + }); + + it('returns an empty fingerprint when no parties exist', async () => { + seedDb({ parties: [] }); + + const data = await collectPlatformData(ARGS); + + expect(data.partiesFingerprint).toBe(''); + }); +}); diff --git a/apps/api/src/isms/documents/data-source.ts b/apps/api/src/isms/documents/data-source.ts new file mode 100644 index 0000000000..f8db007e40 --- /dev/null +++ b/apps/api/src/isms/documents/data-source.ts @@ -0,0 +1,147 @@ +import { createHash } from 'node:crypto'; +import { db } from '@db'; +import { parseStoredAnswers } from '../wizard/wizard-schema'; +import type { IsmsPlatformData } from './types'; + +const CLOUD_CATEGORIES = ['cloud', 'infrastructure', 'software_as_a_service']; +const HIGH_LIKELIHOOD = ['likely', 'very_likely']; +const HIGH_IMPACT = ['major', 'severe']; + +/** + * Reads all platform data used to derive the ISMS foundational documents for a + * single organization. Always scoped by organizationId. The returned shape is + * the raw snapshot — derivation logic lives in the per-document handlers so this + * file only owns the queries. + */ +export async function collectPlatformData({ + organizationId, + frameworkId, +}: { + organizationId: string; + frameworkId: string; +}): Promise { + const [ + organization, + frameworkInstances, + vendors, + memberCount, + membersGrouped, + deviceCount, + risks, + trainingCompletionCount, + ownFramework, + profile, + partiesRows, + ] = await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }), + db.frameworkInstance.findMany({ + where: { organizationId }, + select: { framework: { select: { name: true } } }, + }), + db.vendor.findMany({ + where: { organizationId }, + select: { name: true, category: true, isSubProcessor: true }, + }), + db.member.count({ where: { organizationId, deactivated: false } }), + db.member.groupBy({ + by: ['department'], + where: { organizationId, deactivated: false }, + _count: { _all: true }, + }), + db.device.count({ where: { organizationId } }), + db.risk.findMany({ + where: { organizationId }, + select: { residualLikelihood: true, residualImpact: true }, + }), + db.employeeTrainingVideoCompletion.count({ + where: { member: { organizationId } }, + }), + db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { name: true }, + }), + db.ismsProfile.findUnique({ + where: { organizationId_frameworkId: { organizationId, frameworkId } }, + select: { answers: true }, + }), + db.ismsInterestedParty.findMany({ + where: { + document: { + organizationId, + frameworkId, + type: 'interested_parties_register', + }, + }, + select: { id: true, name: true, category: true }, + }), + ]); + + const frameworkNames = new Set(); + for (const instance of frameworkInstances) { + if (instance.framework?.name) frameworkNames.add(instance.framework.name); + } + if (ownFramework?.name) frameworkNames.add(ownFramework.name); + + const vendorsByCategory: Record = {}; + const subProcessorNames: string[] = []; + const infraVendorNames: string[] = []; + for (const vendor of vendors) { + vendorsByCategory[vendor.category] = + (vendorsByCategory[vendor.category] ?? 0) + 1; + if (vendor.isSubProcessor) subProcessorNames.push(vendor.name); + if (CLOUD_CATEGORIES.includes(vendor.category)) { + infraVendorNames.push(vendor.name); + } + } + + const membersByDepartment: Record = {}; + for (const row of membersGrouped) { + membersByDepartment[row.department] = row._count._all; + } + + const highRiskCount = risks.filter( + (risk) => + HIGH_LIKELIHOOD.includes(risk.residualLikelihood) && + HIGH_IMPACT.includes(risk.residualImpact), + ).length; + + return { + organizationName: organization?.name?.trim() || 'The organization', + frameworkNames: Array.from(frameworkNames).sort(), + vendorCount: vendors.length, + subProcessorCount: subProcessorNames.length, + vendorsByCategory, + subProcessorNames: subProcessorNames.sort(), + infraVendorNames: infraVendorNames.sort(), + memberCount, + membersByDepartment, + deviceCount, + riskCount: risks.length, + highRiskCount, + hasTrainingProgram: trainingCompletionCount > 0, + wizardAnswers: parseStoredAnswers(profile?.answers), + partiesFingerprint: fingerprintParties(partiesRows), + }; +} + +/** + * Stable, order-insensitive SHA-256 of the parties register rows. The + * Requirements document derives one row per party, so a manual party edit (name + * or category) — otherwise invisible to the platform snapshot — must change this + * fingerprint and flag requirements drift. Each row is JSON-encoded (so field + * boundaries can never collide) and the encoded rows are sorted, making the + * result independent of row order. + */ +function fingerprintParties( + rows: Array<{ id: string; name: string; category: string }>, +): string { + if (rows.length === 0) return ''; + const canonical = rows + .map((row) => JSON.stringify([row.id, row.name, row.category])) + .sort() + .join(''); + return createHash('sha256').update(canonical).digest('hex'); +} diff --git a/apps/api/src/isms/documents/generate.spec.ts b/apps/api/src/isms/documents/generate.spec.ts new file mode 100644 index 0000000000..d4eb81cdec --- /dev/null +++ b/apps/api/src/isms/documents/generate.spec.ts @@ -0,0 +1,155 @@ +import type { Prisma } from '@db'; +import { runDerivation } from './generate'; +import type { IsmsPlatformData } from './types'; + +/** Treat a hand-rolled mock as a transaction client without an `as any` cast. */ +function asTx(mock: unknown): Prisma.TransactionClient { + return mock as Prisma.TransactionClient; +} + +const data: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +function registerTable() { + return { + deleteMany: jest.fn().mockResolvedValue({}), + count: jest.fn().mockResolvedValue(2), // two manual rows preserved + createMany: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }; +} + +function buildTx() { + return { + ismsContextIssue: registerTable(), + ismsInterestedParty: registerTable(), + ismsInterestedPartyRequirement: registerTable(), + ismsObjective: registerTable(), + ismsDocument: { findFirst: jest.fn().mockResolvedValue(null) }, + ismsDocumentVersion: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + }; +} + +const baseArgs = { + documentId: 'doc_1', + organizationId: 'org_1', + frameworkId: 'fw_1', + data, +}; + +describe('runDerivation', () => { + it('writes context issues after preserved manual rows', async () => { + const tx = buildTx(); + await runDerivation({ + tx: asTx(tx), + type: 'context_of_organization', + ...baseArgs, + }); + expect(tx.ismsContextIssue.deleteMany).toHaveBeenCalledWith({ + where: { documentId: 'doc_1', source: 'derived' }, + }); + const created = tx.ismsContextIssue.createMany.mock.calls[0][0].data; + expect(created[0].position).toBe(2); + }); + + it('writes interested parties', async () => { + const tx = buildTx(); + await runDerivation({ + tx: asTx(tx), + type: 'interested_parties_register', + ...baseArgs, + }); + expect(tx.ismsInterestedParty.createMany).toHaveBeenCalled(); + const created = tx.ismsInterestedParty.createMany.mock.calls[0][0].data; + expect(created[0].position).toBe(2); + }); + + it('threads wizard answers into the interested-parties register (CS-438)', async () => { + const tx = buildTx(); + await runDerivation({ + tx: asTx(tx), + type: 'interested_parties_register', + ...baseArgs, + data: { + ...data, + wizardAnswers: { insurance: { has: true, insurerName: 'Acme Cyber' } }, + }, + }); + const created = tx.ismsInterestedParty.createMany.mock.calls[0][0].data; + expect( + created.some( + (row: { derivedFrom: string }) => + row.derivedFrom === 'wizard:insurance', + ), + ).toBe(true); + }); + + it('reads the register doc to derive requirements', async () => { + const tx = buildTx(); + tx.ismsDocument.findFirst.mockResolvedValue({ id: 'reg_1' }); + tx.ismsInterestedParty.findMany.mockResolvedValue([ + { id: 'ip_1', name: 'Customers', category: 'Customer' }, + ]); + + await runDerivation({ + tx: asTx(tx), + type: 'interested_parties_requirements', + ...baseArgs, + }); + expect(tx.ismsDocument.findFirst).toHaveBeenCalledWith({ + where: { + organizationId: 'org_1', + frameworkId: 'fw_1', + type: 'interested_parties_register', + }, + select: { id: true }, + }); + expect(tx.ismsInterestedPartyRequirement.createMany).toHaveBeenCalled(); + }); + + it('writes objectives', async () => { + const tx = buildTx(); + await runDerivation({ tx: asTx(tx), type: 'objectives_plan', ...baseArgs }); + expect(tx.ismsObjective.createMany).toHaveBeenCalled(); + }); + + it('creates a version with the derived narrative for isms_scope', async () => { + const tx = buildTx(); + await runDerivation({ tx: asTx(tx), type: 'isms_scope', ...baseArgs }); + expect(tx.ismsDocumentVersion.create).toHaveBeenCalled(); + const created = tx.ismsDocumentVersion.create.mock.calls[0][0].data; + expect(created.narrative.certificateScopeSentence).toBeDefined(); + }); + + it('updates the existing version narrative for leadership', async () => { + const tx = buildTx(); + tx.ismsDocumentVersion.findFirst.mockResolvedValue({ id: 'ver_1' }); + await runDerivation({ + tx: asTx(tx), + type: 'leadership_commitment', + ...baseArgs, + }); + expect(tx.ismsDocumentVersion.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'ver_1' } }), + ); + }); +}); diff --git a/apps/api/src/isms/documents/generate.ts b/apps/api/src/isms/documents/generate.ts new file mode 100644 index 0000000000..4554fbe2b9 --- /dev/null +++ b/apps/api/src/isms/documents/generate.ts @@ -0,0 +1,261 @@ +import { BadRequestException } from '@nestjs/common'; +import type { IsmsDocumentType, Prisma } from '@db'; +import { deriveContextOfOrganization } from './context'; +import { deriveInterestedParties } from './interested-parties'; +import { deriveRequirements } from './requirements'; +import { deriveObjectives } from './objectives'; +import { deriveNarrativeForType, isNarrativeType } from './registry'; +import type { IsmsPlatformData } from './types'; + +type Tx = Prisma.TransactionClient; + +/** + * Replace the derived rows of a register, preserving manual rows and appending the + * derived rows after them (same pattern as the 4.1 context register). + */ +async function replaceDerivedRows({ + derived, + deleteDerived, + countManual, + createMany, +}: { + derived: T[]; + deleteDerived: () => Promise; + countManual: () => Promise; + createMany: (args: { rows: T[]; manualCount: number }) => Promise; +}): Promise { + await deleteDerived(); + const manualCount = await countManual(); + if (derived.length > 0) { + await createMany({ rows: derived, manualCount }); + } +} + +async function generateInterestedParties({ + tx, + documentId, + data, +}: { + tx: Tx; + documentId: string; + data: IsmsPlatformData; +}): Promise { + const derived = deriveInterestedParties(data); + await replaceDerivedRows({ + derived, + deleteDerived: () => + tx.ismsInterestedParty.deleteMany({ + where: { documentId, source: 'derived' }, + }), + countManual: () => + tx.ismsInterestedParty.count({ where: { documentId, source: 'manual' } }), + createMany: ({ rows, manualCount }) => + tx.ismsInterestedParty.createMany({ + data: rows.map((row, index) => ({ + documentId, + name: row.name, + category: row.category, + needsExpectations: row.needsExpectations, + source: row.source, + derivedFrom: row.derivedFrom, + position: manualCount + index, + })), + }), + }); +} + +async function generateRequirements({ + tx, + documentId, + organizationId, + frameworkId, + data, +}: { + tx: Tx; + documentId: string; + organizationId: string; + frameworkId: string; + data: IsmsPlatformData; +}): Promise { + const registerDoc = await tx.ismsDocument.findFirst({ + where: { organizationId, frameworkId, type: 'interested_parties_register' }, + select: { id: true }, + }); + const parties = registerDoc + ? await tx.ismsInterestedParty.findMany({ + where: { documentId: registerDoc.id }, + orderBy: { position: 'asc' }, + select: { id: true, name: true, category: true }, + }) + : []; + + const derived = deriveRequirements({ parties, data }); + await replaceDerivedRows({ + derived, + deleteDerived: () => + tx.ismsInterestedPartyRequirement.deleteMany({ + where: { documentId, source: 'derived' }, + }), + countManual: () => + tx.ismsInterestedPartyRequirement.count({ + where: { documentId, source: 'manual' }, + }), + createMany: ({ rows, manualCount }) => + tx.ismsInterestedPartyRequirement.createMany({ + data: rows.map((row, index) => ({ + documentId, + interestedPartyId: row.interestedPartyId, + partyName: row.partyName, + requirement: row.requirement, + treatment: row.treatment, + source: row.source, + derivedFrom: row.derivedFrom, + position: manualCount + index, + })), + }), + }); +} + +async function generateObjectives({ + tx, + documentId, + data, +}: { + tx: Tx; + documentId: string; + data: IsmsPlatformData; +}): Promise { + const derived = deriveObjectives(data); + await replaceDerivedRows({ + derived, + deleteDerived: () => + tx.ismsObjective.deleteMany({ where: { documentId, source: 'derived' } }), + countManual: () => + tx.ismsObjective.count({ where: { documentId, source: 'manual' } }), + createMany: ({ rows, manualCount }) => + tx.ismsObjective.createMany({ + data: rows.map((row, index) => ({ + documentId, + objective: row.objective, + target: row.target, + cadence: row.cadence, + plan: row.plan, + measurementMethod: row.measurementMethod, + source: row.source, + derivedFrom: row.derivedFrom, + position: manualCount + index, + })), + }), + }); +} + +/** True when a stored narrative actually holds content (not null/undefined or {}). */ +function hasNarrativeContent(narrative: unknown): boolean { + return ( + narrative != null && + typeof narrative === 'object' && + !Array.isArray(narrative) && + Object.keys(narrative).length > 0 + ); +} + +async function generateNarrative({ + tx, + documentId, + type, + data, +}: { + tx: Tx; + documentId: string; + type: IsmsDocumentType; + data: IsmsPlatformData; +}): Promise { + const derived = deriveNarrativeForType({ type, data }); + if (!derived) return; + const narrative: Prisma.InputJsonValue = JSON.parse(JSON.stringify(derived)); + + const latest = await tx.ismsDocumentVersion.findFirst({ + where: { documentId, isLatest: true }, + }); + if (latest) { + // Preserve a non-empty narrative so a regenerate never clobbers the + // customer's manual edits (CS-437 override). An absent or empty ({}) value — + // e.g. a snapshot-only version — is still seeded with the derived narrative. + if (hasNarrativeContent(latest.narrative)) return; + await tx.ismsDocumentVersion.update({ + where: { id: latest.id }, + data: { narrative }, + }); + return; + } + await tx.ismsDocumentVersion.create({ + data: { documentId, version: 1, isLatest: true, narrative }, + }); +} + +/** Run the type-specific derivation inside an open transaction. */ +export async function runDerivation({ + tx, + type, + documentId, + organizationId, + frameworkId, + data, +}: { + tx: Tx; + type: IsmsDocumentType; + documentId: string; + organizationId: string; + frameworkId: string; + data: IsmsPlatformData; +}): Promise { + if (type === 'context_of_organization') { + const derived = deriveContextOfOrganization(data); + await replaceDerivedRows({ + derived, + deleteDerived: () => + tx.ismsContextIssue.deleteMany({ + where: { documentId, source: 'derived' }, + }), + countManual: () => + tx.ismsContextIssue.count({ where: { documentId, source: 'manual' } }), + createMany: ({ rows, manualCount }) => + tx.ismsContextIssue.createMany({ + data: rows.map((row, index) => ({ + documentId, + kind: row.kind, + category: row.category, + description: row.description, + effect: row.effect, + source: row.source, + derivedFrom: row.derivedFrom, + position: manualCount + index, + })), + }), + }); + return; + } + if (type === 'interested_parties_register') { + await generateInterestedParties({ tx, documentId, data }); + return; + } + if (type === 'interested_parties_requirements') { + await generateRequirements({ + tx, + documentId, + organizationId, + frameworkId, + data, + }); + return; + } + if (type === 'objectives_plan') { + await generateObjectives({ tx, documentId, data }); + return; + } + if (isNarrativeType(type)) { + await generateNarrative({ tx, documentId, type, data }); + return; + } + throw new BadRequestException(`Generation not implemented for type ${type}`); +} diff --git a/apps/api/src/isms/documents/interested-parties.spec.ts b/apps/api/src/isms/documents/interested-parties.spec.ts new file mode 100644 index 0000000000..aa46fea46a --- /dev/null +++ b/apps/api/src/isms/documents/interested-parties.spec.ts @@ -0,0 +1,144 @@ +import { + buildInterestedPartiesSections, + deriveInterestedParties, +} from './interested-parties'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +const data: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001', 'GDPR'], + vendorCount: 4, + subProcessorCount: 2, + vendorsByCategory: { cloud: 2, software_as_a_service: 2 }, + subProcessorNames: ['Sub A', 'Sub B'], + infraVendorNames: ['Cloud A'], + memberCount: 10, + membersByDepartment: { it: 6, hr: 4 }, + deviceCount: 8, + riskCount: 3, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +describe('deriveInterestedParties', () => { + it('produces a lean derived set with provenance', () => { + const rows = deriveInterestedParties(data); + expect(rows.length).toBeGreaterThanOrEqual(5); + expect(rows.length).toBeLessThanOrEqual(8); + expect(rows.every((r) => r.source === 'derived')).toBe(true); + expect(rows.every((r) => r.derivedFrom.length > 0)).toBe(true); + expect(rows.every((r) => r.needsExpectations.length > 0)).toBe(true); + }); + + it('emits one regulator party per active framework', () => { + const rows = deriveInterestedParties(data); + const frameworkRows = rows.filter((r) => + r.derivedFrom.startsWith('framework:'), + ); + expect(frameworkRows.map((r) => r.derivedFrom)).toEqual([ + 'framework:ISO 27001', + 'framework:GDPR', + ]); + }); + + it('includes members, customers, vendors and sub-processors', () => { + const provenance = deriveInterestedParties(data).map((r) => r.derivedFrom); + expect(provenance).toEqual( + expect.arrayContaining([ + 'members', + 'customers', + 'vendors', + 'subprocessors', + ]), + ); + }); + + it('assigns sequential positions and is deterministic', () => { + const rows = deriveInterestedParties(data); + rows.forEach((row, index) => expect(row.position).toBe(index)); + expect(deriveInterestedParties(data)).toEqual(rows); + }); + + it('omits sub-processor row when none exist', () => { + const rows = deriveInterestedParties({ ...data, subProcessorCount: 0 }); + expect(rows.some((r) => r.derivedFrom === 'subprocessors')).toBe(false); + }); +}); + +describe('deriveInterestedParties — wizard answers (CS-438)', () => { + it('adds an insurer party when insurance.has', () => { + const rows = deriveInterestedParties({ + ...data, + wizardAnswers: { insurance: { has: true, insurerName: 'Acme Cyber' } }, + }); + const insurer = rows.find((r) => r.derivedFrom === 'wizard:insurance'); + expect(insurer).toBeDefined(); + expect(insurer?.name).toContain('Acme Cyber'); + expect(insurer?.category).toBe('Insurer'); + }); + + it('omits insurer party when insurance.has is false', () => { + const rows = deriveInterestedParties({ + ...data, + wizardAnswers: { insurance: { has: false, insurerName: '' } }, + }); + expect(rows.some((r) => r.derivedFrom === 'wizard:insurance')).toBe(false); + }); + + it('does NOT add sector regulators as parties (they are 4.2c requirement rows only)', () => { + const rows = deriveInterestedParties({ + ...data, + wizardAnswers: { sectorRegulators: ['FINMA', 'custom:My Regulator'] }, + }); + // Sector regulators are surfaced once, as requirement rows in 4.2c, never as + // duplicate parties here. + expect(rows.some((r) => r.derivedFrom === 'wizard:regulator')).toBe(false); + }); + + it('adds a Contractors workforce party when hasContractors', () => { + const rows = deriveInterestedParties({ + ...data, + wizardAnswers: { hasContractors: true }, + }); + const contractors = rows.find( + (r) => r.derivedFrom === 'wizard:contractors', + ); + expect(contractors?.name).toBe('Contractors'); + expect(contractors?.category).toBe('Workforce'); + }); + + it('adds an EU-representative party only when status is appointed', () => { + const appointed = deriveInterestedParties({ + ...data, + wizardAnswers: { euRep: { status: 'appointed', name: 'EU Rep Ltd' } }, + }); + expect( + appointed.find((r) => r.derivedFrom === 'wizard:eu_rep')?.name, + ).toContain('EU Rep Ltd'); + + const pending = deriveInterestedParties({ + ...data, + wizardAnswers: { euRep: { status: 'pending', name: '' } }, + }); + expect(pending.some((r) => r.derivedFrom === 'wizard:eu_rep')).toBe(false); + }); +}); + +describe('buildInterestedPartiesSections', () => { + it('renders a table of parties', () => { + const input: DocumentExportInput = { + contextIssues: [], + interestedParties: [ + { name: 'Customers', category: 'Customer', needsExpectations: 'n' }, + ], + requirements: [], + objectives: [], + narrative: null, + }; + const sections = buildInterestedPartiesSections(input); + expect(sections).toHaveLength(1); + expect(sections[0].table?.rows).toEqual([['Customers', 'Customer', 'n']]); + }); +}); diff --git a/apps/api/src/isms/documents/interested-parties.ts b/apps/api/src/isms/documents/interested-parties.ts new file mode 100644 index 0000000000..bf833d01ef --- /dev/null +++ b/apps/api/src/isms/documents/interested-parties.ts @@ -0,0 +1,190 @@ +import type { IsmsExportSection } from '../utils/export-shared'; +import type { + DerivedInterestedParty, + DocumentExportInput, + IsmsPlatformData, +} from './types'; + +/** + * Map an active framework to the regulator / certification body that is an + * interested party for that framework. Falls back to a generic cert body. + */ +function regulatorForFramework(name: string): { + name: string; + needs: string; +} { + const lower = name.toLowerCase(); + if (lower.includes('iso 27001') || lower.includes('iso27001')) { + return { + name: `Certification body (${name})`, + needs: + 'Evidence that the ISMS conforms to the standard and is operated effectively, sufficient to grant and maintain certification.', + }; + } + if (lower.includes('gdpr')) { + return { + name: 'Data protection authority (GDPR)', + needs: + 'Lawful processing of personal data, timely breach notification and demonstrable data-protection accountability.', + }; + } + if (lower.includes('hipaa')) { + return { + name: 'Regulator (HIPAA)', + needs: + 'Safeguards for protected health information and adherence to the Security and Privacy Rules.', + }; + } + if (lower.includes('soc 2') || lower.includes('soc2')) { + return { + name: `Independent auditor (${name})`, + needs: + 'Evidence that the trust-services criteria are met across the audit period.', + }; + } + return { + name: `Regulator / auditor (${name})`, + needs: `Conformance with the obligations arising from ${name}.`, + }; +} + +/** + * Derive a lean set of interested parties (~5-8) from platform data. Deterministic + * so drift is a pure snapshot comparison. Manual rows are preserved by the caller. + */ +export function deriveInterestedParties( + data: IsmsPlatformData, +): DerivedInterestedParty[] { + const rows: Array> = []; + + if (data.memberCount > 0) { + rows.push({ + name: 'Employees / workforce', + category: 'Employee', + needsExpectations: + 'A safe, well-governed working environment, clear security policies, training, and protection of their personal data.', + source: 'derived', + derivedFrom: 'members', + }); + } + + rows.push({ + name: 'Customers', + category: 'Customer', + needsExpectations: + 'Confidentiality, integrity and availability of their data, contractual and regulatory compliance, and timely incident notification.', + source: 'derived', + derivedFrom: 'customers', + }); + + for (const framework of data.frameworkNames) { + const regulator = regulatorForFramework(framework); + rows.push({ + name: regulator.name, + category: 'Regulator / Certification body', + needsExpectations: regulator.needs, + source: 'derived', + derivedFrom: `framework:${framework}`, + }); + } + + if (data.vendorCount > 0) { + rows.push({ + name: 'Suppliers & service providers', + category: 'Supplier', + needsExpectations: + 'Clear security requirements, prompt cooperation on assessments, and adherence to contractual obligations.', + source: 'derived', + derivedFrom: 'vendors', + }); + } + + if (data.subProcessorCount > 0) { + rows.push({ + name: 'Sub-processors', + category: 'Supplier', + needsExpectations: + 'Defined data-processing instructions, breach-notification terms and protection of data handled on the organization’s behalf.', + source: 'derived', + derivedFrom: 'subprocessors', + }); + } + + rows.push(...wizardDerivedParties(data)); + + return rows.map((row, index) => ({ ...row, position: index })); +} + +/** + * Interested parties contributed by the ISMS wizard answers (CS-438): insurer, + * contractors workforce and an appointed EU representative. + * + * Sector regulators are intentionally NOT added here. They are surfaced once, as + * requirement rows in the 4.2c Requirements document (wizardRegulatorRequirements + * in requirements.ts); adding them as parties too would double-list each + * regulator's obligation across 4.2b and 4.2c. + */ +function wizardDerivedParties( + data: IsmsPlatformData, +): Array> { + const answers = data.wizardAnswers; + const rows: Array> = []; + + if (answers.insurance?.has) { + const insurer = answers.insurance.insurerName?.trim(); + rows.push({ + name: insurer ? `Insurer (${insurer})` : 'Insurer', + category: 'Insurer', + needsExpectations: + 'Demonstrable risk management, prompt incident notification and evidence of effective security controls to support coverage.', + source: 'derived', + derivedFrom: 'wizard:insurance', + }); + } + + if (answers.hasContractors) { + rows.push({ + name: 'Contractors', + category: 'Workforce', + needsExpectations: + 'Clear acceptable-use rules, scoped access aligned to their engagement, and protection of any data they handle.', + source: 'derived', + derivedFrom: 'wizard:contractors', + }); + } + + if (answers.euRep?.status === 'appointed') { + const repName = answers.euRep.name?.trim(); + rows.push({ + name: repName + ? `EU representative (${repName})` + : 'EU representative (Art. 27 GDPR)', + category: 'Regulator', + needsExpectations: + 'Acts as the local point of contact for EU data-protection authorities and data subjects on behalf of the organization.', + source: 'derived', + derivedFrom: 'wizard:eu_rep', + }); + } + + return rows; +} + +export function buildInterestedPartiesSections( + input: DocumentExportInput, +): IsmsExportSection[] { + return [ + { + heading: 'Interested Parties', + emptyText: 'No interested parties recorded.', + table: { + headers: ['Interested party', 'Category', 'Needs & expectations'], + rows: input.interestedParties.map((party) => [ + party.name, + party.category, + party.needsExpectations, + ]), + }, + }, + ]; +} diff --git a/apps/api/src/isms/documents/leadership.ts b/apps/api/src/isms/documents/leadership.ts new file mode 100644 index 0000000000..21fa0fc55c --- /dev/null +++ b/apps/api/src/isms/documents/leadership.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +/** Narrative shape persisted in IsmsDocumentVersion.narrative for leadership_commitment (5.1). */ +export const leadershipNarrativeSchema = z.object({ + statement: z.string(), + commitments: z.array( + z.object({ + key: z.string(), + text: z.string(), + }), + ), +}); + +export type LeadershipNarrative = z.infer; + +/** + * The ISO 27001:2022 clause 5.1(a)-(h) leadership commitments, parameterized with + * the organization name. Deterministic boilerplate. + */ +function commitmentsFor( + organizationName: string, +): LeadershipNarrative['commitments'] { + return [ + { + key: 'a', + text: 'Ensuring the information security policy and information security objectives are established and are compatible with the strategic direction of the organization.', + }, + { + key: 'b', + text: 'Ensuring the integration of the information security management system requirements into the organization’s processes.', + }, + { + key: 'c', + text: 'Ensuring that the resources needed for the information security management system are available.', + }, + { + key: 'd', + text: 'Communicating the importance of effective information security management and of conforming to the information security management system requirements.', + }, + { + key: 'e', + text: 'Ensuring that the information security management system achieves its intended outcome(s).', + }, + { + key: 'f', + text: 'Directing and supporting persons to contribute to the effectiveness of the information security management system.', + }, + { + key: 'g', + text: 'Promoting continual improvement of the information security management system.', + }, + { + key: 'h', + text: `Supporting other relevant management roles within ${organizationName} to demonstrate their leadership as it applies to their areas of responsibility.`, + }, + ]; +} + +/** + * Derive the default leadership-and-commitment statement (5.1). Sign-off uses the + * existing document approver flow. + */ +export function deriveLeadershipNarrative( + data: IsmsPlatformData, +): LeadershipNarrative { + const commitments = commitmentsFor(data.organizationName); + const deputy = deputySpoCommitment(data); + if (deputy) commitments.push(deputy); + + return { + statement: `Top management of ${data.organizationName} is committed to the information security management system (ISMS) and demonstrates leadership and commitment with respect to the ISMS by:`, + commitments, + }; +} + +/** + * Reference the Deputy Security & Privacy Officer (wizard answer) so leadership + * resourcing reflects the appointed deputy or the intent to name one. + */ +function deputySpoCommitment( + data: IsmsPlatformData, +): LeadershipNarrative['commitments'][number] | null { + const deputy = data.wizardAnswers.deputySpo; + if (!deputy) return null; + + if (deputy.toBeNamed) { + return { + key: 'i', + text: 'Committing to appoint a Deputy Security & Privacy Officer to support the SPO and ensure continuity of ISMS leadership.', + }; + } + if (deputy.memberId) { + return { + key: 'i', + text: 'Appointing a Deputy Security & Privacy Officer to support the SPO and ensure continuity of ISMS leadership.', + }; + } + return null; +} + +export function buildLeadershipSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const parsed = leadershipNarrativeSchema.safeParse(input.narrative); + if (!parsed.success) { + return [ + { + heading: 'Leadership and Commitment', + emptyText: 'No leadership statement saved.', + }, + ]; + } + const narrative = parsed.data; + + return [ + { + heading: 'Leadership and Commitment', + paragraphs: [ + { text: narrative.statement }, + ...narrative.commitments.map((commitment) => ({ + label: `(${commitment.key}) `, + text: commitment.text, + })), + ], + }, + ]; +} diff --git a/apps/api/src/isms/documents/narrative.spec.ts b/apps/api/src/isms/documents/narrative.spec.ts new file mode 100644 index 0000000000..bfafca0dcc --- /dev/null +++ b/apps/api/src/isms/documents/narrative.spec.ts @@ -0,0 +1,177 @@ +import { + buildScopeSections, + deriveScopeNarrative, + ismsScopeNarrativeSchema, +} from './scope'; +import { + buildLeadershipSections, + deriveLeadershipNarrative, + leadershipNarrativeSchema, +} from './leadership'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +const data: IsmsPlatformData = { + organizationName: 'Acme Inc', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +const emptyInput = (narrative: unknown): DocumentExportInput => ({ + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative, +}); + +describe('scope narrative (4.3)', () => { + it('derives a schema-valid narrative referencing org + frameworks', () => { + const narrative = deriveScopeNarrative(data); + expect(ismsScopeNarrativeSchema.safeParse(narrative).success).toBe(true); + expect(narrative.certificateScopeSentence).toContain('Acme Inc'); + expect(narrative.certificateScopeSentence).toContain('ISO 27001'); + expect(narrative.dependencies).toEqual( + expect.arrayContaining([ + 'Cloud A (cloud / infrastructure provider).', + 'Sub A (sub-processor).', + ]), + ); + }); + + it('renders scope sections from a saved narrative', () => { + const sections = buildScopeSections(emptyInput(deriveScopeNarrative(data))); + const headings = sections.map((s) => s.heading); + expect(headings).toEqual( + expect.arrayContaining([ + 'Scope statement', + 'Interfaces', + 'Dependencies', + 'Exclusions', + ]), + ); + }); + + it('renders a placeholder when narrative is invalid', () => { + const sections = buildScopeSections(emptyInput({ bogus: true })); + expect(sections[0].emptyText).toBeDefined(); + }); + + it('collapses stray whitespace so the scope sentence has no double spaces (CS-437)', () => { + const narrative = deriveScopeNarrative({ + ...data, + organizationName: 'Comp AI ', + }); + expect(narrative.certificateScopeSentence).not.toMatch(/ {2,}/); + expect(narrative.certificateScopeSentence).toContain('of Comp AI covers'); + }); + + it('normalizes a wizard-confirmed sentence that contains double spaces (CS-437)', () => { + const narrative = deriveScopeNarrative({ + ...data, + wizardAnswers: { + certificateScopeSentence: 'The ISMS covers everything.', + }, + }); + expect(narrative.certificateScopeSentence).toBe( + 'The ISMS covers everything.', + ); + }); + + it('uses the wizard certificate scope sentence when set (CS-438)', () => { + const narrative = deriveScopeNarrative({ + ...data, + wizardAnswers: { + certificateScopeSentence: 'A bespoke, customer-confirmed scope.', + }, + }); + expect(narrative.certificateScopeSentence).toBe( + 'A bespoke, customer-confirmed scope.', + ); + }); + + it('threads cloudScopeSplit into interfaces/dependencies and capabilities into in-scope', () => { + const narrative = deriveScopeNarrative({ + ...data, + wizardAnswers: { + capabilitiesInProduction: ['Payments API', 'Reporting'], + cloudScopeSplit: { + customer: ['Data', 'Databases'], + provider: ['Underlying infrastructure'], + }, + }, + }); + expect(narrative.inScope).toContain('Payments API'); + expect(narrative.interfaces).toEqual( + expect.arrayContaining([ + 'Underlying infrastructure — managed by the cloud provider.', + ]), + ); + expect(narrative.dependencies).toEqual( + expect.arrayContaining([ + 'Data — managed by the organization.', + 'Databases — managed by the organization.', + ]), + ); + }); +}); + +describe('leadership narrative (5.1)', () => { + it('derives all eight (a)-(h) commitments', () => { + const narrative = deriveLeadershipNarrative(data); + expect(leadershipNarrativeSchema.safeParse(narrative).success).toBe(true); + expect(narrative.commitments.map((c) => c.key)).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + ]); + expect(narrative.statement).toContain('Acme Inc'); + }); + + it('renders leadership sections with labelled commitments', () => { + const sections = buildLeadershipSections( + emptyInput(deriveLeadershipNarrative(data)), + ); + expect(sections[0].heading).toBe('Leadership and Commitment'); + expect(sections[0].paragraphs?.some((p) => p.label === '(a) ')).toBe(true); + }); + + it('references the Deputy SPO when appointed (CS-438)', () => { + const narrative = deriveLeadershipNarrative({ + ...data, + wizardAnswers: { deputySpo: { memberId: 'mem_1', toBeNamed: false } }, + }); + const deputy = narrative.commitments.find((c) => c.key === 'i'); + expect(deputy?.text).toContain('Deputy Security & Privacy Officer'); + }); + + it('references the intent to name a Deputy SPO when toBeNamed', () => { + const narrative = deriveLeadershipNarrative({ + ...data, + wizardAnswers: { deputySpo: { memberId: null, toBeNamed: true } }, + }); + const deputy = narrative.commitments.find((c) => c.key === 'i'); + expect(deputy?.text).toContain('appoint a Deputy Security & Privacy Officer'); + }); + + it('omits the Deputy SPO commitment when not provided', () => { + const narrative = deriveLeadershipNarrative(data); + expect(narrative.commitments.some((c) => c.key === 'i')).toBe(false); + }); +}); diff --git a/apps/api/src/isms/documents/objectives.spec.ts b/apps/api/src/isms/documents/objectives.spec.ts new file mode 100644 index 0000000000..9babd6ec37 --- /dev/null +++ b/apps/api/src/isms/documents/objectives.spec.ts @@ -0,0 +1,161 @@ +import { buildObjectivesSections, deriveObjectives } from './objectives'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +const data: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 4, + highRiskCount: 2, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +describe('deriveObjectives', () => { + it('derives framework, training, risk and vendor objectives', () => { + const rows = deriveObjectives(data); + const provenance = rows.map((r) => r.derivedFrom); + expect(provenance).toEqual( + expect.arrayContaining([ + 'framework:ISO 27001', + 'training', + 'risks', + 'vendors', + ]), + ); + expect(rows.every((r) => r.source === 'derived')).toBe(true); + expect(rows.every((r) => r.objective.length > 0)).toBe(true); + }); + + it('references the high/critical risk count in the risk objective target', () => { + const rows = deriveObjectives(data); + const riskRow = rows.find((r) => r.derivedFrom === 'risks'); + expect(riskRow?.target).toContain('2'); + }); + + it('omits the risk objective when there are no risks', () => { + const rows = deriveObjectives({ ...data, riskCount: 0, highRiskCount: 0 }); + expect(rows.some((r) => r.derivedFrom === 'risks')).toBe(false); + }); + + it('assigns sequential positions and is deterministic', () => { + const rows = deriveObjectives(data); + rows.forEach((row, index) => expect(row.position).toBe(index)); + expect(deriveObjectives(data)).toEqual(rows); + }); + + it('uses wizard objectives when provided, overriding defaults (CS-438)', () => { + const rows = deriveObjectives({ + ...data, + wizardAnswers: { + objectives: [ + { objective: 'Reduce phishing click rate', target: '< 3%' }, + { objective: 'Patch criticals in 7 days', target: '100%' }, + ], + }, + }); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.objective)).toEqual([ + 'Reduce phishing click rate', + 'Patch criticals in 7 days', + ]); + expect(rows[0].target).toBe('< 3%'); + expect(rows.every((r) => r.derivedFrom === 'wizard:objective')).toBe(true); + expect(rows.every((r) => r.source === 'derived')).toBe(true); + }); + + it('falls back to defaults when wizard objectives was never set', () => { + const rows = deriveObjectives({ ...data, wizardAnswers: {} }); + expect(rows.some((r) => r.derivedFrom === 'wizard:objective')).toBe(false); + expect(rows.some((r) => r.derivedFrom.startsWith('framework:'))).toBe(true); + }); + + it('respects an explicitly-saved empty objectives array (CS-438)', () => { + const rows = deriveObjectives({ ...data, wizardAnswers: { objectives: [] } }); + expect(rows).toEqual([]); + }); + + it('respects an explicitly-saved array that is empty after dropping blank rows', () => { + const rows = deriveObjectives({ + ...data, + wizardAnswers: { + objectives: [ + { objective: ' ', target: 'ignored' }, + { objective: '', target: '' }, + ], + }, + }); + expect(rows).toEqual([]); + }); +}); + +describe('buildObjectivesSections', () => { + it('renders an objectives table including plan, measurement, cadence and status', () => { + const input: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [ + { + objective: 'Maintain ISO 27001', + target: 'Certified', + cadence: 'Annual', + status: 'on_track', + plan: 'Operate controls and pass the audit', + measurementMethod: 'Audit outcome', + }, + ], + narrative: null, + }; + const sections = buildObjectivesSections(input); + expect(sections[0].table?.headers).toEqual([ + 'Objective', + 'Target', + 'Plan', + 'Measurement', + 'Cadence', + 'Status', + ]); + expect(sections[0].table?.rows).toEqual([ + [ + 'Maintain ISO 27001', + 'Certified', + 'Operate controls and pass the audit', + 'Audit outcome', + 'Annual', + 'on_track', + ], + ]); + }); + + it('renders em-dashes for missing plan and measurement', () => { + const input: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [ + { + objective: 'Reduce phishing click rate', + target: '< 3%', + cadence: null, + status: 'on_track', + plan: null, + measurementMethod: null, + }, + ], + narrative: null, + }; + const sections = buildObjectivesSections(input); + expect(sections[0].table?.rows).toEqual([ + ['Reduce phishing click rate', '< 3%', '—', '—', '—', 'on_track'], + ]); + }); +}); diff --git a/apps/api/src/isms/documents/objectives.ts b/apps/api/src/isms/documents/objectives.ts new file mode 100644 index 0000000000..5e8939eda2 --- /dev/null +++ b/apps/api/src/isms/documents/objectives.ts @@ -0,0 +1,119 @@ +import type { IsmsExportSection } from '../utils/export-shared'; +import type { + DerivedObjective, + DocumentExportInput, + IsmsPlatformData, +} from './types'; + +/** + * Derive a small set of default information-security objectives (6.2) from active + * frameworks, the risk register and the training programme. Deterministic so drift + * is a pure snapshot comparison. Manual rows are preserved by the caller. + */ +export function deriveObjectives(data: IsmsPlatformData): DerivedObjective[] { + // An explicitly-saved objectives array is the user's choice and must be + // respected — even when it ends up empty after dropping blank-text rows. + // Only fall through to the standard derived objectives when the field was + // never set (undefined). Mirrors buildWizardDefaults, which preserves a saved + // empty array rather than reseeding it from defaults. + const savedObjectives = data.wizardAnswers.objectives; + if (savedObjectives !== undefined) { + return savedObjectives + .filter((objective) => objective.objective?.trim()) + .map((objective, index) => ({ + objective: objective.objective, + target: objective.target || null, + cadence: null, + plan: null, + measurementMethod: null, + source: 'derived', + derivedFrom: 'wizard:objective', + position: index, + })); + } + + const rows: Array> = []; + + for (const framework of data.frameworkNames) { + rows.push({ + objective: `Maintain ${framework} compliance`, + target: `Certified / conformant with ${framework}`, + cadence: 'Annual', + plan: `Operate the ISMS controls, complete internal audits and management reviews, and pass the ${framework} audit.`, + measurementMethod: 'Audit outcome and number of non-conformities.', + source: 'derived', + derivedFrom: `framework:${framework}`, + }); + } + + if (data.hasTrainingProgram || data.memberCount > 0) { + rows.push({ + objective: 'Achieve high security-awareness training completion', + target: 'Training completion ≥ 95%', + cadence: 'Quarterly', + plan: 'Assign annual security-awareness training to all staff and track completion through the platform.', + measurementMethod: 'Percentage of staff who completed assigned training.', + source: 'derived', + derivedFrom: 'training', + }); + } + + if (data.riskCount > 0) { + rows.push({ + objective: 'Resolve high and critical risks within SLA', + target: + data.highRiskCount > 0 + ? `Remediate ${data.highRiskCount} high/critical risk${data.highRiskCount === 1 ? '' : 's'} within SLA` + : 'No high/critical risks open beyond SLA', + cadence: 'Monthly', + plan: 'Triage risks in the register, assign owners and treatment plans, and track residual scores to closure.', + measurementMethod: 'Number of high/critical risks open beyond their SLA.', + source: 'derived', + derivedFrom: 'risks', + }); + } + + if (data.vendorCount > 0) { + rows.push({ + objective: 'Complete scheduled vendor security reviews', + target: '100% of in-scope vendors reviewed on schedule', + cadence: 'Annual', + plan: 'Maintain the vendor register, run periodic assessments and follow up on findings.', + measurementMethod: + 'Percentage of vendors reviewed within the review window.', + source: 'derived', + derivedFrom: 'vendors', + }); + } + + return rows.map((row, index) => ({ ...row, position: index })); +} + +export function buildObjectivesSections( + input: DocumentExportInput, +): IsmsExportSection[] { + return [ + { + heading: 'Information Security Objectives', + emptyText: 'No objectives recorded.', + table: { + headers: [ + 'Objective', + 'Target', + 'Plan', + 'Measurement', + 'Cadence', + 'Status', + ], + rows: input.objectives.map((objective) => [ + objective.objective, + objective.target ?? '—', + objective.plan ?? '—', + objective.measurementMethod ?? '—', + objective.cadence ?? '—', + objective.status, + ]), + }, + }, + ]; +} diff --git a/apps/api/src/isms/documents/org-profile.spec.ts b/apps/api/src/isms/documents/org-profile.spec.ts new file mode 100644 index 0000000000..ac4a8e2308 --- /dev/null +++ b/apps/api/src/isms/documents/org-profile.spec.ts @@ -0,0 +1,68 @@ +// Mock @db before importing the unit under test so loadOrgProfile reads from +// these fakes. We only stub the table methods the function actually calls. +const mockDb = { + organization: { findUnique: jest.fn() }, + context: { findMany: jest.fn() }, + ismsProfile: { findUnique: jest.fn() }, +}; + +jest.mock('@db', () => ({ db: mockDb })); + +import { loadOrgProfile } from './org-profile'; +import { DEFAULT_INTENDED_OUTCOMES } from '../wizard/wizard-defaults'; + +const ARGS = { organizationId: 'org_1', frameworkId: 'fw_1' }; + +function seedDb({ answers }: { answers: unknown }) { + mockDb.organization.findUnique.mockResolvedValue({ + name: 'Acme', + website: 'https://acme.test', + }); + mockDb.context.findMany.mockResolvedValue([]); + mockDb.ismsProfile.findUnique.mockResolvedValue({ answers }); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('loadOrgProfile intendedOutcomes', () => { + it('falls back to the default outcomes when intended outcomes were never set', async () => { + seedDb({ answers: {} }); + + const profile = await loadOrgProfile(ARGS); + + expect(profile.intendedOutcomes).toEqual(DEFAULT_INTENDED_OUTCOMES); + }); + + it('falls back to the default outcomes when there is no saved profile', async () => { + mockDb.organization.findUnique.mockResolvedValue({ name: 'Acme', website: null }); + mockDb.context.findMany.mockResolvedValue([]); + mockDb.ismsProfile.findUnique.mockResolvedValue(null); + + const profile = await loadOrgProfile(ARGS); + + expect(profile.intendedOutcomes).toEqual(DEFAULT_INTENDED_OUTCOMES); + }); + + it('respects an explicitly-saved empty intended-outcomes array (CS-438)', async () => { + seedDb({ answers: { intendedOutcomes: [] } }); + + const profile = await loadOrgProfile(ARGS); + + expect(profile.intendedOutcomes).toEqual([]); + }); + + it('uses saved intended outcomes when provided, overriding defaults', async () => { + seedDb({ + answers: { intendedOutcomes: ['Protect customer data', 'Stay certified'] }, + }); + + const profile = await loadOrgProfile(ARGS); + + expect(profile.intendedOutcomes).toEqual([ + 'Protect customer data', + 'Stay certified', + ]); + }); +}); diff --git a/apps/api/src/isms/documents/org-profile.ts b/apps/api/src/isms/documents/org-profile.ts new file mode 100644 index 0000000000..be77f5ccdb --- /dev/null +++ b/apps/api/src/isms/documents/org-profile.ts @@ -0,0 +1,89 @@ +import { db } from '@db'; +import { DEFAULT_INTENDED_OUTCOMES } from '../wizard/wizard-defaults'; +import { parseStoredAnswers } from '../wizard/wizard-schema'; +import type { IsmsKeyValue } from '../utils/export-shared'; +import type { IsmsOrgProfile } from './types'; + +/** + * Exact onboarding question strings (apps/app/.../setup/lib/constants.ts). The + * answers are persisted verbatim into the Context Q&A table at signup; we read + * them back to populate the Context-of-the-Organization overview. Structured + * answers (C-suite, address) are stored as "[object Object]" and skipped. + */ +const QUESTIONS = { + describe: 'Describe your company in a few sentences', + industry: 'What industry is your company in?', + teamSize: 'How many employees do you have?', + workLocation: 'How does your team work?', + dataTypes: 'What types of data do you handle?', + geo: 'Where is your data located?', + infrastructure: 'Where do you host your applications and data?', +} as const; + +/** + * Assemble the narrative inputs for the Context of the Organization document: + * the overview table, the mission statement and the ISMS intended outcomes. + * Everything is best-effort — any field we cannot resolve is simply omitted so + * the document degrades gracefully rather than showing blanks. + */ +export async function loadOrgProfile({ + organizationId, + frameworkId, +}: { + organizationId: string; + frameworkId: string; +}): Promise { + const [organization, contextEntries, profile] = await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, website: true }, + }), + db.context.findMany({ + where: { organizationId }, + // Deterministic ordering so duplicate questions resolve to the same answer + // every export; the earliest-created entry wins (id breaks createdAt ties). + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: { question: true, answer: true }, + }), + db.ismsProfile.findUnique({ + where: { organizationId_frameworkId: { organizationId, frameworkId } }, + select: { answers: true }, + }), + ]); + + // First (earliest-created) entry wins for any duplicated question. + const answers = new Map(); + for (const entry of contextEntries) { + if (!answers.has(entry.question)) { + answers.set(entry.question, (entry.answer ?? '').trim()); + } + } + const get = (question: string): string | null => { + const value = answers.get(question); + return value && value !== '[object Object]' ? value : null; + }; + + const overview: IsmsKeyValue[] = []; + const add = (label: string, value: string | null) => { + if (value) overview.push({ label, value }); + }; + add('Legal entity', organization?.name?.trim() || null); + add('Website', organization?.website?.trim() || null); + add('Industry', get(QUESTIONS.industry)); + add('Workforce', get(QUESTIONS.teamSize)); + add('Working model', get(QUESTIONS.workLocation)); + add('Data handled', get(QUESTIONS.dataTypes)); + add('Data locations', get(QUESTIONS.geo)); + add('Hosting', get(QUESTIONS.infrastructure)); + + const wizard = parseStoredAnswers(profile?.answers); + // Fall back to the defaults only when intended outcomes were never set. A + // saved (even empty) array is the user's choice and must be respected, not + // overwritten on every read. Mirrors buildWizardDefaults. + const intendedOutcomes = + wizard.intendedOutcomes === undefined + ? DEFAULT_INTENDED_OUTCOMES + : wizard.intendedOutcomes; + + return { overview, mission: get(QUESTIONS.describe), intendedOutcomes }; +} diff --git a/apps/api/src/isms/documents/registry.ts b/apps/api/src/isms/documents/registry.ts new file mode 100644 index 0000000000..84a8ee8d24 --- /dev/null +++ b/apps/api/src/isms/documents/registry.ts @@ -0,0 +1,72 @@ +import type { IsmsDocumentType } from '@db'; +import type { ZodTypeAny } from 'zod'; +import type { IsmsExportSection } from '../utils/export-shared'; +import { buildContextSections } from './context'; +import { buildInterestedPartiesSections } from './interested-parties'; +import { buildRequirementsSections } from './requirements'; +import { buildObjectivesSections } from './objectives'; +import { + buildScopeSections, + deriveScopeNarrative, + ismsScopeNarrativeSchema, +} from './scope'; +import { + buildLeadershipSections, + deriveLeadershipNarrative, + leadershipNarrativeSchema, +} from './leadership'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +/** Document types whose content is a singleton narrative stored in version.narrative. */ +const NARRATIVE_TYPES: IsmsDocumentType[] = [ + 'isms_scope', + 'leadership_commitment', +]; + +const EXPORT_SECTION_BUILDERS: Record< + IsmsDocumentType, + (input: DocumentExportInput) => IsmsExportSection[] +> = { + context_of_organization: buildContextSections, + interested_parties_register: buildInterestedPartiesSections, + interested_parties_requirements: buildRequirementsSections, + objectives_plan: buildObjectivesSections, + isms_scope: buildScopeSections, + leadership_commitment: buildLeadershipSections, +}; + +export function buildExportSections({ + type, + input, +}: { + type: IsmsDocumentType; + input: DocumentExportInput; +}): IsmsExportSection[] { + return EXPORT_SECTION_BUILDERS[type](input); +} + +/** Zod schema validating the narrative payload for each singleton document type. */ +export function narrativeSchemaForType( + type: IsmsDocumentType, +): ZodTypeAny | null { + if (type === 'isms_scope') return ismsScopeNarrativeSchema; + if (type === 'leadership_commitment') return leadershipNarrativeSchema; + return null; +} + +/** Derive the default narrative payload for a singleton document type. */ +export function deriveNarrativeForType({ + type, + data, +}: { + type: IsmsDocumentType; + data: IsmsPlatformData; +}): Record | null { + if (type === 'isms_scope') return deriveScopeNarrative(data); + if (type === 'leadership_commitment') return deriveLeadershipNarrative(data); + return null; +} + +export function isNarrativeType(type: IsmsDocumentType): boolean { + return NARRATIVE_TYPES.includes(type); +} diff --git a/apps/api/src/isms/documents/requirements.spec.ts b/apps/api/src/isms/documents/requirements.spec.ts new file mode 100644 index 0000000000..7fa1f89a61 --- /dev/null +++ b/apps/api/src/isms/documents/requirements.spec.ts @@ -0,0 +1,88 @@ +import { buildRequirementsSections, deriveRequirements } from './requirements'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +const data: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 0, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +describe('deriveRequirements', () => { + it('derives one requirement per supplied party and links the party id', () => { + const parties = [ + { id: 'ip_1', name: 'Customers', category: 'Customer' }, + { + id: 'ip_2', + name: 'Regulator', + category: 'Regulator / Certification body', + }, + ]; + const rows = deriveRequirements({ parties, data }); + expect(rows).toHaveLength(2); + expect(rows[0].interestedPartyId).toBe('ip_1'); + expect(rows[0].derivedFrom).toBe('party:ip_1'); + expect(rows.every((r) => r.source === 'derived')).toBe(true); + expect(rows.every((r) => r.requirement.length > 0)).toBe(true); + expect(rows.every((r) => r.treatment.length > 0)).toBe(true); + }); + + it('falls back to a platform-derived default set when no parties exist', () => { + const rows = deriveRequirements({ parties: [], data }); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.interestedPartyId === null)).toBe(true); + expect(rows[0].derivedFrom.startsWith('party:')).toBe(true); + }); + + it('assigns sequential positions', () => { + const rows = deriveRequirements({ parties: [], data }); + rows.forEach((row, index) => expect(row.position).toBe(index)); + }); + + it('appends one requirement row per wizard sector regulator (CS-438)', () => { + const parties = [{ id: 'ip_1', name: 'Customers', category: 'Customer' }]; + const rows = deriveRequirements({ + parties, + data: { + ...data, + wizardAnswers: { sectorRegulators: ['FCA', 'custom:Local Authority'] }, + }, + }); + const regulatorRows = rows.filter((r) => r.derivedFrom === 'wizard:regulator'); + expect(regulatorRows).toHaveLength(2); + expect(regulatorRows.map((r) => r.partyName)).toEqual([ + 'Regulator (FCA)', + 'Regulator (Local Authority)', + ]); + // Wizard rows come after the party rows and keep sequential positions. + expect(regulatorRows[0].position).toBe(parties.length); + expect(regulatorRows.every((r) => r.interestedPartyId === null)).toBe(true); + }); +}); + +describe('buildRequirementsSections', () => { + it('renders a requirements/treatment table', () => { + const input: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [ + { partyName: 'Customers', requirement: 'r', treatment: 't' }, + ], + objectives: [], + narrative: null, + }; + const sections = buildRequirementsSections(input); + expect(sections[0].table?.rows).toEqual([['Customers', 'r', 't']]); + }); +}); diff --git a/apps/api/src/isms/documents/requirements.ts b/apps/api/src/isms/documents/requirements.ts new file mode 100644 index 0000000000..dce2ee33d4 --- /dev/null +++ b/apps/api/src/isms/documents/requirements.ts @@ -0,0 +1,163 @@ +import type { IsmsExportSection } from '../utils/export-shared'; +import { deriveInterestedParties } from './interested-parties'; +import { formatRegulatorLabel } from './wizard-helpers'; +import type { + DerivedInterestedParty, + DerivedRequirement, + DocumentExportInput, + IsmsPlatformData, +} from './types'; + +/** A party row read from the org's Interested Parties Register. */ +export interface PartyInput { + id: string; + name: string; + category: string; +} + +/** + * Map a party (by category / name) to one representative requirement and the + * ISMS treatment that addresses it, referencing relevant policy/control areas + * generically. Deterministic. + */ +function requirementForParty(party: { name: string; category: string }): { + requirement: string; + treatment: string; +} { + const category = party.category.toLowerCase(); + if (category.includes('employee')) { + return { + requirement: + 'A secure workplace, clear acceptable-use rules and protection of their personal data.', + treatment: + 'Addressed by the Access Control, Acceptable Use and HR Security policies, mandatory security-awareness training, and role-based access controls.', + }; + } + if (category.includes('customer')) { + return { + requirement: + 'Confidentiality, integrity and availability of their data and timely breach notification.', + treatment: + 'Addressed by encryption, access control, logging/monitoring and the Incident Response policy with defined notification SLAs.', + }; + } + if (category.includes('regulator') || category.includes('certification')) { + return { + requirement: + 'Demonstrable conformance with the applicable standard or regulation.', + treatment: + 'Addressed by the Statement of Applicability, internal audit programme, management review and the full ISMS control set evidenced in the platform.', + }; + } + if (category.includes('supplier')) { + return { + requirement: + 'Clear security requirements and protection of any data shared with them.', + treatment: + 'Addressed by the Supplier/Vendor Management policy, vendor risk assessments, data-processing agreements and periodic vendor reviews.', + }; + } + return { + requirement: `Relevant security and compliance expectations of ${party.name}.`, + treatment: + 'Addressed by the relevant ISMS policies and controls, monitored through the risk register and management review.', + }; +} + +/** + * Derive one representative requirement + treatment per interested party. Uses the + * org's existing parties register when supplied; otherwise falls back to the + * platform-derived default party set. Manual rows are preserved by the caller. + */ +export function deriveRequirements({ + parties, + data, +}: { + parties: PartyInput[]; + data: IsmsPlatformData; +}): DerivedRequirement[] { + const source: Array<{ + interestedPartyId: string | null; + name: string; + category: string; + }> = + parties.length > 0 + ? parties.map((party) => ({ + interestedPartyId: party.id, + name: party.name, + category: party.category, + })) + : deriveInterestedParties(data).map((party: DerivedInterestedParty) => ({ + interestedPartyId: null, + name: party.name, + category: party.category, + })); + + const partyRows: DerivedRequirement[] = source.map((party, index) => { + const mapped = requirementForParty(party); + return { + partyName: party.name, + requirement: mapped.requirement, + treatment: mapped.treatment, + source: 'derived', + derivedFrom: party.interestedPartyId + ? `party:${party.interestedPartyId}` + : `party:${party.name}`, + position: index, + interestedPartyId: party.interestedPartyId, + }; + }); + + const wizardRows = wizardRegulatorRequirements({ + data, + startPosition: partyRows.length, + }); + + return [...partyRows, ...wizardRows]; +} + +/** + * One requirement + ISMS treatment row per sector regulator named in the wizard + * (CS-438). Sourced as derived with provenance `wizard:regulator`. + */ +function wizardRegulatorRequirements({ + data, + startPosition, +}: { + data: IsmsPlatformData; + startPosition: number; +}): DerivedRequirement[] { + const regulators = data.wizardAnswers.sectorRegulators ?? []; + + return regulators.map((regulator, index) => { + const label = formatRegulatorLabel(regulator); + return { + partyName: `Regulator (${label})`, + requirement: `Conformance with the sector obligations arising from ${label}.`, + treatment: `Addressed by the relevant ISMS policies and controls, the Statement of Applicability, and the compliance monitoring tracked for ${label}.`, + source: 'derived', + derivedFrom: 'wizard:regulator', + position: startPosition + index, + interestedPartyId: null, + }; + }); +} + +export function buildRequirementsSections( + input: DocumentExportInput, +): IsmsExportSection[] { + return [ + { + heading: 'Requirements & ISMS Treatment', + emptyText: 'No requirements recorded.', + table: { + headers: ['Interested party', 'Requirement', 'ISMS treatment'], + rows: input.requirements.map((row) => [ + row.partyName, + row.requirement, + row.treatment, + ]), + }, + }, + ]; +} diff --git a/apps/api/src/isms/documents/scope.ts b/apps/api/src/isms/documents/scope.ts new file mode 100644 index 0000000000..bef67c1730 --- /dev/null +++ b/apps/api/src/isms/documents/scope.ts @@ -0,0 +1,144 @@ +import { z } from 'zod'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; + +/** Narrative shape persisted in IsmsDocumentVersion.narrative for isms_scope (4.3). */ +export const ismsScopeNarrativeSchema = z.object({ + certificateScopeSentence: z.string(), + inScope: z.string(), + interfaces: z.array(z.string()), + dependencies: z.array(z.string()), + exclusions: z.array(z.string()), + justification: z.string().optional(), +}); + +export type IsmsScopeNarrative = z.infer; + +/** + * Collapse runs of whitespace to a single space and trim. Interpolated values + * (org name, framework list) can carry stray/trailing whitespace, which would + * otherwise produce double spaces in the assembled sentence (CS-437). + */ +function normalizeSentence(sentence: string): string { + return sentence.replace(/\s+/g, ' ').trim(); +} + +/** + * Derive a default ISMS scope statement (4.3) from platform data. The certificate + * scope sentence is templated from the org name + active frameworks; interfaces + * come from vendors and dependencies from sub-processors + key infra vendors. + */ +export function deriveScopeNarrative( + data: IsmsPlatformData, +): IsmsScopeNarrative { + const answers = data.wizardAnswers; + const frameworks = + data.frameworkNames.length > 0 + ? data.frameworkNames.join(', ') + : 'the applicable information-security standards'; + + // Wizard-confirmed certificate scope sentence wins over the generated default. + const wizardSentence = answers.certificateScopeSentence?.trim(); + const certificateScopeSentence = normalizeSentence( + wizardSentence && wizardSentence.length > 0 + ? wizardSentence + : `The information security management system of ${data.organizationName} covers the people, processes and technology supporting the delivery and operation of its products and services, in accordance with ${frameworks}.`, + ); + + const inScope = deriveInScope(data); + const interfaces = deriveInterfaces(data); + const dependencies = deriveDependencies(data); + + return { + certificateScopeSentence, + inScope, + interfaces, + dependencies, + exclusions: [], + justification: undefined, + }; +} + +/** In-scope description: prefer the wizard's confirmed live capabilities. */ +function deriveInScope(data: IsmsPlatformData): string { + const capabilities = data.wizardAnswers.capabilitiesInProduction ?? []; + if (capabilities.length > 0) { + return `The ISMS covers the delivery and operation of the following capabilities in production: ${capabilities.join(', ')}. All supporting information assets, personnel${data.memberCount > 0 ? ` (${data.memberCount} workforce members)` : ''}, systems and cloud infrastructure are within scope.`; + } + return `All information assets, personnel${data.memberCount > 0 ? ` (${data.memberCount} workforce members)` : ''}, systems and supporting cloud infrastructure used by ${data.organizationName} to deliver its services are within the ISMS scope.`; +} + +/** Interfaces: include the provider-managed cloud layers named in the wizard. */ +function deriveInterfaces(data: IsmsPlatformData): string[] { + const interfaces: string[] = []; + if (data.vendorCount > 0) { + interfaces.push( + `Third-party suppliers and service providers (${data.vendorCount}) that interface with organizational systems and data.`, + ); + } + for (const layer of data.wizardAnswers.cloudScopeSplit?.provider ?? []) { + interfaces.push(`${layer} — managed by the cloud provider.`); + } + if (interfaces.length === 0) { + interfaces.push('No external supplier interfaces are currently recorded.'); + } + return interfaces; +} + +/** Dependencies: infra vendors, sub-processors and customer-managed cloud layers. */ +function deriveDependencies(data: IsmsPlatformData): string[] { + const dependencies: string[] = []; + for (const name of data.infraVendorNames) { + dependencies.push(`${name} (cloud / infrastructure provider).`); + } + for (const name of data.subProcessorNames) { + dependencies.push(`${name} (sub-processor).`); + } + for (const layer of data.wizardAnswers.cloudScopeSplit?.customer ?? []) { + dependencies.push(`${layer} — managed by the organization.`); + } + if (dependencies.length === 0) { + dependencies.push('No external dependencies are currently recorded.'); + } + return dependencies; +} + +function listToSection(heading: string, items: string[]): IsmsExportSection { + return { + heading, + emptyText: 'None.', + paragraphs: items.map((text) => ({ text })), + }; +} + +export function buildScopeSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const parsed = ismsScopeNarrativeSchema.safeParse(input.narrative); + if (!parsed.success) { + return [{ heading: 'ISMS Scope', emptyText: 'No scope statement saved.' }]; + } + const narrative = parsed.data; + + const sections: IsmsExportSection[] = [ + { + heading: 'Scope statement', + paragraphs: [ + { text: narrative.certificateScopeSentence }, + { label: 'In scope: ', text: narrative.inScope }, + ], + }, + listToSection('Interfaces', narrative.interfaces), + listToSection('Dependencies', narrative.dependencies), + listToSection('Exclusions', narrative.exclusions), + ]; + + if (narrative.justification) { + sections.push({ + heading: 'Justification for exclusions', + paragraphs: [{ text: narrative.justification }], + }); + } + + return sections; +} diff --git a/apps/api/src/isms/documents/snapshot.spec.ts b/apps/api/src/isms/documents/snapshot.spec.ts new file mode 100644 index 0000000000..d0c52e4b28 --- /dev/null +++ b/apps/api/src/isms/documents/snapshot.spec.ts @@ -0,0 +1,218 @@ +import { diffPlatformSnapshots, parsePlatformSnapshot } from './snapshot'; +import type { IsmsPlatformData } from './types'; + +const base: IsmsPlatformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: 'fp-base', +}; + +describe('diffPlatformSnapshots', () => { + it('flags no-baseline when previous is null', () => { + const result = diffPlatformSnapshots({ + type: 'context_of_organization', + previous: null, + current: base, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toEqual(['no-baseline']); + }); + + it('reports not stale when nothing relevant changed', () => { + const result = diffPlatformSnapshots({ + type: 'objectives_plan', + previous: base, + current: base, + }); + expect(result.isStale).toBe(false); + expect(result.changedSources).toHaveLength(0); + }); + + it('only reports sources the objectives doc derives from (risk drift)', () => { + const result = diffPlatformSnapshots({ + type: 'objectives_plan', + previous: base, + current: { ...base, riskCount: 9, deviceCount: 99 }, + }); + expect(result.changedSources).toContain('risks'); + // objectives_plan does not derive from devices. + expect(result.changedSources).not.toContain('devices'); + }); + + it('detects framework drift regardless of order', () => { + const same = diffPlatformSnapshots({ + type: 'isms_scope', + previous: { ...base, frameworkNames: ['ISO 27001', 'SOC 2'] }, + current: { ...base, frameworkNames: ['SOC 2', 'ISO 27001'] }, + }); + expect(same.isStale).toBe(false); + }); + + it('detects vendor category mix drift for context (same total, different mix)', () => { + const result = diffPlatformSnapshots({ + type: 'context_of_organization', + previous: { ...base, vendorsByCategory: { cloud: 3 } }, + current: { ...base, vendorsByCategory: { cloud: 1, hr: 2 } }, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toContain('vendorMix'); + // The total count is unchanged, so the plain vendor source is not flagged. + expect(result.changedSources).not.toContain('vendors'); + }); + + it('detects department mix drift for context (same headcount, different mix)', () => { + const result = diffPlatformSnapshots({ + type: 'context_of_organization', + previous: { ...base, membersByDepartment: { it: 5 } }, + current: { ...base, membersByDepartment: { it: 2, hr: 3 } }, + }); + expect(result.changedSources).toContain('departmentMix'); + expect(result.changedSources).not.toContain('members'); + }); + + it('ignores vendor/department mix key order', () => { + const result = diffPlatformSnapshots({ + type: 'context_of_organization', + previous: { + ...base, + vendorsByCategory: { cloud: 2, hr: 1 }, + membersByDepartment: { it: 3, hr: 2 }, + }, + current: { + ...base, + vendorsByCategory: { hr: 1, cloud: 2 }, + membersByDepartment: { hr: 2, it: 3 }, + }, + }); + expect(result.isStale).toBe(false); + }); + + it('detects member-count drift for objectives (6.2 uses memberCount)', () => { + const result = diffPlatformSnapshots({ + type: 'objectives_plan', + previous: base, + current: { ...base, memberCount: 50 }, + }); + expect(result.changedSources).toContain('members'); + }); + + it('detects wizard-answer drift for documents that derive from them', () => { + const result = diffPlatformSnapshots({ + type: 'interested_parties_register', + previous: { ...base, wizardAnswers: { hasContractors: false } }, + current: { ...base, wizardAnswers: { hasContractors: true } }, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toContain('wizardAnswers'); + }); + + it('ignores wizard-answer key order', () => { + const result = diffPlatformSnapshots({ + type: 'objectives_plan', + previous: { + ...base, + wizardAnswers: { hasContractors: true, certificationBody: 'BSI' }, + }, + current: { + ...base, + wizardAnswers: { certificationBody: 'BSI', hasContractors: true }, + }, + }); + expect(result.isStale).toBe(false); + }); + + it('flags wizard drift for scope and leadership (both derive from wizard answers)', () => { + for (const type of ['isms_scope', 'leadership_commitment'] as const) { + const result = diffPlatformSnapshots({ + type, + previous: { ...base, wizardAnswers: { hasContractors: false } }, + current: { ...base, wizardAnswers: { hasContractors: true } }, + }); + expect(result.changedSources).toContain('wizardAnswers'); + } + }); + + it('flags requirements drift when the parties register fingerprint changes', () => { + const result = diffPlatformSnapshots({ + type: 'interested_parties_requirements', + previous: base, + current: { ...base, partiesFingerprint: 'fp-edited' }, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toContain('parties'); + }); + + it('does not flag requirements drift when the parties fingerprint is unchanged', () => { + const result = diffPlatformSnapshots({ + type: 'interested_parties_requirements', + previous: base, + current: base, + }); + expect(result.isStale).toBe(false); + expect(result.changedSources).not.toContain('parties'); + }); + + it('only the requirements doc treats parties edits as drift', () => { + for (const type of [ + 'context_of_organization', + 'interested_parties_register', + 'objectives_plan', + 'isms_scope', + 'leadership_commitment', + ] as const) { + const result = diffPlatformSnapshots({ + type, + previous: base, + current: { ...base, partiesFingerprint: 'fp-edited' }, + }); + expect(result.changedSources).not.toContain('parties'); + } + }); + + it('leadership only drifts on organization name', () => { + const noChange = diffPlatformSnapshots({ + type: 'leadership_commitment', + previous: base, + current: { ...base, vendorCount: 99 }, + }); + expect(noChange.isStale).toBe(false); + + const renamed = diffPlatformSnapshots({ + type: 'leadership_commitment', + previous: base, + current: { ...base, organizationName: 'NewCo' }, + }); + expect(renamed.changedSources).toContain('organizationName'); + }); +}); + +describe('parsePlatformSnapshot', () => { + it('round-trips a serialized snapshot', () => { + const parsed = parsePlatformSnapshot(JSON.parse(JSON.stringify(base))); + expect(parsed).toEqual(base); + }); + + it('returns null for non-object / missing frameworkNames', () => { + expect(parsePlatformSnapshot(null)).toBeNull(); + expect(parsePlatformSnapshot([1, 2])).toBeNull(); + expect(parsePlatformSnapshot({ foo: 'bar' })).toBeNull(); + }); + + it('defaults partiesFingerprint to empty for legacy snapshots without it', () => { + const { partiesFingerprint: _omit, ...legacy } = base; + const parsed = parsePlatformSnapshot(JSON.parse(JSON.stringify(legacy))); + expect(parsed?.partiesFingerprint).toBe(''); + }); +}); diff --git a/apps/api/src/isms/documents/snapshot.ts b/apps/api/src/isms/documents/snapshot.ts new file mode 100644 index 0000000000..d37dfec93c --- /dev/null +++ b/apps/api/src/isms/documents/snapshot.ts @@ -0,0 +1,222 @@ +import type { IsmsDocumentType, Prisma } from '@db'; +import type { PartialWizardAnswers } from '../wizard/wizard-schema'; +import { parseStoredAnswers } from '../wizard/wizard-schema'; +import type { IsmsPlatformData } from './types'; + +function sameStringSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const setB = new Set(b); + return a.every((item) => setB.has(item)); +} + +/** Order-insensitive comparison of count-by-key maps (vendor/department mix). */ +function sameNumberRecord( + a: Record, + b: Record, +): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of keys) { + if ((a[key] ?? 0) !== (b[key] ?? 0)) return false; + } + return true; +} + +/** + * Deep, key-order-independent comparison of the wizard answers blob. Several + * documents derive rows from these un-derivable inputs, so any edit is drift. + */ +function sameWizardAnswers( + a: PartialWizardAnswers, + b: PartialWizardAnswers, +): boolean { + return stableStringify(a) === stableStringify(b); +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const entries = Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`); + return `{${entries.join(',')}}`; +} + +/** + * Drift sources each document type derives from. Used to scope the diff so a + * document is only flagged stale when an input it actually consumes changes. + * Context (4.1) reads vendorsByCategory + membersByDepartment; objectives (6.2) + * reads memberCount; several docs read the un-derivable wizard answers. + */ +const TYPE_DRIFT_SOURCES: Record> = { + context_of_organization: [ + 'frameworks', + 'vendors', + 'vendorMix', + 'subprocessors', + 'members', + 'departmentMix', + 'devices', + 'wizardAnswers', + ], + interested_parties_register: [ + 'frameworks', + 'vendors', + 'subprocessors', + 'members', + 'wizardAnswers', + ], + interested_parties_requirements: [ + 'frameworks', + 'vendors', + 'subprocessors', + 'members', + 'wizardAnswers', + // Requirements derive one row per Interested Parties Register party, so a + // manual edit to a party (which the rest of this snapshot can't see) is drift. + 'parties', + ], + objectives_plan: [ + 'frameworks', + 'vendors', + 'risks', + 'training', + 'members', + 'wizardAnswers', + ], + isms_scope: [ + 'frameworks', + 'vendors', + 'subprocessors', + 'members', + 'wizardAnswers', + ], + leadership_commitment: ['organizationName', 'wizardAnswers'], +}; + +interface DiffMap { + frameworks: boolean; + vendors: boolean; + vendorMix: boolean; + subprocessors: boolean; + members: boolean; + departmentMix: boolean; + devices: boolean; + risks: boolean; + training: boolean; + organizationName: boolean; + wizardAnswers: boolean; + parties: boolean; +} + +function computeChanges({ + previous, + current, +}: { + previous: IsmsPlatformData; + current: IsmsPlatformData; +}): DiffMap { + return { + frameworks: !sameStringSet(previous.frameworkNames, current.frameworkNames), + vendors: previous.vendorCount !== current.vendorCount, + vendorMix: !sameNumberRecord( + previous.vendorsByCategory, + current.vendorsByCategory, + ), + subprocessors: previous.subProcessorCount !== current.subProcessorCount, + members: previous.memberCount !== current.memberCount, + departmentMix: !sameNumberRecord( + previous.membersByDepartment, + current.membersByDepartment, + ), + devices: previous.deviceCount !== current.deviceCount, + risks: + previous.riskCount !== current.riskCount || + previous.highRiskCount !== current.highRiskCount, + training: previous.hasTrainingProgram !== current.hasTrainingProgram, + organizationName: previous.organizationName !== current.organizationName, + wizardAnswers: !sameWizardAnswers( + previous.wizardAnswers, + current.wizardAnswers, + ), + parties: previous.partiesFingerprint !== current.partiesFingerprint, + }; +} + +/** + * Compare two platform snapshots for a given document type, reporting only the + * sources that document derives from. A missing baseline is always stale. + */ +export function diffPlatformSnapshots({ + type, + previous, + current, +}: { + type: IsmsDocumentType; + previous: IsmsPlatformData | null; + current: IsmsPlatformData; +}): { isStale: boolean; changedSources: string[] } { + if (!previous) { + return { isStale: true, changedSources: ['no-baseline'] }; + } + + const changes = computeChanges({ previous, current }); + const relevant = TYPE_DRIFT_SOURCES[type]; + const changedSources = relevant.filter((source) => changes[source]); + + return { isStale: changedSources.length > 0, changedSources }; +} + +/** Parse a stored JSON snapshot back into IsmsPlatformData. */ +export function parsePlatformSnapshot( + value: Prisma.JsonValue | null | undefined, +): IsmsPlatformData | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + // A platform snapshot always carries frameworkNames; older context-only + // snapshots are compatible because they share the same keys. + if (!('frameworkNames' in record)) return null; + + return { + organizationName: toStr(record.organizationName, 'The organization'), + frameworkNames: toStrArray(record.frameworkNames), + vendorCount: toNum(record.vendorCount), + subProcessorCount: toNum(record.subProcessorCount), + vendorsByCategory: toNumRecord(record.vendorsByCategory), + subProcessorNames: toStrArray(record.subProcessorNames), + infraVendorNames: toStrArray(record.infraVendorNames), + memberCount: toNum(record.memberCount), + membersByDepartment: toNumRecord(record.membersByDepartment), + deviceCount: toNum(record.deviceCount), + riskCount: toNum(record.riskCount), + highRiskCount: toNum(record.highRiskCount), + hasTrainingProgram: record.hasTrainingProgram === true, + wizardAnswers: parseStoredAnswers(record.wizardAnswers), + partiesFingerprint: toStr(record.partiesFingerprint, ''), + }; +} + +function toNum(value: unknown): number { + return typeof value === 'number' ? value : 0; +} + +function toStr(value: unknown, fallback: string): string { + return typeof value === 'string' ? value : fallback; +} + +function toStrArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function toNumRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'number') result[key] = item; + } + return result; +} diff --git a/apps/api/src/isms/documents/types.ts b/apps/api/src/isms/documents/types.ts new file mode 100644 index 0000000000..a902387a16 --- /dev/null +++ b/apps/api/src/isms/documents/types.ts @@ -0,0 +1,123 @@ +import type { IsmsContextSource } from '@db'; +import type { PartialWizardAnswers } from '../wizard/wizard-schema'; +import type { IsmsKeyValue } from '../utils/export-shared'; + +/** + * Platform data shared by every ISMS document derivation. Collected once per + * generate/drift/export call (always org-scoped) and passed to the per-document + * handler. Captured verbatim as the version sourceSnapshot for drift detection. + */ +export interface IsmsPlatformData { + organizationName: string; + /** Names of frameworks the organization is actively pursuing. */ + frameworkNames: string[]; + /** Total third-party vendors. */ + vendorCount: number; + /** Vendors flagged as sub-processors. */ + subProcessorCount: number; + /** Vendor counts keyed by category (cloud, software_as_a_service, ...). */ + vendorsByCategory: Record; + /** Names of vendors flagged as sub-processors. */ + subProcessorNames: string[]; + /** Names of cloud/infrastructure vendors (key dependencies). */ + infraVendorNames: string[]; + /** Total active (non-deactivated) workforce members. */ + memberCount: number; + /** Member counts keyed by department (it, hr, gov, ...). */ + membersByDepartment: Record; + /** Total managed endpoints/devices. */ + deviceCount: number; + /** Total risks in the register. */ + riskCount: number; + /** Risks at high/critical residual severity. */ + highRiskCount: number; + /** Whether the org has any training/awareness content configured. */ + hasTrainingProgram: boolean; + /** + * The org's saved ISMS wizard answers (CS-438) for this framework. Threaded + * into derivation so generated documents reflect the un-derivable inputs the + * customer supplied. Empty object when the wizard has not been filled in. + */ + wizardAnswers: PartialWizardAnswers; + /** + * Stable, order-insensitive fingerprint of the Interested Parties Register + * rows (id + name + category). The Requirements document (4.2c) derives one + * row per party, so a manual edit to a party — invisible to the rest of this + * snapshot — must still flag requirements drift. Empty string when the + * register has no rows yet. + */ + partiesFingerprint: string; +} + +/** A row destined for one of the ISMS registers (interested parties, etc.). */ +export interface DerivedRegisterRow { + source: IsmsContextSource; + derivedFrom: string; + position: number; +} + +export interface DerivedInterestedParty extends DerivedRegisterRow { + name: string; + category: string; + needsExpectations: string; +} + +export interface DerivedRequirement extends DerivedRegisterRow { + interestedPartyId: string | null; + partyName: string; + requirement: string; + treatment: string; +} + +export interface DerivedObjective extends DerivedRegisterRow { + objective: string; + target: string | null; + cadence: string | null; + plan: string | null; + measurementMethod: string | null; +} + +/** + * The organization profile that fills the narrative parts of the Context of the + * Organization document (clause 4.1) — overview table, mission, intended + * outcomes. Assembled from onboarding Q&A + the ISMS wizard at export time. + */ +export interface IsmsOrgProfile { + /** Key/value rows for the "Organization overview" table. */ + overview: IsmsKeyValue[]; + /** Company mission / description narrative, if captured. */ + mission: string | null; + /** Intended outcomes of the ISMS (customer-edited wizard answers or defaults). */ + intendedOutcomes: string[]; +} + +/** Everything a section builder needs to render a document's export sections. */ +export interface DocumentExportInput { + contextIssues: Array<{ + kind: string; + category: string | null; + description: string; + effect: string; + }>; + interestedParties: Array<{ + name: string; + category: string; + needsExpectations: string; + }>; + requirements: Array<{ + partyName: string; + requirement: string; + treatment: string; + }>; + objectives: Array<{ + objective: string; + target: string | null; + cadence: string | null; + status: string; + plan: string | null; + measurementMethod: string | null; + }>; + narrative: unknown; + /** Org overview/mission/outcomes — only populated for the Context document. */ + orgProfile?: IsmsOrgProfile; +} diff --git a/apps/api/src/isms/documents/wizard-helpers.ts b/apps/api/src/isms/documents/wizard-helpers.ts new file mode 100644 index 0000000000..facffa904e --- /dev/null +++ b/apps/api/src/isms/documents/wizard-helpers.ts @@ -0,0 +1,9 @@ +/** + * Helpers shared across the per-document derivations for consuming ISMS wizard + * answers (CS-438). + */ + +/** Strip the `custom:` prefix the wizard uses for free-text regulator entries. */ +export function formatRegulatorLabel(value: string): string { + return value.startsWith('custom:') ? value.slice('custom:'.length) : value; +} diff --git a/apps/api/src/isms/dto/ensure-isms-setup.dto.ts b/apps/api/src/isms/dto/ensure-isms-setup.dto.ts new file mode 100644 index 0000000000..e48c4e9943 --- /dev/null +++ b/apps/api/src/isms/dto/ensure-isms-setup.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class EnsureIsmsSetupDto { + @ApiProperty({ + description: 'ID of the framework to scope the ISMS setup to', + example: 'frk_abc123def456', + }) + @IsString() + frameworkId!: string; +} diff --git a/apps/api/src/isms/dto/export-isms-document.dto.ts b/apps/api/src/isms/dto/export-isms-document.dto.ts new file mode 100644 index 0000000000..2891e57eff --- /dev/null +++ b/apps/api/src/isms/dto/export-isms-document.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +export class ExportIsmsDocumentDto { + @ApiProperty({ + description: 'File format to export the ISMS document as', + enum: ['pdf', 'docx'], + example: 'pdf', + }) + @IsIn(['pdf', 'docx']) + format!: 'pdf' | 'docx'; +} diff --git a/apps/api/src/isms/dto/link-isms-controls.dto.ts b/apps/api/src/isms/dto/link-isms-controls.dto.ts new file mode 100644 index 0000000000..a9dfd0ff74 --- /dev/null +++ b/apps/api/src/isms/dto/link-isms-controls.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsString } from 'class-validator'; + +export class LinkIsmsControlsDto { + @ApiProperty({ + description: 'IDs of the controls to link to the ISMS document', + type: [String], + example: ['ctl_abc123def456', 'ctl_ghi789jkl012'], + }) + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + controlIds!: string[]; +} diff --git a/apps/api/src/isms/dto/submit-isms-for-approval.dto.ts b/apps/api/src/isms/dto/submit-isms-for-approval.dto.ts new file mode 100644 index 0000000000..0be57e7f79 --- /dev/null +++ b/apps/api/src/isms/dto/submit-isms-for-approval.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class SubmitIsmsForApprovalDto { + @ApiProperty({ + description: 'Member ID of the approver to submit the ISMS to', + example: 'mem_abc123def456', + }) + @IsString() + approverId!: string; +} diff --git a/apps/api/src/isms/isms-context-issue.service.spec.ts b/apps/api/src/isms/isms-context-issue.service.spec.ts new file mode 100644 index 0000000000..568a199762 --- /dev/null +++ b/apps/api/src/isms/isms-context-issue.service.spec.ts @@ -0,0 +1,158 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsContextIssueService } from './isms-context-issue.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + ismsContextIssue: { + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsContextIssueService', () => { + let service: IsmsContextIssueService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsContextIssueService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { kind: 'internal' as const, description: 'd', effect: 'e' }, + }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a manual issue with the next position', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + position: 2, + }); + (mockDb.ismsContextIssue.create as jest.Mock).mockResolvedValue({ + id: 'ci_1', + }); + + await service.create(args); + + expect(mockDb.ismsContextIssue.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + documentId: 'doc_1', + source: 'manual', + position: 3, + }), + }); + }); + }); + + describe('update', () => { + const args = { + issueId: 'ci_1', + organizationId: 'org_1', + dto: { description: 'updated' }, + }; + + it('throws NotFoundException when issue not in org', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.update(args)).rejects.toThrow(NotFoundException); + }); + + it('flips a derived row to manual on edit (override)', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + id: 'ci_1', + source: 'derived', + }); + (mockDb.ismsContextIssue.update as jest.Mock).mockResolvedValue({ + id: 'ci_1', + }); + + await service.update(args); + + expect(mockDb.ismsContextIssue.update).toHaveBeenCalledWith({ + where: { id: 'ci_1' }, + data: expect.objectContaining({ + description: 'updated', + source: 'manual', + }), + }); + }); + + it('scopes the lookup by organization', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + id: 'ci_1', + source: 'manual', + }); + (mockDb.ismsContextIssue.update as jest.Mock).mockResolvedValue({}); + + await service.update(args); + + expect(mockDb.ismsContextIssue.findFirst).toHaveBeenCalledWith({ + where: { id: 'ci_1', document: { organizationId: 'org_1' } }, + }); + }); + }); + + describe('remove', () => { + const args = { issueId: 'ci_1', organizationId: 'org_1' }; + + it('throws NotFoundException when issue not in org', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.remove(args)).rejects.toThrow(NotFoundException); + }); + + it('deletes the issue', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + id: 'ci_1', + }); + (mockDb.ismsContextIssue.delete as jest.Mock).mockResolvedValue({}); + + const result = await service.remove(args); + + expect(mockDb.ismsContextIssue.delete).toHaveBeenCalledWith({ + where: { id: 'ci_1' }, + }); + expect(result).toEqual({ success: true }); + }); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + (mockDb.ismsContextIssue.findFirst as jest.Mock).mockResolvedValue({ + id: 'ci_1', + documentId: 'doc_1', + }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsContextIssue.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + issueId: 'ci_1', + organizationId: 'org_1', + dto: { description: 'updated' }, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-context-issue.service.ts b/apps/api/src/isms/isms-context-issue.service.ts new file mode 100644 index 0000000000..7d2fa066be --- /dev/null +++ b/apps/api/src/isms/isms-context-issue.service.ts @@ -0,0 +1,143 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocumentForPositions } from './utils/document-lock'; +import type { + CreateContextIssueInput, + UpdateContextIssueInput, +} from './registers/register-registry'; + +/** + * CRUD for the Context-of-the-Organization (clause 4.1) issue register. Derived + * rows are written by IsmsService.generate; this service handles manual edits and + * overrides. Editing a derived row flips its source to 'manual' so the override is + * preserved across regeneration. + */ +@Injectable() +export class IsmsContextIssueService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateContextIssueInput; + }) { + await this.requireDocument({ documentId, organizationId }); + + return db.$transaction(async (tx) => { + await lockDocumentForPositions(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsContextIssue.create({ + data: { + documentId, + kind: dto.kind, + category: dto.category ?? null, + description: dto.description, + effect: dto.effect, + source: 'manual', + position, + }, + }); + }); + } + + async update({ + issueId, + organizationId, + dto, + }: { + issueId: string; + organizationId: string; + dto: UpdateContextIssueInput; + }) { + const issue = await this.requireIssue({ issueId, organizationId }); + + return db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ tx, documentId: issue.documentId }); + return tx.ismsContextIssue.update({ + where: { id: issueId }, + data: { + kind: dto.kind ?? undefined, + category: dto.category ?? undefined, + description: dto.description ?? undefined, + effect: dto.effect ?? undefined, + position: dto.position ?? undefined, + // Editing a derived row records the override by flipping it to manual. + source: 'manual', + }, + }); + }); + } + + async remove({ + issueId, + organizationId, + }: { + issueId: string; + organizationId: string; + }) { + const issue = await this.requireIssue({ issueId, organizationId }); + await db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ tx, documentId: issue.documentId }); + await tx.ismsContextIssue.delete({ where: { id: issueId } }); + }); + return { success: true }; + } + + /** + * Next position uses max(position)+1 so it survives deletes. Runs on the + * transaction client; the create first takes a per-document advisory lock + * (lockDocumentForPositions) so concurrent creates can't read the same max. + */ + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsContextIssue.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireIssue({ + issueId, + organizationId, + }: { + issueId: string; + organizationId: string; + }) { + const issue = await db.ismsContextIssue.findFirst({ + where: { id: issueId, document: { organizationId } }, + }); + if (!issue) { + throw new NotFoundException('Context issue not found'); + } + return issue; + } +} diff --git a/apps/api/src/isms/isms-context.service.spec.ts b/apps/api/src/isms/isms-context.service.spec.ts new file mode 100644 index 0000000000..a91d741002 --- /dev/null +++ b/apps/api/src/isms/isms-context.service.spec.ts @@ -0,0 +1,308 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsContextService } from './isms-context.service'; +import { collectPlatformData } from './documents/data-source'; +import { runDerivation } from './documents/generate'; +import { + diffPlatformSnapshots, + parsePlatformSnapshot, +} from './documents/snapshot'; +import { buildExportSections } from './documents/registry'; +import { generateIsmsExportFile } from './utils/export-generator'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +jest.mock('@db', () => ({ + db: { + ismsDocument: { findFirst: jest.fn() }, + organization: { findUnique: jest.fn() }, + context: { findMany: jest.fn() }, + ismsProfile: { findUnique: jest.fn() }, + $transaction: jest.fn(), + }, +})); +jest.mock('./documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); +jest.mock('./documents/generate', () => ({ + runDerivation: jest.fn(), +})); +jest.mock('./documents/snapshot', () => ({ + diffPlatformSnapshots: jest.fn(), + parsePlatformSnapshot: jest.fn(), +})); +jest.mock('./documents/registry', () => ({ + buildExportSections: jest.fn(), +})); +jest.mock('./utils/export-generator', () => ({ + generateIsmsExportFile: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockCollect = jest.mocked(collectPlatformData); +const mockRun = jest.mocked(runDerivation); +const mockDiff = jest.mocked(diffPlatformSnapshots); +const mockParse = jest.mocked(parsePlatformSnapshot); +const mockBuild = jest.mocked(buildExportSections); +const mockExport = jest.mocked(generateIsmsExportFile); + +const snapshot = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + subProcessorNames: ['Sub Co'], + infraVendorNames: ['Cloud Co'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +describe('IsmsContextService', () => { + let service: IsmsContextService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsContextService(); + }); + + describe('generate', () => { + const args = { documentId: 'doc_1', organizationId: 'org_1' }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.generate(args)).rejects.toThrow(NotFoundException); + }); + + it.each([ + 'context_of_organization', + 'interested_parties_register', + 'interested_parties_requirements', + 'objectives_plan', + 'isms_scope', + 'leadership_commitment', + ])('dispatches derivation + snapshot for type %s', async (type) => { + (mockDb.ismsDocument.findFirst as jest.Mock) + .mockResolvedValueOnce({ id: 'doc_1', type, frameworkId: 'fw_1' }) + .mockResolvedValueOnce({ id: 'doc_1' }); + mockCollect.mockResolvedValue(snapshot); + const tx = {}; + (mockDb.$transaction as jest.Mock).mockImplementation((cb) => cb(tx)); + + await service.generate(args); + + expect(mockRun).toHaveBeenCalledWith({ + tx, + type, + documentId: 'doc_1', + organizationId: 'org_1', + frameworkId: 'fw_1', + data: snapshot, + }); + expect(upsertLatestSnapshotVersion).toHaveBeenCalledWith({ + tx, + documentId: 'doc_1', + snapshot, + }); + }); + + it('reuses pre-collected data and skips collectPlatformData', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock) + .mockResolvedValueOnce({ + id: 'doc_1', + type: 'objectives_plan', + frameworkId: 'fw_1', + }) + .mockResolvedValueOnce({ id: 'doc_1' }); + const tx = {}; + (mockDb.$transaction as jest.Mock).mockImplementation((cb) => cb(tx)); + const precollected = { ...snapshot, riskCount: 42 }; + + await service.generate({ ...args, data: precollected }); + + expect(mockCollect).not.toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalledWith({ + tx, + type: 'objectives_plan', + documentId: 'doc_1', + organizationId: 'org_1', + frameworkId: 'fw_1', + data: precollected, + }); + expect(upsertLatestSnapshotVersion).toHaveBeenCalledWith({ + tx, + documentId: 'doc_1', + snapshot: precollected, + }); + }); + }); + + describe('drift', () => { + const args = { documentId: 'doc_1', organizationId: 'org_1' }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.drift(args)).rejects.toThrow(NotFoundException); + }); + + it('compares current data against the parsed snapshot by type', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'objectives_plan', + frameworkId: 'fw_1', + versions: [{ sourceSnapshot: snapshot }], + }); + mockCollect.mockResolvedValue({ ...snapshot, riskCount: 9 }); + mockParse.mockReturnValue(snapshot); + mockDiff.mockReturnValue({ isStale: true, changedSources: ['risks'] }); + + const result = await service.drift(args); + + expect(mockParse).toHaveBeenCalledWith(snapshot); + expect(mockDiff).toHaveBeenCalledWith({ + type: 'objectives_plan', + previous: snapshot, + current: { ...snapshot, riskCount: 9 }, + }); + expect(result).toEqual({ isStale: true, changedSources: ['risks'] }); + }); + + it('treats a missing snapshot as no baseline', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'context_of_organization', + frameworkId: 'fw_1', + versions: [], + }); + mockCollect.mockResolvedValue(snapshot); + mockParse.mockReturnValue(null); + mockDiff.mockReturnValue({ + isStale: true, + changedSources: ['no-baseline'], + }); + + await service.drift(args); + + expect(mockDiff).toHaveBeenCalledWith({ + type: 'context_of_organization', + previous: null, + current: snapshot, + }); + }); + }); + + describe('exportDocument', () => { + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.exportDocument({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { format: 'pdf' }, + }), + ).rejects.toThrow(NotFoundException); + }); + + const buildDocument = (type: string) => ({ + id: 'doc_1', + type, + title: 'Doc', + status: 'approved', + preparedBy: 'Comp AI', + approvedAt: null, + declinedAt: null, + framework: { name: 'ISO 27001' }, + organization: { name: 'Acme', primaryColor: '#004D3D' }, + approver: { user: { name: 'Jane', email: 'jane@acme.io' } }, + contextIssues: [ + { + kind: 'external', + category: 'Regulatory & Legal', + description: 'd', + effect: 'e', + }, + ], + interestedParties: [ + { name: 'Customers', category: 'Customer', needsExpectations: 'n' }, + ], + interestedPartyRequirements: [ + { partyName: 'Customers', requirement: 'r', treatment: 't' }, + ], + objectives: [ + { + objective: 'o', + target: 't', + cadence: 'Annual', + status: 'on_track', + plan: 'p', + measurementMethod: 'm', + }, + ], + versions: [ + { version: 2, narrative: { statement: 's', commitments: [] } }, + ], + }); + + it.each([ + ['context_of_organization', 'pdf'], + ['interested_parties_register', 'pdf'], + ['interested_parties_requirements', 'pdf'], + ['objectives_plan', 'pdf'], + ['isms_scope', 'docx'], + ['leadership_commitment', 'docx'], + ] as const)( + 'dispatches export sections for %s (%s)', + async (type, format) => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue( + buildDocument(type), + ); + // The Context document loads the org profile (org + Context Q&A + + // ISMS wizard answers); other types skip it. Stub all three reads. + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + name: 'Acme', + website: 'https://acme.io', + }); + (mockDb.context.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsProfile.findUnique as jest.Mock).mockResolvedValue({ + answers: {}, + }); + mockBuild.mockReturnValue([{ heading: 'H' }]); + mockExport.mockResolvedValue({ + fileBuffer: Buffer.from('bytes'), + mimeType: format === 'pdf' ? 'application/pdf' : 'docx-mime', + filename: `doc.${format}`, + }); + + const result = await service.exportDocument({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { format }, + }); + + expect(mockBuild).toHaveBeenCalledWith( + expect.objectContaining({ type }), + ); + expect(mockExport).toHaveBeenCalledWith( + expect.objectContaining({ + format, + sections: [{ heading: 'H' }], + metadata: expect.objectContaining({ + title: 'Doc', + organizationName: 'Acme', + primaryColor: '#004D3D', + }), + }), + ); + expect(result.fileBuffer).toBeInstanceOf(Buffer); + }, + ); + }); +}); diff --git a/apps/api/src/isms/isms-context.service.ts b/apps/api/src/isms/isms-context.service.ts new file mode 100644 index 0000000000..ec3e995ab0 --- /dev/null +++ b/apps/api/src/isms/isms-context.service.ts @@ -0,0 +1,205 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { ExportIsmsDocumentDto } from './dto/export-isms-document.dto'; +import { collectPlatformData } from './documents/data-source'; +import { runDerivation } from './documents/generate'; +import { loadOrgProfile } from './documents/org-profile'; +import { buildExportSections } from './documents/registry'; +import { + diffPlatformSnapshots, + parsePlatformSnapshot, +} from './documents/snapshot'; +import type { DocumentExportInput, IsmsPlatformData } from './documents/types'; +import { + generateIsmsExportFile, + type IsmsExportResult, +} from './utils/export-generator'; +import { buildExportMetadata } from './utils/export-metadata'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +const DOCUMENT_INCLUDE = { + versions: { where: { isLatest: true }, take: 1 }, + contextIssues: { orderBy: { position: 'asc' } }, + interestedParties: { orderBy: { position: 'asc' } }, + interestedPartyRequirements: { orderBy: { position: 'asc' } }, + objectives: { orderBy: { position: 'asc' } }, +} as const; + +/** + * ISMS document derivation, drift detection and export. Dispatches by document + * type to the per-document handlers under ./documents. Document lifecycle + * (approve/decline/submit) lives in IsmsService; register CRUD in the register + * services. + */ +@Injectable() +export class IsmsContextService { + async generate({ + documentId, + organizationId, + data: precollected, + }: { + documentId: string; + organizationId: string; + /** + * Pre-collected platform data, reused instead of re-querying. Passed by + * generateAll so the expensive multi-query collect runs once for the whole + * batch instead of once per document. Omit for a standalone regenerate. + */ + data?: IsmsPlatformData; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + const data = + precollected ?? + (await collectPlatformData({ + organizationId, + frameworkId: document.frameworkId, + })); + + await db.$transaction(async (tx) => { + await runDerivation({ + tx, + type: document.type, + documentId, + organizationId, + frameworkId: document.frameworkId, + data, + }); + await upsertLatestSnapshotVersion({ tx, documentId, snapshot: data }); + }); + + return this.loadDocument({ documentId, organizationId }); + } + + async drift({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }): Promise<{ isStale: boolean; changedSources: string[] }> { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: { versions: { where: { isLatest: true }, take: 1 } }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + const current = await collectPlatformData({ + organizationId, + frameworkId: document.frameworkId, + }); + const previous = parsePlatformSnapshot( + document.versions[0]?.sourceSnapshot, + ); + + return diffPlatformSnapshots({ type: document.type, previous, current }); + } + + async exportDocument({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: ExportIsmsDocumentDto; + }): Promise { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: { + framework: { select: { name: true } }, + organization: { + select: { name: true, website: true, primaryColor: true }, + }, + approver: { select: { user: { select: { name: true, email: true } } } }, + ...DOCUMENT_INCLUDE, + }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + // The Context document (clause 4.1) renders an org overview, mission and + // intended outcomes; other document types don't need the profile. + const orgProfile = + document.type === 'context_of_organization' + ? await loadOrgProfile({ + organizationId, + frameworkId: document.frameworkId, + }) + : undefined; + + const input: DocumentExportInput = { + contextIssues: document.contextIssues.map((issue) => ({ + kind: issue.kind, + category: issue.category, + description: issue.description, + effect: issue.effect, + })), + interestedParties: document.interestedParties.map((party) => ({ + name: party.name, + category: party.category, + needsExpectations: party.needsExpectations, + })), + requirements: document.interestedPartyRequirements.map((row) => ({ + partyName: row.partyName, + requirement: row.requirement, + treatment: row.treatment, + })), + objectives: document.objectives.map((objective) => ({ + objective: objective.objective, + target: objective.target, + cadence: objective.cadence, + status: objective.status, + plan: objective.plan, + measurementMethod: objective.measurementMethod, + })), + narrative: document.versions[0]?.narrative ?? null, + orgProfile, + }; + + const sections = buildExportSections({ type: document.type, input }); + + return generateIsmsExportFile({ + sections, + format: dto.format, + metadata: buildExportMetadata({ + type: document.type, + title: document.title, + frameworkName: document.framework.name || 'ISO 27001', + version: document.versions[0]?.version ?? 1, + status: document.status, + preparedBy: document.preparedBy, + owner: null, + approverName: + document.approver?.user?.name || + document.approver?.user?.email || + null, + approvedAt: document.approvedAt, + declinedAt: document.declinedAt, + organizationName: document.organization.name, + primaryColor: document.organization.primaryColor, + }), + }); + } + + private async loadDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + return db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: DOCUMENT_INCLUDE, + }); + } +} diff --git a/apps/api/src/isms/isms-document-control.service.spec.ts b/apps/api/src/isms/isms-document-control.service.spec.ts new file mode 100644 index 0000000000..c91cb7c11d --- /dev/null +++ b/apps/api/src/isms/isms-document-control.service.spec.ts @@ -0,0 +1,199 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsDocumentControlService } from './isms-document-control.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + control: { findMany: jest.fn() }, + ismsDocumentControlLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsDocumentControlService', () => { + let service: IsmsDocumentControlService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsDocumentControlService(); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.control.findMany as jest.Mock).mockResolvedValue([ + { id: 'ctl_1' }, + { id: 'ctl_2' }, + ]); + (mockDb.ismsDocumentControlLink.createMany as jest.Mock).mockResolvedValue({ + count: 2, + }); + (mockDb.ismsDocumentControlLink.deleteMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + }); + + describe('addControls', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_2'], + }; + + it('throws NotFoundException when the document is not in the org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.addControls(args)).rejects.toThrow(NotFoundException); + }); + + it('rejects controls that do not belong to the org', async () => { + (mockDb.control.findMany as jest.Mock).mockResolvedValue([ + { id: 'ctl_1' }, + ]); + await expect(service.addControls(args)).rejects.toThrow( + BadRequestException, + ); + expect(mockDb.ismsDocumentControlLink.createMany).not.toHaveBeenCalled(); + }); + + it('verifies controls are org-scoped', async () => { + await service.addControls(args); + expect(mockDb.control.findMany).toHaveBeenCalledWith({ + where: { id: { in: ['ctl_1', 'ctl_2'] }, organizationId: 'org_1' }, + select: { id: true }, + }); + }); + + it('creates links idempotently and de-duplicates input', async () => { + await service.addControls({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_1', 'ctl_2'], + }); + + expect(mockDb.ismsDocumentControlLink.createMany).toHaveBeenCalledWith({ + data: [ + { ismsDocumentId: 'doc_1', controlId: 'ctl_1' }, + { ismsDocumentId: 'doc_1', controlId: 'ctl_2' }, + ], + skipDuplicates: true, + }); + }); + }); + + describe('removeControl', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + controlId: 'ctl_1', + }; + + it('throws NotFoundException when the document is not in the org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.removeControl(args)).rejects.toThrow( + NotFoundException, + ); + }); + + it('deletes the link scoped to the document', async () => { + await service.removeControl(args); + expect(mockDb.ismsDocumentControlLink.deleteMany).toHaveBeenCalledWith({ + where: { ismsDocumentId: 'doc_1', controlId: 'ctl_1' }, + }); + }); + }); + + describe('approval invalidation', () => { + it('reverts an approved document to draft on control add', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + + await service.addControls({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_2'], + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); + + it('reverts an approved document to draft on control remove', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + + await service.removeControl({ + documentId: 'doc_1', + organizationId: 'org_1', + controlId: 'ctl_1', + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); + + it('leaves a draft document untouched on control add', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'draft', + }); + + await service.addControls({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_2'], + }); + + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + + it('does not invalidate an approved document on an idempotent add (no rows inserted)', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + // createMany inserts nothing because the links already exist. + (mockDb.ismsDocumentControlLink.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + + await service.addControls({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1', 'ctl_2'], + }); + + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + + it('does not invalidate an approved document on a no-op remove (no rows deleted)', async () => { + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsDocumentControlLink.deleteMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + + await service.removeControl({ + documentId: 'doc_1', + organizationId: 'org_1', + controlId: 'ctl_1', + }); + + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/isms/isms-document-control.service.ts b/apps/api/src/isms/isms-document-control.service.ts new file mode 100644 index 0000000000..c3454f5c9e --- /dev/null +++ b/apps/api/src/isms/isms-document-control.service.ts @@ -0,0 +1,97 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; + +/** + * Org-level mapping between an ISMS document and the organization's Controls + * (CS-437). Mirrors the Policy<->Control mapping but over the explicit + * IsmsDocumentControlLink junction. Everything is org-scoped: the document and + * every control must belong to the caller's organization. + */ +@Injectable() +export class IsmsDocumentControlService { + async addControls({ + documentId, + organizationId, + controlIds, + }: { + documentId: string; + organizationId: string; + controlIds: string[]; + }) { + await this.requireDocument({ documentId, organizationId }); + + const uniqueControlIds = Array.from(new Set(controlIds)); + const controls = await db.control.findMany({ + where: { id: { in: uniqueControlIds }, organizationId }, + select: { id: true }, + }); + if (controls.length !== uniqueControlIds.length) { + throw new BadRequestException( + 'One or more controls do not belong to the organization', + ); + } + + // Mutating an approved document's control mappings invalidates its sign-off, + // so revert it to draft in the same transaction as the write (mirrors the + // register/narrative edits). Only a REAL change invalidates — an idempotent + // re-link that inserts nothing must not downgrade an approved document. + await db.$transaction(async (tx) => { + const { count } = await tx.ismsDocumentControlLink.createMany({ + data: uniqueControlIds.map((controlId) => ({ + ismsDocumentId: documentId, + controlId, + })), + skipDuplicates: true, + }); + if (count > 0) { + await invalidateApprovalIfNeeded({ tx, documentId }); + } + }); + return { message: 'Controls linked' }; + } + + async removeControl({ + documentId, + organizationId, + controlId, + }: { + documentId: string; + organizationId: string; + controlId: string; + }) { + await this.requireDocument({ documentId, organizationId }); + // Only a real unlink (a row actually deleted) invalidates sign-off; removing + // a control that wasn't linked must not downgrade an approved document. + await db.$transaction(async (tx) => { + const { count } = await tx.ismsDocumentControlLink.deleteMany({ + where: { ismsDocumentId: documentId, controlId }, + }); + if (count > 0) { + await invalidateApprovalIfNeeded({ tx, documentId }); + } + }); + return { message: 'Control unlinked' }; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + select: { id: true }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } +} diff --git a/apps/api/src/isms/isms-interested-party.service.spec.ts b/apps/api/src/isms/isms-interested-party.service.spec.ts new file mode 100644 index 0000000000..31a1adc2c1 --- /dev/null +++ b/apps/api/src/isms/isms-interested-party.service.spec.ts @@ -0,0 +1,148 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsInterestedPartyService } from './isms-interested-party.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + ismsInterestedParty: { + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsInterestedPartyService', () => { + let service: IsmsInterestedPartyService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsInterestedPartyService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { name: 'Customers', category: 'Customer', needsExpectations: 'n' }, + }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a manual party at the next position', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + position: 3, + }); + (mockDb.ismsInterestedParty.create as jest.Mock).mockResolvedValue({ + id: 'ip_1', + }); + + await service.create(args); + + expect(mockDb.ismsInterestedParty.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ source: 'manual', position: 4 }), + }); + }); + }); + + describe('update', () => { + const args = { + partyId: 'ip_1', + organizationId: 'org_1', + dto: { name: 'Updated' }, + }; + + it('throws NotFoundException when party not in org', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue( + null, + ); + await expect(service.update(args)).rejects.toThrow(NotFoundException); + }); + + it('flips a derived row to manual on edit', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + source: 'derived', + }); + (mockDb.ismsInterestedParty.update as jest.Mock).mockResolvedValue({}); + + await service.update(args); + + expect(mockDb.ismsInterestedParty.update).toHaveBeenCalledWith({ + where: { id: 'ip_1' }, + data: expect.objectContaining({ name: 'Updated', source: 'manual' }), + }); + }); + + it('scopes the lookup by organization', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + }); + (mockDb.ismsInterestedParty.update as jest.Mock).mockResolvedValue({}); + await service.update(args); + expect(mockDb.ismsInterestedParty.findFirst).toHaveBeenCalledWith({ + where: { id: 'ip_1', document: { organizationId: 'org_1' } }, + }); + }); + }); + + describe('remove', () => { + const args = { partyId: 'ip_1', organizationId: 'org_1' }; + + it('throws NotFoundException when not in org', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue( + null, + ); + await expect(service.remove(args)).rejects.toThrow(NotFoundException); + }); + + it('deletes the party', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + }); + (mockDb.ismsInterestedParty.delete as jest.Mock).mockResolvedValue({}); + const result = await service.remove(args); + expect(mockDb.ismsInterestedParty.delete).toHaveBeenCalledWith({ + where: { id: 'ip_1' }, + }); + expect(result).toEqual({ success: true }); + }); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + documentId: 'doc_1', + }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsInterestedParty.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + partyId: 'ip_1', + organizationId: 'org_1', + dto: { name: 'Updated' }, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-interested-party.service.ts b/apps/api/src/isms/isms-interested-party.service.ts new file mode 100644 index 0000000000..58b3dcbf8e --- /dev/null +++ b/apps/api/src/isms/isms-interested-party.service.ts @@ -0,0 +1,140 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocumentForPositions } from './utils/document-lock'; +import type { + CreateInterestedPartyInput, + UpdateInterestedPartyInput, +} from './registers/register-registry'; + +/** + * CRUD for the Interested Parties register (clause 4.2a). Derived rows are written + * by IsmsContextService.generate; this service handles manual edits and overrides. + * Editing a derived row flips its source to 'manual' so the override survives + * regeneration. + */ +@Injectable() +export class IsmsInterestedPartyService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateInterestedPartyInput; + }) { + await this.requireDocument({ documentId, organizationId }); + + return db.$transaction(async (tx) => { + await lockDocumentForPositions(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsInterestedParty.create({ + data: { + documentId, + name: dto.name, + category: dto.category, + needsExpectations: dto.needsExpectations, + source: 'manual', + position, + }, + }); + }); + } + + async update({ + partyId, + organizationId, + dto, + }: { + partyId: string; + organizationId: string; + dto: UpdateInterestedPartyInput; + }) { + const party = await this.requireParty({ partyId, organizationId }); + + return db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ tx, documentId: party.documentId }); + return tx.ismsInterestedParty.update({ + where: { id: partyId }, + data: { + name: dto.name ?? undefined, + category: dto.category ?? undefined, + needsExpectations: dto.needsExpectations ?? undefined, + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + partyId, + organizationId, + }: { + partyId: string; + organizationId: string; + }) { + const party = await this.requireParty({ partyId, organizationId }); + await db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ tx, documentId: party.documentId }); + await tx.ismsInterestedParty.delete({ where: { id: partyId } }); + }); + return { success: true }; + } + + /** + * Next position uses max(position)+1 so it survives deletes. Runs on the + * transaction client; the create first takes a per-document advisory lock + * (lockDocumentForPositions) so concurrent creates can't read the same max. + */ + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsInterestedParty.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireParty({ + partyId, + organizationId, + }: { + partyId: string; + organizationId: string; + }) { + const party = await db.ismsInterestedParty.findFirst({ + where: { id: partyId, document: { organizationId } }, + }); + if (!party) { + throw new NotFoundException('Interested party not found'); + } + return party; + } +} diff --git a/apps/api/src/isms/isms-narrative.service.spec.ts b/apps/api/src/isms/isms-narrative.service.spec.ts new file mode 100644 index 0000000000..a1e6db166e --- /dev/null +++ b/apps/api/src/isms/isms-narrative.service.spec.ts @@ -0,0 +1,153 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsNarrativeService } from './isms-narrative.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + ismsDocumentVersion: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +const validScope = { + certificateScopeSentence: 'The ISMS covers Acme.', + inScope: 'Everything.', + interfaces: ['Suppliers'], + dependencies: ['Cloud'], + exclusions: [], +}; + +describe('IsmsNarrativeService', () => { + let service: IsmsNarrativeService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsNarrativeService(); + }); + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }), + ).rejects.toThrow(NotFoundException); + }); + + it('rejects a register-type document that has no narrative schema', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'interested_parties_register', + }); + await expect( + service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects a narrative that fails zod validation', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + }); + await expect( + service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: { certificateScopeSentence: 123 }, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('creates a version when none exists', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + }); + (mockDb.ismsDocumentVersion.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.ismsDocumentVersion.create as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + + await service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }); + + expect(mockDb.ismsDocumentVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ documentId: 'doc_1', version: 1 }), + }), + ); + }); + + it('updates the latest version when present', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + }); + (mockDb.ismsDocumentVersion.findFirst as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + (mockDb.ismsDocumentVersion.update as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + + await service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }); + + expect(mockDb.ismsDocumentVersion.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'ver_1' } }), + ); + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsDocumentVersion.findFirst as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + (mockDb.ismsDocumentVersion.update as jest.Mock).mockResolvedValue({ + id: 'ver_1', + }); + + await service.save({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: validScope, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-narrative.service.ts b/apps/api/src/isms/isms-narrative.service.ts new file mode 100644 index 0000000000..fd06a3ceea --- /dev/null +++ b/apps/api/src/isms/isms-narrative.service.ts @@ -0,0 +1,78 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { narrativeSchemaForType } from './documents/registry'; +import { invalidateApprovalIfNeeded } from './utils/approval'; + +/** + * Saves the singleton-document narrative (clauses 4.3 ISMS Scope and 5.1 + * Leadership Commitment) into the document's latest version. The payload is + * validated against the per-type Zod schema before persisting. + */ +@Injectable() +export class IsmsNarrativeService { + async save({ + documentId, + organizationId, + narrative, + }: { + documentId: string; + organizationId: string; + narrative: unknown; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + const schema = narrativeSchemaForType(document.type); + if (!schema) { + throw new BadRequestException( + `Document type ${document.type} does not store a narrative`, + ); + } + + const parsed = schema.safeParse(narrative); + if (!parsed.success) { + throw new BadRequestException( + `Invalid narrative: ${parsed.error.issues + .map((issue) => `${issue.path.join('.')} ${issue.message}`) + .join('; ')}`, + ); + } + + const value: Prisma.InputJsonValue = JSON.parse( + JSON.stringify(parsed.data), + ); + + // The latest-version read, approval invalidation, and the narrative write + // must all be atomic: reading the latest version outside the transaction + // lets a concurrent save update a stale version or hit the unique + // constraint, and a failed write must not leave the document reverted to + // draft without the new content. + return db.$transaction(async (tx) => { + const latest = await tx.ismsDocumentVersion.findFirst({ + where: { documentId, isLatest: true }, + }); + + await invalidateApprovalIfNeeded({ tx, documentId }); + + if (latest) { + return tx.ismsDocumentVersion.update({ + where: { id: latest.id }, + data: { narrative: value }, + }); + } + + return tx.ismsDocumentVersion.create({ + data: { documentId, version: 1, isLatest: true, narrative: value }, + }); + }); + } +} diff --git a/apps/api/src/isms/isms-objective.service.spec.ts b/apps/api/src/isms/isms-objective.service.spec.ts new file mode 100644 index 0000000000..03e24c59cf --- /dev/null +++ b/apps/api/src/isms/isms-objective.service.spec.ts @@ -0,0 +1,211 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsObjectiveService } from './isms-objective.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + member: { findFirst: jest.fn() }, + ismsObjective: { + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsObjectiveService', () => { + let service: IsmsObjectiveService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsObjectiveService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { objective: 'Maintain ISO 27001', status: 'on_track' as const }, + }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a manual objective with status + next position', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + position: 1, + }); + (mockDb.ismsObjective.create as jest.Mock).mockResolvedValue({ + id: 'obj_1', + }); + + await service.create(args); + + expect(mockDb.ismsObjective.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + source: 'manual', + position: 2, + status: 'on_track', + }), + }); + }); + + it('defaults status to not_started', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsObjective.count as jest.Mock).mockResolvedValue(0); + (mockDb.ismsObjective.create as jest.Mock).mockResolvedValue({}); + await service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { objective: 'X' }, + }); + const call = (mockDb.ismsObjective.create as jest.Mock).mock.calls[0][0]; + expect(call.data.status).toBe('not_started'); + }); + + it('throws NotFoundException when the owner is not in the org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { objective: 'X', ownerMemberId: 'mem_other' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.member.findFirst).toHaveBeenCalledWith({ + where: { id: 'mem_other', organizationId: 'org_1' }, + }); + expect(mockDb.ismsObjective.create).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + const args = { + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { status: 'met' as const, ownerMemberId: 'mem_1' }, + }; + + it('throws NotFoundException when not in org', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.update(args)).rejects.toThrow(NotFoundException); + }); + + it('flips a derived row to manual on edit', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + source: 'derived', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsObjective.update as jest.Mock).mockResolvedValue({}); + + await service.update(args); + + expect(mockDb.ismsObjective.update).toHaveBeenCalledWith({ + where: { id: 'obj_1' }, + data: expect.objectContaining({ + status: 'met', + ownerMemberId: 'mem_1', + source: 'manual', + }), + }); + }); + + it('throws NotFoundException when the owner is not in the org', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.update({ + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { ownerMemberId: 'mem_other' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.member.findFirst).toHaveBeenCalledWith({ + where: { id: 'mem_other', organizationId: 'org_1' }, + }); + }); + + it('clears the owner when given an empty string', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + }); + (mockDb.ismsObjective.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { ownerMemberId: ' ' }, + }); + + expect(mockDb.member.findFirst).not.toHaveBeenCalled(); + expect(mockDb.ismsObjective.update).toHaveBeenCalledWith({ + where: { id: 'obj_1' }, + data: expect.objectContaining({ ownerMemberId: null }), + }); + }); + }); + + describe('remove', () => { + const args = { objectiveId: 'obj_1', organizationId: 'org_1' }; + + it('throws NotFoundException when not in org', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.remove(args)).rejects.toThrow(NotFoundException); + }); + + it('deletes the objective', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + }); + (mockDb.ismsObjective.delete as jest.Mock).mockResolvedValue({}); + const result = await service.remove(args); + expect(result).toEqual({ success: true }); + }); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue({ + id: 'obj_1', + documentId: 'doc_1', + }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + (mockDb.ismsObjective.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { status: 'met' }, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-objective.service.ts b/apps/api/src/isms/isms-objective.service.ts new file mode 100644 index 0000000000..98a5986930 --- /dev/null +++ b/apps/api/src/isms/isms-objective.service.ts @@ -0,0 +1,198 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocumentForPositions } from './utils/document-lock'; +import type { + CreateObjectiveInput, + UpdateObjectiveInput, +} from './registers/register-registry'; + +/** + * CRUD for the Information Security Objectives register (clause 6.2). Derived rows + * are written by IsmsContextService.generate; this service handles manual edits and + * status/owner updates. Editing a derived row flips its source to 'manual'. + */ +@Injectable() +export class IsmsObjectiveService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateObjectiveInput; + }) { + await this.requireDocument({ documentId, organizationId }); + const ownerMemberId = await this.resolveOwner({ + ownerMemberId: dto.ownerMemberId, + organizationId, + }); + + return db.$transaction(async (tx) => { + await lockDocumentForPositions(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsObjective.create({ + data: { + documentId, + objective: dto.objective, + target: dto.target ?? null, + ownerMemberId: ownerMemberId ?? null, + cadence: dto.cadence ?? null, + plan: dto.plan ?? null, + measurementMethod: dto.measurementMethod ?? null, + status: dto.status ?? 'not_started', + source: 'manual', + position, + }, + }); + }); + } + + async update({ + objectiveId, + organizationId, + dto, + }: { + objectiveId: string; + organizationId: string; + dto: UpdateObjectiveInput; + }) { + const objective = await this.requireObjective({ + objectiveId, + organizationId, + }); + // undefined = field omitted (leave as-is); empty string = clear the owner. + const ownerFieldProvided = dto.ownerMemberId !== undefined; + const ownerMemberId = await this.resolveOwner({ + ownerMemberId: dto.ownerMemberId, + organizationId, + }); + + return db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ + tx, + documentId: objective.documentId, + }); + return tx.ismsObjective.update({ + where: { id: objectiveId }, + data: { + objective: dto.objective ?? undefined, + target: dto.target ?? undefined, + ownerMemberId: ownerFieldProvided ? ownerMemberId : undefined, + cadence: dto.cadence ?? undefined, + plan: dto.plan ?? undefined, + measurementMethod: dto.measurementMethod ?? undefined, + status: dto.status ?? undefined, + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + objectiveId, + organizationId, + }: { + objectiveId: string; + organizationId: string; + }) { + const objective = await this.requireObjective({ + objectiveId, + organizationId, + }); + await db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ + tx, + documentId: objective.documentId, + }); + await tx.ismsObjective.delete({ where: { id: objectiveId } }); + }); + return { success: true }; + } + + /** + * Next position uses max(position)+1 so it survives deletes. Runs on the + * transaction client; the create first takes a per-document advisory lock + * (lockDocumentForPositions) so concurrent creates can't read the same max. + */ + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsObjective.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + /** + * Resolve an objective owner. `undefined` (field omitted) is passed through so + * the caller can leave it untouched; an empty/whitespace value clears it; a + * non-empty id must resolve to a member of the document's organization (mirrors + * submitForApproval's approver check). + */ + private async resolveOwner({ + ownerMemberId, + organizationId, + }: { + ownerMemberId: string | undefined; + organizationId: string; + }): Promise { + if (ownerMemberId === undefined) { + return undefined; + } + const trimmed = ownerMemberId.trim(); + if (!trimmed) { + return null; + } + const member = await db.member.findFirst({ + where: { id: trimmed, organizationId }, + }); + if (!member) { + throw new NotFoundException('Owner not found in organization'); + } + return trimmed; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireObjective({ + objectiveId, + organizationId, + }: { + objectiveId: string; + organizationId: string; + }) { + const objective = await db.ismsObjective.findFirst({ + where: { id: objectiveId, document: { organizationId } }, + }); + if (!objective) { + throw new NotFoundException('Objective not found'); + } + return objective; + } +} diff --git a/apps/api/src/isms/isms-registers.controller.spec.ts b/apps/api/src/isms/isms-registers.controller.spec.ts new file mode 100644 index 0000000000..fe99b08ae0 --- /dev/null +++ b/apps/api/src/isms/isms-registers.controller.spec.ts @@ -0,0 +1,264 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { PERMISSIONS_KEY } from '../auth/permission.guard'; +import { IsmsRegistersController } from './isms-registers.controller'; +import { IsmsContextIssueService } from './isms-context-issue.service'; +import { IsmsInterestedPartyService } from './isms-interested-party.service'; +import { IsmsRequirementService } from './isms-requirement.service'; +import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsNarrativeService } from './isms-narrative.service'; + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, + PERMISSIONS_KEY: 'permissions', +})); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('./isms-context-issue.service', () => ({ + IsmsContextIssueService: class {}, +})); +jest.mock('./isms-interested-party.service', () => ({ + IsmsInterestedPartyService: class {}, +})); +jest.mock('./isms-requirement.service', () => ({ + IsmsRequirementService: class {}, +})); +jest.mock('./isms-objective.service', () => ({ + IsmsObjectiveService: class {}, +})); +jest.mock('./isms-narrative.service', () => ({ + IsmsNarrativeService: class {}, +})); + +const reqWith = (body: Record) => + ({ body }) as unknown as Request; + +describe('IsmsRegistersController', () => { + let controller: IsmsRegistersController; + + const contextIssueService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const interestedPartyService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const requirementService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const objectiveService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const narrativeService = { save: jest.fn() }; + + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IsmsRegistersController], + providers: [ + { provide: IsmsContextIssueService, useValue: contextIssueService }, + { + provide: IsmsInterestedPartyService, + useValue: interestedPartyService, + }, + { provide: IsmsRequirementService, useValue: requirementService }, + { provide: IsmsObjectiveService, useValue: objectiveService }, + { provide: IsmsNarrativeService, useValue: narrativeService }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(IsmsRegistersController); + jest.clearAllMocks(); + }); + + describe('createRow', () => { + it('dispatches interested-parties create with documentId, parsed dto, org', async () => { + const dto = { + name: 'Customers', + category: 'Customer', + needsExpectations: 'n', + }; + await controller.createRow( + 'doc_1', + 'interested-parties', + reqWith(dto), + 'org_1', + ); + expect(interestedPartyService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto, + }); + }); + + it('dispatches context-issues create and passes category through', async () => { + const body = { + kind: 'internal', + category: 'Strategic', + description: 'd', + effect: 'e', + }; + await controller.createRow( + 'doc_1', + 'context-issues', + reqWith(body), + 'org_1', + ); + expect(contextIssueService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('dispatches requirements create with parsed dto', async () => { + const body = { partyName: 'C', requirement: 'r', treatment: 't' }; + await controller.createRow( + 'doc_1', + 'requirements', + reqWith(body), + 'org_1', + ); + expect(requirementService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('dispatches objectives create with parsed dto', async () => { + const body = { objective: 'o' }; + await controller.createRow('doc_1', 'objectives', reqWith(body), 'org_1'); + expect(objectiveService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('throws BadRequestException for an unknown register', async () => { + await expect( + controller.createRow('doc_1', 'nope', reqWith({}), 'org_1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('updateRow', () => { + it('dispatches context-issues update with issueId, parsed dto, org', async () => { + const body = { description: 'updated' }; + await controller.updateRow( + 'context-issues', + 'row1', + reqWith(body), + 'org_1', + ); + expect(contextIssueService.update).toHaveBeenCalledWith({ + issueId: 'row1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('dispatches interested-parties update with partyId', async () => { + const body = { name: 'X' }; + await controller.updateRow( + 'interested-parties', + 'ip_1', + reqWith(body), + 'org_1', + ); + expect(interestedPartyService.update).toHaveBeenCalledWith({ + partyId: 'ip_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('throws BadRequestException for an unknown register', async () => { + await expect( + controller.updateRow('nope', 'row1', reqWith({}), 'org_1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('deleteRow', () => { + it('dispatches objectives remove with objectiveId and org', async () => { + await controller.deleteRow('objectives', 'row1', 'org_1'); + expect(objectiveService.remove).toHaveBeenCalledWith({ + objectiveId: 'row1', + organizationId: 'org_1', + }); + }); + + it('dispatches requirements remove with requirementId and org', async () => { + await controller.deleteRow('requirements', 'req_1', 'org_1'); + expect(requirementService.remove).toHaveBeenCalledWith({ + requirementId: 'req_1', + organizationId: 'org_1', + }); + }); + + it('throws BadRequestException for an unknown register', async () => { + await expect( + controller.deleteRow('nope', 'row1', 'org_1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + it('saveNarrative reads req.body.narrative and passes it through', async () => { + await controller.saveNarrative( + 'doc_1', + reqWith({ narrative: { statement: 's' } }), + 'org_1', + ); + expect(narrativeService.save).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + narrative: { statement: 's' }, + }); + }); + + describe('permission metadata', () => { + const reflector = new Reflector(); + const permissionsFor = (method: keyof IsmsRegistersController) => + reflector.get(PERMISSIONS_KEY, IsmsRegistersController.prototype[method]); + + it('gates every mutation with evidence:update', () => { + for (const method of [ + 'createRow', + 'updateRow', + 'deleteRow', + 'saveNarrative', + ] as const) { + expect(permissionsFor(method)).toEqual([ + { resource: 'evidence', actions: ['update'] }, + ]); + } + }); + }); +}); diff --git a/apps/api/src/isms/isms-registers.controller.ts b/apps/api/src/isms/isms-registers.controller.ts new file mode 100644 index 0000000000..6528372300 --- /dev/null +++ b/apps/api/src/isms/isms-registers.controller.ts @@ -0,0 +1,203 @@ +import { + BadRequestException, + Controller, + Delete, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '@/auth/auth-context.decorator'; +import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { IsmsContextIssueService } from './isms-context-issue.service'; +import { IsmsInterestedPartyService } from './isms-interested-party.service'; +import { IsmsRequirementService } from './isms-requirement.service'; +import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsNarrativeService } from './isms-narrative.service'; +import { + createRegisterRegistry, + type IsmsRegisterKey, + type RegisterHandler, +} from './registers/register-registry'; + +/** + * OpenAPI body contracts for the generic register endpoints. They read `req.body` + * directly (the global ValidationPipe mangles nested JSON), so the request shape + * is documented explicitly here. Each register accepts its own fields — the union + * below covers every register's row; per-register validation is enforced at + * runtime by the registry's zod schemas. Mirrors the inline-schema @ApiBody used + * by the policies controller for its @Req()-bodied endpoints. + */ +const REGISTER_ROW_BODY = { + description: 'Register row fields (per-register; validated at runtime by zod)', + schema: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['internal', 'external'] }, + category: { type: 'string' }, + description: { type: 'string' }, + effect: { type: 'string' }, + name: { type: 'string' }, + needsExpectations: { type: 'string' }, + interestedPartyId: { type: 'string' }, + partyName: { type: 'string' }, + requirement: { type: 'string' }, + treatment: { type: 'string' }, + objective: { type: 'string' }, + target: { type: 'string' }, + ownerMemberId: { type: 'string' }, + cadence: { type: 'string' }, + plan: { type: 'string' }, + measurementMethod: { type: 'string' }, + status: { + type: 'string', + enum: ['not_started', 'on_track', 'at_risk', 'met'], + }, + position: { type: 'integer', minimum: 0 }, + }, + }, +} as const; + +const NARRATIVE_BODY = { + description: 'Singleton document narrative payload', + schema: { + type: 'object', + properties: { + narrative: { + type: 'object', + description: + 'Per-type narrative object (e.g. ISMS scope or leadership commitment), validated at runtime by zod', + additionalProperties: true, + }, + }, + required: ['narrative'], + }, +} as const; + +/** + * Generic CRUD for every ISMS register row (context issues, interested parties, + * requirements, objectives) via a single create / update / delete trio routed by + * the `:register` segment, plus the singleton narrative save. Bodies are read + * from `req.body` and validated by the register registry's zod schemas. + */ +@ApiTags('ISMS') +@Controller({ path: 'isms', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class IsmsRegistersController { + private readonly registry: Record; + + constructor( + contextIssueService: IsmsContextIssueService, + interestedPartyService: IsmsInterestedPartyService, + requirementService: IsmsRequirementService, + objectiveService: IsmsObjectiveService, + private readonly narrativeService: IsmsNarrativeService, + ) { + this.registry = createRegisterRegistry({ + contextIssues: contextIssueService, + interestedParties: interestedPartyService, + requirements: requirementService, + objectives: objectiveService, + }); + } + + private resolve(register: string): RegisterHandler { + const handler = this.registry[register as IsmsRegisterKey]; + if (!handler) { + throw new BadRequestException(`Unknown ISMS register: ${register}`); + } + return handler; + } + + @Post('documents/:id/registers/:register') + @HttpCode(HttpStatus.CREATED) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Create a row in an ISMS register' }) + @ApiConsumes('application/json') + @ApiBody(REGISTER_ROW_BODY) + @ApiOkResponse({ description: 'Register row created' }) + async createRow( + @Param('id') id: string, + @Param('register') register: string, + // Read req.body directly: the global ValidationPipe mangles nested JSON. + @Req() req: Request, + @OrganizationId() organizationId: string, + ) { + return this.resolve(register).create({ + documentId: id, + organizationId, + data: req.body, + }); + } + + @Patch('registers/:register/:rowId') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Update a row in an ISMS register' }) + @ApiConsumes('application/json') + @ApiBody(REGISTER_ROW_BODY) + @ApiOkResponse({ description: 'Register row updated' }) + async updateRow( + @Param('register') register: string, + @Param('rowId') rowId: string, + @Req() req: Request, + @OrganizationId() organizationId: string, + ) { + return this.resolve(register).update({ + rowId, + organizationId, + data: req.body, + }); + } + + @Delete('registers/:register/:rowId') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Delete a row in an ISMS register' }) + @ApiOkResponse({ description: 'Register row deleted' }) + async deleteRow( + @Param('register') register: string, + @Param('rowId') rowId: string, + @OrganizationId() organizationId: string, + ) { + return this.resolve(register).remove({ rowId, organizationId }); + } + + // --- Singleton narrative (4.3 scope, 5.1 leadership) --- + + @Post('documents/:id/narrative') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Save a singleton document narrative' }) + @ApiConsumes('application/json') + @ApiBody(NARRATIVE_BODY) + @ApiOkResponse({ description: 'Narrative saved' }) + async saveNarrative( + @Param('id') id: string, + // Read req.body directly: ValidationPipe with transform mangles nested JSON. + @Req() req: Request, + @OrganizationId() organizationId: string, + ) { + const body = (req.body ?? {}) as { narrative?: unknown }; + return this.narrativeService.save({ + documentId: id, + organizationId, + narrative: body.narrative, + }); + } +} diff --git a/apps/api/src/isms/isms-requirement.service.spec.ts b/apps/api/src/isms/isms-requirement.service.spec.ts new file mode 100644 index 0000000000..be92393645 --- /dev/null +++ b/apps/api/src/isms/isms-requirement.service.spec.ts @@ -0,0 +1,224 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsRequirementService } from './isms-requirement.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + ismsInterestedParty: { findFirst: jest.fn() }, + ismsInterestedPartyRequirement: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + // Run the callback with the same mock as the transaction client. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsRequirementService', () => { + let service: IsmsRequirementService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsRequirementService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { partyName: 'Customers', requirement: 'r', treatment: 't' }, + }; + + it('throws NotFoundException when document missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a manual requirement at the next position', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ position: 0 }); + ( + mockDb.ismsInterestedPartyRequirement.create as jest.Mock + ).mockResolvedValue({ id: 'ipr_1' }); + + await service.create(args); + + expect(mockDb.ismsInterestedPartyRequirement.create).toHaveBeenCalledWith( + { + data: expect.objectContaining({ + source: 'manual', + position: 1, + interestedPartyId: null, + }), + }, + ); + }); + + it('rejects a party that does not belong to the document', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { + partyName: 'Customers', + requirement: 'r', + treatment: 't', + interestedPartyId: 'ip_other', + }, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('links a party that belongs to the document', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue({ + id: 'ip_1', + }); + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ position: 0 }); + ( + mockDb.ismsInterestedPartyRequirement.create as jest.Mock + ).mockResolvedValue({ id: 'ipr_1' }); + + await service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { + partyName: 'Customers', + requirement: 'r', + treatment: 't', + interestedPartyId: 'ip_1', + }, + }); + + expect(mockDb.ismsInterestedParty.findFirst).toHaveBeenCalledWith({ + where: { id: 'ip_1', documentId: 'doc_1' }, + select: { id: true }, + }); + expect( + mockDb.ismsInterestedPartyRequirement.create, + ).toHaveBeenCalledWith({ + data: expect.objectContaining({ interestedPartyId: 'ip_1' }), + }); + }); + }); + + describe('update', () => { + const args = { + requirementId: 'ipr_1', + organizationId: 'org_1', + dto: { treatment: 'updated' }, + }; + + it('throws NotFoundException when not in org', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue(null); + await expect(service.update(args)).rejects.toThrow(NotFoundException); + }); + + it('flips a derived row to manual on edit', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ id: 'ipr_1', source: 'derived' }); + ( + mockDb.ismsInterestedPartyRequirement.update as jest.Mock + ).mockResolvedValue({}); + + await service.update(args); + + expect(mockDb.ismsInterestedPartyRequirement.update).toHaveBeenCalledWith( + { + where: { id: 'ipr_1' }, + data: expect.objectContaining({ + treatment: 'updated', + source: 'manual', + }), + }, + ); + }); + + it('rejects relinking to a party from another document', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ id: 'ipr_1', documentId: 'doc_1' }); + (mockDb.ismsInterestedParty.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.update({ + requirementId: 'ipr_1', + organizationId: 'org_1', + dto: { interestedPartyId: 'ip_other' }, + }), + ).rejects.toThrow(BadRequestException); + expect(mockDb.ismsInterestedParty.findFirst).toHaveBeenCalledWith({ + where: { id: 'ip_other', documentId: 'doc_1' }, + select: { id: true }, + }); + }); + }); + + describe('remove', () => { + const args = { requirementId: 'ipr_1', organizationId: 'org_1' }; + + it('throws NotFoundException when not in org', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue(null); + await expect(service.remove(args)).rejects.toThrow(NotFoundException); + }); + + it('deletes the requirement', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ id: 'ipr_1' }); + ( + mockDb.ismsInterestedPartyRequirement.delete as jest.Mock + ).mockResolvedValue({}); + const result = await service.remove(args); + expect(result).toEqual({ success: true }); + }); + }); + + it('reverts an approved document to draft so it needs re-approval', async () => { + ( + mockDb.ismsInterestedPartyRequirement.findFirst as jest.Mock + ).mockResolvedValue({ id: 'ipr_1', documentId: 'doc_1' }); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'approved', + }); + ( + mockDb.ismsInterestedPartyRequirement.update as jest.Mock + ).mockResolvedValue({}); + + await service.update({ + requirementId: 'ipr_1', + organizationId: 'org_1', + dto: { treatment: 'updated' }, + }); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); + }); +}); diff --git a/apps/api/src/isms/isms-requirement.service.ts b/apps/api/src/isms/isms-requirement.service.ts new file mode 100644 index 0000000000..568219d3bf --- /dev/null +++ b/apps/api/src/isms/isms-requirement.service.ts @@ -0,0 +1,194 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocumentForPositions } from './utils/document-lock'; +import type { + CreateRequirementInput, + UpdateRequirementInput, +} from './registers/register-registry'; + +/** + * CRUD for the Interested Parties Requirements & ISMS Treatment register (clauses + * 4.2b/c). Derived rows are written by IsmsContextService.generate; this service + * handles manual edits. Editing a derived row flips its source to 'manual'. + */ +@Injectable() +export class IsmsRequirementService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateRequirementInput; + }) { + await this.requireDocument({ documentId, organizationId }); + // Treat empty/whitespace as "no link" so a blank id can't skip validation + // or be persisted as an invalid reference. + const interestedPartyId = dto.interestedPartyId?.trim() || null; + if (interestedPartyId) { + await this.requirePartyInDocument({ interestedPartyId, documentId }); + } + + return db.$transaction(async (tx) => { + await lockDocumentForPositions(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsInterestedPartyRequirement.create({ + data: { + documentId, + interestedPartyId, + partyName: dto.partyName, + requirement: dto.requirement, + treatment: dto.treatment, + source: 'manual', + position, + }, + }); + }); + } + + async update({ + requirementId, + organizationId, + dto, + }: { + requirementId: string; + organizationId: string; + dto: UpdateRequirementInput; + }) { + const requirement = await this.requireRequirement({ + requirementId, + organizationId, + }); + // undefined = field omitted (leave as-is); empty string = clear the link. + const partyFieldProvided = dto.interestedPartyId !== undefined; + const interestedPartyId = dto.interestedPartyId?.trim() || null; + if (interestedPartyId) { + await this.requirePartyInDocument({ + interestedPartyId, + documentId: requirement.documentId, + }); + } + + return db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ + tx, + documentId: requirement.documentId, + }); + return tx.ismsInterestedPartyRequirement.update({ + where: { id: requirementId }, + data: { + interestedPartyId: partyFieldProvided ? interestedPartyId : undefined, + partyName: dto.partyName ?? undefined, + requirement: dto.requirement ?? undefined, + treatment: dto.treatment ?? undefined, + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + requirementId, + organizationId, + }: { + requirementId: string; + organizationId: string; + }) { + const requirement = await this.requireRequirement({ + requirementId, + organizationId, + }); + await db.$transaction(async (tx) => { + await invalidateApprovalIfNeeded({ + tx, + documentId: requirement.documentId, + }); + await tx.ismsInterestedPartyRequirement.delete({ + where: { id: requirementId }, + }); + }); + return { success: true }; + } + + /** + * Next position uses max(position)+1 so it survives deletes. Runs on the + * transaction client; the create first takes a per-document advisory lock + * (lockDocumentForPositions) so concurrent creates can't read the same max. + */ + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsInterestedPartyRequirement.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + /** Ensures a linked interested party belongs to the same document (and org). */ + private async requirePartyInDocument({ + interestedPartyId, + documentId, + }: { + interestedPartyId: string; + documentId: string; + }) { + const party = await db.ismsInterestedParty.findFirst({ + where: { id: interestedPartyId, documentId }, + select: { id: true }, + }); + if (!party) { + throw new BadRequestException( + 'Interested party does not belong to this document', + ); + } + return party; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireRequirement({ + requirementId, + organizationId, + }: { + requirementId: string; + organizationId: string; + }) { + const requirement = await db.ismsInterestedPartyRequirement.findFirst({ + where: { id: requirementId, document: { organizationId } }, + }); + if (!requirement) { + throw new NotFoundException('Requirement not found'); + } + return requirement; + } +} diff --git a/apps/api/src/isms/isms.controller.permissions.spec.ts b/apps/api/src/isms/isms.controller.permissions.spec.ts new file mode 100644 index 0000000000..145432787d --- /dev/null +++ b/apps/api/src/isms/isms.controller.permissions.spec.ts @@ -0,0 +1,73 @@ +import { Reflector } from '@nestjs/core'; +import { PERMISSIONS_KEY } from '../auth/permission.guard'; +import { IsmsController } from './isms.controller'; + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, + PERMISSIONS_KEY: 'permissions', +})); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('../auth/app-access', () => ({ + resolveRolePermissions: jest.fn(), + permissionsGrant: jest.fn(), +})); +jest.mock('../auth/service-token.config', () => ({ + resolveServiceByName: jest.fn(), +})); +jest.mock('./isms.service', () => ({ + IsmsService: class MockIsmsService {}, +})); +jest.mock('./isms-context.service', () => ({ + IsmsContextService: class MockIsmsContextService {}, +})); +jest.mock('./isms-document-control.service', () => ({ + IsmsDocumentControlService: class MockIsmsDocumentControlService {}, +})); + +describe('IsmsController permission metadata', () => { + const reflector = new Reflector(); + const permissionsFor = (method: keyof IsmsController) => + reflector.get(PERMISSIONS_KEY, IsmsController.prototype[method]); + + it('gates ensure-setup with evidence:read', () => { + expect(permissionsFor('ensureSetup')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + }); + + it('gates read endpoints with evidence:read', () => { + expect(permissionsFor('getDocument')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + expect(permissionsFor('drift')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + expect(permissionsFor('exportDocument')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + }); + + it('gates mutation endpoints with evidence:update', () => { + for (const method of [ + 'generate', + 'addControls', + 'removeControl', + 'submitForApproval', + 'approve', + 'decline', + ] as const) { + expect(permissionsFor(method)).toEqual([ + { resource: 'evidence', actions: ['update'] }, + ]); + } + }); +}); diff --git a/apps/api/src/isms/isms.controller.spec.ts b/apps/api/src/isms/isms.controller.spec.ts new file mode 100644 index 0000000000..fde926f23d --- /dev/null +++ b/apps/api/src/isms/isms.controller.spec.ts @@ -0,0 +1,344 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import type { Response } from 'express'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { resolveRolePermissions, permissionsGrant } from '../auth/app-access'; +import { resolveServiceByName } from '../auth/service-token.config'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { IsmsController } from './isms.controller'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsDocumentControlService } from './isms-document-control.service'; + +const mockResolveRolePermissions = jest.mocked(resolveRolePermissions); +const mockPermissionsGrant = jest.mocked(permissionsGrant); +const mockResolveServiceByName = jest.mocked(resolveServiceByName); + +/** Build a minimal session-auth AuthContext for ensure-setup tests. */ +const sessionContext = ( + overrides: Partial = {}, +): AuthContextType => ({ + organizationId: 'org_1', + authType: 'session', + isApiKey: false, + isServiceToken: false, + isPlatformAdmin: false, + userRoles: ['auditor'], + ...overrides, +}); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, + PERMISSIONS_KEY: 'permissions', +})); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('../auth/app-access', () => ({ + resolveRolePermissions: jest.fn(), + permissionsGrant: jest.fn(), +})); +jest.mock('../auth/service-token.config', () => ({ + resolveServiceByName: jest.fn(), +})); +jest.mock('./isms.service', () => ({ + IsmsService: class MockIsmsService {}, +})); +jest.mock('./isms-context.service', () => ({ + IsmsContextService: class MockIsmsContextService {}, +})); +jest.mock('./isms-document-control.service', () => ({ + IsmsDocumentControlService: class MockIsmsDocumentControlService {}, +})); + +describe('IsmsController', () => { + let controller: IsmsController; + + const mockIsmsService = { + ensureSetup: jest.fn(), + getDocument: jest.fn(), + submitForApproval: jest.fn(), + approve: jest.fn(), + decline: jest.fn(), + }; + const mockContextService = { + generate: jest.fn(), + drift: jest.fn(), + exportDocument: jest.fn(), + }; + const mockDocumentControlService = { + addControls: jest.fn(), + removeControl: jest.fn(), + }; + + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IsmsController], + providers: [ + { provide: IsmsService, useValue: mockIsmsService }, + { provide: IsmsContextService, useValue: mockContextService }, + { + provide: IsmsDocumentControlService, + useValue: mockDocumentControlService, + }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(IsmsController); + jest.clearAllMocks(); + }); + + it('ensureSetup derives the org from the session, not the body', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + mockResolveRolePermissions.mockResolvedValue({ evidence: ['update'] }); + mockPermissionsGrant.mockReturnValue(true); + + const result = await controller.ensureSetup( + { frameworkId: 'fw_1' }, + 'org_1', + sessionContext(), + ); + + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + canWrite: true, + }); + expect(result).toEqual({ success: true }); + }); + + it('ensureSetup threads canWrite=true when the caller has evidence:update', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + mockResolveRolePermissions.mockResolvedValue({ evidence: ['update'] }); + mockPermissionsGrant.mockReturnValue(true); + + await controller.ensureSetup({ frameworkId: 'fw_1' }, 'org_1', sessionContext()); + + expect(mockResolveRolePermissions).toHaveBeenCalledWith('org_1', ['auditor']); + expect(mockPermissionsGrant).toHaveBeenCalledWith( + { evidence: ['update'] }, + 'evidence', + 'update', + ); + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: true }), + ); + }); + + it('ensureSetup threads canWrite=false for a read-only caller', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + mockResolveRolePermissions.mockResolvedValue({ evidence: ['read'] }); + mockPermissionsGrant.mockReturnValue(false); + + await controller.ensureSetup({ frameworkId: 'fw_1' }, 'org_1', sessionContext()); + + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: false }), + ); + }); + + it('ensureSetup grants canWrite to platform admins without resolving roles', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + + await controller.ensureSetup( + { frameworkId: 'fw_1' }, + 'org_1', + sessionContext({ isPlatformAdmin: true }), + ); + + expect(mockResolveRolePermissions).not.toHaveBeenCalled(); + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: true }), + ); + }); + + it('ensureSetup resolves canWrite from API key scopes', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + + await controller.ensureSetup( + { frameworkId: 'fw_1' }, + 'org_1', + sessionContext({ + authType: 'api-key', + isApiKey: true, + userRoles: null, + apiKeyScopes: ['evidence:read'], + }), + ); + + expect(mockResolveRolePermissions).not.toHaveBeenCalled(); + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: false }), + ); + }); + + it('ensureSetup resolves canWrite from service-token permissions', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + mockResolveServiceByName.mockReturnValue({ + envVar: 'SERVICE_TOKEN_X', + name: 'X', + permissions: ['evidence:update'], + }); + + await controller.ensureSetup( + { frameworkId: 'fw_1' }, + 'org_1', + sessionContext({ + authType: 'service', + isServiceToken: true, + serviceName: 'x', + userRoles: null, + }), + ); + + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith( + expect.objectContaining({ canWrite: true }), + ); + }); + + it('getDocument passes documentId and organizationId', async () => { + mockIsmsService.getDocument.mockResolvedValue({ id: 'doc_1' }); + + await controller.getDocument('doc_1', 'org_1'); + + expect(mockIsmsService.getDocument).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + }); + + it('addControls passes documentId, controlIds and org', async () => { + mockDocumentControlService.addControls.mockResolvedValue({ + message: 'Controls linked', + }); + + await controller.addControls('doc_1', { controlIds: ['ctl_1'] }, 'org_1'); + + expect(mockDocumentControlService.addControls).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + controlIds: ['ctl_1'], + }); + }); + + it('removeControl passes documentId, controlId and org', async () => { + mockDocumentControlService.removeControl.mockResolvedValue({ + message: 'Control unlinked', + }); + + await controller.removeControl('doc_1', 'ctl_1', 'org_1'); + + expect(mockDocumentControlService.removeControl).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + controlId: 'ctl_1', + }); + }); + + it('generate delegates to the context service', async () => { + mockContextService.generate.mockResolvedValue({ id: 'doc_1' }); + + await controller.generate('doc_1', 'org_1'); + + expect(mockContextService.generate).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + }); + + it('submitForApproval passes documentId, dto and org', async () => { + const dto = { approverId: 'mem_1' }; + mockIsmsService.submitForApproval.mockResolvedValue({ id: 'doc_1' }); + + await controller.submitForApproval('doc_1', dto, 'org_1'); + + expect(mockIsmsService.submitForApproval).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto, + }); + }); + + it('approve passes documentId, org and userId', async () => { + mockIsmsService.approve.mockResolvedValue({ id: 'doc_1' }); + + await controller.approve('doc_1', 'org_1', 'usr_1'); + + expect(mockIsmsService.approve).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + userId: 'usr_1', + }); + }); + + it('decline passes documentId, org and userId', async () => { + mockIsmsService.decline.mockResolvedValue({ id: 'doc_1' }); + + await controller.decline('doc_1', 'org_1', 'usr_1'); + + expect(mockIsmsService.decline).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + userId: 'usr_1', + }); + }); + + it('drift delegates to the context service', async () => { + mockContextService.drift.mockResolvedValue({ + isStale: false, + changedSources: [], + }); + + await controller.drift('doc_1', 'org_1'); + + expect(mockContextService.drift).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + }); + + it('exportDocument sets headers and sends the buffer', async () => { + const fileBuffer = Buffer.from('pdf-data'); + mockContextService.exportDocument.mockResolvedValue({ + fileBuffer, + mimeType: 'application/pdf', + filename: 'context-of-the-organization-v1.pdf', + }); + const res = { + setHeader: jest.fn(), + send: jest.fn(), + } as unknown as Response; + const dto = { format: 'pdf' as const }; + + await controller.exportDocument('doc_1', dto, 'org_1', res); + + expect(mockContextService.exportDocument).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto, + }); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'application/pdf', + ); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="context-of-the-organization-v1.pdf"', + ); + expect(res.send).toHaveBeenCalledWith(fileBuffer); + }); +}); diff --git a/apps/api/src/isms/isms.controller.ts b/apps/api/src/isms/isms.controller.ts new file mode 100644 index 0000000000..0cdbbec885 --- /dev/null +++ b/apps/api/src/isms/isms.controller.ts @@ -0,0 +1,250 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Res, + UseGuards, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiProduces, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { + AuthContext, + OrganizationId, + UserId, +} from '@/auth/auth-context.decorator'; +import type { AuthContext as AuthContextType } from '@/auth/types'; +import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { resolveRolePermissions, permissionsGrant } from '../auth/app-access'; +import { resolveServiceByName } from '../auth/service-token.config'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsDocumentControlService } from './isms-document-control.service'; +import { EnsureIsmsSetupDto } from './dto/ensure-isms-setup.dto'; +import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto'; +import { ExportIsmsDocumentDto } from './dto/export-isms-document.dto'; +import { LinkIsmsControlsDto } from './dto/link-isms-controls.dto'; + +@ApiTags('ISMS') +@Controller({ path: 'isms', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class IsmsController { + constructor( + private readonly ismsService: IsmsService, + private readonly contextService: IsmsContextService, + private readonly documentControlService: IsmsDocumentControlService, + ) {} + + // Gated at evidence:read so read-only auditors can LIST existing ISMS + // documents, but provisioning only happens when the caller can actually write + // (evidence:update) — resolved below and threaded as `canWrite`. This keeps + // read-only callers from creating rows just by viewing the page. + @Post('ensure-setup') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Ensure ISMS foundational documents exist' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Setup ensured' }) + async ensureSetup( + @Body() dto: EnsureIsmsSetupDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + return this.ismsService.ensureSetup({ + organizationId, + frameworkId: dto.frameworkId, + canWrite: await this.resolveCanWrite(authContext), + }); + } + + /** + * Whether the caller has `evidence:update`, mirroring PermissionGuard's + * precedence (platform admin → API key scopes → service token → roles). Used + * to keep ensure-setup's list path read-only while still letting writers + * provision missing documents. + */ + private async resolveCanWrite(ctx: AuthContextType): Promise { + const RESOURCE = 'evidence'; + const ACTION = 'update'; + if (ctx.isPlatformAdmin) return true; + + if (ctx.isApiKey) { + const scopes = ctx.apiKeyScopes; + // Legacy keys (empty scopes) keep full access until the guard's cutoff; + // the guard already blocks them past the deprecation date. + if (!scopes || scopes.length === 0) return true; + return scopes.includes(`${RESOURCE}:${ACTION}`); + } + + if (ctx.isServiceToken) { + const service = resolveServiceByName(ctx.serviceName); + return service?.permissions.includes(`${RESOURCE}:${ACTION}`) ?? false; + } + + const perms = await resolveRolePermissions( + ctx.organizationId, + ctx.userRoles ?? [], + ); + return permissionsGrant(perms, RESOURCE, ACTION); + } + + @Get('documents/:id') + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Get an ISMS document with its latest version' }) + @ApiOkResponse({ description: 'ISMS document' }) + async getDocument( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.ismsService.getDocument({ documentId: id, organizationId }); + } + + @Post('documents/:id/controls') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Map organization controls to an ISMS document' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Controls linked' }) + async addControls( + @Param('id') id: string, + @Body() dto: LinkIsmsControlsDto, + @OrganizationId() organizationId: string, + ) { + return this.documentControlService.addControls({ + documentId: id, + organizationId, + controlIds: dto.controlIds, + }); + } + + @Delete('documents/:id/controls/:controlId') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Remove a control mapping from an ISMS document' }) + @ApiOkResponse({ description: 'Control unlinked' }) + async removeControl( + @Param('id') id: string, + @Param('controlId') controlId: string, + @OrganizationId() organizationId: string, + ) { + return this.documentControlService.removeControl({ + documentId: id, + organizationId, + controlId, + }); + } + + @Post('documents/:id/generate') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Derive Context-of-the-Organization issues' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document with derived issues' }) + async generate( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.contextService.generate({ documentId: id, organizationId }); + } + + @Post('documents/:id/submit-for-approval') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Submit an ISMS document for approval' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document submitted for approval' }) + async submitForApproval( + @Param('id') id: string, + @Body() dto: SubmitIsmsForApprovalDto, + @OrganizationId() organizationId: string, + ) { + return this.ismsService.submitForApproval({ + documentId: id, + organizationId, + dto, + }); + } + + @Post('documents/:id/approve') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Approve an ISMS document' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document approved' }) + async approve( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @UserId() userId: string, + ) { + return this.ismsService.approve({ + documentId: id, + organizationId, + userId, + }); + } + + @Post('documents/:id/decline') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Decline an ISMS document' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document declined' }) + async decline( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @UserId() userId: string, + ) { + return this.ismsService.decline({ documentId: id, organizationId, userId }); + } + + @Get('documents/:id/drift') + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Detect drift against the approved snapshot' }) + @ApiOkResponse({ description: 'Drift status' }) + async drift( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.contextService.drift({ documentId: id, organizationId }); + } + + @Post('documents/:id/export') + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Export an ISMS document as PDF or DOCX' }) + @ApiConsumes('application/json') + @ApiProduces('application/pdf') + @ApiOkResponse({ description: 'Rendered document' }) + async exportDocument( + @Param('id') id: string, + @Body() dto: ExportIsmsDocumentDto, + @OrganizationId() organizationId: string, + @Res({ passthrough: true }) res: Response, + ): Promise { + const result = await this.contextService.exportDocument({ + documentId: id, + organizationId, + dto, + }); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${result.filename}"`, + ); + res.send(result.fileBuffer); + } +} diff --git a/apps/api/src/isms/isms.module.ts b/apps/api/src/isms/isms.module.ts new file mode 100644 index 0000000000..88a134904a --- /dev/null +++ b/apps/api/src/isms/isms.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { IsmsController } from './isms.controller'; +import { IsmsRegistersController } from './isms-registers.controller'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsContextIssueService } from './isms-context-issue.service'; +import { IsmsDocumentControlService } from './isms-document-control.service'; +import { IsmsInterestedPartyService } from './isms-interested-party.service'; +import { IsmsRequirementService } from './isms-requirement.service'; +import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsNarrativeService } from './isms-narrative.service'; +import { IsmsProfileController } from './wizard/isms-profile.controller'; +import { IsmsProfileService } from './wizard/isms-profile.service'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [ + IsmsController, + IsmsRegistersController, + IsmsProfileController, + ], + providers: [ + IsmsService, + IsmsContextService, + IsmsContextIssueService, + IsmsDocumentControlService, + IsmsInterestedPartyService, + IsmsRequirementService, + IsmsObjectiveService, + IsmsNarrativeService, + IsmsProfileService, + ], + exports: [ + IsmsService, + IsmsContextService, + IsmsContextIssueService, + IsmsDocumentControlService, + IsmsInterestedPartyService, + IsmsRequirementService, + IsmsObjectiveService, + IsmsNarrativeService, + IsmsProfileService, + ], +}) +export class IsmsModule {} 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 new file mode 100644 index 0000000000..ce1dd0a85e --- /dev/null +++ b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts @@ -0,0 +1,101 @@ +import { db } from '@db'; +import { IsmsService } from './isms.service'; + +jest.mock('@db', () => ({ + db: { + frameworkEditorFramework: { findUnique: jest.fn() }, + frameworkEditorIsmsDocumentTemplate: { findMany: jest.fn() }, + ismsDocument: { + findMany: jest.fn(), + createMany: jest.fn(), + }, + control: { findMany: jest.fn() }, + ismsDocumentControlLink: { createMany: jest.fn() }, + }, +})); +jest.mock('./documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); +jest.mock('./documents/generate', () => ({ + runDerivation: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); + +/** Convenience accessor for the first createMany call's `data` array. */ +const createManyData = () => + (mockDb.ismsDocument.createMany as jest.Mock).mock.calls[0][0].data; + +describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no templates seeded)', () => { + let service: IsmsService; + const dto = { organizationId: 'org_1', frameworkId: 'fw_1', canWrite: true }; + const mockTemplates = mockDb.frameworkEditorIsmsDocumentTemplate + .findMany as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsService(); + (mockDb.control.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsDocument.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + (mockDb.ismsDocumentControlLink.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + mockTemplates.mockResolvedValue([]); + }); + + it('creates only missing document types and maps requirements', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ + id: 'fw_1', + requirements: [{ id: 'req_41', name: '4.1 Context', identifier: '4.1' }], + }); + // One existing type so only the other five are created. + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([{ type: 'context_of_organization' }]) // existing-types probe + .mockResolvedValueOnce([]) // created lookup + .mockResolvedValueOnce([ + { + id: 'doc_1', + type: 'context_of_organization', + status: 'draft', + requirementId: 'req_41', + }, + ]); // final list + + const result = await service.ensureSetup(dto); + + expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); + expect(createManyData()).toHaveLength(5); + // Definition-derived docs carry no templateId. + expect(createManyData()[0].templateId).toBeNull(); + expect(result.success).toBe(true); + expect(result.documents[0]).toEqual({ + id: 'doc_1', + type: 'context_of_organization', + status: 'draft', + requirementId: 'req_41', + hasApprovedVersion: false, + }); + }); + + it('leaves requirementId null when no clause matches', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ id: 'fw_1', requirements: [] }); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(createManyData()).toHaveLength(6); + 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 new file mode 100644 index 0000000000..41ea994e3a --- /dev/null +++ b/apps/api/src/isms/isms.service.lifecycle.spec.ts @@ -0,0 +1,280 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { IsmsService } from './isms.service'; +import { collectPlatformData } from './documents/data-source'; +import { runDerivation } from './documents/generate'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +jest.mock('@db', () => ({ + db: { + ismsDocument: { + findFirst: jest.fn(), + update: jest.fn(), + }, + member: { findFirst: jest.fn() }, + $transaction: jest.fn(), + }, +})); +jest.mock('./documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); +jest.mock('./documents/generate', () => ({ + runDerivation: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockCollect = jest.mocked(collectPlatformData); +const mockRunDerivation = jest.mocked(runDerivation); + +describe('IsmsService document lifecycle', () => { + let service: IsmsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsService(); + }); + + describe('getDocument', () => { + it('throws NotFoundException when not found / wrong org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.getDocument({ documentId: 'doc_1', organizationId: 'org_1' }), + ).rejects.toThrow(NotFoundException); + }); + + it('returns the document scoped by org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + const result = await service.getDocument({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + expect(result).toEqual({ id: 'doc_1' }); + expect(mockDb.ismsDocument.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'doc_1', organizationId: 'org_1' }, + }), + ); + }); + + it('includes control links with the linked control id and name', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + controlLinks: [], + }); + + await service.getDocument({ documentId: 'doc_1', organizationId: 'org_1' }); + + const callArgs = (mockDb.ismsDocument.findFirst as jest.Mock).mock + .calls[0][0]; + expect(callArgs.include.controlLinks.select).toEqual({ + id: true, + controlId: true, + control: { select: { id: true, name: true } }, + }); + }); + }); + + describe('submitForApproval', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { approverId: 'mem_1' }, + }; + + it('throws NotFoundException when approver not in org', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.submitForApproval(args)).rejects.toThrow( + NotFoundException, + ); + }); + + it('sets approver and needs_review status', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + }); + + await service.submitForApproval(args); + + expect(mockDb.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: expect.objectContaining({ + approverId: 'mem_1', + status: 'needs_review', + approvedAt: null, + declinedAt: null, + }), + }); + }); + }); + + describe('approve', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + userId: 'usr_1', + }; + + it('throws NotFoundException when member not found', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.approve(args)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when document is not pending approval', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'draft', + approverId: 'mem_1', + frameworkId: 'fw_1', + }); + await expect(service.approve(args)).rejects.toThrow(BadRequestException); + }); + + it('throws ForbiddenException when no approver is assigned', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + approverId: null, + frameworkId: 'fw_1', + }); + await expect(service.approve(args)).rejects.toThrow(ForbiddenException); + }); + + it('throws ForbiddenException when not the assigned approver', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + approverId: 'mem_other', + frameworkId: 'fw_1', + }); + await expect(service.approve(args)).rejects.toThrow(ForbiddenException); + }); + + it('snapshots data and marks approved', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock) + .mockResolvedValueOnce({ + id: 'doc_1', + status: 'needs_review', + approverId: 'mem_1', + frameworkId: 'fw_1', + type: 'context_of_organization', + }) + .mockResolvedValueOnce({ id: 'doc_1', status: 'approved' }); + mockCollect.mockResolvedValue({ + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 1, + subProcessorCount: 0, + vendorsByCategory: {}, + subProcessorNames: [], + infraVendorNames: [], + memberCount: 1, + membersByDepartment: {}, + deviceCount: 0, + riskCount: 0, + highRiskCount: 0, + hasTrainingProgram: false, + wizardAnswers: {}, + partiesFingerprint: '', + }); + const tx = { + ismsDocument: { update: jest.fn().mockResolvedValue({}) }, + }; + (mockDb.$transaction as jest.Mock).mockImplementation((cb) => cb(tx)); + + await service.approve(args); + + expect(mockCollect).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + }); + // Re-derives inside the transaction from the same snapshot, so the + // persisted rows and the snapshot baseline come from one pass. + expect(mockRunDerivation).toHaveBeenCalledWith({ + tx, + type: 'context_of_organization', + documentId: 'doc_1', + organizationId: 'org_1', + frameworkId: 'fw_1', + data: expect.objectContaining({ organizationName: 'Acme' }), + }); + expect(upsertLatestSnapshotVersion).toHaveBeenCalled(); + expect(tx.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: expect.objectContaining({ + status: 'approved', + declinedAt: null, + }), + }); + }); + }); + + describe('decline', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + userId: 'usr_1', + }; + + it('throws NotFoundException when member not found', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.decline(args)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when document is not pending approval', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'approved', + approverId: 'mem_1', + }); + await expect(service.decline(args)).rejects.toThrow(BadRequestException); + }); + + it('throws ForbiddenException when not the assigned approver', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + approverId: 'mem_other', + }); + await expect(service.decline(args)).rejects.toThrow(ForbiddenException); + }); + + it('sets declined status and declinedAt', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + approverId: 'mem_1', + }); + (mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'declined', + }); + + await service.decline(args); + + const call = (mockDb.ismsDocument.update as jest.Mock).mock.calls[0][0]; + expect(call.data.status).toBe('declined'); + expect(call.data.declinedAt).toBeInstanceOf(Date); + }); + }); +}); diff --git a/apps/api/src/isms/isms.service.spec.ts b/apps/api/src/isms/isms.service.spec.ts new file mode 100644 index 0000000000..d8a1f34410 --- /dev/null +++ b/apps/api/src/isms/isms.service.spec.ts @@ -0,0 +1,305 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsService } from './isms.service'; + +jest.mock('@db', () => ({ + db: { + frameworkEditorFramework: { findUnique: jest.fn() }, + frameworkEditorIsmsDocumentTemplate: { findMany: jest.fn() }, + ismsDocument: { + findMany: jest.fn(), + createMany: jest.fn(), + }, + control: { findMany: jest.fn() }, + ismsDocumentControlLink: { createMany: jest.fn() }, + }, +})); +jest.mock('./documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); +jest.mock('./documents/generate', () => ({ + runDerivation: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); + +/** Convenience accessor for the first createMany call's `data` array. */ +const createManyData = () => + (mockDb.ismsDocument.createMany as jest.Mock).mock.calls[0][0].data; + +describe('IsmsService ensureSetup', () => { + let service: IsmsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsService(); + (mockDb.control.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsDocument.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + (mockDb.ismsDocumentControlLink.createMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + }); + + describe('ensureSetup', () => { + const dto = { organizationId: 'org_1', frameworkId: 'fw_1', canWrite: true }; + + const mockTemplates = mockDb.frameworkEditorIsmsDocumentTemplate + .findMany as jest.Mock; + + it('throws NotFoundException when framework not found', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue(null); + await expect(service.ensureSetup(dto)).rejects.toThrow(NotFoundException); + }); + + describe('read-only callers (canWrite: false)', () => { + it('never writes — only lists the existing documents', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ + id: 'fw_1', + requirements: [], + }); + (mockDb.ismsDocument.findMany as jest.Mock).mockResolvedValueOnce([ + { + id: 'doc_1', + type: 'context_of_organization', + status: 'draft', + requirementId: null, + }, + ]); + + const result = await service.ensureSetup({ ...dto, canWrite: false }); + + // No provisioning at all: no template resolution, no creates. + expect(mockTemplates).not.toHaveBeenCalled(); + expect(mockDb.ismsDocument.createMany).not.toHaveBeenCalled(); + expect(mockDb.control.findMany).not.toHaveBeenCalled(); + expect( + mockDb.ismsDocumentControlLink.createMany, + ).not.toHaveBeenCalled(); + // The findMany that ran was the list query, not a provisioning probe. + expect(mockDb.ismsDocument.findMany).toHaveBeenCalledTimes(1); + expect(result.documents).toHaveLength(1); + }); + }); + + describe('template-driven (templates seeded)', () => { + beforeEach(() => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ + id: 'fw_1', + requirements: [ + { id: 'req_41', name: '4.1 Context', identifier: '4.1' }, + { id: 'req_62', name: '6.2 Objectives', identifier: '6.2' }, + ], + }); + }); + + it('creates docs from templates with templateId set', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) // existing-types probe + .mockResolvedValueOnce([]) // created lookup + .mockResolvedValueOnce([]); // final list + + await service.ensureSetup(dto); + + expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); + expect(createManyData()).toHaveLength(1); + expect(createManyData()[0]).toMatchObject({ + type: 'context_of_organization', + title: 'Context of the Organization', + templateId: 'tpl_ctx', + requirementId: 'req_41', // resolved via clause fallback "4.1" + }); + }); + + it('prefers an explicit framework requirement link over clause match', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [ + { frameworkId: 'fw_1', requirementId: 'req_custom' }, + ], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(createManyData()[0].requirementId).toBe('req_custom'); + expect(createManyData()[0].templateId).toBe('tpl_ctx'); + }); + + it('falls back to clause match when no link exists for the framework', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_obj', + documentType: 'objectives_plan', + name: 'Objectives and Plan', + clause: '6.2', + requirementLinks: [], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(createManyData()[0].requirementId).toBe('req_62'); + }); + + it('skips templates whose document type already exists', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [], + }, + { + id: 'tpl_obj', + documentType: 'objectives_plan', + name: 'Objectives and Plan', + clause: '6.2', + requirementLinks: [], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([{ type: 'context_of_organization' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(createManyData()).toHaveLength(1); + expect(createManyData()[0].type).toBe('objectives_plan'); + }); + + it('auto-derives org control links from the template control links', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [ + { controlTemplateId: 'ct_1' }, + { controlTemplateId: 'ct_2' }, + ], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) // existing-types probe + .mockResolvedValueOnce([ + { id: 'doc_new', type: 'context_of_organization' }, + ]) // created lookup + .mockResolvedValueOnce([]); // final list + (mockDb.control.findMany as jest.Mock).mockResolvedValue([ + { id: 'ctl_1' }, + { id: 'ctl_2' }, + ]); + + await service.ensureSetup(dto); + + expect(mockDb.control.findMany).toHaveBeenCalledWith({ + where: { + organizationId: 'org_1', + controlTemplateId: { in: ['ct_1', 'ct_2'] }, + }, + select: { id: true }, + }); + expect(mockDb.ismsDocumentControlLink.createMany).toHaveBeenCalledWith({ + data: [ + { ismsDocumentId: 'doc_new', controlId: 'ctl_1' }, + { ismsDocumentId: 'doc_new', controlId: 'ctl_2' }, + ], + skipDuplicates: true, + }); + }); + + it('skips control derivation when the template has no control links', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [], + }, + ]); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { id: 'doc_new', type: 'context_of_organization' }, + ]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(mockDb.control.findMany).not.toHaveBeenCalled(); + expect( + mockDb.ismsDocumentControlLink.createMany, + ).not.toHaveBeenCalled(); + }); + + it('preserves existing links on re-run by skipping created types', async () => { + mockTemplates.mockResolvedValue([ + { + id: 'tpl_ctx', + documentType: 'context_of_organization', + name: 'Context of the Organization', + clause: '4.1', + requirementLinks: [], + controlLinks: [{ controlTemplateId: 'ct_1' }], + }, + ]); + // Document already exists, so no create and no control derivation runs; + // any manual control links the org added are left untouched. + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([{ type: 'context_of_organization' }]) + .mockResolvedValueOnce([]); + + await service.ensureSetup(dto); + + expect(mockDb.ismsDocument.createMany).not.toHaveBeenCalled(); + expect(mockDb.control.findMany).not.toHaveBeenCalled(); + expect( + mockDb.ismsDocumentControlLink.createMany, + ).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/api/src/isms/isms.service.ts b/apps/api/src/isms/isms.service.ts new file mode 100644 index 0000000000..4d92c33d3f --- /dev/null +++ b/apps/api/src/isms/isms.service.ts @@ -0,0 +1,298 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto'; +import { deriveControlLinks, resolveDocumentPlans } from './utils/ensure-setup-plan'; +import { collectPlatformData } from './documents/data-source'; +import { runDerivation } from './documents/generate'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +/** + * ISMS foundational document lifecycle: setup, retrieval and sign-off. Context + * derivation/drift/export live in IsmsContextService and issue CRUD in + * IsmsContextIssueService. + */ +@Injectable() +export class IsmsService { + /** + * List the org's ISMS documents, provisioning missing ones first only when the + * caller can write (`evidence:update`). Read-only callers never trigger writes. + */ + async ensureSetup({ + organizationId, + frameworkId, + canWrite, + }: { + organizationId: string; + frameworkId: string; + canWrite: boolean; + }) { + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + include: { + requirements: { select: { id: true, name: true, identifier: true } }, + }, + }); + + if (!framework) { + throw new NotFoundException('Framework not found'); + } + + if (canWrite) { + await this.provisionMissingDocuments({ + organizationId, + frameworkId, + requirements: framework.requirements, + }); + } + + const documents = await db.ismsDocument.findMany({ + where: { organizationId, frameworkId }, + }); + + return { + success: true, + documents: documents.map((doc) => ({ + id: doc.id, + type: doc.type, + status: doc.status, + requirementId: doc.requirementId, + hasApprovedVersion: doc.status === 'approved', + })), + }; + } + + /** + * Create any missing ISMS documents for the (org, framework), then derive + * control links for just the newly-created types so manual links on existing + * documents stay untouched. `createMany` + `skipDuplicates` makes this safe + * under concurrent calls — the unique (org, framework, type) constraint + * absorbs the race, mirroring the idempotent ensureProfile pattern. + */ + private async provisionMissingDocuments({ + organizationId, + frameworkId, + requirements, + }: { + organizationId: string; + frameworkId: string; + requirements: Array<{ id: string; name: string; identifier: string }>; + }) { + const existing = await db.ismsDocument.findMany({ + where: { organizationId, frameworkId }, + select: { type: true }, + }); + const existingTypes = new Set(existing.map((doc) => doc.type)); + + const plans = await resolveDocumentPlans({ frameworkId, requirements }); + const missingPlans = plans.filter((plan) => !existingTypes.has(plan.type)); + if (missingPlans.length === 0) return; + + await db.ismsDocument.createMany({ + data: missingPlans.map((plan) => ({ + organizationId, + frameworkId, + type: plan.type, + title: plan.title, + status: 'draft', + requirementId: plan.requirementId, + templateId: plan.templateId, + })), + skipDuplicates: true, + }); + + const created = await db.ismsDocument.findMany({ + where: { + organizationId, + frameworkId, + type: { in: missingPlans.map((plan) => plan.type) }, + }, + select: { id: true, type: true }, + }); + const controlTemplatesByType = new Map( + missingPlans.map((plan) => [plan.type, plan.controlTemplateIds]), + ); + + for (const doc of created) { + await deriveControlLinks({ + documentId: doc.id, + organizationId, + controlTemplateIds: controlTemplatesByType.get(doc.type) ?? [], + }); + } + } + + async getDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: { + versions: { where: { isLatest: true }, take: 1 }, + contextIssues: { orderBy: { position: 'asc' } }, + interestedParties: { orderBy: { position: 'asc' } }, + interestedPartyRequirements: { orderBy: { position: 'asc' } }, + objectives: { orderBy: { position: 'asc' } }, + controlLinks: { + select: { + id: true, + controlId: true, + control: { select: { id: true, name: true } }, + }, + }, + }, + }); + + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + return document; + } + + async submitForApproval({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: SubmitIsmsForApprovalDto; + }) { + const approver = await db.member.findFirst({ + where: { id: dto.approverId, organizationId, deactivated: false }, + }); + if (!approver) { + throw new NotFoundException('Approver not found in organization'); + } + + await this.requireDocument({ documentId, organizationId }); + + return db.ismsDocument.update({ + where: { id: documentId }, + data: { + approverId: dto.approverId, + status: 'needs_review', + approvedAt: null, + declinedAt: null, + }, + }); + } + + async approve({ + documentId, + organizationId, + userId, + }: { + documentId: string; + organizationId: string; + userId: string; + }) { + const member = await this.requireMember({ organizationId, userId }); + const document = await this.requireDocument({ documentId, organizationId }); + this.assertPendingApprovalBy({ document, member }); + + const snapshot = await collectPlatformData({ + organizationId, + frameworkId: document.frameworkId, + }); + + await db.$transaction(async (tx) => { + // Re-derive in the same transaction so the persisted rows and the snapshot + // baseline come from one pass (otherwise the approved content can drift). + await runDerivation({ + tx, + type: document.type, + documentId, + organizationId, + frameworkId: document.frameworkId, + data: snapshot, + }); + await upsertLatestSnapshotVersion({ tx, documentId, snapshot }); + await tx.ismsDocument.update({ + where: { id: documentId }, + data: { status: 'approved', approvedAt: new Date(), declinedAt: null }, + }); + }); + + return this.getDocument({ documentId, organizationId }); + } + + async decline({ + documentId, + organizationId, + userId, + }: { + documentId: string; + organizationId: string; + userId: string; + }) { + const member = await this.requireMember({ organizationId, userId }); + const document = await this.requireDocument({ documentId, organizationId }); + this.assertPendingApprovalBy({ document, member }); + + return db.ismsDocument.update({ + where: { id: documentId }, + data: { status: 'declined', declinedAt: new Date() }, + }); + } + + /** + * Guard shared by approve/decline: the document must be awaiting review and the + * acting member must be its assigned approver. + */ + private assertPendingApprovalBy({ + document, + member, + }: { + document: { status: string; approverId: string | null }; + member: { id: string }; + }) { + if (document.status !== 'needs_review') { + throw new BadRequestException('Document is not pending approval'); + } + if (!document.approverId || document.approverId !== member.id) { + throw new ForbiddenException('Document is not pending your approval'); + } + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + }); + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + return document; + } + + private async requireMember({ + organizationId, + userId, + }: { + organizationId: string; + userId: string; + }) { + const member = await db.member.findFirst({ + where: { organizationId, userId, deactivated: false }, + }); + if (!member) { + throw new NotFoundException('Member not found'); + } + return member; + } +} diff --git a/apps/api/src/isms/registers/register-registry.spec.ts b/apps/api/src/isms/registers/register-registry.spec.ts new file mode 100644 index 0000000000..ce00c0ff46 --- /dev/null +++ b/apps/api/src/isms/registers/register-registry.spec.ts @@ -0,0 +1,223 @@ +import { BadRequestException } from '@nestjs/common'; +import type { IsmsContextIssueService } from '../isms-context-issue.service'; +import type { IsmsInterestedPartyService } from '../isms-interested-party.service'; +import type { IsmsObjectiveService } from '../isms-objective.service'; +import type { IsmsRequirementService } from '../isms-requirement.service'; +import { + createRegisterRegistry, + ISMS_REGISTER_KEYS, + type RegisterServices, +} from './register-registry'; + +describe('createRegisterRegistry', () => { + const contextIssues = { create: jest.fn(), update: jest.fn(), remove: jest.fn() }; + const interestedParties = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const requirements = { create: jest.fn(), update: jest.fn(), remove: jest.fn() }; + const objectives = { create: jest.fn(), update: jest.fn(), remove: jest.fn() }; + + const services = { + contextIssues, + interestedParties, + requirements, + objectives, + } as unknown as RegisterServices; + + const registry = createRegisterRegistry(services); + + beforeEach(() => jest.clearAllMocks()); + + it('exposes a handler for every register key', () => { + expect(Object.keys(registry).sort()).toEqual([...ISMS_REGISTER_KEYS].sort()); + }); + + describe('context-issues', () => { + it('create dispatches with documentId and parsed dto, passing category through', async () => { + const data = { + kind: 'internal', + category: 'Strategic', + description: 'd', + effect: 'e', + }; + await registry['context-issues'].create({ + documentId: 'doc_1', + organizationId: 'org_1', + data, + }); + expect(contextIssues.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: data, + }); + }); + + it('update dispatches with issueId', async () => { + await registry['context-issues'].update({ + rowId: 'row1', + organizationId: 'org_1', + data: { description: 'x' }, + }); + expect(contextIssues.update).toHaveBeenCalledWith({ + issueId: 'row1', + organizationId: 'org_1', + dto: { description: 'x' }, + }); + }); + + it('remove dispatches with issueId', async () => { + await registry['context-issues'].remove({ + rowId: 'row1', + organizationId: 'org_1', + }); + expect(contextIssues.remove).toHaveBeenCalledWith({ + issueId: 'row1', + organizationId: 'org_1', + }); + }); + + it('create throws BadRequestException when description is missing', () => { + expect(() => + registry['context-issues'].create({ + documentId: 'doc_1', + organizationId: 'org_1', + data: { kind: 'internal', effect: 'e' }, + }), + ).toThrow(BadRequestException); + expect(contextIssues.create).not.toHaveBeenCalled(); + }); + }); + + describe('interested-parties', () => { + it('create dispatches with documentId and parsed dto', async () => { + const data = { name: 'Customers', category: 'Customer', needsExpectations: 'n' }; + await registry['interested-parties'].create({ + documentId: 'doc_1', + organizationId: 'org_1', + data, + }); + expect(interestedParties.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: data, + }); + }); + + it('update dispatches with partyId', async () => { + await registry['interested-parties'].update({ + rowId: 'ip_1', + organizationId: 'org_1', + data: { name: 'X' }, + }); + expect(interestedParties.update).toHaveBeenCalledWith({ + partyId: 'ip_1', + organizationId: 'org_1', + dto: { name: 'X' }, + }); + }); + + it('remove dispatches with partyId', async () => { + await registry['interested-parties'].remove({ + rowId: 'ip_1', + organizationId: 'org_1', + }); + expect(interestedParties.remove).toHaveBeenCalledWith({ + partyId: 'ip_1', + organizationId: 'org_1', + }); + }); + }); + + describe('requirements', () => { + it('create dispatches with documentId and parsed dto', async () => { + const data = { partyName: 'C', requirement: 'r', treatment: 't' }; + await registry.requirements.create({ + documentId: 'doc_1', + organizationId: 'org_1', + data, + }); + expect(requirements.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: data, + }); + }); + + it('update dispatches with requirementId', async () => { + await registry.requirements.update({ + rowId: 'req_1', + organizationId: 'org_1', + data: { requirement: 'r2' }, + }); + expect(requirements.update).toHaveBeenCalledWith({ + requirementId: 'req_1', + organizationId: 'org_1', + dto: { requirement: 'r2' }, + }); + }); + + it('remove dispatches with requirementId', async () => { + await registry.requirements.remove({ + rowId: 'req_1', + organizationId: 'org_1', + }); + expect(requirements.remove).toHaveBeenCalledWith({ + requirementId: 'req_1', + organizationId: 'org_1', + }); + }); + }); + + describe('objectives', () => { + it('create dispatches with documentId and parsed dto', async () => { + const data = { objective: 'o' }; + await registry.objectives.create({ + documentId: 'doc_1', + organizationId: 'org_1', + data, + }); + expect(objectives.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: data, + }); + }); + + it('update dispatches with objectiveId', async () => { + await registry.objectives.update({ + rowId: 'obj_1', + organizationId: 'org_1', + data: { status: 'met' }, + }); + expect(objectives.update).toHaveBeenCalledWith({ + objectiveId: 'obj_1', + organizationId: 'org_1', + dto: { status: 'met' }, + }); + }); + + it('remove dispatches with objectiveId', async () => { + await registry.objectives.remove({ + rowId: 'obj_1', + organizationId: 'org_1', + }); + expect(objectives.remove).toHaveBeenCalledWith({ + objectiveId: 'obj_1', + organizationId: 'org_1', + }); + }); + + it('create throws BadRequestException when status is not in the enum', () => { + expect(() => + registry.objectives.create({ + documentId: 'doc_1', + organizationId: 'org_1', + data: { objective: 'o', status: 'bogus' }, + }), + ).toThrow(BadRequestException); + expect(objectives.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/isms/registers/register-registry.ts b/apps/api/src/isms/registers/register-registry.ts new file mode 100644 index 0000000000..7af3da2eb2 --- /dev/null +++ b/apps/api/src/isms/registers/register-registry.ts @@ -0,0 +1,210 @@ +import { BadRequestException } from '@nestjs/common'; +import { z } from 'zod'; +import type { IsmsContextIssueService } from '../isms-context-issue.service'; +import type { IsmsInterestedPartyService } from '../isms-interested-party.service'; +import type { IsmsObjectiveService } from '../isms-objective.service'; +import type { IsmsRequirementService } from '../isms-requirement.service'; + +/** + * One generic dispatch for every ISMS register row (context issues, interested + * parties, requirements, objectives). The controller exposes three endpoints — + * create / update / delete — and routes by the `:register` path segment to the + * matching service here. Bodies are validated with zod off `req.body` (the + * global ValidationPipe mangles nested JSON), per-register schemas mirroring the + * original DTOs. This replaces the 13 near-identical per-register endpoints. + */ + +const position = z.number().int().min(0).optional(); +const OBJECTIVE_STATUS = ['not_started', 'on_track', 'at_risk', 'met'] as const; + +const schemas = { + contextIssueCreate: z.object({ + kind: z.enum(['internal', 'external']), + category: z.string().optional(), + description: z.string(), + effect: z.string(), + position, + }), + contextIssueUpdate: z.object({ + kind: z.enum(['internal', 'external']).optional(), + category: z.string().optional(), + description: z.string().optional(), + effect: z.string().optional(), + position, + }), + interestedPartyCreate: z.object({ + name: z.string(), + category: z.string(), + needsExpectations: z.string(), + position, + }), + interestedPartyUpdate: z.object({ + name: z.string().optional(), + category: z.string().optional(), + needsExpectations: z.string().optional(), + position, + }), + requirementCreate: z.object({ + interestedPartyId: z.string().optional(), + partyName: z.string(), + requirement: z.string(), + treatment: z.string(), + position, + }), + requirementUpdate: z.object({ + interestedPartyId: z.string().optional(), + partyName: z.string().optional(), + requirement: z.string().optional(), + treatment: z.string().optional(), + position, + }), + objectiveCreate: z.object({ + objective: z.string(), + target: z.string().optional(), + ownerMemberId: z.string().optional(), + cadence: z.string().optional(), + plan: z.string().optional(), + measurementMethod: z.string().optional(), + status: z.enum(OBJECTIVE_STATUS).optional(), + position, + }), + objectiveUpdate: z.object({ + objective: z.string().optional(), + target: z.string().optional(), + ownerMemberId: z.string().optional(), + cadence: z.string().optional(), + plan: z.string().optional(), + measurementMethod: z.string().optional(), + status: z.enum(OBJECTIVE_STATUS).optional(), + position, + }), +} as const; + +// Inferred input types — the single source of truth for register row shapes. +// Service method signatures use these directly; the per-register DTO classes were +// removed because they only duplicated these schemas. +export type CreateContextIssueInput = z.infer; +export type UpdateContextIssueInput = z.infer; +export type CreateInterestedPartyInput = z.infer< + typeof schemas.interestedPartyCreate +>; +export type UpdateInterestedPartyInput = z.infer< + typeof schemas.interestedPartyUpdate +>; +export type CreateRequirementInput = z.infer; +export type UpdateRequirementInput = z.infer; +export type CreateObjectiveInput = z.infer; +export type UpdateObjectiveInput = z.infer; + +export const ISMS_REGISTER_KEYS = [ + 'context-issues', + 'interested-parties', + 'requirements', + 'objectives', +] as const; + +export type IsmsRegisterKey = (typeof ISMS_REGISTER_KEYS)[number]; + +export interface RegisterHandler { + create(args: { + documentId: string; + organizationId: string; + data: unknown; + }): Promise; + update(args: { + rowId: string; + organizationId: string; + data: unknown; + }): Promise; + remove(args: { rowId: string; organizationId: string }): Promise; +} + +function parse(schema: z.ZodType, data: unknown): T { + const result = schema.safeParse(data ?? {}); + if (!result.success) { + const detail = result.error.issues + .map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`) + .join('; '); + throw new BadRequestException(`Invalid register row: ${detail}`); + } + return result.data; +} + +export interface RegisterServices { + contextIssues: IsmsContextIssueService; + interestedParties: IsmsInterestedPartyService; + requirements: IsmsRequirementService; + objectives: IsmsObjectiveService; +} + +/** Build the register → handler map from the injected per-register services. */ +export function createRegisterRegistry( + services: RegisterServices, +): Record { + return { + 'context-issues': { + create: ({ documentId, organizationId, data }) => + services.contextIssues.create({ + documentId, + organizationId, + dto: parse(schemas.contextIssueCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.contextIssues.update({ + issueId: rowId, + organizationId, + dto: parse(schemas.contextIssueUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.contextIssues.remove({ issueId: rowId, organizationId }), + }, + 'interested-parties': { + create: ({ documentId, organizationId, data }) => + services.interestedParties.create({ + documentId, + organizationId, + dto: parse(schemas.interestedPartyCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.interestedParties.update({ + partyId: rowId, + organizationId, + dto: parse(schemas.interestedPartyUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.interestedParties.remove({ partyId: rowId, organizationId }), + }, + requirements: { + create: ({ documentId, organizationId, data }) => + services.requirements.create({ + documentId, + organizationId, + dto: parse(schemas.requirementCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.requirements.update({ + requirementId: rowId, + organizationId, + dto: parse(schemas.requirementUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.requirements.remove({ requirementId: rowId, organizationId }), + }, + objectives: { + create: ({ documentId, organizationId, data }) => + services.objectives.create({ + documentId, + organizationId, + dto: parse(schemas.objectiveCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.objectives.update({ + objectiveId: rowId, + organizationId, + dto: parse(schemas.objectiveUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.objectives.remove({ objectiveId: rowId, organizationId }), + }, + }; +} diff --git a/apps/api/src/isms/utils/approval.ts b/apps/api/src/isms/utils/approval.ts new file mode 100644 index 0000000000..186bfcae04 --- /dev/null +++ b/apps/api/src/isms/utils/approval.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@db'; + +/** + * Editing an approved ISMS document invalidates its sign-off: revert it to draft + * so the change must be re-approved (mirrors policy approval invalidation). Only + * touches the document when it is currently `approved`; a no-op otherwise. Must + * run inside the same transaction as the content write so a failed write does not + * leave the document reverted to draft without the new content. + */ +export async function invalidateApprovalIfNeeded({ + tx, + documentId, +}: { + tx: Prisma.TransactionClient; + documentId: string; +}): Promise { + const document = await tx.ismsDocument.findUnique({ + where: { id: documentId }, + select: { status: true }, + }); + + if (document?.status !== 'approved') { + return; + } + + await tx.ismsDocument.update({ + where: { id: documentId }, + data: { status: 'draft', approvedAt: null, approverId: null }, + }); +} diff --git a/apps/api/src/isms/utils/context-derivation.spec.ts b/apps/api/src/isms/utils/context-derivation.spec.ts new file mode 100644 index 0000000000..4bf5a78244 --- /dev/null +++ b/apps/api/src/isms/utils/context-derivation.spec.ts @@ -0,0 +1,125 @@ +import { + deriveContextIssues, + EXTERNAL_ISSUE_CATEGORIES, + INTERNAL_ISSUE_CATEGORIES, + type ContextDerivationInput, +} from './context-derivation'; + +const baseInput: ContextDerivationInput = { + frameworkNames: ['ISO 27001'], + vendorCount: 5, + subProcessorCount: 2, + vendorsByCategory: { cloud: 2, software_as_a_service: 3 }, + memberCount: 12, + membersByDepartment: { it: 4, hr: 2, none: 6 }, + deviceCount: 8, +}; + +describe('deriveContextIssues', () => { + it('produces both internal and external issues with provenance', () => { + const issues = deriveContextIssues(baseInput); + + expect(issues.length).toBeGreaterThanOrEqual(4); + expect(issues.length).toBeLessThanOrEqual(8); + expect(issues.some((i) => i.kind === 'external')).toBe(true); + expect(issues.some((i) => i.kind === 'internal')).toBe(true); + expect(issues.every((i) => i.source === 'derived')).toBe(true); + expect(issues.every((i) => i.derivedFrom.length > 0)).toBe(true); + expect(issues.every((i) => i.effect.length > 0)).toBe(true); + }); + + it('assigns sequential positions', () => { + const issues = deriveContextIssues(baseInput); + issues.forEach((issue, index) => expect(issue.position).toBe(index)); + }); + + it('emits one external framework issue per active framework', () => { + const issues = deriveContextIssues({ + ...baseInput, + frameworkNames: ['ISO 27001', 'SOC 2'], + }); + const frameworkIssues = issues.filter((i) => + i.derivedFrom.startsWith('framework:'), + ); + expect(frameworkIssues).toHaveLength(2); + expect(frameworkIssues.map((i) => i.derivedFrom)).toEqual([ + 'framework:ISO 27001', + 'framework:SOC 2', + ]); + }); + + it('includes a sub-processor data-protection issue when sub-processors exist', () => { + const issues = deriveContextIssues(baseInput); + expect(issues.some((i) => i.derivedFrom === 'subprocessors')).toBe(true); + }); + + it('emits a remote-work issue when there are no devices', () => { + const issues = deriveContextIssues({ ...baseInput, deviceCount: 0 }); + const deviceIssue = issues.find((i) => i.derivedFrom === 'devices'); + expect(deviceIssue?.description).toContain('remote'); + }); + + it('is deterministic for identical input', () => { + expect(deriveContextIssues(baseInput)).toEqual( + deriveContextIssues(baseInput), + ); + }); +}); + +describe('deriveContextIssues — categories', () => { + it('tags framework issues as Regulatory & Legal', () => { + const issue = deriveContextIssues(baseInput).find((i) => + i.derivedFrom.startsWith('framework:'), + ); + expect(issue?.category).toBe('Regulatory & Legal'); + }); + + it('tags the vendor issue as Technological', () => { + const issue = deriveContextIssues(baseInput).find( + (i) => i.kind === 'external' && i.derivedFrom === 'vendors', + ); + expect(issue?.category).toBe('Technological'); + }); + + it('tags the sub-processor issue as Regulatory & Legal', () => { + const issue = deriveContextIssues(baseInput).find( + (i) => i.derivedFrom === 'subprocessors', + ); + expect(issue?.category).toBe('Regulatory & Legal'); + }); + + it('tags the workforce issue as Governance & Structure', () => { + const issue = deriveContextIssues(baseInput).find( + (i) => i.derivedFrom === 'members', + ); + expect(issue?.category).toBe('Governance & Structure'); + }); + + it('tags cloud-footprint and device issues as Capabilities & Resources', () => { + const issues = deriveContextIssues(baseInput); + const cloud = issues.find( + (i) => i.kind === 'internal' && i.derivedFrom === 'vendors', + ); + const device = issues.find((i) => i.derivedFrom === 'devices'); + expect(cloud?.category).toBe('Capabilities & Resources'); + expect(device?.category).toBe('Capabilities & Resources'); + }); + + it('tags the remote-work fallback issue as Capabilities & Resources', () => { + const issue = deriveContextIssues({ ...baseInput, deviceCount: 0 }).find( + (i) => i.derivedFrom === 'devices', + ); + expect(issue?.description).toContain('remote'); + expect(issue?.category).toBe('Capabilities & Resources'); + }); + + it('only uses categories from the published taxonomy', () => { + const valid = new Set([ + ...EXTERNAL_ISSUE_CATEGORIES, + ...INTERNAL_ISSUE_CATEGORIES, + ]); + for (const issue of deriveContextIssues(baseInput)) { + expect(valid.has(issue.category)).toBe(true); + } + }); +}); diff --git a/apps/api/src/isms/utils/context-derivation.ts b/apps/api/src/isms/utils/context-derivation.ts new file mode 100644 index 0000000000..95d71ac378 --- /dev/null +++ b/apps/api/src/isms/utils/context-derivation.ts @@ -0,0 +1,181 @@ +import type { IsmsContextIssueKind, IsmsContextSource } from '@db'; + +/** + * Deterministic derivation of "Context of the Organization" (ISO 27001 clause 4.1) + * internal & external issues from platform data. No AI — the same inputs always + * produce the same set of issues, so drift detection is a pure comparison of the + * captured snapshot against a freshly recomputed snapshot. + */ + +/** Raw platform data the derivation reads. Captured verbatim as the sourceSnapshot. */ +export interface ContextDerivationInput { + /** Names of the frameworks the organization is actively pursuing. */ + frameworkNames: string[]; + /** Total third-party vendors tracked in the org. */ + vendorCount: number; + /** Vendors flagged as sub-processors. */ + subProcessorCount: number; + /** Vendor counts keyed by category (e.g. cloud, software_as_a_service). */ + vendorsByCategory: Record; + /** Total active (non-deactivated) workforce members. */ + memberCount: number; + /** Member counts keyed by department (e.g. it, hr, gov). */ + membersByDepartment: Record; + /** Total managed endpoints/devices. */ + deviceCount: number; +} + +/** + * The ISO 27001 clause 4.1 category taxonomy. Auditors expect external and + * internal issues grouped under these headings; the export renders one table + * row per issue with its category, and the editor offers these as options. + */ +export const EXTERNAL_ISSUE_CATEGORIES = [ + 'Regulatory & Legal', + 'Market & Economic', + 'Technological', + 'Social & Cultural', +] as const; + +export const INTERNAL_ISSUE_CATEGORIES = [ + 'Governance & Structure', + 'Strategy & Objectives', + 'Capabilities & Resources', + 'Culture & Values', +] as const; + +export type ExternalIssueCategory = (typeof EXTERNAL_ISSUE_CATEGORIES)[number]; +export type InternalIssueCategory = (typeof INTERNAL_ISSUE_CATEGORIES)[number]; + +/** A single derived issue, ready to be written as an IsmsContextIssue row. */ +export interface DerivedContextIssue { + kind: IsmsContextIssueKind; + category: ExternalIssueCategory | InternalIssueCategory; + description: string; + effect: string; + source: IsmsContextSource; + derivedFrom: string; + position: number; +} + +function buildExternalIssues( + input: ContextDerivationInput, +): Array> { + const issues: Array> = []; + + for (const name of input.frameworkNames) { + issues.push({ + kind: 'external', + category: 'Regulatory & Legal', + description: `Compliance obligations arising from the ${name} framework that the organization is pursuing.`, + effect: `The ISMS must implement and evidence controls sufficient to satisfy ${name}, shaping ISMS objectives and the audit scope.`, + source: 'derived', + derivedFrom: `framework:${name}`, + }); + } + + if (input.vendorCount > 0) { + issues.push({ + kind: 'external', + category: 'Technological', + description: `Reliance on ${input.vendorCount} third-party vendor${input.vendorCount === 1 ? '' : 's'}${input.subProcessorCount > 0 ? `, of which ${input.subProcessorCount} act as sub-processor${input.subProcessorCount === 1 ? '' : 's'}` : ''}.`, + effect: + 'Supplier risk and data-sharing arrangements extend the ISMS boundary and require vendor due diligence and ongoing monitoring.', + source: 'derived', + derivedFrom: 'vendors', + }); + } + + if (input.subProcessorCount > 0) { + issues.push({ + kind: 'external', + category: 'Regulatory & Legal', + description: `Personal or customer data is processed by ${input.subProcessorCount} sub-processor${input.subProcessorCount === 1 ? '' : 's'}, creating regulatory and data-protection obligations.`, + effect: + 'The ISMS must address data-protection, breach-notification and contractual safeguards for data handled outside the organization.', + source: 'derived', + derivedFrom: 'subprocessors', + }); + } + + return issues; +} + +function buildInternalIssues( + input: ContextDerivationInput, +): Array> { + const issues: Array> = []; + + if (input.memberCount > 0) { + const departments = Object.keys(input.membersByDepartment).filter( + (dept) => dept !== 'none' && input.membersByDepartment[dept] > 0, + ); + const departmentSummary = + departments.length > 0 ? ` spanning ${departments.join(', ')}` : ''; + issues.push({ + kind: 'internal', + category: 'Governance & Structure', + description: `A workforce of ${input.memberCount} member${input.memberCount === 1 ? '' : 's'}${departmentSummary}.`, + effect: + 'Headcount and organizational structure determine security awareness, segregation of duties and access-management needs within the ISMS.', + source: 'derived', + derivedFrom: 'members', + }); + } + + const cloudVendors = + (input.vendorsByCategory.cloud ?? 0) + + (input.vendorsByCategory.infrastructure ?? 0) + + (input.vendorsByCategory.software_as_a_service ?? 0); + if (cloudVendors > 0) { + issues.push({ + kind: 'internal', + category: 'Capabilities & Resources', + description: `A cloud-centric technology footprint built on ${cloudVendors} infrastructure and SaaS provider${cloudVendors === 1 ? '' : 's'}.`, + effect: + 'The chosen architecture defines where data resides and which technical controls (encryption, access control, logging) the ISMS must enforce.', + source: 'derived', + derivedFrom: 'vendors', + }); + } + + if (input.deviceCount > 0) { + issues.push({ + kind: 'internal', + category: 'Capabilities & Resources', + description: `${input.deviceCount} managed endpoint${input.deviceCount === 1 ? '' : 's'} used by the workforce.`, + effect: + 'Endpoint posture (encryption, patching, configuration) is a core ISMS objective and drives device-management controls.', + source: 'derived', + derivedFrom: 'devices', + }); + } else { + issues.push({ + kind: 'internal', + category: 'Capabilities & Resources', + description: + 'A predominantly remote working model with limited centrally-managed hardware.', + effect: + 'Remote work shifts ISMS emphasis toward identity, endpoint and SaaS controls rather than physical security.', + source: 'derived', + derivedFrom: 'devices', + }); + } + + return issues; +} + +/** + * Produce a lean, deterministic set of internal/external context issues from the + * captured platform data. Position is assigned sequentially so the register has a + * stable order. + */ +export function deriveContextIssues( + input: ContextDerivationInput, +): DerivedContextIssue[] { + const ordered = [ + ...buildExternalIssues(input), + ...buildInternalIssues(input), + ]; + return ordered.map((issue, index) => ({ ...issue, position: index })); +} diff --git a/apps/api/src/isms/utils/document-lock.ts b/apps/api/src/isms/utils/document-lock.ts new file mode 100644 index 0000000000..22fef4f602 --- /dev/null +++ b/apps/api/src/isms/utils/document-lock.ts @@ -0,0 +1,15 @@ +import type { Prisma } from '@db'; + +/** + * Serialize register-row position allocation for a single document. The Postgres + * transaction-scoped advisory lock (keyed on the document id) is held until the + * surrounding transaction commits, so two concurrent creates can't both read the + * same max(position) and persist duplicate ordering keys. Call this inside the + * create transaction, before computing the next position. + */ +export async function lockDocumentForPositions( + tx: Prisma.TransactionClient, + documentId: string, +): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${documentId}, 0))`; +} diff --git a/apps/api/src/isms/utils/document-types.spec.ts b/apps/api/src/isms/utils/document-types.spec.ts new file mode 100644 index 0000000000..dd479691ec --- /dev/null +++ b/apps/api/src/isms/utils/document-types.spec.ts @@ -0,0 +1,74 @@ +import { ISMS_TYPE_DEFINITIONS, matchRequirementId } from './document-types'; + +describe('ISMS_TYPE_DEFINITIONS', () => { + it('defines all six foundational document types with clauses', () => { + expect(ISMS_TYPE_DEFINITIONS).toHaveLength(6); + const types = ISMS_TYPE_DEFINITIONS.map((d) => d.type); + expect(types).toEqual( + expect.arrayContaining([ + 'context_of_organization', + 'interested_parties_register', + 'interested_parties_requirements', + 'isms_scope', + 'leadership_commitment', + 'objectives_plan', + ]), + ); + }); + + 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([ + 'interested_parties_register', + 'interested_parties_requirements', + ]); + }); +}); + +describe('matchRequirementId', () => { + const requirements = [ + { + id: 'req-41', + name: '4.1 Understanding the organization', + identifier: '4.1', + }, + { id: 'req-42', name: '4.2 Interested parties', identifier: '4.2' }, + { id: 'req-141', name: '14.1 Security in development', identifier: '14.1' }, + ]; + + it('matches an exact clause identifier', () => { + expect(matchRequirementId({ clause: '4.1', requirements })).toBe('req-41'); + }); + + it('does not confuse 4.1 with 14.1', () => { + expect(matchRequirementId({ clause: '4.1', requirements })).not.toBe( + 'req-141', + ); + }); + + it('matches via the name when identifier is empty', () => { + expect( + matchRequirementId({ + clause: '5.1', + requirements: [ + { id: 'req-51', name: '5.1 Leadership', identifier: '' }, + ], + }), + ).toBe('req-51'); + }); + + it('returns null when no requirement matches', () => { + expect(matchRequirementId({ clause: '6.2', requirements })).toBeNull(); + }); + + it('does not match a clause that is a prefix of another (4.1 vs 4.11)', () => { + expect( + matchRequirementId({ + clause: '4.1', + requirements: [ + { id: 'req-411', name: '4.11 Other', identifier: '4.11' }, + ], + }), + ).toBeNull(); + }); +}); diff --git a/apps/api/src/isms/utils/document-types.ts b/apps/api/src/isms/utils/document-types.ts new file mode 100644 index 0000000000..a4179bdbd5 --- /dev/null +++ b/apps/api/src/isms/utils/document-types.ts @@ -0,0 +1,87 @@ +import type { IsmsDocumentType } from '@db'; + +/** ISO 27001 clause each foundational document type satisfies. */ +export interface IsmsTypeDefinition { + type: IsmsDocumentType; + /** Clause number used to match the FrameworkEditorRequirement (e.g. "4.1"). */ + clause: string; + title: string; + /** Short summary of what the document covers (used as the template description). */ + description: string; +} + +/** + * The full set of ISMS foundational documents and the single source the + * framework-editor template seed derives from (see packages/db .../seed.ts). + * ensure-setup falls back to this list when no templates exist in the DB. + * Several types share a clause (4.2 → register + requirements). + */ +export const ISMS_TYPE_DEFINITIONS: IsmsTypeDefinition[] = [ + { + type: 'context_of_organization', + clause: '4.1', + title: 'Context of the Organization', + description: + 'Internal and external issues relevant to the ISMS and their effect on its intended outcomes (ISO 27001 clause 4.1).', + }, + { + type: 'interested_parties_register', + clause: '4.2', + title: 'Interested Parties Register', + description: + 'The interested parties relevant to the ISMS together with their needs and expectations (ISO 27001 clause 4.2).', + }, + { + type: 'interested_parties_requirements', + clause: '4.2', + title: 'Interested Parties Requirements', + description: + 'The requirements of interested parties and how the ISMS addresses them (ISO 27001 clause 4.2).', + }, + { + type: 'isms_scope', + clause: '4.3', + title: 'ISMS Scope', + description: + 'The boundaries and applicability of the ISMS, including the interfaces and dependencies considered (ISO 27001 clause 4.3).', + }, + { + type: 'leadership_commitment', + clause: '5.1', + title: 'Leadership and Commitment', + description: + 'Evidence of top management leadership and commitment to the ISMS (ISO 27001 clause 5.1).', + }, + { + type: 'objectives_plan', + clause: '6.2', + title: 'Information Security Objectives and Plan', + description: + 'Measurable information security objectives and the plan to achieve them (ISO 27001 clause 6.2).', + }, +]; + +/** + * Find the requirement whose name or identifier starts with the given clause + * number. Matches "4.1", "4.1.1", "4.1 Understanding..." but not "14.1". + */ +export function matchRequirementId({ + clause, + requirements, +}: { + clause: string; + requirements: Array<{ id: string; name: string; identifier: string }>; +}): string | null { + const matches = (value: string | null | undefined): boolean => { + if (!value) return false; + const trimmed = value.trim(); + if (trimmed === clause) return true; + // Must be followed by a separator so "4.1" does not match "4.11". + return new RegExp(`^${clause.replace('.', '\\.')}(\\D|$)`).test(trimmed); + }; + + const found = requirements.find( + (req) => matches(req.identifier) || matches(req.name), + ); + return found?.id ?? null; +} diff --git a/apps/api/src/isms/utils/docx-renderer.spec.ts b/apps/api/src/isms/utils/docx-renderer.spec.ts new file mode 100644 index 0000000000..bc155c039f --- /dev/null +++ b/apps/api/src/isms/utils/docx-renderer.spec.ts @@ -0,0 +1,82 @@ +import { renderIsmsDocx } from './docx-renderer'; +import type { + IsmsExportMetadata, + IsmsExportSection, +} from './export-shared'; + +// Exercises the REAL renderer (no mock). docx exposes a CommonJS `require` +// entry, so jest resolves it without transforming node_modules. + +const metadata: IsmsExportMetadata = { + title: 'Context of the Organization', + clause: '4.1', + documentCode: 'ACME-ISMS-001', + standardLabel: 'ISO/IEC 27001:2022', + frameworkName: 'ISO 27001', + version: 2, + preparedBy: 'Comp AI', + owner: 'CISO', + status: 'approved', + approverName: 'Jane Approver', + approvedAt: '2026-01-15', + declinedAt: null, + classification: 'Internal', + nextReview: 'Annual, or on material change', + issueDate: '2026-01-01', + organizationName: 'Acme Corp', + primaryColor: '#004D3D', +}; + +const sections: IsmsExportSection[] = [ + { + heading: '1. Purpose', + intro: 'This document establishes the context of the organization.', + paragraphs: [ + { label: 'Effect: ', text: 'Bounds the ISMS.' }, + { text: 'A plain paragraph with no label.', bold: true }, + ], + }, + { + heading: '2. Organization overview', + keyValues: [ + { label: 'Legal name', value: 'Acme Corp' }, + { label: 'Sector', value: 'Software' }, + ], + }, + { + heading: '3. Interested parties', + table: { + headers: ['Party', 'Category', 'Needs & expectations'], + rows: [ + ['Customers', 'External', 'Confidentiality of data'], + ['Regulators', 'External', 'Demonstrable compliance'], + ], + }, + }, + { + heading: '4. Intended outcomes', + bullets: ['Protect confidentiality', 'Maintain availability'], + }, +]; + +const PK_MAGIC = [0x50, 0x4b]; + +describe('renderIsmsDocx', () => { + it('renders a non-empty DOCX (ZIP) buffer covering every content block', async () => { + const buffer = await renderIsmsDocx({ sections, metadata }); + + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.length).toBeGreaterThan(0); + // A .docx is a ZIP archive: it must start with the PK local-file header. + expect(buffer[0]).toBe(PK_MAGIC[0]); + expect(buffer[1]).toBe(PK_MAGIC[1]); + }); + + it('does not throw and still emits a valid ZIP for an empty sections array', async () => { + const buffer = await renderIsmsDocx({ sections: [], metadata }); + + expect(buffer.length).toBeGreaterThan(0); + expect(buffer[0]).toBe(PK_MAGIC[0]); + expect(buffer[1]).toBe(PK_MAGIC[1]); + }); +}); diff --git a/apps/api/src/isms/utils/docx-renderer.ts b/apps/api/src/isms/utils/docx-renderer.ts new file mode 100644 index 0000000000..7e4a361772 --- /dev/null +++ b/apps/api/src/isms/utils/docx-renderer.ts @@ -0,0 +1,298 @@ +import { + AlignmentType, + BorderStyle, + Document, + Footer, + PageNumber, + Packer, + Paragraph, + ShadingType, + Table, + TableCell, + TableRow, + TextRun, + WidthType, +} from 'docx'; +import { + metadataRows, + type IsmsExportMetadata, + type IsmsExportParagraph, + type IsmsExportSection, + type IsmsExportTable, + type IsmsKeyValue, +} from './export-shared'; + +const DEFAULT_ACCENT = '004D3D'; +const INK = '212121'; +const MUTED = '6E6E6E'; +const HAIRLINE = 'DFDFDF'; +const ZEBRA = 'F7F7F7'; +const WHITE = 'FFFFFF'; + +function normalizeHexColor(hex: string | null): string { + if (!hex) return DEFAULT_ACCENT; + const clean = hex.replace('#', '').trim(); + return /^[0-9a-fA-F]{6}$/.test(clean) ? clean.toUpperCase() : DEFAULT_ACCENT; +} + +const thin = { style: BorderStyle.SINGLE, size: 4, color: HAIRLINE }; +const TABLE_BORDERS = { + top: thin, + bottom: thin, + left: thin, + right: thin, + insideHorizontal: thin, + insideVertical: thin, +}; + +function shaded(fill: string) { + return { type: ShadingType.CLEAR, fill, color: 'auto' }; +} + +function cell({ + text, + bold, + color, + fill, + width, +}: { + text: string; + bold?: boolean; + color?: string; + fill?: string; + width?: number; +}): TableCell { + return new TableCell({ + width: width ? { size: width, type: WidthType.DXA } : undefined, + shading: fill ? shaded(fill) : undefined, + margins: { top: 60, bottom: 60, left: 90, right: 90 }, + children: [ + new Paragraph({ + children: [new TextRun({ text, bold, color: color ?? INK })], + }), + ], + }); +} + +/** A 2-column label/value table (metadata block + organization overview). */ +function keyValueTable(rows: IsmsKeyValue[]): Table { + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + columnWidths: [2600, 6426], + borders: TABLE_BORDERS, + rows: rows.map( + (row) => + new TableRow({ + children: [ + cell({ + text: row.label, + bold: true, + color: MUTED, + fill: ZEBRA, + width: 2600, + }), + cell({ text: row.value, width: 6426 }), + ], + }), + ), + }); +} + +/** A bordered data table with a shaded accent header row. */ +function dataTable({ + table, + accent, +}: { + table: IsmsExportTable; + accent: string; +}): Table { + const widths = + table.headers.length === 3 ? [1900, 3000, 4126] : undefined; + const headerRow = new TableRow({ + tableHeader: true, + children: table.headers.map( + (header, index) => + new TableCell({ + width: widths ? { size: widths[index], type: WidthType.DXA } : undefined, + shading: shaded(accent), + margins: { top: 60, bottom: 60, left: 90, right: 90 }, + children: [ + new Paragraph({ + children: [new TextRun({ text: header, bold: true, color: WHITE })], + }), + ], + }), + ), + }); + const bodyRows = table.rows.map( + (row) => + new TableRow({ + children: row.map((value, index) => + cell({ + text: value, + bold: widths ? index === 0 : false, + width: widths ? widths[index] : undefined, + }), + ), + }), + ); + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + columnWidths: widths, + borders: TABLE_BORDERS, + rows: [headerRow, ...bodyRows], + }); +} + +function paragraphRuns(paragraph: IsmsExportParagraph): TextRun[] { + const runs: TextRun[] = []; + if (paragraph.label) { + runs.push(new TextRun({ text: paragraph.label, bold: true })); + } + runs.push(new TextRun({ text: paragraph.text, bold: paragraph.bold })); + return runs; +} + +function sectionElements({ + section, + accent, +}: { + section: IsmsExportSection; + accent: string; +}): Array { + const elements: Array = [ + new Paragraph({ + spacing: { before: 280, after: 120 }, + children: [ + new TextRun({ text: section.heading, bold: true, color: accent, size: 26 }), + ], + }), + ]; + + const hasContent = + Boolean(section.intro) || + Boolean(section.paragraphs?.length) || + Boolean(section.bullets?.length) || + Boolean(section.keyValues?.length) || + Boolean(section.table && section.table.rows.length); + + if (!hasContent) { + elements.push( + new Paragraph({ + children: [ + new TextRun({ text: section.emptyText ?? 'No entries recorded.' }), + ], + }), + ); + return elements; + } + + if (section.intro) { + elements.push( + new Paragraph({ spacing: { after: 80 }, children: [new TextRun(section.intro)] }), + ); + } + for (const paragraph of section.paragraphs ?? []) { + elements.push( + new Paragraph({ spacing: { after: 60 }, children: paragraphRuns(paragraph) }), + ); + } + for (const bullet of section.bullets ?? []) { + elements.push( + new Paragraph({ bullet: { level: 0 }, children: [new TextRun(bullet)] }), + ); + } + if (section.keyValues?.length) elements.push(keyValueTable(section.keyValues)); + if (section.table && section.table.rows.length) { + elements.push(dataTable({ table: section.table, accent })); + } + + return elements; +} + +function coverBlock(metadata: IsmsExportMetadata): Paragraph[] { + const center = AlignmentType.CENTER; + const block: Paragraph[] = []; + if (metadata.organizationName) { + block.push( + new Paragraph({ + alignment: center, + spacing: { before: 480, after: 80 }, + children: [ + new TextRun({ text: metadata.organizationName, bold: true, size: 26, color: INK }), + ], + }), + ); + } + block.push( + new Paragraph({ + alignment: center, + spacing: { after: 160 }, + children: [new TextRun({ text: metadata.standardLabel, size: 22, color: MUTED })], + }), + new Paragraph({ + alignment: center, + spacing: { after: 60 }, + children: [new TextRun({ text: metadata.title, bold: true, size: 44, color: metadata.primaryColor ? normalizeHexColor(metadata.primaryColor) : DEFAULT_ACCENT })], + }), + new Paragraph({ + alignment: center, + spacing: { after: 320 }, + children: [new TextRun({ text: `Clause ${metadata.clause}`, size: 22, color: MUTED })], + }), + ); + return block; +} + +function pageFooter(metadata: IsmsExportMetadata): Footer { + const left = [metadata.organizationName, metadata.classification] + .filter(Boolean) + .join(' · '); + return new Footer({ + children: [ + new Paragraph({ + tabStops: [{ type: 'right', position: 9026 }], + children: [ + new TextRun({ text: left, size: 16, color: MUTED }), + new TextRun({ text: `\t${metadata.documentCode} · Page `, size: 16, color: MUTED }), + new TextRun({ children: [PageNumber.CURRENT], size: 16, color: MUTED }), + new TextRun({ text: ' of ', size: 16, color: MUTED }), + new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 16, color: MUTED }), + ], + }), + ], + }); +} + +/** + * Render an ISMS document to a polished DOCX matching the PDF: a centred cover + * block, a metadata table, numbered sections, bullet lists, a key/value + * overview and bordered data tables with a shaded accent header, plus a footer + * with the org, classification and page numbers. + */ +export async function renderIsmsDocx({ + sections, + metadata, +}: { + sections: IsmsExportSection[]; + metadata: IsmsExportMetadata; +}): Promise { + const accent = normalizeHexColor(metadata.primaryColor); + + const body: Array = [ + ...coverBlock(metadata), + keyValueTable(metadataRows(metadata)), + ...sections.flatMap((section) => sectionElements({ section, accent })), + ]; + + const doc = new Document({ + sections: [ + { + footers: { default: pageFooter(metadata) }, + children: body, + }, + ], + }); + + return Packer.toBuffer(doc); +} diff --git a/apps/api/src/isms/utils/ensure-setup-plan.ts b/apps/api/src/isms/utils/ensure-setup-plan.ts new file mode 100644 index 0000000000..e79a7385b2 --- /dev/null +++ b/apps/api/src/isms/utils/ensure-setup-plan.ts @@ -0,0 +1,95 @@ +import { db } from '@db'; +import type { IsmsDocumentType } from '@db'; +import { ISMS_TYPE_DEFINITIONS, matchRequirementId } from './document-types'; + +export interface IsmsDocumentPlan { + type: IsmsDocumentType; + title: string; + requirementId: string | null; + templateId: string | null; + controlTemplateIds: string[]; +} + +/** + * Build one create-plan per ISMS document type. Template-driven when the + * FrameworkEditorIsmsDocumentTemplate rows are seeded; the requirement comes + * from the framework-scoped link if present, otherwise from clause matching. + * Falls back to ISMS_TYPE_DEFINITIONS (no templates) so unseeded DBs still + * work — those plans carry a null templateId and no control links. + */ +export async function resolveDocumentPlans({ + frameworkId, + requirements, +}: { + frameworkId: string; + requirements: Array<{ id: string; name: string; identifier: string }>; +}): Promise { + const templates = await db.frameworkEditorIsmsDocumentTemplate.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { + // Order so requirementLinks[0] is a deterministic pick across runs. + requirementLinks: { + where: { frameworkId }, + orderBy: [{ requirementId: 'asc' }, { id: 'asc' }], + }, + controlLinks: { + where: { frameworkId }, + select: { controlTemplateId: true }, + }, + }, + }); + + if (templates.length === 0) { + return ISMS_TYPE_DEFINITIONS.map((def) => ({ + type: def.type, + title: def.title, + templateId: null, + controlTemplateIds: [], + requirementId: matchRequirementId({ clause: def.clause, requirements }), + })); + } + + return templates.map((template) => ({ + type: template.documentType, + title: template.name, + templateId: template.id, + controlTemplateIds: template.controlLinks.map( + (link) => link.controlTemplateId, + ), + requirementId: + template.requirementLinks[0]?.requirementId ?? + matchRequirementId({ clause: template.clause, requirements }), + })); +} + +/** + * Best-effort: turn a template's framework-scoped control template links into + * org-level IsmsDocumentControlLink rows by resolving the org's Controls that + * were instantiated from those control templates. Idempotent (skipDuplicates) + * and silent when nothing resolves, so re-runs preserve existing links. + */ +export async function deriveControlLinks({ + documentId, + organizationId, + controlTemplateIds, +}: { + documentId: string; + organizationId: string; + controlTemplateIds: string[]; +}): Promise { + if (controlTemplateIds.length === 0) return; + + const controls = await db.control.findMany({ + where: { organizationId, controlTemplateId: { in: controlTemplateIds } }, + select: { id: true }, + }); + if (controls.length === 0) return; + + await db.ismsDocumentControlLink.createMany({ + data: controls.map((control) => ({ + ismsDocumentId: documentId, + controlId: control.id, + })), + skipDuplicates: true, + }); +} diff --git a/apps/api/src/isms/utils/export-generator.spec.ts b/apps/api/src/isms/utils/export-generator.spec.ts new file mode 100644 index 0000000000..8eb3cf5afe --- /dev/null +++ b/apps/api/src/isms/utils/export-generator.spec.ts @@ -0,0 +1,117 @@ +import { + generateIsmsExportFile, + type IsmsExportMetadata, + type IsmsExportSection, +} from './export-generator'; +import { renderIsmsDocx } from './docx-renderer'; + +// docx is ESM-only; the renderer is exercised separately and mocked here so the +// dispatch logic stays unit-testable without transforming node_modules. +jest.mock('./docx-renderer', () => ({ + renderIsmsDocx: jest.fn(), +})); + +const mockRenderDocx = jest.mocked(renderIsmsDocx); + +const metadata: IsmsExportMetadata = { + title: 'Context of the Organization', + clause: '4.1', + documentCode: 'ACME-ISMS-001', + standardLabel: 'ISO/IEC 27001:2022', + frameworkName: 'ISO 27001', + version: 2, + preparedBy: 'Comp AI', + owner: 'Comp AI', + status: 'approved', + approverName: 'Jane Doe', + approvedAt: new Date('2026-05-01T00:00:00.000Z'), + declinedAt: null, + classification: 'Internal', + nextReview: 'Annual, or on material change', + issueDate: new Date('2026-01-01'), + organizationName: 'Acme Inc', + primaryColor: '#123456', +}; + +const paragraphSections: IsmsExportSection[] = [ + { + heading: 'External issues', + paragraphs: [ + { text: '1. Pursuing ISO 27001', bold: true }, + { label: 'Effect: ', text: 'Shapes scope' }, + ], + }, + { + heading: 'Internal issues', + paragraphs: [{ text: '1. 12 workforce members', bold: true }], + }, +]; + +const tableSections: IsmsExportSection[] = [ + { + heading: 'Interested Parties', + emptyText: 'No interested parties recorded.', + table: { + headers: ['Interested party', 'Category', 'Needs & expectations'], + rows: [['Customers', 'Customer', 'Confidentiality of their data']], + }, + }, +]; + +describe('generateIsmsExportFile', () => { + beforeEach(() => jest.clearAllMocks()); + + it('renders a real PDF buffer for format=pdf', async () => { + const result = await generateIsmsExportFile({ + sections: paragraphSections, + metadata, + format: 'pdf', + }); + + expect(result.mimeType).toBe('application/pdf'); + expect(result.filename).toBe('context-of-the-organization-v2.pdf'); + expect(result.fileBuffer).toBeInstanceOf(Buffer); + expect(result.fileBuffer.length).toBeGreaterThan(0); + expect(result.fileBuffer.subarray(0, 4).toString()).toBe('%PDF'); + expect(mockRenderDocx).not.toHaveBeenCalled(); + }); + + it('renders a PDF buffer for table-based sections', async () => { + const result = await generateIsmsExportFile({ + sections: tableSections, + metadata, + format: 'pdf', + }); + expect(result.fileBuffer.subarray(0, 4).toString()).toBe('%PDF'); + expect(result.fileBuffer.length).toBeGreaterThan(0); + }); + + it('delegates to the docx renderer for format=docx', async () => { + mockRenderDocx.mockResolvedValue(Buffer.from('docx-bytes')); + + const result = await generateIsmsExportFile({ + sections: paragraphSections, + metadata, + format: 'docx', + }); + + expect(mockRenderDocx).toHaveBeenCalledWith({ + sections: paragraphSections, + metadata, + }); + expect(result.mimeType).toBe( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ); + expect(result.filename).toBe('context-of-the-organization-v2.docx'); + expect(result.fileBuffer).toBeInstanceOf(Buffer); + }); + + it('handles an empty section set without throwing', async () => { + const result = await generateIsmsExportFile({ + sections: [], + metadata, + format: 'pdf', + }); + expect(result.fileBuffer.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/isms/utils/export-generator.ts b/apps/api/src/isms/utils/export-generator.ts new file mode 100644 index 0000000000..e43ca195c0 --- /dev/null +++ b/apps/api/src/isms/utils/export-generator.ts @@ -0,0 +1,50 @@ +import { renderIsmsDocx } from './docx-renderer'; +import { renderIsmsPdf } from './pdf-renderer'; +import { + DOCX_MIME_TYPE, + type IsmsExportFormat, + type IsmsExportMetadata, + type IsmsExportResult, + type IsmsExportSection, +} from './export-shared'; + +export type { + IsmsExportFormat, + IsmsExportIssue, + IsmsExportMetadata, + IsmsExportResult, + IsmsExportSection, +} from './export-shared'; + +export async function generateIsmsExportFile({ + sections, + metadata, + format, +}: { + sections: IsmsExportSection[]; + metadata: IsmsExportMetadata; + format: IsmsExportFormat; +}): Promise { + const baseName = `${sanitizeName(metadata.title)}-v${metadata.version}`; + + if (format === 'docx') { + return { + fileBuffer: await renderIsmsDocx({ sections, metadata }), + mimeType: DOCX_MIME_TYPE, + filename: `${baseName}.docx`, + }; + } + + return { + fileBuffer: renderIsmsPdf({ sections, metadata }), + mimeType: 'application/pdf', + filename: `${baseName}.pdf`, + }; +} + +function sanitizeName(name: string): string { + return (name || 'isms-document') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} diff --git a/apps/api/src/isms/utils/export-metadata.ts b/apps/api/src/isms/utils/export-metadata.ts new file mode 100644 index 0000000000..71e9438eb7 --- /dev/null +++ b/apps/api/src/isms/utils/export-metadata.ts @@ -0,0 +1,83 @@ +import type { IsmsDocumentType } from '@db'; +import { ISMS_TYPE_DEFINITIONS } from './document-types'; +import { standardLabel, type IsmsExportMetadata } from './export-shared'; + +const DEFAULT_CLASSIFICATION = 'Internal'; +const DEFAULT_NEXT_REVIEW = 'Annual, or on material change'; +const DEFAULT_OWNER = 'Security & Privacy Owner'; + +/** A short org code for the document ID, e.g. "Comp AI" -> "CA", "Acme" -> "ACME". */ +function orgCode(name: string | null): string { + const cleaned = (name ?? '').replace(/[^A-Za-z0-9 ]/g, ' ').trim(); + if (!cleaned) return 'ORG'; + const words = cleaned.split(/\s+/); + if (words.length === 1) return words[0].slice(0, 4).toUpperCase(); + return words + .map((word) => word[0]) + .join('') + .slice(0, 4) + .toUpperCase(); +} + +/** 1-based document number within the ISMS pack (Context of the Organization = 001). */ +function documentNumber(type: IsmsDocumentType): string { + const index = ISMS_TYPE_DEFINITIONS.findIndex((def) => def.type === type); + return String((index < 0 ? 0 : index) + 1).padStart(3, '0'); +} + +function clauseFor(type: IsmsDocumentType): string { + return ISMS_TYPE_DEFINITIONS.find((def) => def.type === type)?.clause ?? ''; +} + +/** + * Build the full export metadata (cover block + metadata table) from the stored + * document and organization. Fields the platform does not yet capture + * (classification, review cadence, owner) fall back to ISO-sensible defaults. + */ +export function buildExportMetadata({ + type, + title, + frameworkName, + version, + status, + preparedBy, + owner, + approverName, + approvedAt, + declinedAt, + organizationName, + primaryColor, +}: { + type: IsmsDocumentType; + title: string; + frameworkName: string; + version: number; + status: string | null; + preparedBy: string | null; + owner: string | null; + approverName: string | null; + approvedAt: Date | null; + declinedAt: Date | null; + organizationName: string | null; + primaryColor: string | null; +}): IsmsExportMetadata { + return { + title, + clause: clauseFor(type), + documentCode: `${orgCode(organizationName)}-ISMS-${documentNumber(type)}`, + standardLabel: standardLabel(frameworkName), + frameworkName, + version, + preparedBy, + owner: owner || preparedBy || DEFAULT_OWNER, + status, + approverName, + approvedAt, + declinedAt, + classification: DEFAULT_CLASSIFICATION, + nextReview: DEFAULT_NEXT_REVIEW, + issueDate: approvedAt ?? new Date(), + organizationName, + primaryColor, + }; +} diff --git a/apps/api/src/isms/utils/export-shared.ts b/apps/api/src/isms/utils/export-shared.ts new file mode 100644 index 0000000000..9f9c2b2ced --- /dev/null +++ b/apps/api/src/isms/utils/export-shared.ts @@ -0,0 +1,161 @@ +import type { IsmsContextIssueKind } from '@db'; + +export type IsmsExportFormat = 'pdf' | 'docx'; + +export interface IsmsExportIssue { + kind: IsmsContextIssueKind; + description: string; + effect: string; +} + +export interface IsmsExportMetadata { + title: string; + /** ISO clause this document satisfies, e.g. "4.1". */ + clause: string; + /** Short human document code, e.g. "CAI-ISMS-001". */ + documentCode: string; + /** Formal standard label for the cover, e.g. "ISO/IEC 27001:2022". */ + standardLabel: string; + frameworkName: string; + version: number; + preparedBy: string | null; + /** The role/person accountable for the document. */ + owner: string | null; + status: string | null; + approverName: string | null; + approvedAt: Date | string | null; + declinedAt: Date | string | null; + /** Information classification, e.g. "Internal". */ + classification: string; + /** Review cadence sentence, e.g. "Annual, or on material change". */ + nextReview: string; + /** Effective/issue date shown in the metadata table. */ + issueDate: Date | string | null; + organizationName: string | null; + primaryColor: string | null; +} + +export interface IsmsExportResult { + fileBuffer: Buffer; + mimeType: string; + filename: string; +} + +/** A label/value pair — used by the metadata table and key/value overview. */ +export interface IsmsKeyValue { + label: string; + value: string; +} + +/** + * A heading plus any combination of content blocks, rendered in this order: + * intro → paragraphs → bullets → key/value table → data table. The unit of + * every export; both the PDF and DOCX renderers consume the same shape. + */ +export interface IsmsExportSection { + heading: string; + /** Optional lead-in paragraph rendered directly under the heading. */ + intro?: string; + /** Free-text paragraphs. */ + paragraphs?: IsmsExportParagraph[]; + /** Bullet-list items. */ + bullets?: string[]; + /** Label/value pairs rendered as a 2-column overview table. */ + keyValues?: IsmsKeyValue[]; + /** Tabular content (registers + the category issue tables render here). */ + table?: IsmsExportTable; + /** Shown (instead of any content) when the section is empty. */ + emptyText?: string; +} + +export interface IsmsExportParagraph { + /** Optional bold lead-in label, e.g. "Effect: ". */ + label?: string; + text: string; + /** Render the whole paragraph bold (used for numbered list titles). */ + bold?: boolean; +} + +export interface IsmsExportTable { + headers: string[]; + rows: string[][]; +} + +export const DOCX_MIME_TYPE = + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +/** Map a framework name to its formal standard label for the cover page. */ +const STANDARD_LABELS: Record = { + 'ISO 27001': 'ISO/IEC 27001:2022', + 'ISO 27001:2022': 'ISO/IEC 27001:2022', + 'ISO 27001:2013': 'ISO/IEC 27001:2013', + 'SOC 2': 'SOC 2', + GDPR: 'GDPR', + HIPAA: 'HIPAA', + 'PCI DSS': 'PCI DSS', +}; + +export function standardLabel(frameworkName: string): string { + return STANDARD_LABELS[frameworkName] ?? frameworkName; +} + +/** Format a date as YYYY-MM-DD (deterministic; matches the reference document). */ +export function formatExportDate(value: Date | string | null): string { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toISOString().slice(0, 10); +} + +function humanStatus(status: string | null): string { + switch (status) { + case 'approved': + return 'Approved'; + case 'needs_review': + return 'Pending approval'; + case 'declined': + return 'Declined'; + case 'draft': + return 'Draft'; + default: + return status ? status : 'Draft'; + } +} + +/** "v2 · APPROVED" — the version cell of the metadata table. */ +export function versionLabel(metadata: IsmsExportMetadata): string { + return `v${metadata.version} · ${humanStatus(metadata.status).toUpperCase()}`; +} + +/** Human-readable approval status used in the metadata table. */ +export function approvalLine(metadata: IsmsExportMetadata): string { + // Declined wins over a stale approvedAt: a document can carry an approvedAt + // from a prior cycle yet currently be declined. + if (metadata.status === 'declined' || metadata.declinedAt) { + return `Declined on ${formatExportDate(metadata.declinedAt)}`; + } + if (metadata.approvedAt) { + return `Approved on ${formatExportDate(metadata.approvedAt)}`; + } + if (metadata.status === 'needs_review') return 'Pending approval'; + return 'Not approved'; +} + +/** The key/value rows of the cover metadata table (shared by PDF + DOCX). */ +export function metadataRows(metadata: IsmsExportMetadata): IsmsKeyValue[] { + return [ + { label: 'Document ID', value: metadata.documentCode }, + { label: 'Document title', value: metadata.title }, + { + label: 'Clause reference', + value: `${metadata.standardLabel}, Clause ${metadata.clause}`, + }, + { label: 'Version', value: versionLabel(metadata) }, + { label: 'Issue date', value: formatExportDate(metadata.issueDate) }, + { label: 'Owner', value: metadata.owner || metadata.preparedBy || 'Comp AI' }, + { label: 'Approver', value: metadata.approverName || '—' }, + { label: 'Approval status', value: approvalLine(metadata) }, + { label: 'Classification', value: metadata.classification }, + { label: 'Next review', value: metadata.nextReview }, + ]; +} diff --git a/apps/api/src/isms/utils/pdf-renderer.ts b/apps/api/src/isms/utils/pdf-renderer.ts new file mode 100644 index 0000000000..b7bc8cc64f --- /dev/null +++ b/apps/api/src/isms/utils/pdf-renderer.ts @@ -0,0 +1,269 @@ +import { jsPDF } from 'jspdf'; +import { autoTable } from 'jspdf-autotable'; +import { + metadataRows, + type IsmsExportMetadata, + type IsmsExportSection, + type IsmsKeyValue, + type IsmsExportTable, +} from './export-shared'; + +type Rgb = [number, number, number]; + +const INK: Rgb = [33, 33, 33]; +const MUTED: Rgb = [110, 110, 110]; +const HAIRLINE: Rgb = [223, 223, 223]; +const ZEBRA: Rgb = [247, 247, 247]; + +/** jsPDF instance once jspdf-autotable has attached its result accessor. */ +interface JsPdfWithAutoTable extends jsPDF { + lastAutoTable?: { finalY: number }; +} + +function accentColor(hex: string | null): Rgb { + const fallback: Rgb = [0, 77, 61]; + if (!hex) return fallback; + const clean = hex.replace('#', ''); + const r = parseInt(clean.substring(0, 2), 16); + const g = parseInt(clean.substring(2, 4), 16); + const b = parseInt(clean.substring(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return fallback; + return [r, g, b]; +} + +/** + * Render an ISMS document to a polished, auditor-ready PDF: a centred cover + * block, a metadata table, numbered sections, real bordered tables (the + * category issue tables and the key/value overview) and a footer carrying the + * org, classification and page numbers. Mirrors the DOCX renderer's structure. + */ +export function renderIsmsPdf({ + sections, + metadata, +}: { + sections: IsmsExportSection[]; + metadata: IsmsExportMetadata; +}): Buffer { + const pdf = new jsPDF({ unit: 'mm', format: 'a4' }); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 18; + const contentWidth = pageWidth - margin * 2; + const accent = accentColor(metadata.primaryColor); + const bottomLimit = pageHeight - 16; + let y = margin; + + const ensureSpace = (needed: number) => { + if (y + needed > bottomLimit) { + pdf.addPage(); + y = margin; + } + }; + + const finalY = (): number => + (pdf as JsPdfWithAutoTable).lastAutoTable?.finalY ?? y; + + const writeWrapped = (text: string, style: 'normal' | 'bold') => { + pdf.setFont('helvetica', style); + pdf.setFontSize(10.5); + pdf.setTextColor(...INK); + for (const line of pdf.splitTextToSize(text, contentWidth)) { + ensureSpace(6); + pdf.text(line, margin, y); + y += 5; + } + }; + + const writeBullet = (text: string) => { + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(10.5); + pdf.setTextColor(...INK); + const lines = pdf.splitTextToSize(text, contentWidth - 6); + lines.forEach((line: string, index: number) => { + ensureSpace(6); + if (index === 0) pdf.text('•', margin + 1, y); + pdf.text(line, margin + 6, y); + y += 5; + }); + y += 1; + }; + + const renderKeyValues = (rows: IsmsKeyValue[]) => { + autoTable(pdf, { + startY: y, + margin: { left: margin, right: margin, bottom: 16 }, + theme: 'grid', + styles: { + fontSize: 9.5, + cellPadding: 2.2, + lineColor: HAIRLINE, + lineWidth: 0.1, + textColor: INK, + valign: 'top', + overflow: 'linebreak', + }, + columnStyles: { + 0: { cellWidth: 48, fontStyle: 'bold', fillColor: ZEBRA, textColor: MUTED }, + 1: { cellWidth: contentWidth - 48 }, + }, + body: rows.map((row) => [row.label, row.value]), + }); + y = finalY() + 4; + }; + + const renderTable = (table: IsmsExportTable) => { + const threeCol = table.headers.length === 3; + autoTable(pdf, { + startY: y, + margin: { left: margin, right: margin, bottom: 16 }, + theme: 'grid', + head: [table.headers], + body: table.rows, + styles: { + fontSize: 9, + cellPadding: 2.2, + lineColor: HAIRLINE, + lineWidth: 0.1, + textColor: INK, + valign: 'top', + overflow: 'linebreak', + }, + headStyles: { + fillColor: accent, + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 9, + }, + columnStyles: threeCol + ? { + 0: { cellWidth: 32, fontStyle: 'bold' }, + 1: { cellWidth: 56 }, + 2: { cellWidth: contentWidth - 88 }, + } + : {}, + }); + y = finalY() + 4; + }; + + const renderSection = (section: IsmsExportSection) => { + ensureSpace(14); + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(13); + pdf.setTextColor(...accent); + pdf.text(section.heading, margin, y); + y += 7; + + const hasContent = + Boolean(section.intro) || + Boolean(section.paragraphs?.length) || + Boolean(section.bullets?.length) || + Boolean(section.keyValues?.length) || + Boolean(section.table && section.table.rows.length); + + if (!hasContent) { + writeWrapped(section.emptyText ?? 'No entries recorded.', 'normal'); + y += 4; + return; + } + + if (section.intro) { + writeWrapped(section.intro, 'normal'); + y += 2; + } + for (const paragraph of section.paragraphs ?? []) { + const text = paragraph.label + ? `${paragraph.label}${paragraph.text}` + : paragraph.text; + writeWrapped(text, paragraph.bold ? 'bold' : 'normal'); + y += 1.5; + } + for (const bullet of section.bullets ?? []) writeBullet(bullet); + if (section.keyValues?.length) renderKeyValues(section.keyValues); + if (section.table && section.table.rows.length) renderTable(section.table); + y += 4; + }; + + drawCover(); + renderMetadataTable(); + for (const section of sections) renderSection(section); + drawFooters(); + + return Buffer.from(pdf.output('arraybuffer')); + + function drawCover() { + const centerX = pageWidth / 2; + y = 32; + if (metadata.organizationName) { + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(13); + pdf.setTextColor(...INK); + pdf.text(metadata.organizationName, centerX, y, { align: 'center' }); + y += 8; + } + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(11); + pdf.setTextColor(...MUTED); + pdf.text(metadata.standardLabel, centerX, y, { align: 'center' }); + y += 13; + + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(22); + pdf.setTextColor(...accent); + const titleLines = pdf.splitTextToSize(metadata.title, contentWidth); + pdf.text(titleLines, centerX, y, { align: 'center' }); + y += titleLines.length * 9 + 1; + + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(11); + pdf.setTextColor(...MUTED); + pdf.text(`Clause ${metadata.clause}`, centerX, y, { align: 'center' }); + y += 13; + } + + function renderMetadataTable() { + autoTable(pdf, { + startY: y, + margin: { left: margin, right: margin, bottom: 16 }, + theme: 'grid', + styles: { + fontSize: 9, + cellPadding: 2.2, + lineColor: HAIRLINE, + lineWidth: 0.1, + textColor: INK, + valign: 'top', + overflow: 'linebreak', + }, + columnStyles: { + 0: { cellWidth: 42, fontStyle: 'bold', fillColor: ZEBRA, textColor: MUTED }, + 1: { cellWidth: contentWidth - 42 }, + }, + body: metadataRows(metadata).map((row) => [row.label, row.value]), + }); + y = finalY() + 10; + } + + function drawFooters() { + const pageCount = pdf.getNumberOfPages(); + const footerY = pageHeight - 9; + const left = [metadata.organizationName, metadata.classification] + .filter(Boolean) + .join(' · '); + for (let page = 1; page <= pageCount; page += 1) { + pdf.setPage(page); + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(8); + pdf.setTextColor(150, 150, 150); + pdf.setDrawColor(...HAIRLINE); + pdf.setLineWidth(0.1); + pdf.line(margin, footerY - 3, pageWidth - margin, footerY - 3); + if (left) pdf.text(left, margin, footerY); + pdf.text( + `${metadata.documentCode} · Page ${page} of ${pageCount}`, + pageWidth - margin, + footerY, + { align: 'right' }, + ); + } + } +} diff --git a/apps/api/src/isms/utils/version-snapshot.spec.ts b/apps/api/src/isms/utils/version-snapshot.spec.ts new file mode 100644 index 0000000000..b9b1484efc --- /dev/null +++ b/apps/api/src/isms/utils/version-snapshot.spec.ts @@ -0,0 +1,78 @@ +import type { Prisma } from '@db'; +import { upsertLatestSnapshotVersion } from './version-snapshot'; + +// A fake transaction client: only the ismsDocumentVersion methods the unit +// touches are stubbed. No module mock — the unit under test is imported real. +function makeTx() { + const ismsDocumentVersion = { + findFirst: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }; + const tx = { ismsDocumentVersion } as unknown as Prisma.TransactionClient; + return { tx, ismsDocumentVersion }; +} + +const snapshot = { frameworkNames: ['ISO 27001'], vendorCount: 3 }; + +describe('upsertLatestSnapshotVersion', () => { + it('updates the existing latest version with the serialized snapshot (UPDATE branch)', async () => { + const { tx, ismsDocumentVersion } = makeTx(); + ismsDocumentVersion.findFirst.mockResolvedValue({ id: 'ver_existing' }); + + await upsertLatestSnapshotVersion({ + tx, + documentId: 'doc_1', + snapshot, + }); + + expect(ismsDocumentVersion.findFirst).toHaveBeenCalledWith({ + where: { documentId: 'doc_1', isLatest: true }, + }); + expect(ismsDocumentVersion.update).toHaveBeenCalledWith({ + where: { id: 'ver_existing' }, + data: { sourceSnapshot: snapshot }, + }); + expect(ismsDocumentVersion.create).not.toHaveBeenCalled(); + }); + + it('creates version 1 marked latest when none exists (CREATE branch)', async () => { + const { tx, ismsDocumentVersion } = makeTx(); + ismsDocumentVersion.findFirst.mockResolvedValue(null); + + await upsertLatestSnapshotVersion({ + tx, + documentId: 'doc_2', + snapshot, + }); + + expect(ismsDocumentVersion.update).not.toHaveBeenCalled(); + expect(ismsDocumentVersion.create).toHaveBeenCalledWith({ + data: { + documentId: 'doc_2', + version: 1, + isLatest: true, + narrative: {}, + sourceSnapshot: snapshot, + }, + }); + }); + + it('serializes the snapshot through JSON, dropping undefined fields', async () => { + const { tx, ismsDocumentVersion } = makeTx(); + ismsDocumentVersion.findFirst.mockResolvedValue(null); + + await upsertLatestSnapshotVersion({ + tx, + documentId: 'doc_3', + snapshot: { keep: 'yes', drop: undefined, nested: { ok: 1 } }, + }); + + const createArg = ismsDocumentVersion.create.mock.calls[0][0]; + expect(createArg.data.sourceSnapshot).toEqual({ + keep: 'yes', + nested: { ok: 1 }, + }); + expect('drop' in createArg.data.sourceSnapshot).toBe(false); + }); +}); diff --git a/apps/api/src/isms/utils/version-snapshot.ts b/apps/api/src/isms/utils/version-snapshot.ts new file mode 100644 index 0000000000..227a79c060 --- /dev/null +++ b/apps/api/src/isms/utils/version-snapshot.ts @@ -0,0 +1,45 @@ +import type { Prisma } from '@db'; + +const EMPTY_NARRATIVE: Prisma.InputJsonValue = {}; + +/** + * Persist the derived-data snapshot onto the document's latest version, creating + * version 1 if none exists. The snapshot is the drift baseline. Serializing + * through JSON keeps it a plain Prisma.InputJsonValue without unsafe casts. The + * existing narrative is preserved (only sourceSnapshot is written). + */ +export async function upsertLatestSnapshotVersion({ + tx, + documentId, + snapshot, +}: { + tx: Prisma.TransactionClient; + documentId: string; + snapshot: unknown; +}): Promise { + const sourceSnapshot: Prisma.InputJsonValue = JSON.parse( + JSON.stringify(snapshot), + ); + + const latest = await tx.ismsDocumentVersion.findFirst({ + where: { documentId, isLatest: true }, + }); + + if (latest) { + await tx.ismsDocumentVersion.update({ + where: { id: latest.id }, + data: { sourceSnapshot }, + }); + return; + } + + await tx.ismsDocumentVersion.create({ + data: { + documentId, + version: 1, + isLatest: true, + narrative: EMPTY_NARRATIVE, + sourceSnapshot, + }, + }); +} diff --git a/apps/api/src/isms/wizard/dto/generate-all.dto.ts b/apps/api/src/isms/wizard/dto/generate-all.dto.ts new file mode 100644 index 0000000000..9e8fc98d39 --- /dev/null +++ b/apps/api/src/isms/wizard/dto/generate-all.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class GenerateAllDto { + @ApiProperty({ + description: + 'ID of the framework to generate all ISMS profile documents for', + example: 'frk_abc123def456', + }) + @IsString() + frameworkId!: string; +} diff --git a/apps/api/src/isms/wizard/isms-profile.controller.spec.ts b/apps/api/src/isms/wizard/isms-profile.controller.spec.ts new file mode 100644 index 0000000000..77c7248646 --- /dev/null +++ b/apps/api/src/isms/wizard/isms-profile.controller.spec.ts @@ -0,0 +1,143 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Request } from 'express'; +import { Reflector } from '@nestjs/core'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { + PermissionGuard, + PERMISSIONS_KEY, +} from '../../auth/permission.guard'; +import { IsmsProfileController } from './isms-profile.controller'; +import { IsmsProfileService } from './isms-profile.service'; + +jest.mock('../../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); +jest.mock('../../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, + PERMISSIONS_KEY: 'permissions', +})); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('./isms-profile.service', () => ({ + IsmsProfileService: class MockIsmsProfileService {}, +})); + +const reqWith = (body: unknown): Request => ({ body }) as unknown as Request; + +describe('IsmsProfileController', () => { + let controller: IsmsProfileController; + + const mockService = { + getProfile: jest.fn(), + saveProfile: jest.fn(), + generateAll: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IsmsProfileController], + providers: [{ provide: IsmsProfileService, useValue: mockService }], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(IsmsProfileController); + jest.clearAllMocks(); + }); + + describe('getProfile', () => { + it('requires a frameworkId', async () => { + await expect( + controller.getProfile('', 'org_1'), + ).rejects.toThrow(BadRequestException); + }); + + it('delegates to the service with framework + org', async () => { + mockService.getProfile.mockResolvedValue({ answers: null }); + await controller.getProfile('fw_1', 'org_1'); + expect(mockService.getProfile).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + }); + }); + }); + + describe('saveProfile', () => { + it('validates the body and delegates', async () => { + mockService.saveProfile.mockResolvedValue({ id: 'pf_1' }); + await controller.saveProfile( + reqWith({ + frameworkId: 'fw_1', + answers: { hasContractors: true }, + complete: false, + }), + 'org_1', + ); + expect(mockService.saveProfile).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + answers: { hasContractors: true }, + complete: false, + }); + }); + + it('defaults complete to false when omitted', async () => { + mockService.saveProfile.mockResolvedValue({ id: 'pf_1' }); + await controller.saveProfile( + reqWith({ frameworkId: 'fw_1', answers: {} }), + 'org_1', + ); + expect(mockService.saveProfile).toHaveBeenCalledWith( + expect.objectContaining({ complete: false }), + ); + }); + + it('rejects an invalid body with BadRequestException', async () => { + await expect( + controller.saveProfile(reqWith({ answers: {} }), 'org_1'), + ).rejects.toThrow(BadRequestException); + expect(mockService.saveProfile).not.toHaveBeenCalled(); + }); + }); + + describe('generateAll', () => { + it('delegates to the service with framework + org', async () => { + mockService.generateAll.mockResolvedValue({ success: true }); + await controller.generateAll({ frameworkId: 'fw_1' }, 'org_1'); + expect(mockService.generateAll).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + }); + }); + }); + + describe('permission metadata', () => { + const reflector = new Reflector(); + const permissionsFor = (method: keyof IsmsProfileController) => + reflector.get(PERMISSIONS_KEY, IsmsProfileController.prototype[method]); + + it('gates getProfile with evidence:read', () => { + expect(permissionsFor('getProfile')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); + }); + + it('gates saveProfile and generateAll with evidence:update', () => { + for (const method of ['saveProfile', 'generateAll'] as const) { + expect(permissionsFor(method)).toEqual([ + { resource: 'evidence', actions: ['update'] }, + ]); + } + }); + }); +}); diff --git a/apps/api/src/isms/wizard/isms-profile.controller.ts b/apps/api/src/isms/wizard/isms-profile.controller.ts new file mode 100644 index 0000000000..f258bd3373 --- /dev/null +++ b/apps/api/src/isms/wizard/isms-profile.controller.ts @@ -0,0 +1,127 @@ +import { + BadRequestException, + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '@/auth/auth-context.decorator'; +import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { RequirePermission } from '../../auth/require-permission.decorator'; +import { IsmsProfileService } from './isms-profile.service'; +import { GenerateAllDto } from './dto/generate-all.dto'; +import { saveWizardProfileSchema } from './wizard-schema'; + +/** + * OpenAPI body contract for POST /v1/isms/profile. It reads `req.body` directly + * (the global ValidationPipe mangles the nested answers JSON), so the request + * shape is documented explicitly here and validated at runtime by + * `saveWizardProfileSchema`. Mirrors the inline-schema @ApiBody the registers + * controller uses for its @Req()-bodied endpoints (e.g. REGISTER_ROW_BODY). + */ +const SAVE_PROFILE_BODY = { + description: 'Partial ISMS wizard answers (validated at runtime by zod)', + schema: { + type: 'object', + properties: { + frameworkId: { type: 'string' }, + answers: { + type: 'object', + description: + 'Deeply-partial wizard answers (any subset of the wizard steps)', + additionalProperties: true, + }, + complete: { + type: 'boolean', + description: 'Whether the wizard is being finalized', + }, + }, + required: ['frameworkId', 'answers'], + }, +} as const; + +/** + * ISMS wizard profile endpoints (CS-438). The profile holds the ~12 un-derivable + * wizard answers (IsmsProfile.answers) that feed document generation. Shares the + * `isms` controller path and the evidence:read / evidence:update gating used by + * the rest of the ISMS module. + */ +@ApiTags('ISMS') +@Controller({ path: 'isms', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class IsmsProfileController { + constructor(private readonly profileService: IsmsProfileService) {} + + @Get('profile') + @RequirePermission('evidence', 'read') + @ApiOperation({ summary: 'Get the ISMS wizard profile, defaults and members' }) + @ApiOkResponse({ description: 'Wizard profile, defaults and member options' }) + async getProfile( + @Query('frameworkId') frameworkId: string, + @OrganizationId() organizationId: string, + ) { + if (!frameworkId) { + throw new BadRequestException('frameworkId is required'); + } + return this.profileService.getProfile({ organizationId, frameworkId }); + } + + @Post('profile') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Save (partial) ISMS wizard answers' }) + @ApiConsumes('application/json') + @ApiBody(SAVE_PROFILE_BODY) + @ApiOkResponse({ description: 'Saved profile' }) + async saveProfile( + // Read req.body directly: ValidationPipe with transform mangles the nested + // answers JSON. We validate with the shared Zod schema instead. + @Req() req: Request, + @OrganizationId() organizationId: string, + ) { + const parsed = saveWizardProfileSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestException(parsed.error.issues); + } + const { frameworkId, answers, complete } = parsed.data; + + return this.profileService.saveProfile({ + organizationId, + frameworkId, + answers, + complete: complete ?? false, + }); + } + + @Post('generate-all') + @HttpCode(HttpStatus.OK) + @RequirePermission('evidence', 'update') + @ApiOperation({ summary: 'Ensure and regenerate all ISMS documents' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Regenerated ISMS documents' }) + async generateAll( + @Body() dto: GenerateAllDto, + @OrganizationId() organizationId: string, + ) { + return this.profileService.generateAll({ + organizationId, + frameworkId: dto.frameworkId, + }); + } +} diff --git a/apps/api/src/isms/wizard/isms-profile.service.spec.ts b/apps/api/src/isms/wizard/isms-profile.service.spec.ts new file mode 100644 index 0000000000..b93ba7df88 --- /dev/null +++ b/apps/api/src/isms/wizard/isms-profile.service.spec.ts @@ -0,0 +1,288 @@ +import { NotFoundException } from '@nestjs/common'; +import { ZodError } from 'zod'; +import { db } from '@db'; +import { IsmsProfileService } from './isms-profile.service'; +import { IsmsService } from '../isms.service'; +import { IsmsContextService } from '../isms-context.service'; +import { computeWizardDefaults } from './wizard-defaults'; +import { collectPlatformData } from '../documents/data-source'; + +jest.mock('@db', () => ({ + db: { + frameworkEditorFramework: { findUnique: jest.fn() }, + ismsProfile: { + upsert: jest.fn(), + update: jest.fn(), + }, + ismsDocument: { findMany: jest.fn() }, + member: { findMany: jest.fn() }, + }, +})); +jest.mock('./wizard-defaults', () => ({ + computeWizardDefaults: jest.fn(), +})); +jest.mock('../documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockDefaults = jest.mocked(computeWizardDefaults); +const mockCollect = jest.mocked(collectPlatformData); + +const fullAnswers = { + deputySpo: { memberId: 'mem_1', toBeNamed: false }, + internalAuditApproach: 'in_house' as const, + certificationBody: 'BSI', + insurance: { has: true, insurerName: 'Acme Cyber' }, + sectorRegulators: ['FINMA'], + hasContractors: true, + capabilitiesInProduction: ['Payments API'], + cloudScopeSplit: { customer: ['Data'], provider: ['Infra'] }, + euRep: { status: 'appointed' as const, name: 'EU Rep Ltd' }, + certificateScopeSentence: 'The ISMS covers everything.', + objectives: [{ objective: 'Stay certified', target: '100%' }], + intendedOutcomes: ['Protect data'], +}; + +const defaultsFixture = { + capabilitiesInProduction: [], + certificateScopeSentence: 'default sentence', + objectives: [], + intendedOutcomes: [], + cloudScopeSplit: { customer: [], provider: [] }, + sectorRegulatorOptions: [], +}; + +const platformData = { + organizationName: 'Acme', + frameworkNames: ['ISO 27001'], + vendorCount: 0, + subProcessorCount: 0, + vendorsByCategory: {}, + subProcessorNames: [], + infraVendorNames: [], + memberCount: 0, + membersByDepartment: {}, + deviceCount: 0, + riskCount: 0, + highRiskCount: 0, + hasTrainingProgram: false, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +const args = { organizationId: 'org_1', frameworkId: 'fw_1' }; + +describe('IsmsProfileService', () => { + let service: IsmsProfileService; + let ismsService: jest.Mocked>; + let contextService: jest.Mocked>; + + beforeEach(() => { + jest.clearAllMocks(); + ismsService = { ensureSetup: jest.fn() }; + contextService = { generate: jest.fn() }; + service = new IsmsProfileService( + ismsService as unknown as IsmsService, + contextService as unknown as IsmsContextService, + ); + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'fw_1', + }); + mockDefaults.mockResolvedValue(defaultsFixture); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([]); + mockCollect.mockResolvedValue(platformData); + }); + + describe('getProfile', () => { + it('throws NotFoundException when framework not found', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue(null); + await expect(service.getProfile(args)).rejects.toThrow(NotFoundException); + }); + + it('upserts the profile row (get-or-init, race-safe)', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: {}, + }); + + const result = await service.getProfile(args); + + expect(mockDb.ismsProfile.upsert).toHaveBeenCalledWith({ + where: { + organizationId_frameworkId: { + organizationId: 'org_1', + frameworkId: 'fw_1', + }, + }, + update: {}, + create: { organizationId: 'org_1', frameworkId: 'fw_1', answers: {} }, + }); + expect(result.answers).toBeNull(); + expect(result.defaults).toEqual(defaultsFixture); + expect(result.members).toEqual([]); + }); + + it('returns saved answers when the profile has them', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: { hasContractors: true }, + }); + + const result = await service.getProfile(args); + expect(result.answers).toEqual({ hasContractors: true }); + }); + + it('maps members to {id,name} using name then email', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: {}, + }); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([ + { id: 'mem_1', user: { name: 'Alice', email: 'a@x.com' } }, + { id: 'mem_2', user: { name: null, email: 'b@x.com' } }, + ]); + + const result = await service.getProfile(args); + expect(result.members).toEqual([ + { id: 'mem_1', name: 'Alice' }, + { id: 'mem_2', name: 'b@x.com' }, + ]); + }); + }); + + describe('saveProfile', () => { + it('merges the partial payload onto stored answers', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: { certificationBody: 'BSI' }, + completedAt: null, + }); + (mockDb.ismsProfile.update as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: { certificationBody: 'BSI', hasContractors: true }, + completedAt: null, + }); + + await service.saveProfile({ + ...args, + answers: { hasContractors: true }, + complete: false, + }); + + const updateArg = (mockDb.ismsProfile.update as jest.Mock).mock.calls[0][0]; + expect(updateArg.data.answers).toEqual({ + certificationBody: 'BSI', + hasContractors: true, + }); + expect(updateArg.data.completedAt).toBeNull(); + }); + + it('sets completedAt and validates the full schema when complete=true', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: {}, + completedAt: null, + }); + (mockDb.ismsProfile.update as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: fullAnswers, + completedAt: new Date(), + }); + + await service.saveProfile({ + ...args, + answers: fullAnswers, + complete: true, + }); + + const updateArg = (mockDb.ismsProfile.update as jest.Mock).mock.calls[0][0]; + expect(updateArg.data.completedAt).toBeInstanceOf(Date); + }); + + it('rejects completion when the merged answers are incomplete', async () => { + (mockDb.ismsProfile.upsert as jest.Mock).mockResolvedValue({ + id: 'pf_1', + answers: {}, + completedAt: null, + }); + + await expect( + service.saveProfile({ + ...args, + answers: { certificationBody: 'BSI' }, + complete: true, + }), + ).rejects.toBeInstanceOf(ZodError); + expect(mockDb.ismsProfile.update).not.toHaveBeenCalled(); + }); + }); + + describe('generateAll', () => { + it('ensures setup, collects platform data once, then regenerates every document', async () => { + ismsService.ensureSetup.mockResolvedValue({ + success: true, + documents: [], + }); + (mockDb.ismsDocument.findMany as jest.Mock).mockResolvedValue([ + { id: 'doc_1', type: 'context_of_organization' }, + { id: 'doc_2', type: 'objectives_plan' }, + ]); + contextService.generate + .mockResolvedValueOnce({ id: 'doc_1' } as never) + .mockResolvedValueOnce({ id: 'doc_2' } as never); + + const result = await service.generateAll(args); + + expect(ismsService.ensureSetup).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + canWrite: true, + }); + // Expensive platform collect runs once for the whole batch. + expect(mockCollect).toHaveBeenCalledTimes(1); + expect(mockCollect).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkId: 'fw_1', + }); + expect(contextService.generate).toHaveBeenCalledTimes(2); + // The pre-collected snapshot is threaded into every generate call. + expect(contextService.generate).toHaveBeenNthCalledWith(1, { + documentId: 'doc_1', + organizationId: 'org_1', + data: platformData, + }); + expect(contextService.generate).toHaveBeenNthCalledWith(2, { + documentId: 'doc_2', + organizationId: 'org_1', + data: platformData, + }); + expect(result).toEqual({ + success: true, + documents: [{ id: 'doc_1' }, { id: 'doc_2' }], + }); + }); + + it('generates the parties register before the requirements register', async () => { + ismsService.ensureSetup.mockResolvedValue({ + success: true, + documents: [], + }); + // findMany returns requirements before the register (unordered DB order). + (mockDb.ismsDocument.findMany as jest.Mock).mockResolvedValue([ + { id: 'doc_reqs', type: 'interested_parties_requirements' }, + { id: 'doc_register', type: 'interested_parties_register' }, + ]); + contextService.generate.mockResolvedValue({ id: 'x' } as never); + + await service.generateAll(args); + + const generatedOrder = contextService.generate.mock.calls.map( + ([call]) => call.documentId, + ); + expect(generatedOrder).toEqual(['doc_register', 'doc_reqs']); + }); + }); +}); diff --git a/apps/api/src/isms/wizard/isms-profile.service.ts b/apps/api/src/isms/wizard/isms-profile.service.ts new file mode 100644 index 0000000000..e296572061 --- /dev/null +++ b/apps/api/src/isms/wizard/isms-profile.service.ts @@ -0,0 +1,221 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { IsmsDocumentType, Prisma } from '@db'; +import { IsmsService } from '../isms.service'; +import { IsmsContextService } from '../isms-context.service'; +import { collectPlatformData } from '../documents/data-source'; +import { computeWizardDefaults } from './wizard-defaults'; +import { mergeWizardAnswers } from './merge-answers'; +import { + parseStoredAnswers, + wizardAnswersSchema, + type PartialWizardAnswers, +} from './wizard-schema'; + +/** A member option surfaced for the Deputy SPO / sign-off pickers. */ +export interface WizardMemberOption { + id: string; + name: string; +} + +/** + * Deterministic generation order for generateAll. The Parties register MUST be + * generated before the Requirements register, since requirements derive from + * the persisted parties; an unordered findMany could regenerate requirements + * against a stale/empty register. Lower number = generated first; unlisted + * types fall back to GENERATION_ORDER_DEFAULT. + */ +const GENERATION_ORDER: Record = { + context_of_organization: 0, + interested_parties_register: 1, + interested_parties_requirements: 2, + isms_scope: 3, + leadership_commitment: 4, + objectives_plan: 5, +}; +const GENERATION_ORDER_DEFAULT = Object.keys(GENERATION_ORDER).length; + +/** + * IsmsProfile lifecycle: get-or-init, partial save, completion, and the + * wizard-driven generate-all. One profile row per org + framework. The answers + * JSON is validated against the shared wizard Zod schema on read and write. + */ +@Injectable() +export class IsmsProfileService { + constructor( + private readonly ismsService: IsmsService, + private readonly contextService: IsmsContextService, + ) {} + + /** + * Return the saved answers (or null), the computed pre-population defaults, and + * the member options. Ensures the profile row exists (get-or-init), mirroring + * ensure-setup, so the wizard always has a row to PATCH against. + */ + async getProfile({ + organizationId, + frameworkId, + }: { + organizationId: string; + frameworkId: string; + }): Promise<{ + answers: PartialWizardAnswers | null; + defaults: Awaited>; + members: WizardMemberOption[]; + }> { + await this.requireFramework({ frameworkId }); + + const profile = await this.ensureProfile({ organizationId, frameworkId }); + const [defaults, members] = await Promise.all([ + computeWizardDefaults({ organizationId, frameworkId }), + this.listMembers({ organizationId }), + ]); + + const stored = parseStoredAnswers(profile.answers); + const hasAnswers = Object.keys(stored).length > 0; + + return { + answers: hasAnswers ? stored : null, + defaults, + members, + }; + } + + /** + * Merge a partial answers payload onto the stored answers and save. When + * `complete` is true the merged result is validated against the full schema and + * completedAt is set. + */ + async saveProfile({ + organizationId, + frameworkId, + answers, + complete, + }: { + organizationId: string; + frameworkId: string; + answers: PartialWizardAnswers; + complete: boolean; + }) { + await this.requireFramework({ frameworkId }); + + const profile = await this.ensureProfile({ organizationId, frameworkId }); + const stored = parseStoredAnswers(profile.answers); + const merged = mergeWizardAnswers({ stored, incoming: answers }); + + if (complete) { + wizardAnswersSchema.parse(merged); + } + + const serialized: Prisma.InputJsonValue = JSON.parse(JSON.stringify(merged)); + + const updated = await db.ismsProfile.update({ + where: { id: profile.id }, + data: { + answers: serialized, + completedAt: complete ? new Date() : profile.completedAt, + }, + }); + + return { + id: updated.id, + answers: parseStoredAnswers(updated.answers), + completedAt: updated.completedAt, + }; + } + + /** + * Ensure all six ISMS documents exist, then regenerate each from the latest + * profile + platform data. Called by the wizard on completion so every document + * reflects the answers just saved. Returns the regenerated documents. + */ + async generateAll({ + organizationId, + frameworkId, + }: { + organizationId: string; + frameworkId: string; + }) { + await this.requireFramework({ frameworkId }); + + // The wizard is an evidence:update flow, so it always provisions. + await this.ismsService.ensureSetup({ + organizationId, + frameworkId, + canWrite: true, + }); + + const documents = await db.ismsDocument.findMany({ + where: { organizationId, frameworkId }, + select: { id: true, type: true }, + }); + const ordered = [...documents].sort( + (a, b) => + (GENERATION_ORDER[a.type] ?? GENERATION_ORDER_DEFAULT) - + (GENERATION_ORDER[b.type] ?? GENERATION_ORDER_DEFAULT), + ); + + // Collect the expensive platform snapshot once and reuse it across every + // document, instead of re-querying it per document inside generate(). + const data = await collectPlatformData({ organizationId, frameworkId }); + + type GeneratedDocument = Awaited< + ReturnType + >; + const generated: GeneratedDocument[] = []; + for (const doc of ordered) { + const result = await this.contextService.generate({ + documentId: doc.id, + organizationId, + data, + }); + generated.push(result); + } + + return { success: true, documents: generated }; + } + + private async ensureProfile({ + organizationId, + frameworkId, + }: { + organizationId: string; + frameworkId: string; + }) { + const empty: Prisma.InputJsonValue = {}; + // Idempotent: concurrent callers can't trip the unique constraint. + return db.ismsProfile.upsert({ + where: { organizationId_frameworkId: { organizationId, frameworkId } }, + update: {}, + create: { organizationId, frameworkId, answers: empty }, + }); + } + + private async listMembers({ + organizationId, + }: { + organizationId: string; + }): Promise { + const members = await db.member.findMany({ + where: { organizationId, deactivated: false }, + select: { id: true, user: { select: { name: true, email: true } } }, + orderBy: { createdAt: 'asc' }, + }); + + return members.map((member) => ({ + id: member.id, + name: member.user?.name || member.user?.email || 'Unknown member', + })); + } + + private async requireFramework({ frameworkId }: { frameworkId: string }) { + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { id: true }, + }); + if (!framework) { + throw new NotFoundException('Framework not found'); + } + return framework; + } +} diff --git a/apps/api/src/isms/wizard/merge-answers.spec.ts b/apps/api/src/isms/wizard/merge-answers.spec.ts new file mode 100644 index 0000000000..c73c9e212b --- /dev/null +++ b/apps/api/src/isms/wizard/merge-answers.spec.ts @@ -0,0 +1,48 @@ +import { mergeWizardAnswers } from './merge-answers'; + +describe('mergeWizardAnswers', () => { + it('shallow-merges nested objects without clobbering siblings', () => { + const merged = mergeWizardAnswers({ + stored: { insurance: { has: true, insurerName: 'Acme' } }, + incoming: { insurance: { insurerName: 'Beta' } }, + }); + expect(merged.insurance).toEqual({ has: true, insurerName: 'Beta' }); + }); + + it('replaces scalars and arrays wholesale', () => { + const merged = mergeWizardAnswers({ + stored: { + certificationBody: 'BSI', + sectorRegulators: ['FINMA'], + }, + incoming: { sectorRegulators: ['FCA', 'HIPAA'] }, + }); + expect(merged.certificationBody).toBe('BSI'); + expect(merged.sectorRegulators).toEqual(['FCA', 'HIPAA']); + }); + + it('ignores undefined incoming fields', () => { + const merged = mergeWizardAnswers({ + stored: { hasContractors: true }, + incoming: { hasContractors: undefined }, + }); + expect(merged.hasContractors).toBe(true); + }); + + it('does not mutate the stored input', () => { + const stored = { insurance: { has: false, insurerName: '' } }; + mergeWizardAnswers({ + stored, + incoming: { insurance: { has: true } }, + }); + expect(stored.insurance.has).toBe(false); + }); + + it('seeds a nested object that did not exist before', () => { + const merged = mergeWizardAnswers({ + stored: {}, + incoming: { deputySpo: { toBeNamed: true } }, + }); + expect(merged.deputySpo).toEqual({ toBeNamed: true }); + }); +}); diff --git a/apps/api/src/isms/wizard/merge-answers.ts b/apps/api/src/isms/wizard/merge-answers.ts new file mode 100644 index 0000000000..9380c17d43 --- /dev/null +++ b/apps/api/src/isms/wizard/merge-answers.ts @@ -0,0 +1,41 @@ +import type { PartialWizardAnswers } from './wizard-schema'; + +/** + * Merge an incoming partial wizard payload onto the stored answers. Nested + * objects (deputySpo, insurance, cloudScopeSplit, euRep) are shallow-merged so a + * single-field PATCH does not clobber sibling fields; scalars and arrays are + * replaced wholesale. Returns a new object — neither input is mutated. + */ +export function mergeWizardAnswers({ + stored, + incoming, +}: { + stored: PartialWizardAnswers; + incoming: PartialWizardAnswers; +}): PartialWizardAnswers { + const merged: PartialWizardAnswers = { ...stored }; + + for (const key of Object.keys(incoming) as Array) { + const value = incoming[key]; + if (value === undefined) continue; + + if (isPlainObject(value)) { + const current = merged[key]; + const base: Record = isPlainObject(current) + ? current + : {}; + Object.assign(merged, { [key]: { ...base, ...value } }); + continue; + } + + Object.assign(merged, { [key]: value }); + } + + return merged; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) + ); +} diff --git a/apps/api/src/isms/wizard/wizard-defaults.spec.ts b/apps/api/src/isms/wizard/wizard-defaults.spec.ts new file mode 100644 index 0000000000..5f01627702 --- /dev/null +++ b/apps/api/src/isms/wizard/wizard-defaults.spec.ts @@ -0,0 +1,110 @@ +import { db } from '@db'; +import { collectPlatformData } from '../documents/data-source'; +import { + DEFAULT_CLOUD_SCOPE_SPLIT, + DEFAULT_INTENDED_OUTCOMES, + computeWizardDefaults, +} from './wizard-defaults'; +import { SECTOR_REGULATOR_OPTIONS } from './wizard-schema'; +import type { IsmsPlatformData } from '../documents/types'; + +jest.mock('@db', () => ({ + db: { context: { findFirst: jest.fn() } }, +})); +jest.mock('../documents/data-source', () => ({ + collectPlatformData: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockCollect = jest.mocked(collectPlatformData); + +const platformData: IsmsPlatformData = { + organizationName: 'Acme Inc', + frameworkNames: ['ISO 27001'], + vendorCount: 2, + subProcessorCount: 1, + vendorsByCategory: { cloud: 2 }, + subProcessorNames: ['Sub A'], + infraVendorNames: ['Cloud A'], + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, + riskCount: 2, + highRiskCount: 1, + hasTrainingProgram: true, + wizardAnswers: {}, + partiesFingerprint: '', +}; + +const args = { organizationId: 'org_1', frameworkId: 'fw_1' }; + +describe('computeWizardDefaults', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCollect.mockResolvedValue(platformData); + }); + + it('returns the certificate scope sentence from the scope derivation', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue(null); + const result = await computeWizardDefaults(args); + expect(result.certificateScopeSentence).toContain('Acme Inc'); + expect(result.certificateScopeSentence).toContain('ISO 27001'); + }); + + it('returns the default objectives (objective + target) from the derivation', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue(null); + const result = await computeWizardDefaults(args); + expect(result.objectives.length).toBeGreaterThan(0); + expect(result.objectives[0]).toHaveProperty('objective'); + expect(result.objectives[0]).toHaveProperty('target'); + }); + + it('returns the static intended outcomes, cloud split and regulator options', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue(null); + const result = await computeWizardDefaults(args); + expect(result.intendedOutcomes).toEqual(DEFAULT_INTENDED_OUTCOMES); + expect(result.cloudScopeSplit).toEqual(DEFAULT_CLOUD_SCOPE_SPLIT); + expect(result.sectorRegulatorOptions).toEqual([...SECTOR_REGULATOR_OPTIONS]); + }); + + it('splits the Types of Services context answer into capabilities', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue({ + answer: '- Payments API\n- Reporting dashboard\n- Mobile app', + }); + const result = await computeWizardDefaults(args); + expect(result.capabilitiesInProduction).toEqual([ + 'Payments API', + 'Reporting dashboard', + 'Mobile app', + ]); + }); + + it('returns [] capabilities when no services context exists', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue(null); + const result = await computeWizardDefaults(args); + expect(result.capabilitiesInProduction).toEqual([]); + }); + + it('splits a prose paragraph answer into per-sentence capabilities', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue({ + answer: + 'The company provides a payments API. It also offers a reporting dashboard. Its model includes a mobile app.', + }); + const result = await computeWizardDefaults(args); + expect(result.capabilitiesInProduction).toEqual([ + 'The company provides a payments API.', + 'It also offers a reporting dashboard.', + 'Its model includes a mobile app.', + ]); + }); + + it('does not shred decimals or single-clause prose into fragments', async () => { + (mockDb.context.findFirst as jest.Mock).mockResolvedValue({ + answer: 'A single hosted platform with 99.9% uptime for enterprise teams.', + }); + const result = await computeWizardDefaults(args); + expect(result.capabilitiesInProduction).toEqual([ + 'A single hosted platform with 99.9% uptime for enterprise teams.', + ]); + }); +}); diff --git a/apps/api/src/isms/wizard/wizard-defaults.ts b/apps/api/src/isms/wizard/wizard-defaults.ts new file mode 100644 index 0000000000..8044156d0d --- /dev/null +++ b/apps/api/src/isms/wizard/wizard-defaults.ts @@ -0,0 +1,113 @@ +import { db } from '@db'; +import { collectPlatformData } from '../documents/data-source'; +import { deriveScopeNarrative } from '../documents/scope'; +import { deriveObjectives } from '../documents/objectives'; +import { SECTOR_REGULATOR_OPTIONS } from './wizard-schema'; + +/** The Context Q&A question key that stores the org's services description. */ +const SERVICES_CONTEXT_QUESTION = 'Types of Services Provided'; + +/** Default intended ISMS outcomes (5.x) offered for confirmation. */ +export const DEFAULT_INTENDED_OUTCOMES: string[] = [ + 'Protect the confidentiality, integrity and availability of information assets.', + 'Meet applicable legal, regulatory and contractual information-security obligations.', + 'Maintain customer and stakeholder trust through demonstrable security practices.', + 'Identify, assess and treat information-security risks within defined tolerances.', + 'Continually improve the effectiveness of the information security management system.', +]; + +/** Default cloud scope split between customer- and provider-managed layers (4.3). */ +export const DEFAULT_CLOUD_SCOPE_SPLIT: { + customer: string[]; + provider: string[]; +} = { + customer: ['Data', 'Databases', 'Application configuration'], + provider: ['Underlying infrastructure'], +}; + +/** The shape returned by GET /v1/isms/profile under `defaults`. */ +export interface WizardDefaults { + capabilitiesInProduction: string[]; + certificateScopeSentence: string; + objectives: Array<{ objective: string; target: string }>; + intendedOutcomes: string[]; + cloudScopeSplit: { customer: string[]; provider: string[] }; + sectorRegulatorOptions: string[]; +} + +/** + * Compute the pre-population defaults for the wizard. Sourced from the same + * platform-data + derivation logic that drives document generation, so the + * confirmed-default flow stays consistent with what generation would produce. + */ +export async function computeWizardDefaults({ + organizationId, + frameworkId, +}: { + organizationId: string; + frameworkId: string; +}): Promise { + const [data, capabilitiesInProduction] = await Promise.all([ + collectPlatformData({ organizationId, frameworkId }), + loadServicesFromContext({ organizationId }), + ]); + + const scope = deriveScopeNarrative(data); + const objectives = deriveObjectives(data).map((row) => ({ + objective: row.objective, + target: row.target ?? '', + })); + + return { + capabilitiesInProduction, + certificateScopeSentence: scope.certificateScopeSentence, + objectives, + intendedOutcomes: DEFAULT_INTENDED_OUTCOMES, + cloudScopeSplit: DEFAULT_CLOUD_SCOPE_SPLIT, + sectorRegulatorOptions: [...SECTOR_REGULATOR_OPTIONS], + }; +} + +/** + * Read the org's "Types of Services Provided" Context answer and split it into a + * list of candidate capabilities. The answer is free text, so we split on lines / + * bullets / common separators and drop empties. Returns [] when absent. + */ +async function loadServicesFromContext({ + organizationId, +}: { + organizationId: string; +}): Promise { + const entry = await db.context.findFirst({ + where: { organizationId, question: SERVICES_CONTEXT_QUESTION }, + select: { answer: true }, + }); + if (!entry?.answer) return []; + + return splitServicesAnswer(entry.answer); +} + +/** + * Split the services answer into individual capability items. + * + * The answer is usually one of two shapes: an explicitly delimited list (lines, + * bullets, semicolons) or a short prose paragraph (the AI-generated default is a + * ~60-word paragraph of declarative sentences). When explicit delimiters are + * present we honour them; otherwise we fall back to sentence boundaries so prose + * still renders as a real per-item tick-list rather than one lump (CS-437). + * + * Sentence splitting only fires on a period/!/? followed by whitespace and a + * capital letter, so decimals (`99.9%`) and lowercase abbreviations don't get + * shredded into garbage fragments. If even that yields a single item (a genuine + * one-clause blob), it stays as one item. + */ +function splitServicesAnswer(answer: string): string[] { + const hasExplicitDelimiters = /\r?\n|[•·;]/.test(answer); + const parts = hasExplicitDelimiters + ? answer.split(/\r?\n|[•·;]/) + : answer.split(/(?<=[.!?])\s+(?=[A-Z])/); + + return parts + .map((item) => item.replace(/^[\s\-*\d.)]+/, '').trim()) + .filter((item) => item.length > 0); +} diff --git a/apps/api/src/isms/wizard/wizard-schema.spec.ts b/apps/api/src/isms/wizard/wizard-schema.spec.ts new file mode 100644 index 0000000000..61fcc979b9 --- /dev/null +++ b/apps/api/src/isms/wizard/wizard-schema.spec.ts @@ -0,0 +1,100 @@ +import { + parseStoredAnswers, + partialWizardAnswersSchema, + saveWizardProfileSchema, + wizardAnswersSchema, +} from './wizard-schema'; + +const fullAnswers = { + deputySpo: { memberId: 'mem_1', toBeNamed: false }, + internalAuditApproach: 'in_house' as const, + certificationBody: 'BSI', + insurance: { has: true, insurerName: 'Acme Cyber' }, + sectorRegulators: ['FINMA', 'custom:Local Authority'], + hasContractors: true, + capabilitiesInProduction: ['Payments API'], + cloudScopeSplit: { customer: ['Data'], provider: ['Infrastructure'] }, + euRep: { status: 'appointed' as const, name: 'EU Rep Ltd' }, + certificateScopeSentence: 'The ISMS covers everything.', + objectives: [{ objective: 'Stay certified', target: '100%' }], + intendedOutcomes: ['Protect data'], +}; + +describe('wizardAnswersSchema (full)', () => { + it('accepts a fully-populated answers object', () => { + expect(wizardAnswersSchema.safeParse(fullAnswers).success).toBe(true); + }); + + it('rejects a missing required field on completion', () => { + const { certificationBody, ...rest } = fullAnswers; + expect(wizardAnswersSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects an invalid enum value', () => { + expect( + wizardAnswersSchema.safeParse({ + ...fullAnswers, + internalAuditApproach: 'guesswork', + }).success, + ).toBe(false); + }); + + it('allows null internalAuditApproach', () => { + expect( + wizardAnswersSchema.safeParse({ + ...fullAnswers, + internalAuditApproach: null, + }).success, + ).toBe(true); + }); +}); + +describe('partialWizardAnswersSchema (incremental save)', () => { + it('accepts an empty object', () => { + expect(partialWizardAnswersSchema.safeParse({}).success).toBe(true); + }); + + it('accepts a single-step payload', () => { + const parsed = partialWizardAnswersSchema.safeParse({ + insurance: { has: true, insurerName: 'X' }, + }); + expect(parsed.success).toBe(true); + }); + + it('still validates types within a partial payload', () => { + expect( + partialWizardAnswersSchema.safeParse({ hasContractors: 'yes' }).success, + ).toBe(false); + }); +}); + +describe('saveWizardProfileSchema', () => { + it('requires a frameworkId', () => { + expect( + saveWizardProfileSchema.safeParse({ answers: {} }).success, + ).toBe(false); + }); + + it('accepts frameworkId + partial answers + complete flag', () => { + const parsed = saveWizardProfileSchema.safeParse({ + frameworkId: 'fw_1', + answers: { certificationBody: 'BSI' }, + complete: true, + }); + expect(parsed.success).toBe(true); + }); +}); + +describe('parseStoredAnswers', () => { + it('returns {} for malformed input', () => { + expect(parseStoredAnswers('not-json')).toEqual({}); + expect(parseStoredAnswers(null)).toEqual({}); + expect(parseStoredAnswers(undefined)).toEqual({}); + }); + + it('returns the parsed partial answers for valid input', () => { + expect(parseStoredAnswers({ hasContractors: true })).toEqual({ + hasContractors: true, + }); + }); +}); diff --git a/apps/api/src/isms/wizard/wizard-schema.ts b/apps/api/src/isms/wizard/wizard-schema.ts new file mode 100644 index 0000000000..c62b4f8740 --- /dev/null +++ b/apps/api/src/isms/wizard/wizard-schema.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; + +/** + * The single source of truth for the IsmsProfile.answers JSON blob (CS-438). + * These are the ~12 wizard answers that cannot be derived from platform data and + * that feed ISMS document generation. Validated on every read and write. + * + * - `wizardAnswersSchema` validates the full, completed shape (used on complete). + * - `partialWizardAnswersSchema` validates a partial save while the user steps + * through the wizard (every field optional, deeply). + */ + +export const INTERNAL_AUDIT_APPROACHES = [ + 'in_house', + 'external_firm', + 'training_planned', +] as const; + +export const EU_REP_STATUSES = ['appointed', 'not_required', 'pending'] as const; + +/** + * Suggested sector-regulator options surfaced by the wizard. Customers may also + * send a free-text value prefixed with `custom:` (e.g. `custom:My Regulator`). + */ +export const SECTOR_REGULATOR_OPTIONS = [ + 'FINMA', + 'FCA', + 'HIPAA', + 'PCI DSS', + 'healthcare', + 'critical_infrastructure', +] as const; + +const deputySpoSchema = z.object({ + memberId: z.string().nullable(), + toBeNamed: z.boolean(), +}); + +const insuranceSchema = z.object({ + has: z.boolean(), + insurerName: z.string(), +}); + +const cloudScopeSplitSchema = z.object({ + customer: z.array(z.string()), + provider: z.array(z.string()), +}); + +const euRepSchema = z.object({ + status: z.enum(EU_REP_STATUSES), + name: z.string(), +}); + +const objectiveSchema = z.object({ + objective: z.string(), + target: z.string(), +}); + +/** The full, completed WizardAnswers shape (validated on complete=true). */ +export const wizardAnswersSchema = z.object({ + deputySpo: deputySpoSchema, + internalAuditApproach: z.enum(INTERNAL_AUDIT_APPROACHES).nullable(), + certificationBody: z.string(), + insurance: insuranceSchema, + sectorRegulators: z.array(z.string()), + hasContractors: z.boolean(), + capabilitiesInProduction: z.array(z.string()), + cloudScopeSplit: cloudScopeSplitSchema, + euRep: euRepSchema, + certificateScopeSentence: z.string(), + objectives: z.array(objectiveSchema), + intendedOutcomes: z.array(z.string()), +}); + +export type WizardAnswers = z.infer; + +/** + * Deeply-partial variant for incremental saves. Nested objects/arrays are all + * optional so the client can PATCH a single step's answers. + */ +export const partialWizardAnswersSchema = z.object({ + deputySpo: deputySpoSchema.partial().optional(), + internalAuditApproach: z.enum(INTERNAL_AUDIT_APPROACHES).nullable().optional(), + certificationBody: z.string().optional(), + insurance: insuranceSchema.partial().optional(), + sectorRegulators: z.array(z.string()).optional(), + hasContractors: z.boolean().optional(), + capabilitiesInProduction: z.array(z.string()).optional(), + cloudScopeSplit: cloudScopeSplitSchema.partial().optional(), + euRep: euRepSchema.partial().optional(), + certificateScopeSentence: z.string().optional(), + objectives: z.array(objectiveSchema).optional(), + intendedOutcomes: z.array(z.string()).optional(), +}); + +export type PartialWizardAnswers = z.infer; + +/** The body schema for POST /v1/isms/profile. */ +export const saveWizardProfileSchema = z.object({ + frameworkId: z.string().min(1), + answers: partialWizardAnswersSchema, + complete: z.boolean().optional(), +}); + +export type SaveWizardProfileInput = z.infer; + +/** + * Parse a stored answers blob (Prisma JSON) into a partial WizardAnswers. Unknown + * shapes degrade to an empty object so a malformed row never breaks reads. + */ +export function parseStoredAnswers(value: unknown): PartialWizardAnswers { + const parsed = partialWizardAnswersSchema.safeParse(value); + return parsed.success ? parsed.data : {}; +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx index f9f324e13d..e4d3676ac2 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx @@ -18,24 +18,12 @@ import Link from 'next/link'; import { useMemo } from 'react'; import useSWR from 'swr'; import { evidenceFormDefinitionList, meetingSubTypeValues } from '../forms'; +import { useIso27001FrameworkId } from '../isms/hooks/useIso27001FrameworkId'; import { SOAOverviewCard } from './SOAOverviewCard'; type FormStatuses = Record; -type FrameworkListResponse = { - data: Array<{ - id: string; - frameworkId: string; - framework: { - id: string; - name: string; - description: string | null; - visible: boolean; - }; - }>; -}; const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; -const ISO27001_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; const MEETING_SUB_TYPES = meetingSubTypeValues; const MEETING_ALL_TYPES = new Set([...MEETING_SUB_TYPES, 'meeting']); @@ -119,6 +107,7 @@ function StatusBadge({ } export function CompanyOverviewCards({ organizationId }: { organizationId: string }) { + const iso27001FrameworkId = useIso27001FrameworkId(organizationId); const swrKey: readonly [string, string] = ['/v1/evidence-forms/statuses', organizationId]; const { data: statuses } = useSWR( @@ -133,16 +122,6 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin ); const { data: findingsResponse } = useOrganizationFindings(); - const { data: frameworksResponse } = useSWR( - ['/v1/frameworks', organizationId] as const, - async ([endpoint, orgId]: readonly [string, string]) => { - const response = await apiClient.get(endpoint, orgId); - if (response.error || !response.data) { - throw new Error(response.error ?? 'Failed to load frameworks'); - } - return response.data; - }, - ); const activeIssueCounts = useMemo(() => { const counts: Record = {}; @@ -178,16 +157,6 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin return map; }, [visibleForms]); - const iso27001Framework = useMemo(() => { - const frameworks = frameworksResponse?.data ?? []; - return frameworks.find( - (frameworkInstance) => - !!frameworkInstance.framework?.name && - ISO27001_NAMES.includes(frameworkInstance.framework.name), - ); - }, [frameworksResponse]); - const iso27001FrameworkId = iso27001Framework?.frameworkId ?? null; - return ( {iso27001FrameworkId && ( diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/DocumentsPageTabs.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/DocumentsPageTabs.tsx index 4602d20e99..2f8e337038 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/DocumentsPageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/DocumentsPageTabs.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useFeatureFlag } from '@trycompai/analytics'; import { PageHeader, PageLayout, @@ -11,24 +12,57 @@ import { import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { ReactNode } from 'react'; import { useCallback } from 'react'; +import { useIso27001FrameworkId } from '../isms/hooks/useIso27001FrameworkId'; + +/** + * PostHog flag gating the ISMS area while it's privately tested. Enable it + * per-org in PostHog to expose the tab. Local development falls through so the + * tab stays visible without PostHog configured. + */ +const ISMS_FEATURE_FLAG = 'is-isms-enabled'; interface DocumentsPageTabsProps { - overviewContent: ReactNode; + organizationId: string; + ismsContent: ReactNode; + companyFormsContent: ReactNode; settingsContent: ReactNode; } -const DEFAULT_TAB = 'overview'; +const ISMS_TAB = 'iso-27001'; +const COMPANY_FORMS_TAB = 'overview'; +const SETTINGS_TAB = 'settings'; +const DEFAULT_TAB = COMPANY_FORMS_TAB; -function tabParamToInternal(tabParam: string | null): string { - if (tabParam === 'settings') return 'settings'; +function tabParamToInternal({ + tabParam, + showIsmsTab, +}: { + tabParam: string | null; + showIsmsTab: boolean; +}): string { + if (tabParam === SETTINGS_TAB) return SETTINGS_TAB; + if (tabParam === ISMS_TAB && showIsmsTab) return ISMS_TAB; return DEFAULT_TAB; } -export function DocumentsPageTabs({ overviewContent, settingsContent }: DocumentsPageTabsProps) { +export function DocumentsPageTabs({ + organizationId, + ismsContent, + companyFormsContent, + settingsContent, +}: DocumentsPageTabsProps) { const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); - const activeTab = tabParamToInternal(searchParams.get('tab')); + // The ISO 27001 (ISMS) tab appears only when the organization has ISO 27001 + // active AND the ISMS feature flag is enabled (private testing). Development + // falls through so the tab is visible locally without PostHog. + const hasIso27001 = !!useIso27001FrameworkId(organizationId); + const ismsFlagEnabled = useFeatureFlag(ISMS_FEATURE_FLAG); + const showIsmsTab = + hasIso27001 && + (ismsFlagEnabled || process.env.NODE_ENV === 'development'); + const activeTab = tabParamToInternal({ tabParam: searchParams.get('tab'), showIsmsTab }); const handleTabChange = useCallback( (value: string) => { @@ -44,6 +78,9 @@ export function DocumentsPageTabs({ overviewContent, settingsContent }: Document [pathname, router, searchParams], ); + // When ISO 27001 is not active the IA is unchanged: a single "Overview" tab plus Settings. + const companyFormsLabel = showIsmsTab ? 'Company Forms' : 'Overview'; + return ( - Overview - Settings + {showIsmsTab && ISO 27001 (ISMS)} + {companyFormsLabel} + Settings } /> } > - {overviewContent} - {settingsContent} + {showIsmsTab && {ismsContent}} + {companyFormsContent} + {settingsContent} ); diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx index 8c45498b31..760ebffc61 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx @@ -1,23 +1,15 @@ -import { - Badge, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Text, -} from '@trycompai/design-system'; -import { api } from '@/lib/api-client'; -import { usePermissions } from '@/hooks/use-permissions'; -import Link from 'next/link'; import { useMemo } from 'react'; import useSWR from 'swr'; +import { api } from '@/lib/api-client'; +import { usePermissions } from '@/hooks/use-permissions'; +import { IsmsDocumentCard } from '../isms/components/shared'; +import type { IsmsDocumentStatus } from '../isms/isms-types'; const STATEMENT_OF_APPLICABILITY_FORM = { type: 'statement-of-applicability', title: 'Statement of Applicability', description: - "Auto-complete Statement of Applicability for ISO 27001. Generate answers based on your organization's policies and documentation.", + "Auto-completed for ISO 27001 from your organization's policies and documentation.", } as const; interface SOAOverviewCardProps { @@ -36,71 +28,24 @@ type SOASetupResponse = { } | null; }; -type SOAApprovalStatus = - | 'Approved' - | 'Declined' - | 'Pending' - | 'Not approved' - | 'Loading' - | 'Unavailable'; - -function SOAApprovalStatusBadge({ status }: { status: SOAApprovalStatus }) { - const statusConfig: Record< - SOAApprovalStatus, - { label: SOAApprovalStatus; className: string } - > = { - Loading: { - label: 'Loading', - className: - 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', - }, - Unavailable: { - label: 'Unavailable', - className: - 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', - }, - Approved: { - label: 'Approved', - className: - 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400', - }, - Pending: { - label: 'Pending', - className: - 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-400', - }, - Declined: { - label: 'Declined', - className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400', - }, - 'Not approved': { - label: 'Not approved', - className: - 'bg-slate-100 text-slate-800 dark:bg-slate-950/30 dark:text-slate-400', - }, - }; - - const { label, className } = statusConfig[status]; - return ( - - {label} - - ); +/** Map the SOA document state onto the shared ISMS status vocabulary. */ +function toIsmsStatus(document: SOASetupResponse['document']): IsmsDocumentStatus | null { + if (!document) return null; + if (document.approvedAt) return 'approved'; + if (document.declinedAt) return 'declined'; + if (document.status === 'needs_review' || document.approverId) return 'needs_review'; + return 'draft'; } -export function SOAOverviewCard({ - organizationId, - iso27001FrameworkId, -}: SOAOverviewCardProps) { +export function SOAOverviewCard({ organizationId, iso27001FrameworkId }: SOAOverviewCardProps) { const form = STATEMENT_OF_APPLICABILITY_FORM; const { hasPermission } = usePermissions(); + // Least privilege: only audit:create users hit the writing ensure-setup; + // read-only users use the read-only get-setup endpoint. const soaEndpoint = hasPermission('audit', 'create') ? '/v1/soa/ensure-setup' : '/v1/soa/get-setup'; - const { data: soaSetupResponse, error: soaSetupError, isLoading: isLoadingSOASetup } = - useSWR( + const { data: soaSetupResponse, error: soaSetupError } = useSWR( [soaEndpoint, organizationId, iso27001FrameworkId], async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => { const response = await api.post(endpoint, { @@ -117,45 +62,20 @@ export function SOAOverviewCard({ }, ); - const document = soaSetupResponse?.document; - const approvalStatus = useMemo(() => { - if (isLoadingSOASetup) return 'Loading'; - if (soaSetupError || !soaSetupResponse?.success) return 'Unavailable'; - if (!document) return 'Not approved'; - if (document.approvedAt) return 'Approved'; - if (document.declinedAt) return 'Declined'; - if ( - document.status === 'needs_review' || - !!document.approverId - ) { - return 'Pending'; - } - return 'Not approved'; - }, [document, isLoadingSOASetup, soaSetupError, soaSetupResponse?.success]); + const status = useMemo(() => { + if (soaSetupError || !soaSetupResponse?.success) return null; + return toIsmsStatus(soaSetupResponse.document); + }, [soaSetupError, soaSetupResponse]); return ( -
-
- - {form.title} - - 1 -
-
- - - - {form.title} -
- {form.description} -
-
- - - -
- -
+
+
); } diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx new file mode 100644 index 0000000000..a1a226bec0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx @@ -0,0 +1,159 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ─── Mock api client ───────────────────────────────────────── +const mockGet = vi.fn(); +const mockPost = vi.fn(); +vi.mock('@/lib/api-client', () => ({ + api: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, + apiClient: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, +})); + +// ─── Mock SWR (synchronous, key-aware) ─────────────────────── +type SWRKey = readonly unknown[] | string | null; +vi.mock('swr', () => ({ + default: (key: SWRKey) => { + if (Array.isArray(key) && key[0] === '/v1/frameworks') { + return { + data: { + data: [{ id: 'fi-1', frameworkId: 'fw-iso', framework: { id: 'fw-iso', name: 'ISO 27001' } }], + }, + }; + } + if (Array.isArray(key) && key[0] === '/v1/isms/ensure-setup') { + return { + data: { + success: true, + documents: [ + { id: 'd1', type: 'context_of_organization', status: 'draft', requirementId: null, hasApprovedVersion: false }, + ], + }, + }; + } + if (Array.isArray(key) && key[2] === 'drift') { + return { data: { isStale: false, changedSources: [] } }; + } + // SOAOverviewCard's own ensure-setup + return { data: { success: true, configuration: {}, document: null }, isLoading: false, error: null }; + }, +})); + +// ─── Mock design system ────────────────────────────────────── +type Kids = { children?: React.ReactNode }; +vi.mock('@trycompai/design-system', () => ({ + Alert: ({ children }: Kids) =>
{children}
, + AlertTitle: ({ children }: Kids) => {children}, + AlertDescription: ({ children }: Kids) =>
{children}
, + Spinner: () => , + Badge: ({ children }: Kids) => {children}, + Button: ({ children }: Kids) => , + Card: ({ children }: Kids) =>
{children}
, + CardContent: ({ children }: Kids) =>
{children}
, + CardDescription: ({ children }: Kids) =>

{children}

, + CardHeader: ({ children }: Kids) =>
{children}
, + CardTitle: ({ children }: Kids) =>

{children}

, + Grid: ({ children }: Kids) =>
{children}
, + Heading: ({ children }: Kids) =>

{children}

, + HStack: ({ children }: Kids) =>
{children}
, + Stack: ({ children }: Kids) =>
{children}
, + Section: ({ title, description, actions, children }: Kids & { + title?: string; + description?: string; + actions?: React.ReactNode; + }) => ( +
+ {title ?

{title}

: null} + {description ?

{description}

: null} + {actions} + {children} +
+ ), + Text: ({ children }: Kids) => {children}, + Empty: ({ children }: Kids) =>
{children}
, + EmptyContent: ({ children }: Kids) =>
{children}
, + EmptyDescription: ({ children }: Kids) =>

{children}

, + EmptyHeader: ({ children }: Kids) =>
{children}
, + EmptyMedia: ({ children }: Kids) =>
{children}
, + EmptyTitle: ({ children }: Kids) =>

{children}

, +})); + +// ─── Mock design-system icons ──────────────────────────────── +vi.mock('@trycompai/design-system/icons', () => { + const Icon = () => ; + return { + ArrowLeft: Icon, + ArrowRight: Icon, + CheckmarkFilled: Icon, + CircleDash: Icon, + DocumentMultiple_01: Icon, + Edit: Icon, + Incomplete: Icon, + MagicWand: Icon, + Misuse: Icon, + Renew: Icon, + Time: Icon, + WarningAlt: Icon, + WarningAltFilled: Icon, + }; +}); + +// ─── Mock next/link ────────────────────────────────────────── +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +import { IsmsOverview } from './IsmsOverview'; + +describe('IsmsOverview', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all 6 foundational document cards', () => { + render(); + + expect(screen.getByText(/Context of the Organization/)).toBeInTheDocument(); + expect(screen.getByText(/Interested Parties Register/)).toBeInTheDocument(); + expect(screen.getByText(/Interested Parties Requirements/)).toBeInTheDocument(); + expect(screen.getByText(/ISMS Scope/)).toBeInTheDocument(); + expect(screen.getByText(/Leadership and Commitment/)).toBeInTheDocument(); + expect(screen.getByText(/Information Security Objectives and Plan/)).toBeInTheDocument(); + }); + + it('renders the Foundational Documents section heading', () => { + render(); + expect(screen.getByText('Foundational Documents')).toBeInTheDocument(); + }); + + it('does not render the Statement of Applicability (it lives in general documents)', () => { + render(); + // SOA was moved out of the ISMS tab back into the general documents list. + expect(screen.queryByText('Statement of Applicability')).not.toBeInTheDocument(); + }); + + it('links the Context of the Organization card to its detail page', () => { + render(); + const contextLink = screen + .getAllByRole('link') + .find((link) => link.getAttribute('href')?.includes('/documents/isms/context-of-organization')); + expect(contextLink).toBeDefined(); + }); + + it('links all six foundational documents to their detail pages', () => { + render(); + // All six foundational documents are now implemented — none are "Coming soon". + expect(screen.queryByText('Coming soon')).not.toBeInTheDocument(); + const ismsDetailLinks = screen + .getAllByRole('link') + .filter((link) => link.getAttribute('href')?.includes('/documents/isms/')); + expect(ismsDetailLinks).toHaveLength(6); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx new file mode 100644 index 0000000000..18b0c6ec91 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Grid, + Section, + Spinner, + Stack, +} from '@trycompai/design-system'; +import { + CheckmarkFilled, + DocumentMultiple_01, + Incomplete, + MagicWand, + Renew, + WarningAlt, + WarningAltFilled, +} from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useMemo } from 'react'; +import useSWR from 'swr'; +import { usePermissions } from '@/hooks/use-permissions'; +import { api } from '@/lib/api-client'; +import { useIso27001FrameworkId } from '../../isms/hooks/useIso27001FrameworkId'; +import { + ISMS_TYPE_META, + ismsTypeToSlug, + type IsmsDriftResult, + type IsmsEnsureSetupResponse, +} from '../../isms/isms-types'; +import { + IsmsDocumentCard, + IsmsEmptyState, + IsmsSummaryRow, + type IsmsSummaryStat, +} from '../../isms/components/shared'; + +export function IsmsOverview({ organizationId }: { organizationId: string }) { + const iso27001FrameworkId = useIso27001FrameworkId(organizationId); + const { hasPermission } = usePermissions(); + const canRunWizard = hasPermission('evidence', 'update'); + + const { + data: setupResponse, + error: setupError, + isLoading: isSetupLoading, + mutate: mutateSetup, + } = useSWR( + iso27001FrameworkId + ? (['/v1/isms/ensure-setup', organizationId, iso27001FrameworkId] as const) + : null, + async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => { + const response = await api.post(endpoint, { + frameworkId, + }); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load ISMS documents'); + } + return response.data; + }, + ); + + const documents = useMemo(() => { + const list = setupResponse?.documents; + return Array.isArray(list) ? list : []; + }, [setupResponse]); + + const contextDoc = documents.find((doc) => doc.type === 'context_of_organization'); + + const { data: contextDrift } = useSWR( + contextDoc ? (['/v1/isms/documents', contextDoc.id, 'drift'] as const) : null, + async ([base, id]: readonly [string, string, string]) => { + const response = await api.get(`${base}/${id}/drift`); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load drift status'); + } + return response.data; + }, + ); + + const isContextStale = !!contextDrift?.isStale; + + const summary = useMemo(() => { + const total = ISMS_TYPE_META.length; + const approved = documents.filter((doc) => doc.status === 'approved').length; + const outstanding = total - approved; + const needsReview = isContextStale ? 1 : 0; + return [ + { label: 'Documents', value: total, icon: DocumentMultiple_01 }, + { label: 'Approved', value: approved, icon: CheckmarkFilled, tone: 'success' }, + { label: 'Outstanding', value: outstanding, icon: Incomplete }, + { + label: 'Needs review', + value: needsReview, + icon: WarningAltFilled, + tone: needsReview > 0 ? 'warning' : 'default', + }, + ]; + }, [documents, isContextStale]); + + if (!iso27001FrameworkId) { + return ( + + ); + } + + // Surface a load failure with a retry instead of a silently zeroed summary. + if (setupError && !setupResponse) { + return ( + }> + Couldn't load your ISMS documents + +
+
+ {setupError instanceof Error + ? setupError.message + : 'Something went wrong loading your ISMS foundational documents.'} +
+
+ +
+
+
+
+ ); + } + + // Loading the first response: show a spinner rather than an all-zero summary. + if (!setupResponse && isSetupLoading) { + return ( +
+ +
+ ); + } + + const wizardAction = canRunWizard ? ( + + + + ) : undefined; + + return ( + + + +
+ + {ISMS_TYPE_META.map((meta) => { + const setupDoc = documents.find((doc) => doc.type === meta.type); + const isStale = meta.type === 'context_of_organization' ? isContextStale : false; + return ( + + ); + })} + +
+
+ ); +} 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 new file mode 100644 index 0000000000..cfa51758d0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx @@ -0,0 +1,168 @@ +import { Breadcrumb, PageLayout, Text } from '@trycompai/design-system'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { serverApi } from '@/lib/api-server'; +import { hasPermission } from '@/lib/permissions'; +import { resolveUserPermissions } from '@/lib/permissions.server'; +import { auth } from '@/utils/auth'; +import { ContextOfOrganizationClient } from '../components/ContextOfOrganizationClient'; +import { InterestedPartiesClient } from '../components/InterestedPartiesClient'; +import type { ApproverOption } from '../components/IsmsApprovalSection'; +import { LeadershipClient } from '../components/LeadershipClient'; +import { ObjectivesClient } from '../components/ObjectivesClient'; +import { RequirementsClient } from '../components/RequirementsClient'; +import { ScopeClient } from '../components/ScopeClient'; +import { + ISMS_SLUG_TO_TYPE, + ISMS_TYPE_META, + ISO27001_NAMES, + type IsmsDocument as IsmsDocumentData, + type IsmsDocumentType, + type IsmsEnsureSetupResponse, +} from '../isms-types'; + +/** Shared props every ISMS detail client receives. */ +interface IsmsDetailClientProps { + organizationId: string; + documentId: string; + fallbackData: IsmsDocumentData | null; + currentMemberId: string | null; + approverOptions: ApproverOption[]; +} + +const ISMS_DETAIL_CLIENTS: Record< + IsmsDocumentType, + (props: IsmsDetailClientProps) => React.JSX.Element +> = { + context_of_organization: ContextOfOrganizationClient, + interested_parties_register: InterestedPartiesClient, + interested_parties_requirements: RequirementsClient, + objectives_plan: ObjectivesClient, + isms_scope: ScopeClient, + leadership_commitment: LeadershipClient, +}; + +interface FrameworkApiResponse { + data: Array<{ id: string; frameworkId: string; framework: { id: string; name: string } }>; +} + +interface PeopleApiResponse { + data: Array<{ + id: string; + role: string; + userId: string; + deactivated: boolean; + user: { id: string; name: string | null; email: string }; + }>; +} + +export default async function IsmsDocumentPage({ + params, +}: { + params: Promise<{ orgId: string; type: string }>; +}) { + const { orgId, type: typeSlug } = await params; + const documentType = ISMS_SLUG_TO_TYPE[typeSlug]; + if (!documentType) notFound(); + + const meta = ISMS_TYPE_META.find((entry) => entry.type === documentType); + if (!meta) notFound(); + + const breadcrumb = ( + }, + }, + { label: meta.title, isCurrent: true }, + ]} + /> + ); + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) notFound(); + const organizationId = session.session.activeOrganizationId ?? orgId; + + const [frameworksResult, peopleResult] = await Promise.all([ + serverApi.get('/v1/frameworks'), + serverApi.get('/v1/people'), + ]); + + const frameworks = frameworksResult.data?.data ?? []; + const isoFramework = frameworks.find( + (instance) => instance.framework?.name && ISO27001_NAMES.includes(instance.framework.name), + ); + + if (!isoFramework) { + return ( + + {breadcrumb} +
+ + Add the ISO 27001 framework to your organization to manage this document. + +
+
+ ); + } + + const setupResult = await serverApi.post('/v1/isms/ensure-setup', { + frameworkId: isoFramework.frameworkId, + }); + + const setupDoc = setupResult.data?.documents?.find((doc) => doc.type === documentType); + if (!setupDoc) { + return ( + + {breadcrumb} +
+ Unable to load this document. Please try again later. +
+
+ ); + } + + const documentResult = await serverApi.get( + `/v1/isms/documents/${setupDoc.id}`, + ); + const fallbackData = documentResult.data ?? null; + + // If /v1/people is unavailable to this user (e.g. no member:read), approval + // simply degrades to "unavailable" — no approvers, no current member — rather + // than breaking the page. + const people = peopleResult.data?.data ?? []; + const currentMember = people.find((p) => p.userId === session.user.id && !p.deactivated) ?? null; + + // An approver is any active member whose effective permissions include + // evidence:update (the same gate that lets a user manage ISMS documents), + // resolved via RBAC rather than hardcoded role strings. + const activeMembers = people.filter((p) => !p.deactivated); + const approverFlags = await Promise.all( + activeMembers.map(async (p) => { + const permissions = await resolveUserPermissions(p.role, organizationId); + return hasPermission(permissions, 'evidence', 'update'); + }), + ); + const approverOptions: ApproverOption[] = activeMembers + .filter((_, index) => approverFlags[index]) + .map((p) => ({ id: p.id, name: p.user?.name ?? p.user?.email ?? 'Unknown' })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const DetailClient = ISMS_DETAIL_CLIENTS[documentType]; + + return ( + + {breadcrumb} + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/AddIssueForm.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AddIssueForm.tsx new file mode 100644 index 0000000000..fbfc89ad48 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AddIssueForm.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Button, + Field, + FieldError, + HStack, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@trycompai/design-system'; +import { Add } from '@trycompai/design-system/icons'; +import { Controller, useForm } from 'react-hook-form'; +import { categoriesForKind, type IsmsContextIssueKind } from '../isms-types'; +import { issueSchema, type IssueFormValues } from './issue-schema'; +import { IsmsAddCard, IsmsFieldLabel } from './shared'; + +interface AddIssueFormProps { + kind: IsmsContextIssueKind; + onAdd: (params: IssueFormValues) => Promise; +} + +export function AddIssueForm({ kind, onAdd }: AddIssueFormProps) { + return ( + + {({ close }) => } + + ); +} + +function AddIssueFields({ + kind, + onAdd, + onClose, +}: AddIssueFormProps & { onClose: () => void }) { + const categories = categoriesForKind(kind); + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(issueSchema), + defaultValues: { category: categories[0], description: '', effect: '' }, + }); + + const handleAdd = handleSubmit(async (values) => { + try { + await onAdd(values); + } catch { + // Keep the user's input and the form open when the save fails. + return; + } + reset({ category: categories[0], description: '', effect: '' }); + onClose(); + }); + + return ( +
+ + ( + + )} + /> + {errors.category?.message} + +
+ + ( +