From ec63f205523a9974f2157a10c01e7ce58b0faa02 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 29 May 2026 13:26:36 -0400 Subject: [PATCH 01/37] 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 --- ...5-29-isms-foundational-documents-design.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/specs/2026-05-29-isms-foundational-documents-design.md diff --git a/docs/specs/2026-05-29-isms-foundational-documents-design.md b/docs/specs/2026-05-29-isms-foundational-documents-design.md new file mode 100644 index 0000000000..5d9ed64a04 --- /dev/null +++ b/docs/specs/2026-05-29-isms-foundational-documents-design.md @@ -0,0 +1,235 @@ +# ISMS Foundational Documents — Design (CS-437) + +- **Ticket:** [CS-437 — Feature: A. Foundational documents](https://linear.app/compai/issue/CS-437) +- **Project:** ISMS Management-System Layer for ISO27001 (Release 1 = cross-cutting platform layer **J** + foundational documents **A**) +- **Sub-tickets:** CS-438 (wizard), CS-439 (Context 4.1), CS-440 (Interested Parties 4.2a), CS-441 (Requirements & Treatment 4.2b/c), CS-442 (Scope 4.3), CS-443 (Leadership 5.1), CS-444 (Objectives 6.2) +- **Status:** Approved (placement + IA + build/release strategy); ready for implementation +- **Date:** 2026-05-29 + +--- + +## 1. Problem + +Customers and consultants hand-write the ISO 27001 Clause 4–6 management-system documents even though the platform already holds most of the underlying data (org profile, frameworks, vendors, members, controls, policies). This is the modal cause of Stage-1 findings on **4.1, 4.2, 4.3, 5.1, 6.2** (Pressmaster, Kidan). We need the platform to generate these as auditor-ready, branded, versioned, signed, exportable documents that stay current as the underlying data changes. + +## 2. Goals / Non-goals + +**Goals** +- Generate the six foundational documents from existing platform data, customer-branded, editable, exportable as **DOCX and PDF**, signed off and versioned like policies/SOA. +- Reproduce the Pressmaster/Kidan packs from platform output alone, closing findings 4.1/4.2/4.3/5.1/6.2. +- "Generate-and-edit, not generate-once": living artifacts that signal drift when source data changes. + +**Non-goals (this ticket)** +- The broader ISMS layer (governance roles, monitoring, internal audit, management review — project work areas B–L). Built so those can slot in later, not built now. +- Re-authoring Annex A control content; new frameworks; localisation rollout (data model must be localisation-ready though). +- Re-grouping the existing evidence forms by framework (they're control-mediated/many-to-many — left as-is under their own tab). + +## 3. Placement & Information Architecture (the load-bearing decision) + +### 3.1 Critical evaluation of the options + +Paul's ticket and project text **contradict each other**: + +| Source | Says | Verdict | +|---|---|---| +| Ticket CS-437 | "A new **top-level ISMS section** in the navigation" | ❌ Wrong. The rail's top-level sections (Compliance, Security, Trust, Settings) are *products* gated by subscription/feature flags (`AppShellWrapper.tsx`). ISMS is the management-system layer of the ISO 27001 **compliance** offering, not a product. Violates the project's own principle #4. | +| Project description | "Compliance → ISMS Governance → Documents" | ✅ Closer — under Compliance. | +| Design principle #4 | "Sit **alongside Policies, not in a new silo**" | ✅ Authoritative. | + +**Decision: none of the above verbatim — put it in the existing Documents page, grouped by framework.** The codebase proves why: the **Statement of Applicability already lives in `/[orgId]/documents`** (`statement-of-applicability/`) as an ISO 27001 ISMS document with auto-fill → edit → submit-for-approval → approve/decline → version → export. The six foundational documents are siblings of the SOA (all ISO 27001 management-system clause documents). Placing them anywhere other than next to the SOA would orphan the SOA and split the ISMS document set. + +### 3.2 Grouping: by framework + +The Documents page is **framework-grouped**. The whole platform is already framework-conditional (`FrameworkInstance`, per-framework Trust status, the SOA card only renders when ISO 27001 is active — principle #5). "ISMS" *is* ISO 27001 terminology (clauses 4–10), so the honest tab label is **`ISO 27001 (ISMS)`**. + +``` +Compliance → Documents +┌──────────────────────────────────────────────────────┐ +│ [ ISO 27001 (ISMS) ] [ Company Forms ] [ Settings ] │ ← ISO tab only if ISO 27001 active +├──────────────────────────────────────────────────────┤ +│ Foundational Documents 6 │ +│ Context 4.1 · Interested Parties 4.2a · Scope 4.3 │ +│ Leadership 5.1 · Objectives 6.2 · Req. Register 4.2bc│ +│ │ +│ Statement of Applicability 1 │ ← SOA moves under this tab +│ Annex A applicability (6.1.3) │ +└──────────────────────────────────────────────────────┘ +``` + +- Route stays `/[orgId]/documents`, gated `evidence:read` (same `requireRoutePermission('documents', orgId)` as today, same as SOA). +- Tabs rendered as **one tab per active framework that has a doc-pack** (today: ISO 27001) + Company Forms + Settings. Built to scale to N framework tabs; only ISO 27001 is populated now. +- The `ISO 27001 (ISMS)` tab and its cards are framework-conditional (visible iff ISO 27001 `FrameworkInstance` exists) **and** behind a rollout flag (§9). + +### 3.3 Framework linkage is canonical, not bolted on + +ISO 27001 clauses 4–10 already exist as `FrameworkEditorRequirement` rows in the seed (`"4.1 Context of the organization"`, `"4.2 …"`, `"4.3 …"`, `"5.1 Leadership"`, `"9.2/9.3 Performance"`, `"10.1 Improvement"`, …). So each foundational document maps **1:1 to its ISO 27001 clause requirement**, the same requirement model controls/policies/tasks already use. This yields: +1. Framework membership for free (`document → requirement → framework`). +2. The auditor "where is clause 4.1 evidenced?" link (project work area J) for free. +3. Per-clause drift/coverage reasoning. + +> **To verify during implementation:** that clause **6.2 (objectives)** exists as an ISO 27001 requirement like 4.1/5.1 do. If absent, add it (custom/seed) or attach the Objectives doc to the framework instance directly. + +> **Nuance:** control-mediated documents (the legacy evidence forms, via `ControlDocumentType → Control → FrameworkInstance`) are many-to-many across frameworks, so framework tabs are *filtered views, not exclusive buckets*. The foundational docs + SOA are ISO-27001-only and map 1:1, so this is clean for this feature. + +## 4. Architecture + +### 4.1 Engine: follow the SOA pattern, not the Policy/TipTap pattern + +The documents are **structured, data-derived registers + saved narrative**, not free-form rich text. The SOA pattern (`apps/api/src/soa/*`, `apps/app/.../documents/statement-of-applicability/*`) is the template — closer than Policies: + +| Concern | Reuse source | +|---|---| +| Auto-fill from platform data | SOA `ensure-setup` / `useSOAAutoFill` pattern | +| Edit + override (recorded) | per-row `source: derived \| manual` + audit log | +| Sign-off workflow (submit/approve/decline) | SOA `submit-for-approval` / `approve` / `decline` + `useSOADocument` | +| Versioning | Policy `PolicyVersion` + SOA `version`/`isLatest` | +| Drift signal | snapshot-vs-current hash (new) — analogous to policy `hasDraftChanges` | +| Export PDF + branding | `policy-pdf-renderer.service.ts` `getAccentColor(org.primaryColor)` | +| Export DOCX | **net-new** (`docx` npm package) | +| Detail-page tabs | `PolicyPageTabs` / `DocumentsPageTabs` URL-`?tab=` pattern | + +### 4.2 Source data is canonical (principle #2, #6, #10) + +Structured registers are **real tables** (queryable, per-row override, localisation-ready), not free text baked into a blob. The document artifact renders by combining registers + narrative sections. Derived rows carry `source='derived'`; user edits flip them to `source='manual'` and record the override. **Drift** = compare a snapshot of the derived source data (captured at last generate/approve) against current data; affected sections show "may be out of date" until reviewed/regenerated. + +## 5. Data model (Prisma) — `packages/db/prisma/schema/isms.prisma` + +> Prefixed CUIDs via `generate_prefixed_cuid('')`. All scoped by `organizationId`. + +```prisma +enum IsmsDocumentType { + context_of_organization // 4.1 + interested_parties_register // 4.2a + interested_parties_requirements // 4.2b/c + isms_scope // 4.3 + leadership_commitment // 5.1 + objectives_plan // 6.2 +} + +enum IsmsDocumentStatus { draft in_progress needs_review approved declined } + +model IsmsDocument { + id String @id @default(dbgenerated("generate_prefixed_cuid('ismsd')")) + organizationId String + frameworkInstanceId String // ISO 27001 instance + requirementId String? // ISO clause requirement (4.1, 4.2, …) + type IsmsDocumentType + title String + status IsmsDocumentStatus @default(draft) + approverId String? // Member + approvedAt DateTime? + declinedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) + versions IsmsDocumentVersion[] + contextIssues IsmsContextIssue[] // 4.1 register (slice 1) + // 4.2/4.3/6.2 registers added with their sub-tickets + + @@unique([organizationId, frameworkInstanceId, type]) + @@index([organizationId, type]) +} + +model IsmsDocumentVersion { + id String @id @default(dbgenerated("generate_prefixed_cuid('ismsv')")) + documentId String + version Int + narrative Json // per-section saved narrative + section-level overrides + sourceSnapshot Json // snapshot of derived inputs at generate/approve (drift baseline) + pdfUrl String? + docxUrl String? + publishedById String? + publishedAt DateTime? + createdAt DateTime @default(now()) + + document IsmsDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + @@unique([documentId, version]) +} + +// --- Slice 1 register: Context of the Organization (4.1) --- +enum IsmsContextIssueKind { internal external } +enum IsmsContextSource { derived manual } + +model IsmsContextIssue { + id String @id @default(dbgenerated("generate_prefixed_cuid('ismsci')")) + documentId String + kind IsmsContextIssueKind + description String // the internal/external issue + effect String // effect on ISMS objectives (4.1 requires this) + source IsmsContextSource @default(derived) + derivedFrom String? // provenance, e.g. "framework:ISO27001", "vendor:" + position Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + document IsmsDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + @@index([documentId, kind]) +} +``` + +**Subsequent registers (their sub-tickets):** +- `IsmsInterestedParty` (4.2a): name, partyType, source (`vendor|framework|member|manual`), derivedFrom, needs/expectations. +- `IsmsInterestedPartyRequirement` (4.2b/c): interestedPartyId, requirement text, ismsTreatment (linked policy/control id + note). +- `IsmsScope` (4.3): `certificateScopeSentence` (first-class, customer-approved), inScope narrative, interfaces[], dependencies[] (from vendors/sub-processors), exclusions[]. +- `IsmsObjective` (6.2): objective, target, ownerMemberId, cadence, plan, measurementMethod, status. +- Leadership (5.1): narrative-only on the document version + wizard inputs; no separate register. + +## 6. API (NestJS) — `apps/api/src/isms/` + +`@Controller({ path: 'isms', version: '1' })`, `@UseGuards(HybridAuthGuard, PermissionGuard)`. + +| Endpoint | Permission | Purpose | +|---|---|---| +| `POST /isms/ensure-setup` | `evidence:read` | Ensure the ISO 27001 instance + doc rows exist for the org; return statuses (mirrors SOA `ensure-setup`). | +| `POST /isms/documents/:type/generate` | `evidence:update` | Auto-fill a document's registers/narrative from platform data; capture `sourceSnapshot`. | +| `GET /isms/documents/:id` | `evidence:read` | Document + latest version + registers. | +| `PATCH /isms/documents/:id/issues/:issueId` (and register CRUD) | `evidence:update` | Override a derived row → `source=manual` (recorded). | +| `POST /isms/documents/:id/submit-for-approval` | `evidence:update` | Set approver, status `needs_review`. | +| `POST /isms/documents/:id/approve` \| `/decline` | `evidence:update` | Sign-off (approver only). | +| `GET /isms/documents/:id/drift` | `evidence:read` | Compare `sourceSnapshot` vs current derived data → per-section stale flags. | +| `POST /isms/documents/:id/export` `{ format: 'pdf' \| 'docx' }` | `evidence:read` | Render branded artifact; store `pdfUrl`/`docxUrl`. | + +- **Permissions decision:** reuse the existing **`evidence`** resource (the `documents` route + SOA already sit on it). Avoids minting an `isms` resource now; revisit if/when the broader ISMS area (B–L) becomes its own section. `AuditLogInterceptor` logs automatically given `@RequirePermission`. +- Class-transformer gotcha: for endpoints receiving complex nested JSON, read `req.body` directly (per repo gotcha), don't rely on `ValidationPipe transform:true` for the narrative blob. + +## 7. Export pipeline + +- **PDF:** reuse `policy-pdf-renderer.service.ts` accent/branding (`getAccentColor(org.primaryColor)`, fallback `#004D3D`) and logo. +- **DOCX (net-new):** add the **`docx`** npm package; build `apps/api/src/isms/export/docx-renderer.ts` that renders the same structured content with org branding. Shared `export-generator.ts` dispatches `pdf|docx` from one `IsmsDocument` content shape (mirrors `soa/utils/export-generator.ts`, which currently hard-throws on non-pdf). + +## 8. Frontend — `apps/app/src/app/(app)/[orgId]/documents/` + +- **Tabs:** generalise `DocumentsPageTabs` to render framework tabs + Company Forms + Settings. Add `isms/` components under `documents/`. +- **ISO 27001 (ISMS) tab:** `IsmsOverview` with a *Foundational Documents* section (6 cards w/ status: Not started / Draft / Pending / Approved + drift badge) and a *Statement of Applicability* section (move the existing `SOAOverviewCard` here). +- **Document detail:** `documents/isms/[type]/page.tsx` — server-fetch + `useIsmsDocument` SWR hook (fallbackData, `revalidateOnMount:!initialData`, guarded `mutate`). Editable register table with inline override, narrative fields (RHF + Zod), drift banner, submit-for-approval/approve UI (reuse SOA `SubmitApprovalDialog`/`SOAPendingApprovalAlert` patterns), PDF + DOCX export buttons. +- **Design system:** `PageLayout`/`PageHeader`/`Tabs`/`Stack`/`Card` from `@trycompai/design-system`; Carbon icons. Run `audit-design-system` after edits. + +## 9. Release strategy — all-or-nothing, flag-gated + +- Build **slice-first** (de-risk), but the `ISO 27001 (ISMS)` tab stays **off in production** behind an `isIsmsEnabled`-style flag resolved in `[orgId]/layout.tsx` (same pattern as `isQuestionnaireEnabled`/`isSecurityEnabled`) until **all six docs + wizard + PDF/DOCX** are complete. +- Rationale: DoD requires all six exportable in one session; a partial management-system pack is an **audit liability**, not a partial win; project defines Release 1 as the whole foundational pack. +- Framework-conditional visibility (ISO 27001 active) applies on top of the flag. + +## 10. Build sequence + +1. **Spec** (this doc). ✅ +2. **Substrate slice:** `IsmsDocument`/`IsmsDocumentVersion` + `IsmsContextIssue` models + migration; ISMS API module (ensure-setup, generate, get, override, sign-off, drift, export) with RBAC + Jest tests; **DOCX renderer**. +3. **IA:** framework-grouped `DocumentsPageTabs` + `ISO 27001 (ISMS)` tab (flagged) + move SOA card. +4. **Context of the Organization (4.1) E2E:** detail page, hooks, generate/edit/override/drift/sign-off/export; Vitest tests (admin write vs read-only). +5. **Fast-follow (sub-tickets):** 4.2a, 4.2b/c, 4.3, 5.1, 6.2 registers + detail pages on the proven rail. +6. **Wizard (CS-438):** shared ~15-question component feeding un-derivable inputs. +7. **Flip the flag** when the pack is complete. + +## 11. Testing + +- **API (Jest):** generate/autofill, override recording, drift computation, sign-off transitions, export dispatch; admin (write) + read-only (`evidence:read` only) permission scenarios. +- **App (Vitest + testing-library):** overview cards, document detail edit/override, drift banner, approval gating, export buttons; admin vs read-only. +- TDD for non-trivial logic (drift, autofill mapping). Typecheck (`turbo run typecheck`) after each milestone. + +## 12. Open items to confirm during build + +- Clause **6.2** existence as an ISO 27001 requirement (§3.3). +- Whether a direct `IsmsDocument.requirementId → FrameworkEditorRequirement` FK is sufficient for the auditor-view link, or a join table is needed for multi-requirement docs (e.g., 4.2b/c spanning 4.2b + 4.2c). +- Exact derive-vs-ask split per document (wizard CS-438 has the detailed inventory; ~15-question cap). From a549077006d360586a505872e8b72dad158445fe Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 29 May 2026 13:28:10 -0400 Subject: [PATCH 02/37] 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 --- .../migration.sql | 109 +++++++++++++++ packages/db/prisma/schema/auth.prisma | 1 + .../db/prisma/schema/framework-editor.prisma | 2 + packages/db/prisma/schema/isms.prisma | 130 ++++++++++++++++++ packages/db/prisma/schema/organization.prisma | 1 + 5 files changed, 243 insertions(+) create mode 100644 packages/db/prisma/migrations/20260529172752_add_isms_foundational_documents/migration.sql create mode 100644 packages/db/prisma/schema/isms.prisma diff --git a/packages/db/prisma/migrations/20260529172752_add_isms_foundational_documents/migration.sql b/packages/db/prisma/migrations/20260529172752_add_isms_foundational_documents/migration.sql new file mode 100644 index 0000000000..cd68f2a5a1 --- /dev/null +++ b/packages/db/prisma/migrations/20260529172752_add_isms_foundational_documents/migration.sql @@ -0,0 +1,109 @@ +-- CreateEnum +CREATE TYPE "IsmsDocumentType" AS ENUM ('context_of_organization', 'interested_parties_register', 'interested_parties_requirements', 'isms_scope', 'leadership_commitment', 'objectives_plan'); + +-- CreateEnum +CREATE TYPE "IsmsDocumentStatus" AS ENUM ('draft', 'in_progress', 'needs_review', 'approved', 'declined'); + +-- CreateEnum +CREATE TYPE "IsmsContextIssueKind" AS ENUM ('internal', 'external'); + +-- CreateEnum +CREATE TYPE "IsmsContextSource" AS ENUM ('derived', 'manual'); + +-- CreateTable +CREATE TABLE "IsmsDocument" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('isms_doc'::text), + "organizationId" TEXT NOT NULL, + "frameworkId" TEXT NOT NULL, + "requirementId" TEXT, + "type" "IsmsDocumentType" NOT NULL, + "title" TEXT NOT NULL, + "status" "IsmsDocumentStatus" NOT NULL DEFAULT 'draft', + "preparedBy" TEXT NOT NULL DEFAULT 'Comp AI', + "approverId" TEXT, + "approvedAt" TIMESTAMP(3), + "declinedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IsmsDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "IsmsDocumentVersion" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('isms_ver'::text), + "documentId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "isLatest" BOOLEAN NOT NULL DEFAULT true, + "narrative" JSONB NOT NULL, + "sourceSnapshot" JSONB, + "pdfUrl" TEXT, + "docxUrl" TEXT, + "publishedById" TEXT, + "publishedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IsmsDocumentVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "IsmsContextIssue" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('isms_ci'::text), + "documentId" TEXT NOT NULL, + "kind" "IsmsContextIssueKind" NOT NULL, + "description" TEXT NOT NULL, + "effect" TEXT NOT NULL, + "source" "IsmsContextSource" NOT NULL DEFAULT 'derived', + "derivedFrom" TEXT, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IsmsContextIssue_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IsmsDocument_organizationId_type_idx" ON "IsmsDocument"("organizationId", "type"); + +-- CreateIndex +CREATE INDEX "IsmsDocument_frameworkId_idx" ON "IsmsDocument"("frameworkId"); + +-- CreateIndex +CREATE INDEX "IsmsDocument_requirementId_idx" ON "IsmsDocument"("requirementId"); + +-- CreateIndex +CREATE INDEX "IsmsDocument_status_idx" ON "IsmsDocument"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "IsmsDocument_organizationId_frameworkId_type_key" ON "IsmsDocument"("organizationId", "frameworkId", "type"); + +-- CreateIndex +CREATE INDEX "IsmsDocumentVersion_documentId_idx" ON "IsmsDocumentVersion"("documentId"); + +-- CreateIndex +CREATE INDEX "IsmsDocumentVersion_documentId_isLatest_idx" ON "IsmsDocumentVersion"("documentId", "isLatest"); + +-- CreateIndex +CREATE UNIQUE INDEX "IsmsDocumentVersion_documentId_version_key" ON "IsmsDocumentVersion"("documentId", "version"); + +-- CreateIndex +CREATE INDEX "IsmsContextIssue_documentId_kind_idx" ON "IsmsContextIssue"("documentId", "kind"); + +-- AddForeignKey +ALTER TABLE "IsmsDocument" ADD CONSTRAINT "IsmsDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IsmsDocument" ADD CONSTRAINT "IsmsDocument_frameworkId_fkey" FOREIGN KEY ("frameworkId") REFERENCES "FrameworkEditorFramework"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IsmsDocument" ADD CONSTRAINT "IsmsDocument_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FrameworkEditorRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IsmsDocument" ADD CONSTRAINT "IsmsDocument_approverId_fkey" FOREIGN KEY ("approverId") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IsmsDocumentVersion" ADD CONSTRAINT "IsmsDocumentVersion_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "IsmsDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IsmsContextIssue" ADD CONSTRAINT "IsmsContextIssue_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "IsmsDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 6c9c12383c..8ff53b3aac 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -217,6 +217,7 @@ model Member { assignedPolicies Policy[] @relation("PolicyAssignee") // Policies where this member is an assignee approvedPolicies Policy[] @relation("PolicyApprover") // Policies where this member is an approver approvedSOADocuments SOADocument[] @relation("SOADocumentApprover") // SOA documents where this member is an approver + approvedIsmsDocuments IsmsDocument[] @relation("IsmsDocumentApprover") // ISMS documents where this member is an approver risks Risk[] tasks Task[] vendors Vendor[] diff --git a/packages/db/prisma/schema/framework-editor.prisma b/packages/db/prisma/schema/framework-editor.prisma index cdc427cc69..3a04110fc4 100644 --- a/packages/db/prisma/schema/framework-editor.prisma +++ b/packages/db/prisma/schema/framework-editor.prisma @@ -22,6 +22,7 @@ model FrameworkEditorFramework { frameworkInstances FrameworkInstance[] soaConfigurations SOAFrameworkConfiguration[] // Multiple SOA config versions per framework soaDocuments SOADocument[] // SOA documents from organizations + ismsDocuments IsmsDocument[] // ISMS foundational documents from organizations timelineTemplates TimelineTemplate[] versions FrameworkVersion[] controlPolicyLinks FrameworkEditorControlPolicyTemplateLink[] @@ -45,6 +46,7 @@ model FrameworkEditorRequirement { controlTemplates FrameworkEditorControlTemplate[] requirementMaps RequirementMap[] + ismsDocuments IsmsDocument[] // ISMS foundational documents mapped to this clause requirement // Dates createdAt DateTime @default(now()) diff --git a/packages/db/prisma/schema/isms.prisma b/packages/db/prisma/schema/isms.prisma new file mode 100644 index 0000000000..ac224db33f --- /dev/null +++ b/packages/db/prisma/schema/isms.prisma @@ -0,0 +1,130 @@ +// ISMS Foundational Documents (ISO 27001 clauses 4.1-4.3, 5.1, 6.2) +// +// Generated-and-edited management-system documents that live alongside the SOA +// in the Documents page (Compliance -> Documents -> "ISO 27001 (ISMS)" tab). +// Mirrors the SOADocument pattern: links the global framework template +// (FrameworkEditorFramework) + organization, auto-fills from platform data, +// supports edit/override, versioning, sign-off, drift detection, and export. +// +// Each document maps 1:1 to its ISO 27001 clause requirement +// (FrameworkEditorRequirement) so it inherits framework grouping and powers the +// auditor "where is this clause evidenced?" link. + +enum IsmsDocumentType { + context_of_organization // 4.1 + interested_parties_register // 4.2a + interested_parties_requirements // 4.2b / 4.2c + isms_scope // 4.3 + leadership_commitment // 5.1 + objectives_plan // 6.2 +} + +enum IsmsDocumentStatus { + draft // being created / edited + in_progress // being generated + needs_review // submitted for approval + approved // signed off + declined // approval declined +} + +model IsmsDocument { + id String @id @default(dbgenerated("generate_prefixed_cuid('isms_doc'::text)")) + + // Framework + organization context. Mirrors SOADocument: references the global + // framework template + the org, not the per-org FrameworkInstance. + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + frameworkId String + framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade) + + // ISO 27001 clause this document satisfies (4.1, 4.2, ...). Optional because the + // clause requirement may not be seeded for every type until verified (e.g. 6.2). + requirementId String? + requirement FrameworkEditorRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull) + + type IsmsDocumentType + title String + status IsmsDocumentStatus @default(draft) + + // Approval / sign-off (mirrors SOADocument). + preparedBy String @default("Comp AI") + approverId String? + approver Member? @relation("IsmsDocumentApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade) + approvedAt DateTime? + declinedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relationships + versions IsmsDocumentVersion[] + contextIssues IsmsContextIssue[] // 4.1 register; other registers added with their sub-tickets + + @@unique([organizationId, frameworkId, type]) // one document per type per framework per org + @@index([organizationId, type]) + @@index([frameworkId]) + @@index([requirementId]) + @@index([status]) +} + +model IsmsDocumentVersion { + id String @id @default(dbgenerated("generate_prefixed_cuid('isms_ver'::text)")) + documentId String + document IsmsDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + + version Int @default(1) + isLatest Boolean @default(true) + + // Per-section saved narrative + section-level overrides (stored as data, not free + // text, so the model is localisation-ready). + narrative Json + // Snapshot of derived inputs captured at generate / approve. Drift is detected by + // comparing this against the current derived data. + sourceSnapshot Json? + + // Rendered, branded exports. + pdfUrl String? + docxUrl String? + + // User/member id who published this version (plain id, like SOAAnswer.createdBy). + publishedById String? + publishedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([documentId, version]) + @@index([documentId]) + @@index([documentId, isLatest]) +} + +// --- Register: Context of the Organization (clause 4.1) --- + +enum IsmsContextIssueKind { + internal + external +} + +enum IsmsContextSource { + derived // auto-populated from platform data + manual // user-authored, or a derived value the user overrode (override recorded) +} + +model IsmsContextIssue { + id String @id @default(dbgenerated("generate_prefixed_cuid('isms_ci'::text)")) + documentId String + document IsmsDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + + kind IsmsContextIssueKind + description String // the internal / external issue + effect String // effect on the ISMS and its objectives (required by clause 4.1) + + source IsmsContextSource @default(derived) + derivedFrom String? // provenance for derived rows, e.g. "framework:iso27001", "vendor:" + + position Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([documentId, kind]) +} diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index 5fbb485609..5af0ee1d8c 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -55,6 +55,7 @@ model Organization { questionnaires Questionnaire[] securityQuestionnaireManualAnswers SecurityQuestionnaireManualAnswer[] soaDocuments SOADocument[] + ismsDocuments IsmsDocument[] primaryColor String? trustPortalFaqs Json? // Array of { question: string, answer: string, order: number } From da24c23615ef37483bbd7b84276849bb4e3b43ec Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 29 May 2026 13:40:12 -0400 Subject: [PATCH 03/37] 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 --- apps/api/package.json | 1 + apps/api/src/app.module.ts | 2 + .../src/isms/dto/create-context-issue.dto.ts | 18 + .../api/src/isms/dto/ensure-isms-setup.dto.ts | 9 + .../src/isms/dto/export-isms-document.dto.ts | 6 + .../isms/dto/submit-isms-for-approval.dto.ts | 6 + .../src/isms/dto/update-context-issue.dto.ts | 21 + .../isms/isms-context-issue.service.spec.ts | 130 ++++ .../src/isms/isms-context-issue.service.ts | 107 ++++ .../api/src/isms/isms-context.service.spec.ts | 267 ++++++++ apps/api/src/isms/isms-context.service.ts | 211 +++++++ apps/api/src/isms/isms.controller.spec.ts | 267 ++++++++ apps/api/src/isms/isms.controller.ts | 210 +++++++ apps/api/src/isms/isms.module.ts | 14 + apps/api/src/isms/isms.service.spec.ts | 250 ++++++++ apps/api/src/isms/isms.service.ts | 210 +++++++ .../api/src/isms/utils/context-data-source.ts | 80 +++ .../src/isms/utils/context-derivation.spec.ts | 113 ++++ apps/api/src/isms/utils/context-derivation.ts | 195 ++++++ .../api/src/isms/utils/document-types.spec.ts | 71 +++ apps/api/src/isms/utils/document-types.ts | 71 +++ apps/api/src/isms/utils/docx-renderer.ts | 131 ++++ .../src/isms/utils/export-generator.spec.ts | 74 +++ apps/api/src/isms/utils/export-generator.ts | 169 ++++++ apps/api/src/isms/utils/export-shared.ts | 54 ++ apps/api/src/isms/utils/version-snapshot.ts | 45 ++ bun.lock | 15 + packages/docs/openapi.json | 573 +++++++++++++++++- 28 files changed, 3312 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/isms/dto/create-context-issue.dto.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/submit-isms-for-approval.dto.ts create mode 100644 apps/api/src/isms/dto/update-context-issue.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.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.spec.ts create mode 100644 apps/api/src/isms/isms.service.ts create mode 100644 apps/api/src/isms/utils/context-data-source.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-types.spec.ts create mode 100644 apps/api/src/isms/utils/document-types.ts create mode 100644 apps/api/src/isms/utils/docx-renderer.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-shared.ts create mode 100644 apps/api/src/isms/utils/version-snapshot.ts diff --git a/apps/api/package.json b/apps/api/package.json index acf8fe92c4..8a627b230c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -94,6 +94,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", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ccb8410374..2d940c5ddd 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -34,6 +34,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'; @@ -104,6 +105,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- VectorStoreModule, KnowledgeBaseModule, SOAModule, + IsmsModule, IntegrationPlatformModule, CloudSecurityModule, BrowserbaseModule, diff --git a/apps/api/src/isms/dto/create-context-issue.dto.ts b/apps/api/src/isms/dto/create-context-issue.dto.ts new file mode 100644 index 0000000000..f8ab86c995 --- /dev/null +++ b/apps/api/src/isms/dto/create-context-issue.dto.ts @@ -0,0 +1,18 @@ +import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import type { IsmsContextIssueKind } from '@db'; + +export class CreateContextIssueDto { + @IsIn(['internal', 'external']) + kind!: IsmsContextIssueKind; + + @IsString() + description!: string; + + @IsString() + effect!: string; + + @IsOptional() + @IsInt() + @Min(0) + position?: number; +} 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..e6027e04b2 --- /dev/null +++ b/apps/api/src/isms/dto/ensure-isms-setup.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class EnsureIsmsSetupDto { + @IsString() + organizationId!: string; + + @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..cd4776c725 --- /dev/null +++ b/apps/api/src/isms/dto/export-isms-document.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from 'class-validator'; + +export class ExportIsmsDocumentDto { + @IsIn(['pdf', 'docx']) + format!: 'pdf' | 'docx'; +} 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..720dd05681 --- /dev/null +++ b/apps/api/src/isms/dto/submit-isms-for-approval.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class SubmitIsmsForApprovalDto { + @IsString() + approverId!: string; +} diff --git a/apps/api/src/isms/dto/update-context-issue.dto.ts b/apps/api/src/isms/dto/update-context-issue.dto.ts new file mode 100644 index 0000000000..1eb19f4ce5 --- /dev/null +++ b/apps/api/src/isms/dto/update-context-issue.dto.ts @@ -0,0 +1,21 @@ +import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import type { IsmsContextIssueKind } from '@db'; + +export class UpdateContextIssueDto { + @IsOptional() + @IsIn(['internal', 'external']) + kind?: IsmsContextIssueKind; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + effect?: string; + + @IsOptional() + @IsInt() + @Min(0) + position?: number; +} 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..ff09605dcd --- /dev/null +++ b/apps/api/src/isms/isms-context-issue.service.spec.ts @@ -0,0 +1,130 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsContextIssueService } from './isms-context-issue.service'; + +jest.mock('@db', () => ({ + db: { + ismsDocument: { findFirst: jest.fn() }, + ismsContextIssue: { + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, +})); + +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.count as jest.Mock).mockResolvedValue(3); + (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 }); + }); + }); +}); 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..8aca671a8a --- /dev/null +++ b/apps/api/src/isms/isms-context-issue.service.ts @@ -0,0 +1,107 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { CreateContextIssueDto } from './dto/create-context-issue.dto'; +import { UpdateContextIssueDto } from './dto/update-context-issue.dto'; + +/** + * 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: CreateContextIssueDto; + }) { + await this.requireDocument({ documentId, organizationId }); + const position = + dto.position ?? + (await db.ismsContextIssue.count({ where: { documentId } })); + + return db.ismsContextIssue.create({ + data: { + documentId, + kind: dto.kind, + description: dto.description, + effect: dto.effect, + source: 'manual', + position, + }, + }); + } + + async update({ + issueId, + organizationId, + dto, + }: { + issueId: string; + organizationId: string; + dto: UpdateContextIssueDto; + }) { + await this.requireIssue({ issueId, organizationId }); + + return db.ismsContextIssue.update({ + where: { id: issueId }, + data: { + kind: dto.kind ?? 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; + }) { + await this.requireIssue({ issueId, organizationId }); + await db.ismsContextIssue.delete({ where: { id: issueId } }); + return { success: true }; + } + + 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..fe60adda15 --- /dev/null +++ b/apps/api/src/isms/isms-context.service.spec.ts @@ -0,0 +1,267 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsContextService } from './isms-context.service'; +import { collectContextData } from './utils/context-data-source'; +import { + deriveContextIssues, + diffSnapshots, +} from './utils/context-derivation'; +import { generateIsmsExportFile } from './utils/export-generator'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +jest.mock('@db', () => ({ + db: { + ismsDocument: { findFirst: jest.fn() }, + ismsContextIssue: { + deleteMany: jest.fn(), + count: jest.fn(), + createMany: jest.fn(), + }, + $transaction: jest.fn(), + }, +})); +jest.mock('./utils/context-data-source', () => ({ + collectContextData: jest.fn(), +})); +jest.mock('./utils/context-derivation', () => ({ + deriveContextIssues: jest.fn(), + diffSnapshots: 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(collectContextData); +const mockDerive = jest.mocked(deriveContextIssues); +const mockDiff = jest.mocked(diffSnapshots); +const mockExport = jest.mocked(generateIsmsExportFile); + +const snapshot = { + frameworkNames: ['ISO 27001'], + vendorCount: 3, + subProcessorCount: 1, + vendorsByCategory: { cloud: 3 }, + memberCount: 5, + membersByDepartment: { it: 5 }, + deviceCount: 4, +}; + +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('throws BadRequestException for unsupported type', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'isms_scope', + frameworkId: 'fw_1', + }); + await expect(service.generate(args)).rejects.toThrow(BadRequestException); + }); + + it('replaces derived rows, preserves manual rows, and snapshots', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock) + .mockResolvedValueOnce({ + id: 'doc_1', + type: 'context_of_organization', + frameworkId: 'fw_1', + }) + .mockResolvedValueOnce({ id: 'doc_1', contextIssues: [] }); + mockCollect.mockResolvedValue(snapshot); + mockDerive.mockReturnValue([ + { + kind: 'external', + description: 'd1', + effect: 'e1', + source: 'derived', + derivedFrom: 'framework:ISO 27001', + position: 0, + }, + { + kind: 'internal', + description: 'd2', + effect: 'e2', + source: 'derived', + derivedFrom: 'members', + position: 1, + }, + ]); + const tx = { + ismsContextIssue: { + deleteMany: jest.fn().mockResolvedValue({}), + count: jest.fn().mockResolvedValue(2), // two manual rows preserved + createMany: jest.fn().mockResolvedValue({}), + }, + }; + (mockDb.$transaction as jest.Mock).mockImplementation((cb) => cb(tx)); + + await service.generate(args); + + expect(tx.ismsContextIssue.deleteMany).toHaveBeenCalledWith({ + where: { documentId: 'doc_1', source: 'derived' }, + }); + expect(tx.ismsContextIssue.count).toHaveBeenCalledWith({ + where: { documentId: 'doc_1', source: 'manual' }, + }); + // Derived rows appended AFTER the 2 preserved manual rows. + const created = tx.ismsContextIssue.createMany.mock.calls[0][0].data; + expect(created).toHaveLength(2); + expect(created[0].position).toBe(2); + expect(created[1].position).toBe(3); + expect(upsertLatestSnapshotVersion).toHaveBeenCalledWith({ + tx, + documentId: 'doc_1', + snapshot, + }); + }); + }); + + 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 stored snapshot', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + frameworkId: 'fw_1', + versions: [{ sourceSnapshot: snapshot }], + }); + mockCollect.mockResolvedValue({ ...snapshot, vendorCount: 9 }); + mockDiff.mockReturnValue({ isStale: true, changedSources: ['vendors'] }); + + const result = await service.drift(args); + + expect(mockDiff).toHaveBeenCalledWith({ + previous: snapshot, + current: { ...snapshot, vendorCount: 9 }, + }); + expect(result).toEqual({ isStale: true, changedSources: ['vendors'] }); + }); + + it('treats a missing snapshot as no baseline', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + frameworkId: 'fw_1', + versions: [], + }); + mockCollect.mockResolvedValue(snapshot); + mockDiff.mockReturnValue({ + isStale: true, + changedSources: ['no-baseline'], + }); + + await service.drift(args); + + expect(mockDiff).toHaveBeenCalledWith({ + 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 = () => ({ + id: 'doc_1', + title: 'Context of the Organization', + 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', description: 'd', effect: 'e' }, + ], + versions: [{ version: 2 }], + }); + + it('returns a pdf buffer', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue( + buildDocument(), + ); + mockExport.mockResolvedValue({ + fileBuffer: Buffer.from('pdf'), + mimeType: 'application/pdf', + filename: 'context.pdf', + }); + + const result = await service.exportDocument({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { format: 'pdf' }, + }); + + expect(mockExport).toHaveBeenCalledWith( + expect.objectContaining({ + format: 'pdf', + issues: [{ kind: 'external', description: 'd', effect: 'e' }], + metadata: expect.objectContaining({ + title: 'Context of the Organization', + frameworkName: 'ISO 27001', + version: 2, + organizationName: 'Acme', + primaryColor: '#004D3D', + approverName: 'Jane', + }), + }), + ); + expect(result.mimeType).toBe('application/pdf'); + }); + + it('returns a docx buffer', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue( + buildDocument(), + ); + mockExport.mockResolvedValue({ + fileBuffer: Buffer.from('docx'), + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + filename: 'context.docx', + }); + + const result = await service.exportDocument({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: { format: 'docx' }, + }); + + expect(mockExport).toHaveBeenCalledWith( + expect.objectContaining({ format: 'docx' }), + ); + expect(result.fileBuffer).toBeInstanceOf(Buffer); + expect(result.filename).toBe('context.docx'); + }); + }); +}); 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..b2bd905dfc --- /dev/null +++ b/apps/api/src/isms/isms-context.service.ts @@ -0,0 +1,211 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { ExportIsmsDocumentDto } from './dto/export-isms-document.dto'; +import { collectContextData } from './utils/context-data-source'; +import { + deriveContextIssues, + diffSnapshots, + type ContextSourceSnapshot, +} from './utils/context-derivation'; +import { + generateIsmsExportFile, + type IsmsExportIssue, + type IsmsExportResult, +} from './utils/export-generator'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +/** + * Context-of-the-Organization (clause 4.1) derivation, drift detection and + * export. Kept separate from IsmsService so each file stays focused; the lifecycle + * (approve/decline/submit) lives in IsmsService. + */ +@Injectable() +export class IsmsContextService { + async generate({ + 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'); + } + + if (document.type !== 'context_of_organization') { + throw new BadRequestException( + `Generation not yet implemented for type ${document.type}`, + ); + } + + const snapshot = await collectContextData({ + organizationId, + frameworkId: document.frameworkId, + }); + const derived = deriveContextIssues(snapshot); + + await db.$transaction(async (tx) => { + await tx.ismsContextIssue.deleteMany({ + where: { documentId, source: 'derived' }, + }); + // Manual rows are preserved; derived rows are appended after them. + const manualCount = await tx.ismsContextIssue.count({ + where: { documentId, source: 'manual' }, + }); + if (derived.length > 0) { + await tx.ismsContextIssue.createMany({ + data: derived.map((issue, index) => ({ + documentId, + kind: issue.kind, + description: issue.description, + effect: issue.effect, + source: issue.source, + derivedFrom: issue.derivedFrom, + position: manualCount + index, + })), + }); + } + await upsertLatestSnapshotVersion({ tx, documentId, snapshot }); + }); + + return this.getDocumentWithIssues({ 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 collectContextData({ + organizationId, + frameworkId: document.frameworkId, + }); + const previous = parseSnapshot(document.versions[0]?.sourceSnapshot); + + return diffSnapshots({ 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, primaryColor: true } }, + approver: { select: { user: { select: { name: true, email: true } } } }, + contextIssues: { orderBy: { position: 'asc' } }, + versions: { where: { isLatest: true }, take: 1 }, + }, + }); + + if (!document) { + throw new NotFoundException('ISMS document not found'); + } + + const issues: IsmsExportIssue[] = document.contextIssues.map((issue) => ({ + kind: issue.kind, + description: issue.description, + effect: issue.effect, + })); + + return generateIsmsExportFile({ + issues, + format: dto.format, + metadata: { + title: document.title, + frameworkName: document.framework.name || 'ISO 27001', + version: document.versions[0]?.version ?? 1, + preparedBy: document.preparedBy, + status: document.status, + 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 getDocumentWithIssues({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + return db.ismsDocument.findFirst({ + where: { id: documentId, organizationId }, + include: { + versions: { where: { isLatest: true }, take: 1 }, + contextIssues: { orderBy: { position: 'asc' } }, + }, + }); + } +} + +function parseSnapshot( + value: Prisma.JsonValue | null | undefined, +): ContextSourceSnapshot | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const record = value as Record; + return { + frameworkNames: toStringArray(record.frameworkNames), + vendorCount: toNumber(record.vendorCount), + subProcessorCount: toNumber(record.subProcessorCount), + vendorsByCategory: toNumberRecord(record.vendorsByCategory), + memberCount: toNumber(record.memberCount), + membersByDepartment: toNumberRecord(record.membersByDepartment), + deviceCount: toNumber(record.deviceCount), + }; +} + +function toNumber(value: unknown): number { + return typeof value === 'number' ? value : 0; +} + +function toStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function toNumberRecord(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/isms.controller.spec.ts b/apps/api/src/isms/isms.controller.spec.ts new file mode 100644 index 0000000000..233620e59f --- /dev/null +++ b/apps/api/src/isms/isms.controller.spec.ts @@ -0,0 +1,267 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import type { Response } from 'express'; +import { Reflector } from '@nestjs/core'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { PERMISSIONS_KEY } from '../auth/permission.guard'; +import { IsmsController } from './isms.controller'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsContextIssueService } from './isms-context-issue.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.service', () => ({ + IsmsService: class MockIsmsService {}, +})); +jest.mock('./isms-context.service', () => ({ + IsmsContextService: class MockIsmsContextService {}, +})); +jest.mock('./isms-context-issue.service', () => ({ + IsmsContextIssueService: class MockIsmsContextIssueService {}, +})); + +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 mockContextIssueService = { + create: jest.fn(), + update: jest.fn(), + remove: 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: IsmsContextIssueService, useValue: mockContextIssueService }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(IsmsController); + jest.clearAllMocks(); + }); + + it('ensureSetup passes dto to the service', async () => { + mockIsmsService.ensureSetup.mockResolvedValue({ success: true }); + const dto = { organizationId: 'org_1', frameworkId: 'fw_1' }; + + const result = await controller.ensureSetup(dto); + + expect(mockIsmsService.ensureSetup).toHaveBeenCalledWith(dto); + expect(result).toEqual({ success: 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('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('createContextIssue passes documentId, dto and org', async () => { + const dto = { kind: 'internal' as const, description: 'd', effect: 'e' }; + mockContextIssueService.create.mockResolvedValue({ id: 'issue_1' }); + + await controller.createContextIssue('doc_1', dto, 'org_1'); + + expect(mockContextIssueService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto, + }); + }); + + it('updateContextIssue passes issueId, dto and org', async () => { + const dto = { description: 'updated' }; + mockContextIssueService.update.mockResolvedValue({ id: 'issue_1' }); + + await controller.updateContextIssue('issue_1', dto, 'org_1'); + + expect(mockContextIssueService.update).toHaveBeenCalledWith({ + issueId: 'issue_1', + organizationId: 'org_1', + dto, + }); + }); + + it('deleteContextIssue passes issueId and org', async () => { + mockContextIssueService.remove.mockResolvedValue({ success: true }); + + await controller.deleteContextIssue('issue_1', 'org_1'); + + expect(mockContextIssueService.remove).toHaveBeenCalledWith({ + issueId: 'issue_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 and org', async () => { + mockIsmsService.decline.mockResolvedValue({ id: 'doc_1' }); + + await controller.decline('doc_1', 'org_1'); + + expect(mockIsmsService.decline).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_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); + }); + + describe('permission metadata', () => { + const reflector = new Reflector(); + const permissionsFor = (method: keyof IsmsController) => + reflector.get(PERMISSIONS_KEY, IsmsController.prototype[method]); + + it('gates ensure-setup with audit:create', () => { + expect(permissionsFor('ensureSetup')).toEqual([ + { resource: 'audit', actions: ['create'] }, + ]); + }); + + it('gates read endpoints with audit:read', () => { + expect(permissionsFor('getDocument')).toEqual([ + { resource: 'audit', actions: ['read'] }, + ]); + expect(permissionsFor('drift')).toEqual([ + { resource: 'audit', actions: ['read'] }, + ]); + expect(permissionsFor('exportDocument')).toEqual([ + { resource: 'audit', actions: ['read'] }, + ]); + }); + + it('gates mutation endpoints with audit:update', () => { + for (const method of [ + 'generate', + 'createContextIssue', + 'updateContextIssue', + 'deleteContextIssue', + 'submitForApproval', + 'approve', + 'decline', + ] as const) { + expect(permissionsFor(method)).toEqual([ + { resource: 'audit', actions: ['update'] }, + ]); + } + }); + }); +}); diff --git a/apps/api/src/isms/isms.controller.ts b/apps/api/src/isms/isms.controller.ts new file mode 100644 index 0000000000..b9fb465bd3 --- /dev/null +++ b/apps/api/src/isms/isms.controller.ts @@ -0,0 +1,210 @@ +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 { 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 { UserId } from '@/auth/auth-context.decorator'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsContextIssueService } from './isms-context-issue.service'; +import { EnsureIsmsSetupDto } from './dto/ensure-isms-setup.dto'; +import { CreateContextIssueDto } from './dto/create-context-issue.dto'; +import { UpdateContextIssueDto } from './dto/update-context-issue.dto'; +import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto'; +import { ExportIsmsDocumentDto } from './dto/export-isms-document.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 contextIssueService: IsmsContextIssueService, + ) {} + + @Post('ensure-setup') + @HttpCode(HttpStatus.OK) + @RequirePermission('audit', 'create') + @ApiOperation({ summary: 'Ensure ISMS foundational documents exist' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Setup ensured' }) + async ensureSetup(@Body() dto: EnsureIsmsSetupDto) { + return this.ismsService.ensureSetup(dto); + } + + @Get('documents/:id') + @RequirePermission('audit', '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/generate') + @HttpCode(HttpStatus.OK) + @RequirePermission('audit', '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/context-issues') + @HttpCode(HttpStatus.CREATED) + @RequirePermission('audit', 'update') + @ApiOperation({ summary: 'Create a manual context issue' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Context issue created' }) + async createContextIssue( + @Param('id') id: string, + @Body() dto: CreateContextIssueDto, + @OrganizationId() organizationId: string, + ) { + return this.contextIssueService.create({ + documentId: id, + organizationId, + dto, + }); + } + + @Post('context-issues/:issueId') + @HttpCode(HttpStatus.OK) + @RequirePermission('audit', 'update') + @ApiOperation({ summary: 'Update a context issue' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Context issue updated' }) + async updateContextIssue( + @Param('issueId') issueId: string, + @Body() dto: UpdateContextIssueDto, + @OrganizationId() organizationId: string, + ) { + return this.contextIssueService.update({ issueId, organizationId, dto }); + } + + @Delete('context-issues/:issueId') + @HttpCode(HttpStatus.OK) + @RequirePermission('audit', 'update') + @ApiOperation({ summary: 'Delete a context issue' }) + @ApiOkResponse({ description: 'Context issue deleted' }) + async deleteContextIssue( + @Param('issueId') issueId: string, + @OrganizationId() organizationId: string, + ) { + return this.contextIssueService.remove({ issueId, organizationId }); + } + + @Post('documents/:id/submit-for-approval') + @HttpCode(HttpStatus.OK) + @RequirePermission('audit', '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('audit', '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('audit', 'update') + @ApiOperation({ summary: 'Decline an ISMS document' }) + @ApiConsumes('application/json') + @ApiOkResponse({ description: 'Document declined' }) + async decline( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.ismsService.decline({ documentId: id, organizationId }); + } + + @Get('documents/:id/drift') + @RequirePermission('audit', '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('audit', '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..fed292061a --- /dev/null +++ b/apps/api/src/isms/isms.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { IsmsController } from './isms.controller'; +import { IsmsService } from './isms.service'; +import { IsmsContextService } from './isms-context.service'; +import { IsmsContextIssueService } from './isms-context-issue.service'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [IsmsController], + providers: [IsmsService, IsmsContextService, IsmsContextIssueService], + exports: [IsmsService, IsmsContextService, IsmsContextIssueService], +}) +export class IsmsModule {} 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..7bb4b1cec7 --- /dev/null +++ b/apps/api/src/isms/isms.service.spec.ts @@ -0,0 +1,250 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsService } from './isms.service'; +import { collectContextData } from './utils/context-data-source'; +import { upsertLatestSnapshotVersion } from './utils/version-snapshot'; + +jest.mock('@db', () => ({ + db: { + frameworkEditorFramework: { findUnique: jest.fn() }, + ismsDocument: { + findMany: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + member: { findFirst: jest.fn() }, + $transaction: jest.fn(), + }, +})); +jest.mock('./utils/context-data-source', () => ({ + collectContextData: jest.fn(), +})); +jest.mock('./utils/version-snapshot', () => ({ + upsertLatestSnapshotVersion: jest.fn(), +})); + +const mockDb = jest.mocked(db); +const mockCollect = jest.mocked(collectContextData); + +describe('IsmsService', () => { + let service: IsmsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new IsmsService(); + }); + + describe('ensureSetup', () => { + const dto = { organizationId: 'org_1', frameworkId: 'fw_1' }; + + it('throws NotFoundException when framework not found', async () => { + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue( + null, + ); + await expect(service.ensureSetup(dto)).rejects.toThrow(NotFoundException); + }); + + 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' }]) + .mockResolvedValueOnce([ + { + id: 'doc_1', + type: 'context_of_organization', + status: 'draft', + requirementId: 'req_41', + }, + ]); + (mockDb.ismsDocument.create as jest.Mock).mockResolvedValue({}); + + const result = await service.ensureSetup(dto); + + expect(mockDb.ismsDocument.create).toHaveBeenCalledTimes(5); + 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([]); + (mockDb.ismsDocument.create as jest.Mock).mockResolvedValue({}); + + await service.ensureSetup(dto); + + expect(mockDb.ismsDocument.create).toHaveBeenCalledTimes(6); + const firstCall = (mockDb.ismsDocument.create as jest.Mock).mock + .calls[0][0]; + expect(firstCall.data.requirementId).toBeNull(); + }); + }); + + 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' }, + }), + ); + }); + }); + + 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 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', + 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', + approverId: 'mem_1', + frameworkId: 'fw_1', + }) + .mockResolvedValueOnce({ id: 'doc_1', status: 'approved' }); + mockCollect.mockResolvedValue({ + frameworkNames: ['ISO 27001'], + vendorCount: 1, + subProcessorCount: 0, + vendorsByCategory: {}, + memberCount: 1, + membersByDepartment: {}, + deviceCount: 0, + }); + 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', + }); + expect(upsertLatestSnapshotVersion).toHaveBeenCalled(); + expect(tx.ismsDocument.update).toHaveBeenCalledWith({ + where: { id: 'doc_1' }, + data: expect.objectContaining({ + status: 'approved', + declinedAt: null, + }), + }); + }); + }); + + describe('decline', () => { + it('throws NotFoundException when document not found', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.decline({ documentId: 'doc_1', organizationId: 'org_1' }), + ).rejects.toThrow(NotFoundException); + }); + + it('sets declined status and declinedAt', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'declined', + }); + + await service.decline({ documentId: 'doc_1', organizationId: 'org_1' }); + + 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.ts b/apps/api/src/isms/isms.service.ts new file mode 100644 index 0000000000..fdfaf9f343 --- /dev/null +++ b/apps/api/src/isms/isms.service.ts @@ -0,0 +1,210 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { EnsureIsmsSetupDto } from './dto/ensure-isms-setup.dto'; +import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto'; +import { + ISMS_TYPE_DEFINITIONS, + matchRequirementId, +} from './utils/document-types'; +import { collectContextData } from './utils/context-data-source'; +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 { + async ensureSetup(dto: EnsureIsmsSetupDto) { + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: dto.frameworkId }, + include: { + requirements: { select: { id: true, name: true, identifier: true } }, + }, + }); + + if (!framework) { + throw new NotFoundException('Framework not found'); + } + + const existing = await db.ismsDocument.findMany({ + where: { + organizationId: dto.organizationId, + frameworkId: dto.frameworkId, + }, + select: { type: true }, + }); + const existingTypes = new Set(existing.map((doc) => doc.type)); + + for (const def of ISMS_TYPE_DEFINITIONS) { + if (existingTypes.has(def.type)) continue; + const requirementId = matchRequirementId({ + clause: def.clause, + requirements: framework.requirements, + }); + await db.ismsDocument.create({ + data: { + organizationId: dto.organizationId, + frameworkId: dto.frameworkId, + type: def.type, + title: def.title, + status: 'draft', + requirementId, + }, + }); + } + + const documents = await db.ismsDocument.findMany({ + where: { + organizationId: dto.organizationId, + frameworkId: dto.frameworkId, + }, + }); + + return { + success: true, + documents: documents.map((doc) => ({ + id: doc.id, + type: doc.type, + status: doc.status, + requirementId: doc.requirementId, + hasApprovedVersion: doc.status === 'approved', + })), + }; + } + + 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' } }, + }, + }); + + 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 }); + + if (document.approverId && document.approverId !== member.id) { + throw new ForbiddenException('Document is not pending your approval'); + } + + const snapshot = await collectContextData({ + organizationId, + frameworkId: document.frameworkId, + }); + + await db.$transaction(async (tx) => { + 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, + }: { + documentId: string; + organizationId: string; + }) { + await this.requireDocument({ documentId, organizationId }); + + return db.ismsDocument.update({ + where: { id: documentId }, + data: { status: 'declined', declinedAt: new Date() }, + }); + } + + 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/utils/context-data-source.ts b/apps/api/src/isms/utils/context-data-source.ts new file mode 100644 index 0000000000..b481e99e36 --- /dev/null +++ b/apps/api/src/isms/utils/context-data-source.ts @@ -0,0 +1,80 @@ +import { db } from '@db'; +import type { ContextDerivationInput } from './context-derivation'; + +/** + * Reads the platform data used to derive Context-of-the-Organization issues for a + * single organization. Always scoped by organizationId. The returned shape is the + * raw snapshot — derivation and drift logic live in context-derivation.ts so this + * file only owns the queries. + */ +export async function collectContextData({ + organizationId, + frameworkId, +}: { + organizationId: string; + frameworkId: string; +}): Promise { + const [ + frameworkInstances, + vendorCount, + subProcessorCount, + vendorsGrouped, + memberCount, + membersGrouped, + deviceCount, + ] = await Promise.all([ + db.frameworkInstance.findMany({ + where: { organizationId }, + select: { framework: { select: { name: true } } }, + }), + db.vendor.count({ where: { organizationId } }), + db.vendor.count({ where: { organizationId, isSubProcessor: true } }), + db.vendor.groupBy({ + by: ['category'], + where: { organizationId }, + _count: { _all: 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 } }), + ]); + + // Ensure the document's own framework is represented even if no instance exists yet. + const frameworkNames = new Set(); + for (const instance of frameworkInstances) { + if (instance.framework?.name) { + frameworkNames.add(instance.framework.name); + } + } + const ownFramework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { name: true }, + }); + if (ownFramework?.name) { + frameworkNames.add(ownFramework.name); + } + + const vendorsByCategory: Record = {}; + for (const row of vendorsGrouped) { + vendorsByCategory[row.category] = row._count._all; + } + + const membersByDepartment: Record = {}; + for (const row of membersGrouped) { + membersByDepartment[row.department] = row._count._all; + } + + return { + frameworkNames: Array.from(frameworkNames).sort(), + vendorCount, + subProcessorCount, + vendorsByCategory, + memberCount, + membersByDepartment, + deviceCount, + }; +} 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..90e4769321 --- /dev/null +++ b/apps/api/src/isms/utils/context-derivation.spec.ts @@ -0,0 +1,113 @@ +import { + deriveContextIssues, + diffSnapshots, + 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('diffSnapshots', () => { + it('flags stale with no-baseline when there is no previous snapshot', () => { + const result = diffSnapshots({ previous: null, current: baseInput }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toContain('no-baseline'); + }); + + it('reports not stale when snapshots match', () => { + const result = diffSnapshots({ previous: baseInput, current: baseInput }); + expect(result.isStale).toBe(false); + expect(result.changedSources).toHaveLength(0); + }); + + it('detects a changed vendor count', () => { + const result = diffSnapshots({ + previous: baseInput, + current: { ...baseInput, vendorCount: 9 }, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toContain('vendors'); + }); + + it('detects framework additions/removals regardless of order', () => { + const same = diffSnapshots({ + previous: { ...baseInput, frameworkNames: ['ISO 27001', 'SOC 2'] }, + current: { ...baseInput, frameworkNames: ['SOC 2', 'ISO 27001'] }, + }); + expect(same.isStale).toBe(false); + + const changed = diffSnapshots({ + previous: { ...baseInput, frameworkNames: ['ISO 27001'] }, + current: { ...baseInput, frameworkNames: ['ISO 27001', 'SOC 2'] }, + }); + expect(changed.changedSources).toContain('frameworks'); + }); + + it('detects member and device drift', () => { + const result = diffSnapshots({ + previous: baseInput, + current: { ...baseInput, memberCount: 20, deviceCount: 15 }, + }); + expect(result.changedSources).toEqual( + expect.arrayContaining(['members', 'devices']), + ); + }); +}); 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..1456568d72 --- /dev/null +++ b/apps/api/src/isms/utils/context-derivation.ts @@ -0,0 +1,195 @@ +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; +} + +/** A single derived issue, ready to be written as an IsmsContextIssue row. */ +export interface DerivedContextIssue { + kind: IsmsContextIssueKind; + description: string; + effect: string; + source: IsmsContextSource; + derivedFrom: string; + position: number; +} + +/** The snapshot persisted onto the version for later drift comparison. */ +export type ContextSourceSnapshot = ContextDerivationInput; + +function buildExternalIssues(input: ContextDerivationInput): Array< + Omit +> { + const issues: Array> = []; + + for (const name of input.frameworkNames) { + issues.push({ + kind: 'external', + 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', + 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', + 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< + Omit +> { + 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', + 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', + 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', + 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', + 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 })); +} + +/** Compare two snapshots and report which derived sources changed (drift). */ +export function diffSnapshots({ + previous, + current, +}: { + previous: ContextSourceSnapshot | null; + current: ContextSourceSnapshot; +}): { isStale: boolean; changedSources: string[] } { + if (!previous) { + return { isStale: true, changedSources: ['no-baseline'] }; + } + + const changed: string[] = []; + + const 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)); + }; + + if (!sameStringSet(previous.frameworkNames, current.frameworkNames)) { + changed.push('frameworks'); + } + if (previous.vendorCount !== current.vendorCount) { + changed.push('vendors'); + } + if (previous.subProcessorCount !== current.subProcessorCount) { + changed.push('subprocessors'); + } + if (previous.memberCount !== current.memberCount) { + changed.push('members'); + } + if (previous.deviceCount !== current.deviceCount) { + changed.push('devices'); + } + + return { isStale: changed.length > 0, changedSources: changed }; +} 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..7d7ad84b24 --- /dev/null +++ b/apps/api/src/isms/utils/document-types.spec.ts @@ -0,0 +1,71 @@ +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..04efe409c4 --- /dev/null +++ b/apps/api/src/isms/utils/document-types.ts @@ -0,0 +1,71 @@ +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; +} + +/** + * The full set of ISMS foundational documents. ensure-setup creates one row per + * entry. 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', + }, + { + type: 'interested_parties_register', + clause: '4.2', + title: 'Interested Parties Register', + }, + { + type: 'interested_parties_requirements', + clause: '4.2', + title: 'Interested Parties Requirements', + }, + { + type: 'isms_scope', + clause: '4.3', + title: 'ISMS Scope', + }, + { + type: 'leadership_commitment', + clause: '5.1', + title: 'Leadership and Commitment', + }, + { + type: 'objectives_plan', + clause: '6.2', + title: 'Information Security Objectives and Plan', + }, +]; + +/** + * 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.ts b/apps/api/src/isms/utils/docx-renderer.ts new file mode 100644 index 0000000000..e3ebdc866e --- /dev/null +++ b/apps/api/src/isms/utils/docx-renderer.ts @@ -0,0 +1,131 @@ +import { + Document, + HeadingLevel, + Packer, + Paragraph, + TextRun, +} from 'docx'; +import { + metadataLines, + type IsmsExportIssue, + type IsmsExportMetadata, +} from './export-shared'; + +const DEFAULT_ACCENT = '004D3D'; + +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; +} + +function metadataParagraphs(metadata: IsmsExportMetadata): Paragraph[] { + return metadataLines(metadata).map( + (text) => + new Paragraph({ + children: [new TextRun({ text, color: '505050', size: 20 })], + }), + ); +} + +function sectionParagraphs({ + heading, + issues, + accent, +}: { + heading: string; + issues: IsmsExportIssue[]; + accent: string; +}): Paragraph[] { + const paragraphs: Paragraph[] = [ + new Paragraph({ + heading: HeadingLevel.HEADING_2, + spacing: { before: 240, after: 120 }, + children: [new TextRun({ text: heading, bold: true, color: accent })], + }), + ]; + + if (issues.length === 0) { + paragraphs.push( + new Paragraph({ + children: [new TextRun({ text: 'No issues recorded.' })], + }), + ); + return paragraphs; + } + + issues.forEach((issue, index) => { + paragraphs.push( + new Paragraph({ + spacing: { before: 120 }, + children: [ + new TextRun({ text: `${index + 1}. ${issue.description}`, bold: true }), + ], + }), + new Paragraph({ + children: [ + new TextRun({ text: 'Effect: ', bold: true }), + new TextRun({ text: issue.effect }), + ], + }), + ); + }); + + return paragraphs; +} + +export async function renderIsmsDocx({ + issues, + metadata, +}: { + issues: IsmsExportIssue[]; + metadata: IsmsExportMetadata; +}): Promise { + const accent = normalizeHexColor(metadata.primaryColor); + + const header: Paragraph[] = []; + if (metadata.organizationName) { + header.push( + new Paragraph({ + children: [ + new TextRun({ + text: metadata.organizationName, + bold: true, + color: accent, + size: 28, + }), + ], + }), + ); + } + header.push( + new Paragraph({ + heading: HeadingLevel.HEADING_1, + spacing: { after: 200 }, + children: [new TextRun({ text: metadata.title, bold: true })], + }), + ); + + const doc = new Document({ + sections: [ + { + children: [ + ...header, + ...metadataParagraphs(metadata), + ...sectionParagraphs({ + heading: 'External issues', + issues: issues.filter((issue) => issue.kind === 'external'), + accent, + }), + ...sectionParagraphs({ + heading: 'Internal issues', + issues: issues.filter((issue) => issue.kind === 'internal'), + accent, + }), + ], + }, + ], + }); + + return Packer.toBuffer(doc); +} 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..c2d4e4f854 --- /dev/null +++ b/apps/api/src/isms/utils/export-generator.spec.ts @@ -0,0 +1,74 @@ +import { generateIsmsExportFile, type IsmsExportMetadata } 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', + frameworkName: 'ISO 27001', + version: 2, + preparedBy: 'Comp AI', + status: 'approved', + approverName: 'Jane Doe', + approvedAt: new Date('2026-05-01T00:00:00.000Z'), + declinedAt: null, + organizationName: 'Acme Inc', + primaryColor: '#123456', +}; + +const issues = [ + { kind: 'external' as const, description: 'Pursuing ISO 27001', effect: 'Shapes scope' }, + { kind: 'internal' as const, description: '12 workforce members', effect: 'Drives access mgmt' }, +]; + +describe('generateIsmsExportFile', () => { + beforeEach(() => jest.clearAllMocks()); + + it('renders a real PDF buffer for format=pdf', async () => { + const result = await generateIsmsExportFile({ + issues, + 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); + // PDF magic header. + expect(result.fileBuffer.subarray(0, 4).toString()).toBe('%PDF'); + expect(mockRenderDocx).not.toHaveBeenCalled(); + }); + + it('delegates to the docx renderer for format=docx', async () => { + mockRenderDocx.mockResolvedValue(Buffer.from('docx-bytes')); + + const result = await generateIsmsExportFile({ + issues, + metadata, + format: 'docx', + }); + + expect(mockRenderDocx).toHaveBeenCalledWith({ issues, 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 issue set without throwing', async () => { + const result = await generateIsmsExportFile({ + issues: [], + 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..512bccb470 --- /dev/null +++ b/apps/api/src/isms/utils/export-generator.ts @@ -0,0 +1,169 @@ +import { jsPDF } from 'jspdf'; +import { renderIsmsDocx } from './docx-renderer'; +import { + DOCX_MIME_TYPE, + metadataLines, + type IsmsExportFormat, + type IsmsExportIssue, + type IsmsExportMetadata, + type IsmsExportResult, +} from './export-shared'; + +export type { + IsmsExportFormat, + IsmsExportIssue, + IsmsExportMetadata, + IsmsExportResult, +} from './export-shared'; + +export async function generateIsmsExportFile({ + issues, + metadata, + format, +}: { + issues: IsmsExportIssue[]; + metadata: IsmsExportMetadata; + format: IsmsExportFormat; +}): Promise { + const baseName = `${sanitizeName(metadata.title)}-v${metadata.version}`; + + if (format === 'docx') { + return { + fileBuffer: await renderIsmsDocx({ issues, metadata }), + mimeType: DOCX_MIME_TYPE, + filename: `${baseName}.docx`, + }; + } + + return { + fileBuffer: generateIsmsPdf({ issues, metadata }), + mimeType: 'application/pdf', + filename: `${baseName}.pdf`, + }; +} + +function hexToRgb(hex: string | null): { r: number; g: number; b: number } { + const fallback = { r: 0, g: 77, b: 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 }; +} + +function generateIsmsPdf({ + issues, + metadata, +}: { + issues: IsmsExportIssue[]; + metadata: IsmsExportMetadata; +}): Buffer { + const pdf = new jsPDF(); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 20; + const contentWidth = pageWidth - margin * 2; + const lineHeight = 7; + let y = margin; + const accent = hexToRgb(metadata.primaryColor); + + const ensureSpace = (required: number) => { + if (y + required > pageHeight - margin) { + pdf.addPage(); + y = margin; + } + }; + const writeLines = ( + lines: string[], + fontStyle: 'normal' | 'bold' = 'normal', + ) => { + if (lines.length === 0) return; + pdf.setFont('helvetica', fontStyle); + for (const line of lines) { + ensureSpace(lineHeight); + pdf.text(line, margin, y); + y += lineHeight; + } + }; + + // Branded accent bar + organization name. + pdf.setLineWidth(3); + pdf.setDrawColor(accent.r, accent.g, accent.b); + pdf.line(margin, y, pageWidth - margin, y); + y += lineHeight * 1.5; + + if (metadata.organizationName) { + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(20); + pdf.setTextColor(0, 0, 0); + pdf.text(metadata.organizationName, margin, y); + y += lineHeight * 1.5; + } + + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(16); + pdf.text(metadata.title, margin, y); + y += lineHeight * 1.8; + + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(10); + pdf.setTextColor(80, 80, 80); + for (const line of metadataLines(metadata)) { + pdf.text(line, margin, y); + y += lineHeight; + } + y += lineHeight; + + writeSection({ + heading: 'External issues', + issues: issues.filter((issue) => issue.kind === 'external'), + }); + writeSection({ + heading: 'Internal issues', + issues: issues.filter((issue) => issue.kind === 'internal'), + }); + + function writeSection({ + heading, + issues: sectionIssues, + }: { + heading: string; + issues: IsmsExportIssue[]; + }) { + pdf.setTextColor(accent.r, accent.g, accent.b); + pdf.setFontSize(13); + ensureSpace(lineHeight * 1.5); + pdf.setFont('helvetica', 'bold'); + pdf.text(heading, margin, y); + y += lineHeight * 1.4; + pdf.setTextColor(0, 0, 0); + pdf.setFontSize(11); + + if (sectionIssues.length === 0) { + writeLines(['No issues recorded.']); + y += lineHeight * 0.5; + return; + } + + sectionIssues.forEach((issue, index) => { + const title = `${index + 1}. ${issue.description}`; + writeLines(pdf.splitTextToSize(title, contentWidth), 'bold'); + writeLines( + pdf.splitTextToSize(`Effect: ${issue.effect}`, contentWidth), + ); + ensureSpace(lineHeight * 0.5); + y += lineHeight * 0.5; + }); + } + + return Buffer.from(pdf.output('arraybuffer')); +} + +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-shared.ts b/apps/api/src/isms/utils/export-shared.ts new file mode 100644 index 0000000000..a4c4850ef7 --- /dev/null +++ b/apps/api/src/isms/utils/export-shared.ts @@ -0,0 +1,54 @@ +import type { IsmsContextIssueKind } from '@db'; + +export type IsmsExportFormat = 'pdf' | 'docx'; + +export interface IsmsExportIssue { + kind: IsmsContextIssueKind; + description: string; + effect: string; +} + +export interface IsmsExportMetadata { + title: string; + frameworkName: string; + version: number; + preparedBy: string | null; + status: string | null; + approverName: string | null; + approvedAt: Date | string | null; + declinedAt: Date | string | null; + organizationName: string | null; + primaryColor: string | null; +} + +export interface IsmsExportResult { + fileBuffer: Buffer; + mimeType: string; + filename: string; +} + +export const DOCX_MIME_TYPE = + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +/** Human-readable approval status used by both the PDF and DOCX headers. */ +export function approvalLine(metadata: IsmsExportMetadata): string { + if (metadata.approvedAt) { + return `Approved on ${new Date(metadata.approvedAt).toLocaleDateString()}`; + } + if (metadata.declinedAt) { + return `Declined on ${new Date(metadata.declinedAt).toLocaleDateString()}`; + } + if (metadata.status === 'needs_review') return 'Pending approval'; + return 'Not approved'; +} + +export function metadataLines(metadata: IsmsExportMetadata): string[] { + return [ + `Framework: ${metadata.frameworkName}`, + `Version: v${metadata.version}`, + `Prepared by: ${metadata.preparedBy || 'Comp AI'}`, + `Approval status: ${approvalLine(metadata)}`, + `Approver: ${metadata.approverName || 'N/A'}`, + `Exported: ${new Date().toLocaleDateString()}`, + ]; +} 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..7388351817 --- /dev/null +++ b/apps/api/src/isms/utils/version-snapshot.ts @@ -0,0 +1,45 @@ +import type { Prisma } from '@db'; +import type { ContextSourceSnapshot } from './context-derivation'; + +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. + */ +export async function upsertLatestSnapshotVersion({ + tx, + documentId, + snapshot, +}: { + tx: Prisma.TransactionClient; + documentId: string; + snapshot: ContextSourceSnapshot; +}): 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/bun.lock b/bun.lock index e5cfd7955e..a25c004805 100644 --- a/bun.lock +++ b/bun.lock @@ -161,6 +161,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", @@ -3899,6 +3900,8 @@ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "docx": ["docx@9.7.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], @@ -4395,6 +4398,8 @@ "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], + "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], @@ -5149,6 +5154,8 @@ "minimal-polyfills": ["minimal-polyfills@2.2.3", "", {}, "sha512-oxdmJ9cL+xV72h0xYxp4tP2d5/fTBpP45H8DIOn9pASuF8a3IYTf+25fMGDYGiWW+MFsuog6KD6nfmhZJQ+uUw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -6631,6 +6638,10 @@ "xdg-portable": ["xdg-portable@10.6.0", "", { "dependencies": { "os-paths": "^7.4.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-xrcqhWDvtZ7WLmt8G4f3hHy37iK7D2idtosRgkeiSPZEPmBShp0VfmRBLWAPC6zLF48APJ21yfea+RfQMF4/Aw=="], + "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], + + "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], @@ -7507,6 +7518,8 @@ "dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "docx/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dub/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -8975,6 +8988,8 @@ "dmg-builder/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "duplexer2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 68ada0dd85..a1f541d9d9 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -18218,6 +18218,540 @@ } } }, + "/v1/isms/ensure-setup": { + "post": { + "operationId": "IsmsController_ensureSetup_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnsureIsmsSetupDto" + } + } + } + }, + "responses": { + "200": { + "description": "Setup ensured" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Ensure ISMS foundational documents exist", + "tags": [ + "ISMS" + ], + "description": "Ensure ISMS foundational documents exist in Comp AI.", + "x-mint": { + "metadata": { + "title": "Ensure ISMS foundational documents exist | Comp AI API", + "sidebarTitle": "Ensure ISMS foundational documents exist", + "description": "Ensure ISMS foundational documents exist in Comp AI.", + "og:title": "Ensure ISMS foundational documents exist | Comp AI API", + "og:description": "Ensure ISMS foundational documents exist in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "isms-ensure-setup" + } + } + }, + "/v1/isms/documents/{id}": { + "get": { + "operationId": "IsmsController_getDocument_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ISMS document" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Get an ISMS document with its latest version", + "tags": [ + "ISMS" + ], + "description": "Get an ISMS document with its latest version in Comp AI.", + "x-mint": { + "metadata": { + "title": "Get an ISMS document with its latest version | Comp AI API", + "sidebarTitle": "Get an ISMS document with its latest version", + "description": "Get an ISMS document with its latest version in Comp AI.", + "og:title": "Get an ISMS document with its latest version | Comp AI API", + "og:description": "Get an ISMS document with its latest version in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "get-document" + } + } + }, + "/v1/isms/documents/{id}/generate": { + "post": { + "operationId": "IsmsController_generate_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Document with derived issues" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Derive Context-of-the-Organization issues", + "tags": [ + "ISMS" + ], + "description": "Derive Context-of-the-Organization issues in Comp AI.", + "x-mint": { + "metadata": { + "title": "Derive Context-of-the-Organization issues | Comp AI API", + "sidebarTitle": "Derive Context-of-the-Organization issues", + "description": "Derive Context-of-the-Organization issues in Comp AI.", + "og:title": "Derive Context-of-the-Organization issues | Comp AI API", + "og:description": "Derive Context-of-the-Organization issues in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "generate" + } + } + }, + "/v1/isms/documents/{id}/context-issues": { + "post": { + "operationId": "IsmsController_createContextIssue_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateContextIssueDto" + } + } + } + }, + "responses": { + "200": { + "description": "Context issue created" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Create a manual context issue", + "tags": [ + "ISMS" + ], + "description": "Create a manual context issue in Comp AI.", + "x-mint": { + "metadata": { + "title": "Create a manual context issue | Comp AI API", + "sidebarTitle": "Create a manual context issue", + "description": "Create a manual context issue in Comp AI.", + "og:title": "Create a manual context issue | Comp AI API", + "og:description": "Create a manual context issue in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "create-context-issue" + } + } + }, + "/v1/isms/context-issues/{issueId}": { + "post": { + "operationId": "IsmsController_updateContextIssue_v1", + "parameters": [ + { + "name": "issueId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateContextIssueDto" + } + } + } + }, + "responses": { + "200": { + "description": "Context issue updated" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Update a context issue", + "tags": [ + "ISMS" + ], + "description": "Update a context issue in Comp AI.", + "x-mint": { + "metadata": { + "title": "Update a context issue | Comp AI API", + "sidebarTitle": "Update a context issue", + "description": "Update a context issue in Comp AI.", + "og:title": "Update a context issue | Comp AI API", + "og:description": "Update a context issue in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "update-context-issue" + } + }, + "delete": { + "operationId": "IsmsController_deleteContextIssue_v1", + "parameters": [ + { + "name": "issueId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Context issue deleted" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Delete a context issue", + "tags": [ + "ISMS" + ], + "description": "Delete a context issue in Comp AI.", + "x-mint": { + "metadata": { + "title": "Delete a context issue | Comp AI API", + "sidebarTitle": "Delete a context issue", + "description": "Delete a context issue in Comp AI.", + "og:title": "Delete a context issue | Comp AI API", + "og:description": "Delete a context issue in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "delete-context-issue" + } + } + }, + "/v1/isms/documents/{id}/submit-for-approval": { + "post": { + "operationId": "IsmsController_submitForApproval_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitIsmsForApprovalDto" + } + } + } + }, + "responses": { + "200": { + "description": "Document submitted for approval" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Submit an ISMS document for approval", + "tags": [ + "ISMS" + ], + "description": "Submit an ISMS document for approval in Comp AI.", + "x-mint": { + "metadata": { + "title": "Submit an ISMS document for approval | Comp AI API", + "sidebarTitle": "Submit an ISMS document for approval", + "description": "Submit an ISMS document for approval in Comp AI.", + "og:title": "Submit an ISMS document for approval | Comp AI API", + "og:description": "Submit an ISMS document for approval in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "isms-submit-for-approval" + } + } + }, + "/v1/isms/documents/{id}/approve": { + "post": { + "operationId": "IsmsController_approve_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Document approved" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Approve an ISMS document", + "tags": [ + "ISMS" + ], + "description": "Approve an ISMS document in Comp AI.", + "x-mint": { + "metadata": { + "title": "Approve an ISMS document | Comp AI API", + "sidebarTitle": "Approve an ISMS document", + "description": "Approve an ISMS document in Comp AI.", + "og:title": "Approve an ISMS document | Comp AI API", + "og:description": "Approve an ISMS document in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "approve" + } + } + }, + "/v1/isms/documents/{id}/decline": { + "post": { + "operationId": "IsmsController_decline_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Document declined" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Decline an ISMS document", + "tags": [ + "ISMS" + ], + "description": "Decline an ISMS document in Comp AI.", + "x-mint": { + "metadata": { + "title": "Decline an ISMS document | Comp AI API", + "sidebarTitle": "Decline an ISMS document", + "description": "Decline an ISMS document in Comp AI.", + "og:title": "Decline an ISMS document | Comp AI API", + "og:description": "Decline an ISMS document in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "decline" + } + } + }, + "/v1/isms/documents/{id}/drift": { + "get": { + "operationId": "IsmsController_drift_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Drift status" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Detect drift against the approved snapshot", + "tags": [ + "ISMS" + ], + "description": "Detect drift against the approved snapshot in Comp AI.", + "x-mint": { + "metadata": { + "title": "Detect drift against the approved snapshot | Comp AI API", + "sidebarTitle": "Detect drift against the approved snapshot", + "description": "Detect drift against the approved snapshot in Comp AI.", + "og:title": "Detect drift against the approved snapshot | Comp AI API", + "og:description": "Detect drift against the approved snapshot in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "drift" + } + } + }, + "/v1/isms/documents/{id}/export": { + "post": { + "operationId": "IsmsController_exportDocument_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportIsmsDocumentDto" + } + } + } + }, + "responses": { + "200": { + "description": "Rendered document" + } + }, + "security": [ + { + "apikey": [] + }, + { + "oauth2": [] + } + ], + "summary": "Export an ISMS document as PDF or DOCX", + "tags": [ + "ISMS" + ], + "description": "Export an ISMS document as PDF or DOCX in Comp AI.", + "x-mint": { + "metadata": { + "title": "Export an ISMS document as PDF or DOCX | Comp AI API", + "sidebarTitle": "Export an ISMS document as PDF or DOCX", + "description": "Export an ISMS document as PDF or DOCX in Comp AI.", + "og:title": "Export an ISMS document as PDF or DOCX | Comp AI API", + "og:description": "Export an ISMS document as PDF or DOCX in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "isms-export-document" + } + } + }, "/v1/integrations/connections/providers": { "get": { "operationId": "ConnectionsController_listProviders_v1", @@ -23668,14 +24202,6 @@ "type": "string" } }, - { - "name": "frameworkInstanceId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, { "name": "formType", "required": true, @@ -23698,6 +24224,14 @@ ], "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -24938,6 +25472,9 @@ "name": "Frameworks", "description": "Manage SOC 2, ISO 27001, HIPAA, GDPR, and custom framework instances, requirements, scores, and sync history." }, + { + "name": "ISMS" + }, { "name": "Integrations", "description": "Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables, and collect automated evidence." @@ -28635,6 +29172,26 @@ "type": "object", "properties": {} }, + "EnsureIsmsSetupDto": { + "type": "object", + "properties": {} + }, + "CreateContextIssueDto": { + "type": "object", + "properties": {} + }, + "UpdateContextIssueDto": { + "type": "object", + "properties": {} + }, + "SubmitIsmsForApprovalDto": { + "type": "object", + "properties": {} + }, + "ExportIsmsDocumentDto": { + "type": "object", + "properties": {} + }, "CreateConnectionDto": { "type": "object", "properties": { From 53a37149011b0db46080b99c7d9c7efdd5b147ab Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 29 May 2026 13:51:02 -0400 Subject: [PATCH 04/37] 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 --- .../components/CompanyOverviewCards.tsx | 48 ++-- .../components/DocumentsPageTabs.tsx | 44 +++- .../components/isms/IsmsOverview.test.tsx | 108 +++++++++ .../components/isms/IsmsOverview.tsx | 151 ++++++++++++ .../components/isms/IsmsStatusBadge.tsx | 45 ++++ .../[orgId]/documents/isms/[type]/page.tsx | 140 +++++++++++ .../isms/components/AddIssueForm.tsx | 90 ++++++++ .../ContextOfOrganizationClient.test.tsx | 216 +++++++++++++++++ .../ContextOfOrganizationClient.tsx | 217 ++++++++++++++++++ .../documents/isms/components/DriftBanner.tsx | 51 ++++ .../isms/components/IsmsApprovalSection.tsx | 182 +++++++++++++++ .../documents/isms/components/IssueRow.tsx | 105 +++++++++ .../isms/components/IssuesRegister.tsx | 119 ++++++++++ .../documents/isms/hooks/useIsmsDocument.ts | 216 +++++++++++++++++ .../isms/hooks/useIso27001FrameworkId.ts | 41 ++++ .../[orgId]/documents/isms/isms-types.ts | 138 +++++++++++ .../src/app/(app)/[orgId]/documents/page.tsx | 26 ++- 17 files changed, 1891 insertions(+), 46 deletions(-) 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/components/isms/IsmsStatusBadge.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/IsmsApprovalSection.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/hooks/useIsmsDocument.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 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..6aff134a5a 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']); @@ -118,9 +106,19 @@ function StatusBadge({ ); } -export function CompanyOverviewCards({ organizationId }: { organizationId: string }) { +export function CompanyOverviewCards({ + organizationId, + isIsmsEnabled = false, +}: { + organizationId: string; + isIsmsEnabled?: boolean; +}) { const swrKey: readonly [string, string] = ['/v1/evidence-forms/statuses', organizationId]; + // When ISMS is on, the SOA card lives in the ISO 27001 (ISMS) tab instead. + const iso27001FrameworkId = useIso27001FrameworkId(organizationId); + const showSoaCard = !isIsmsEnabled && !!iso27001FrameworkId; + const { data: statuses } = useSWR( swrKey, async ([endpoint, orgId]: readonly [string, string]) => { @@ -133,16 +131,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,19 +166,9 @@ 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 && ( + {showSoaCard && iso27001FrameworkId && ( { @@ -44,6 +61,9 @@ export function DocumentsPageTabs({ overviewContent, settingsContent }: Document [pathname, router, searchParams], ); + // When ISMS is off the IA is unchanged: a single "Overview" tab plus Settings. + const companyFormsLabel = isIsmsEnabled ? 'Company Forms' : 'Overview'; + return ( - Overview - Settings + {isIsmsEnabled && ( + ISO 27001 (ISMS) + )} + {companyFormsLabel} + Settings } /> } > - {overviewContent} - {settingsContent} + {isIsmsEnabled && {ismsContent}} + {companyFormsContent} + {settingsContent} ); 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..e3ecee6024 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx @@ -0,0 +1,108 @@ +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 ────────────────────────────────────── +vi.mock('@trycompai/design-system', () => ({ + Badge: ({ children }: { children: React.ReactNode }) => {children}, + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + CardHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + Stack: ({ children }: { children: React.ReactNode }) =>
{children}
, + Text: ({ children }: { children: React.ReactNode }) => {children}, +})); + +// ─── 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('renders the Statement of Applicability section', () => { + render(); + // SOAOverviewCard renders "Statement of Applicability" as its section title + card title. + expect(screen.getAllByText('Statement of Applicability').length).toBeGreaterThan(0); + }); + + 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('marks the not-yet-implemented documents as Coming soon', () => { + render(); + // Five of the six cards are not implemented yet. + expect(screen.getAllByText('Coming soon')).toHaveLength(5); + }); +}); 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..07d2d096a8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { + Badge, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Stack, + Text, +} from '@trycompai/design-system'; +import Link from 'next/link'; +import { useMemo } from 'react'; +import useSWR from 'swr'; +import { api } from '@/lib/api-client'; +import { useIso27001FrameworkId } from '../../isms/hooks/useIso27001FrameworkId'; +import { + ISMS_TYPE_META, + ismsTypeToSlug, + type IsmsDocumentStatus, + type IsmsDocumentType, + type IsmsDriftResult, + type IsmsEnsureSetupResponse, + type IsmsSetupDocument, +} from '../../isms/isms-types'; +import { SOAOverviewCard } from '../SOAOverviewCard'; +import { IsmsStatusBadge } from './IsmsStatusBadge'; + +function FoundationalDocumentCard({ + organizationId, + type, + setupDoc, + isStale, +}: { + organizationId: string; + type: IsmsDocumentType; + setupDoc: IsmsSetupDocument | undefined; + isStale: boolean; +}) { + const meta = ISMS_TYPE_META.find((entry) => entry.type === type); + if (!meta) return null; + + const status: IsmsDocumentStatus | null = setupDoc?.status ?? null; + const title = `${meta.clause} ${meta.title}`; + + const cardBody = ( + + + {title} +
+ {meta.description} +
+
+ + {meta.detailRouteEnabled ? ( + + ) : ( + Coming soon + )} + +
+ ); + + if (!meta.detailRouteEnabled) { + return
{cardBody}
; + } + + return ( + {cardBody} + ); +} + +export function IsmsOverview({ organizationId }: { organizationId: string }) { + const iso27001FrameworkId = useIso27001FrameworkId(organizationId); + + const { data: setupResponse } = 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, { + organizationId: orgId, + 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; + }, + ); + + if (!iso27001FrameworkId) { + return ( +
+ + Add the ISO 27001 framework to your organization to manage ISMS foundational documents. + +
+ ); + } + + return ( + +
+
+ + Foundational Documents + + {ISMS_TYPE_META.length} +
+
+ {ISMS_TYPE_META.map((meta) => { + const setupDoc = documents.find((doc) => doc.type === meta.type); + const isStale = + meta.type === 'context_of_organization' ? !!contextDrift?.isStale : false; + return ( + + ); + })} +
+
+ + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsStatusBadge.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsStatusBadge.tsx new file mode 100644 index 0000000000..edaf7cf7a2 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsStatusBadge.tsx @@ -0,0 +1,45 @@ +import type { IsmsDocumentStatus } from '../../isms/isms-types'; + +type DisplayStatus = 'Not started' | 'Draft' | 'Pending' | 'Approved' | 'Declined'; + +const STATUS_CONFIG: Record = { + 'Not started': 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', + Draft: 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', + Pending: 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-400', + Approved: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400', + Declined: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400', +}; + +function toDisplayStatus(status: IsmsDocumentStatus | null): DisplayStatus { + if (!status) return 'Not started'; + if (status === 'approved') return 'Approved'; + if (status === 'declined') return 'Declined'; + if (status === 'needs_review' || status === 'in_progress') return 'Pending'; + return 'Draft'; +} + +const PILL_CLASS = + 'inline-flex items-center rounded-sm px-1.5 py-1 text-[10px] font-semibold uppercase tracking-wider leading-none'; + +export function IsmsStatusBadge({ + status, + isStale, +}: { + status: IsmsDocumentStatus | null; + isStale?: boolean; +}) { + const display = toDisplayStatus(status); + + return ( +
+ {display} + {isStale && ( + + Out of date + + )} +
+ ); +} 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..a0fef64dce --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx @@ -0,0 +1,140 @@ +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 { parseRolesString } from '@/lib/permissions'; +import { auth } from '@/utils/auth'; +import { ContextOfOrganizationClient } from '../components/ContextOfOrganizationClient'; +import type { ApproverOption } from '../components/IsmsApprovalSection'; +import { + ISMS_SLUG_TO_TYPE, + ISMS_TYPE_META, + ISO27001_NAMES, + type IsmsDocument as IsmsDocumentData, + type IsmsEnsureSetupResponse, +} from '../isms-types'; + +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 }, + ]} + /> + ); + + if (!meta.detailRouteEnabled) { + return ( + + {breadcrumb} +
+ {meta.title} is coming soon. +
+
+ ); + } + + 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', { + organizationId, + 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; + + const people = peopleResult.data?.data ?? []; + const currentMember = people.find((p) => p.userId === session.user.id && !p.deactivated) ?? null; + const approverOptions: ApproverOption[] = people + .filter( + (p) => + !p.deactivated && + parseRolesString(p.role).some((role) => role === 'owner' || role === 'admin'), + ) + .map((p) => ({ id: p.id, name: p.user?.name ?? p.user?.email ?? 'Unknown' })) + .sort((a, b) => a.name.localeCompare(b.name)); + + 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..826be4cfba --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AddIssueForm.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Textarea } from '@trycompai/design-system'; +import { Add } from '@trycompai/design-system/icons'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import type { IsmsContextIssueKind } from '../isms-types'; + +const addIssueSchema = z.object({ + description: z.string().min(1, 'Description is required'), + effect: z.string().min(1, 'Effect is required'), +}); + +type AddIssueValues = z.infer; + +interface AddIssueFormProps { + kind: IsmsContextIssueKind; + onAdd: (params: { description: string; effect: string }) => Promise; +} + +export function AddIssueForm({ kind, onAdd }: AddIssueFormProps) { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(addIssueSchema), + defaultValues: { description: '', effect: '' }, + }); + + const handleAdd = handleSubmit(async (values) => { + await onAdd(values); + reset({ description: '', effect: '' }); + }); + + return ( +
+
+
+ ( +