From 300f2a1aed312adbd3a6a0675f7f48c987657a4c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 20 Jul 2026 23:23:18 -0400 Subject: [PATCH 1/9] fix(deps): bump adm-zip 0.5.18 -> 0.6.0 in apps/api (Dependabot #88/#89) (#3462) adm-zip < 0.6.0 allows a crafted ZIP to trigger a 4GB memory allocation (DoS). This is reachable: apps/api/src/questionnaire/utils/content-extractor.ts runs `new AdmZip(fileBuffer)` on user-uploaded questionnaire files (xlsx/docx are ZIP containers). Bumps the direct dependency to ^0.6.0 (patched) and updates bun.lock. Our usage (new AdmZip, getEntry, getData, addFile, toBuffer) is unchanged in 0.6.0. Supersedes Dependabot PR #3451, which bumped package.json but left bun.lock out of sync. Co-authored-by: Claude Opus 4.8 --- apps/api/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 423679e678..cb34ae6c38 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -89,7 +89,7 @@ "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.34.2", "@upstash/vector": "^1.2.2", - "adm-zip": "^0.5.16", + "adm-zip": "^0.6.0", "ai": "^6.0.175", "archiver": "^7.0.1", "axios": "^1.16.0", diff --git a/bun.lock b/bun.lock index e95936b8d4..c740d501af 100644 --- a/bun.lock +++ b/bun.lock @@ -153,7 +153,7 @@ "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.34.2", "@upstash/vector": "^1.2.2", - "adm-zip": "^0.5.16", + "adm-zip": "^0.6.0", "ai": "^6.0.175", "archiver": "^7.0.1", "axios": "^1.16.0", @@ -3170,7 +3170,7 @@ "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], - "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], + "adm-zip": ["adm-zip@0.6.0", "", {}, "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg=="], "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], From 94c33b1202661f7e99fc4e3b41e46e79878f81c2 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 20 Jul 2026 23:33:41 -0400 Subject: [PATCH 2/9] fix(deps): patch engine.io (#93) and body-parser (#92) Dependabot alerts (#3464) - engine.io: add bun override to ^6.6.7. The vulnerable 6.5.5 (Engine.IO polling connection-exhaustion DoS, <6.6.7) was pulled transitively via @trigger.dev/core -> socket.io@4.7.4. Override collapses it to 6.6.9 (the version top-level socket.io@4.8.3 already uses); bun.lock now has a single engine.io@6.6.9. Not a reachable DoS for us (we run no engine.io server; trigger.dev uses it client-side) but removes the alert. - body-parser: bump the transitive dep in apps/mcp-server (runtime, via express@5.2.1) 2.2.2 -> 2.3.0 (patched; DoS via invalid limit silently disabling size enforcement). Lockfile-only; express's ^2.2.1 range permits 2.3.0, so it stays transitive and survives SDK regeneration. Co-authored-by: Claude Opus 4.8 --- apps/mcp-server/package-lock.json | 31 ++++++++++++++++++++++--------- bun.lock | 11 +---------- package.json | 1 + 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/mcp-server/package-lock.json b/apps/mcp-server/package-lock.json index 23dac74809..20a64896d4 100644 --- a/apps/mcp-server/package-lock.json +++ b/apps/mcp-server/package-lock.json @@ -1398,20 +1398,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1421,6 +1421,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", diff --git a/bun.lock b/bun.lock index c740d501af..bf3c95d29b 100644 --- a/bun.lock +++ b/bun.lock @@ -851,6 +851,7 @@ "axios": "^1.16.0", "dompurify": "^3.4.12", "effect": "^3.20.0", + "engine.io": "^6.6.7", "form-data": "^4.0.6", "hono": "^4.12.25", "ip-address": "^10.2.0", @@ -2768,8 +2769,6 @@ "@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="], - "@types/cookie": ["@types/cookie@0.4.1", "", {}, "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="], - "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], @@ -8704,8 +8703,6 @@ "@trigger.dev/core/socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "@trigger.dev/core/socket.io/engine.io": ["engine.io@6.5.5", "", { "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.4.1", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA=="], - "@trycompai/api/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@trycompai/app/@mendable/firecrawl-js/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -9574,10 +9571,6 @@ "@trigger.dev/core/execa/onetime/mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], - "@trigger.dev/core/socket.io/engine.io/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], - - "@trigger.dev/core/socket.io/engine.io/cookie": ["cookie@0.4.2", "", {}, "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="], - "@trycompai/api/ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@trycompai/app/ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -9788,8 +9781,6 @@ "@sentry/bundler-plugins/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], diff --git a/package.json b/package.json index ad1a1a2c44..d959d95bac 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@vitest/ui": "^3.2.7", "form-data": "^4.0.6", "ws": "^8.21.0", + "engine.io": "^6.6.7", "semver": "^7.8.5", "hono": "^4.12.25", "@hono/node-server": "^1.19.13", From 8ab5709c2a484510fe9ca8f1c4c04df5ac1ababc Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 20 Jul 2026 23:39:53 -0400 Subject: [PATCH 3/9] fix(deps): override tar to ^7.5.19 to clear node-tar Dependabot alerts (#94-#104) (#3466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A batch of 11 node-tar advisories (Critical->Moderate: decompression DoS, path traversal, symlink/hardlink escape, PAX parsing differentials) flagged every tar copy below 7.5.19. bun.lock had tar@6.2.1 (x5, via build tooling: node-gyp, cacache, @electron/rebuild, app-builder-lib, giget), tar@7.5.11 (fleetctl), and tar@7.5.20 (already patched). node-tar backported no fixes to the 6.x line, so the only remediation is tar >= 7.5.19. Added a bun override "tar": "^7.5.19" — bun.lock now resolves a single tar@7.5.20 for all consumers. tar@7 is a dual CJS/ESM package with a stable extract/create API, so the CJS build tools keep working via require(). Reachability was low pre-fix (all vulnerable copies were build-time tooling or trusted-source CLI extraction, none extract attacker-controlled archives in the runtime API), but the override removes the vulnerable versions entirely. Co-authored-by: Claude Opus 4.8 --- bun.lock | 61 +--------------------------------------------------- package.json | 1 + 2 files changed, 2 insertions(+), 60 deletions(-) diff --git a/bun.lock b/bun.lock index bf3c95d29b..251af10f31 100644 --- a/bun.lock +++ b/bun.lock @@ -866,6 +866,7 @@ "semver": "^7.8.5", "shell-quote": "^1.8.4", "systeminformation": "^5.31.17", + "tar": "^7.5.19", "tmp": "^0.2.7", "uuid": "^11.1.1", "vite": "^6.4.3", @@ -6657,8 +6658,6 @@ "@electron/rebuild/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], - "@electron/rebuild/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - "@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], @@ -7259,8 +7258,6 @@ "app-builder-lib/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "app-builder-lib/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -7323,8 +7320,6 @@ "cacache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "cacache/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - "cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "cacheable-request/normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], @@ -7481,8 +7476,6 @@ "firecrawl/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "fleetctl/tar": ["tar@7.5.11", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ=="], - "fork-ts-checker-webpack-plugin/cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], "fork-ts-checker-webpack-plugin/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -7507,8 +7500,6 @@ "giget/nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], - "giget/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - "git-raw-commits/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "git-raw-commits/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -7673,8 +7664,6 @@ "node-gyp/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "node-gyp/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - "npm/@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], "npm/@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -8343,14 +8332,6 @@ "@electron/rebuild/ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@electron/rebuild/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], - - "@electron/rebuild/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - - "@electron/rebuild/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - - "@electron/rebuild/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], "@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -8821,14 +8802,6 @@ "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], - "app-builder-lib/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], - - "app-builder-lib/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - - "app-builder-lib/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - - "app-builder-lib/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "archiver-utils/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -8863,12 +8836,6 @@ "cacache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "cacache/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - - "cacache/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - - "cacache/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "cli-highlight/parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], @@ -9037,14 +9004,6 @@ "giget/nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "giget/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], - - "giget/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - - "giget/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - - "giget/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "istanbul-lib-report/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -9149,14 +9108,6 @@ "node-gyp/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "node-gyp/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], - - "node-gyp/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - - "node-gyp/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - - "node-gyp/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "npm-package-arg/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "npm/minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -9451,8 +9402,6 @@ "@electron/rebuild/ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@electron/rebuild/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "@inference/tracing/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.206.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-yIVDu9jX//nV5wSMLZLdHdb1SKHIMj9k+wQVFtln5Flcgdldz9BkHtavvExQiJqBZg2OpEEJEZmzQazYztdz2A=="], "@inference/tracing/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.206.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.206.0", "@opentelemetry/core": "2.1.0", "@opentelemetry/resources": "2.1.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-SQ2yTmqe4Mw9RI3a/glVkfjWPsXh6LySvnljXubiZq4zu+UP8NMJt2j82ZsYb+KpD7Eu+/41/7qlJnjdeVjz7Q=="], @@ -9609,8 +9558,6 @@ "app-builder-lib/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "app-builder-lib/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -9621,8 +9568,6 @@ "cacache/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "cacache/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "config-file-ts/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -9657,8 +9602,6 @@ "fstream/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "giget/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "jest-config/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -9679,8 +9622,6 @@ "node-gyp/glob/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], - "node-gyp/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "npm/minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "npm/minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/package.json b/package.json index d959d95bac..98a4a21af9 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "form-data": "^4.0.6", "ws": "^8.21.0", "engine.io": "^6.6.7", + "tar": "^7.5.19", "semver": "^7.8.5", "hono": "^4.12.25", "@hono/node-server": "^1.19.13", From 42e5ebd789d034ca07ff996985301cc150ea8dc1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:14:56 -0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(isms):=20internal=20audit=20programme,?= =?UTF-8?q?=20plan=20and=20report=20=E2=80=94=20clause=209.2=20(CS-724)=20?= =?UTF-8?q?(#3468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(isms): internal audit programme, plan and report — clause 9.2 (CS-724) Adds the ninth ISMS document type: Compliance > ISMS > Internal Audit. - Programme paragraph (editable, seeded with a default) rendered verbatim into the generated document - Audit instances with server-generated IA-YYYY-NN references, templated scope/criteria, auditor pulled from ISMS > Roles > Internal Auditor, planned dates, status, and a conclusion verdict + narrative - Controls Tested table seeded with the default 15-row sample set (management-system clauses + high-impact Annex A controls); result + notes recorded per row - Findings with server-generated F-NN references, type/owner/due date/status/closure evidence, optional link back to a control row - Three sign-off slots per audit, frozen into the published version snapshot and rendered as the document's sign-off table - Submit gate: at least one audit, and a conclusion verdict on every completed audit (enforced server-side, mirrored in the client) * fix(isms): align internal-audit components with design-system prop types The Vercel next build enforces the design-system unions that the local tsc pass missed: Stack gap has no '5', TableCell omits className, and Select.onValueChange passes string | null. * fix(isms): address internal-audit review findings - scope audit/control/finding lookups to the internal-audit document type - seed the programme paragraph only when the draft narrative is empty, so a concurrent setup call can never overwrite an edit - trim required text fields (scope, criteria, control reference) so whitespace-only values are rejected - marking a control row non-conformity/observation now opens the findings form pre-filled with the linked row (per the ticket flow), and the edit form pre-fills clause text like the add form - preserve unsaved sign-off input across sibling register refreshes - gate the control-row Save button on validity - document the audit register fields in the OpenAPI body schema - cover the internal-audit derivation path in generate.spec * fix(isms): make the programme seed atomic and re-key the finding prompt - seed the programme via a conditional update that matches only a NULL draft narrative, removing the read-then-write race entirely (and with it the duplicated empty-narrative predicate) - key the prefilled linked-finding form by row and type, so re-marking the same control with a different result re-initializes the form * fix(isms): treat an empty-object narrative as seedable, matching generate The programme seed's conditional update now matches NULL or {} — the same empty definition generateNarrative uses — so the two seed paths can never drift while still never overwriting a populated draft. * feat(isms): render closure evidence in the exported findings table Closed corrective actions now show how they were evidenced in the generated document — appended to the description cell, keeping the reference document's seven-column findings table. --------- Co-authored-by: Tofik Hasanov --- apps/api/src/isms/documents/generate.spec.ts | 18 + apps/api/src/isms/documents/generate.ts | 7 + .../isms/documents/internal-audit-defaults.ts | 163 ++++++++ .../internal-audit-export-data.spec.ts | 177 +++++++++ .../documents/internal-audit-export-data.ts | 150 ++++++++ .../src/isms/documents/internal-audit.spec.ts | 352 ++++++++++++++++++ apps/api/src/isms/documents/internal-audit.ts | 285 ++++++++++++++ apps/api/src/isms/documents/registry.ts | 17 +- apps/api/src/isms/documents/snapshot.ts | 3 + apps/api/src/isms/documents/types.ts | 60 +++ .../isms/isms-audit-control.service.spec.ts | 150 ++++++++ .../src/isms/isms-audit-control.service.ts | 165 ++++++++ .../isms/isms-audit-finding.service.spec.ts | 183 +++++++++ .../src/isms/isms-audit-finding.service.ts | 248 ++++++++++++ apps/api/src/isms/isms-audit.service.spec.ts | 219 +++++++++++ apps/api/src/isms/isms-audit.service.ts | 220 +++++++++++ .../isms/isms-registers.controller.spec.ts | 92 +++++ .../api/src/isms/isms-registers.controller.ts | 64 +++- .../api/src/isms/isms-version.service.spec.ts | 1 + apps/api/src/isms/isms-version.service.ts | 3 + apps/api/src/isms/isms.module.ts | 9 + ...isms.service.ensure-setup-fallback.spec.ts | 4 +- .../src/isms/isms.service.lifecycle.spec.ts | 52 +++ apps/api/src/isms/isms.service.spec.ts | 52 ++- apps/api/src/isms/isms.service.ts | 70 +++- .../src/isms/registers/register-registry.ts | 157 ++++++++ .../api/src/isms/utils/document-types.spec.ts | 12 +- apps/api/src/isms/utils/document-types.ts | 7 + apps/api/src/isms/utils/export-payload.ts | 31 ++ .../src/isms/wizard/isms-profile.service.ts | 1 + .../[orgId]/documents/isms/[type]/page.tsx | 38 ++ .../documents/isms/components/AuditCard.tsx | 276 ++++++++++++++ .../isms/components/AuditControlRow.tsx | 282 ++++++++++++++ .../isms/components/AuditControlsTable.tsx | 202 ++++++++++ .../documents/isms/components/AuditFields.tsx | 209 +++++++++++ .../isms/components/AuditFindingFields.tsx | 212 +++++++++++ .../isms/components/AuditFindingRow.tsx | 229 ++++++++++++ .../isms/components/AuditFindingsSection.tsx | 199 ++++++++++ .../isms/components/AuditSignoffCard.tsx | 183 +++++++++ .../documents/isms/components/AuditsList.tsx | 97 +++++ .../ContextOfOrganizationClient.test.tsx | 1 + .../InterestedPartiesClient.test.tsx | 1 + .../components/InternalAuditClient.test.tsx | 295 +++++++++++++++ .../isms/components/InternalAuditClient.tsx | 172 +++++++++ .../components/IsmsApprovalSection.test.tsx | 1 + .../components/IsmsControlMappings.test.tsx | 1 + .../isms/components/LeadershipClient.test.tsx | 1 + .../isms/components/MonitoringClient.test.tsx | 1 + .../isms/components/ObjectivesClient.test.tsx | 1 + .../isms/components/ProgrammeCard.tsx | 136 +++++++ .../components/RequirementsClient.test.tsx | 1 + .../isms/components/ScopeClient.test.tsx | 1 + .../documents/isms/components/audit-schema.ts | 105 ++++++ .../internal-audit-constants.test.ts | 73 ++++ .../components/internal-audit-constants.ts | 131 +++++++ .../documents/isms/hooks/useIsmsDocument.ts | 5 +- .../[orgId]/documents/isms/isms-types.ts | 93 ++++- .../migration.sql | 124 ++++++ packages/db/prisma/schema/isms.prisma | 176 +++++++++ packages/db/prisma/seed/seed.ts | 7 + 60 files changed, 6208 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/isms/documents/internal-audit-defaults.ts create mode 100644 apps/api/src/isms/documents/internal-audit-export-data.spec.ts create mode 100644 apps/api/src/isms/documents/internal-audit-export-data.ts create mode 100644 apps/api/src/isms/documents/internal-audit.spec.ts create mode 100644 apps/api/src/isms/documents/internal-audit.ts create mode 100644 apps/api/src/isms/isms-audit-control.service.spec.ts create mode 100644 apps/api/src/isms/isms-audit-control.service.ts create mode 100644 apps/api/src/isms/isms-audit-finding.service.spec.ts create mode 100644 apps/api/src/isms/isms-audit-finding.service.ts create mode 100644 apps/api/src/isms/isms-audit.service.spec.ts create mode 100644 apps/api/src/isms/isms-audit.service.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditControlRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditControlsTable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditFields.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditFindingFields.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditFindingRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditFindingsSection.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditSignoffCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditsList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/InternalAuditClient.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/InternalAuditClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/ProgrammeCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/audit-schema.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/internal-audit-constants.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/isms/components/internal-audit-constants.ts create mode 100644 packages/db/prisma/migrations/20260721090000_isms_internal_audit/migration.sql diff --git a/apps/api/src/isms/documents/generate.spec.ts b/apps/api/src/isms/documents/generate.spec.ts index 3547f853ec..163aba2e44 100644 --- a/apps/api/src/isms/documents/generate.spec.ts +++ b/apps/api/src/isms/documents/generate.spec.ts @@ -155,4 +155,22 @@ describe('runDerivation', () => { // A regenerate must never clobber the customer's manual narrative. expect(tx.ismsDocument.update).not.toHaveBeenCalled(); }); + + it('seeds the internal-audit programme onto an empty draft (CS-724)', async () => { + const tx = buildTx(); + await runDerivation({ tx: asTx(tx), type: 'internal_audit', ...baseArgs }); + const updated = tx.ismsDocument.update.mock.calls[0][0].data; + expect(updated.draftNarrative.programme).toContain( + 'Acme runs an annual internal audit', + ); + }); + + it('preserves an edited internal-audit programme on regenerate (CS-724)', async () => { + const tx = buildTx(); + tx.ismsDocument.findUnique.mockResolvedValue({ + draftNarrative: { programme: 'Customer-edited programme.' }, + }); + await runDerivation({ tx: asTx(tx), type: 'internal_audit', ...baseArgs }); + expect(tx.ismsDocument.update).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/isms/documents/generate.ts b/apps/api/src/isms/documents/generate.ts index a1a3270587..cfd717f2a8 100644 --- a/apps/api/src/isms/documents/generate.ts +++ b/apps/api/src/isms/documents/generate.ts @@ -262,6 +262,13 @@ export async function runDerivation({ await seedMetricsIfMissing({ tx, documentId }); return; } + if (type === 'internal_audit') { + // Only the Programme paragraph is derivable; audit instances are + // customer-created (their Controls Tested rows seed at audit creation). + // Seed-if-empty, so a regenerate never clobbers an edited programme. + await generateNarrative({ tx, documentId, type, data }); + return; + } if (isNarrativeType(type)) { await generateNarrative({ tx, documentId, type, data }); return; diff --git a/apps/api/src/isms/documents/internal-audit-defaults.ts b/apps/api/src/isms/documents/internal-audit-defaults.ts new file mode 100644 index 0000000000..e0077d626f --- /dev/null +++ b/apps/api/src/isms/documents/internal-audit-defaults.ts @@ -0,0 +1,163 @@ +import type { SeedAuditControlDefinition } from './types'; + +/** + * Default text for the Internal Audit document (clause 9.2, CS-724). Every + * field ships with auditor-defensible template text a layperson can accept + * unedited; the customer may edit any of it. Kept ASCII-safe for the PDF + * renderer (jsPDF standard fonts cannot render characters like ≥). + */ + +/** The Programme paragraph, rendered verbatim into the generated document. */ +export function defaultProgrammeText(organizationName: string): string { + return `${organizationName} runs an annual internal audit of the whole ISMS. Each audit checks conformity to ISO/IEC 27001:2022 and effective implementation of the controls in the Statement of Applicability. Findings are tracked to closure through the platform and reviewed at the next management review.`; +} + +/** Default Scope for a new audit instance. */ +export const DEFAULT_AUDIT_SCOPE = + 'The whole ISMS as defined in the ISMS Scope Statement (Clause 4.3).'; + +/** Default Criteria for a new audit instance. */ +export const DEFAULT_AUDIT_CRITERIA = + 'ISO/IEC 27001:2022 and the Statement of Applicability.'; + +/** + * The bracketed verdict choices in the conclusion template: "Overall, this + * audit found the ISMS to [...] to ISO/IEC 27001:2022." + */ +export const CONCLUSION_VERDICT_TEXT: Record = { + conform: 'conform', + substantially_conform: + 'substantially conform with the non-conformities recorded below', + not_yet_conform: 'not yet conform', +}; + +/** Assemble the rendered conclusion sentence for a chosen verdict. */ +export function conclusionSentence(verdict: string): string { + const choice = CONCLUSION_VERDICT_TEXT[verdict] ?? verdict; + return `Overall, this audit found the ISMS to ${choice} to ISO/IEC 27001:2022. Corrective actions are tracked in the findings table.`; +} + +/** + * The fifteen default Controls Tested rows seeded per audit, covering the + * most-probed management-system clauses and high-impact Annex A controls. + * "Where to find it" entries begin with "Comp AI" to signal the default + * location; the customer overwrites the field when evidence lives elsewhere. + * Seeding is idempotent by controlKey (seedAuditControlsIfMissing) and never + * overwrites customer edits. + */ +export const SEED_AUDIT_CONTROL_DEFINITIONS: SeedAuditControlDefinition[] = [ + { + controlKey: 'clause_4_1_context', + controlRef: 'Clause 4.1 Context', + whatWasTested: + 'Whether the org has identified internal and external issues affecting the ISMS.', + whereToFind: 'Comp AI > ISMS > Documents > Context of the Organization', + }, + { + controlKey: 'clause_4_3_scope', + controlRef: 'Clause 4.3 Scope', + whatWasTested: + 'Whether the ISMS scope is documented, current, and reflects the certificate scope statement.', + whereToFind: 'Comp AI > ISMS > Documents > ISMS Scope Statement', + }, + { + controlKey: 'clause_5_2_policy', + controlRef: 'Clause 5.2 Policy', + whatWasTested: + 'Whether the information security policy is documented, approved, and communicated.', + whereToFind: 'Comp AI > Policies > Information Security & Privacy Governance', + }, + { + controlKey: 'clause_6_1_risk', + controlRef: 'Clause 6.1 Risk', + whatWasTested: + 'Whether risks affecting the ISMS are identified, assessed, and have a recorded treatment.', + whereToFind: 'Comp AI > Risks (risk register)', + }, + { + controlKey: 'clause_7_2_competence', + controlRef: 'Clause 7.2 Competence', + whatWasTested: + 'Whether the competence of persons doing ISMS work is defined and evidenced.', + whereToFind: 'Comp AI > ISMS > Roles > per-holder competence records', + }, + { + controlKey: 'clause_8_1_operational_planning', + controlRef: 'Clause 8.1 Operational planning', + whatWasTested: + 'Whether ISMS processes have been planned, implemented, and controlled.', + whereToFind: 'Comp AI > Policies + Controls + Tasks', + }, + { + controlKey: 'clause_9_1_monitoring', + controlRef: 'Clause 9.1 Monitoring', + whatWasTested: + 'Whether info-security performance and ISMS effectiveness are being measured.', + whereToFind: 'Comp AI > ISMS > Monitoring', + }, + { + controlKey: 'clause_9_3_management_review', + controlRef: 'Clause 9.3 Management review', + whatWasTested: + 'Whether top management reviews the ISMS at planned intervals with required inputs and outputs.', + whereToFind: 'Comp AI > ISMS > Management Review', + }, + { + controlKey: 'a_5_1_policies', + controlRef: 'A.5.1 Policies', + whatWasTested: + 'Whether an information security policy set is defined, approved, published and reviewed.', + whereToFind: 'Comp AI > Policies (full list)', + }, + { + controlKey: 'a_5_15_access_control', + controlRef: 'A.5.15 Access control', + whatWasTested: + 'Whether access rights are granted, reviewed and removed on a defined basis.', + whereToFind: + 'Comp AI > Policies > Access Control & Least Privilege + related evidence tasks', + }, + { + controlKey: 'a_5_19_supplier_relationships', + controlRef: 'A.5.19 Supplier relationships', + whatWasTested: + 'Whether information-security risks associated with suppliers are identified and managed.', + whereToFind: + 'Comp AI > Vendors > vendor register + Comp AI > Policies > Vendor & Third-Party Risk', + }, + { + controlKey: 'a_5_24_incident_management', + controlRef: 'A.5.24 Incident management', + whatWasTested: + 'Whether an information-security incident management process is defined and evidenced.', + whereToFind: + 'Comp AI > Policies > Incident Response & Breach Notification + Comp AI > Findings', + }, + { + controlKey: 'a_8_7_malware', + controlRef: 'A.8.7 Malware', + whatWasTested: + 'Whether protection against malware is in place and supported by user awareness.', + whereToFind: + 'Comp AI > Policies > Security Configuration Hardening & Anti-Malware', + }, + { + controlKey: 'a_8_13_backup', + controlRef: 'A.8.13 Backup', + whatWasTested: + 'Whether backups of information, software and systems are taken and tested.', + whereToFind: + "Comp AI > Evidence tasks tagged 'backup' + Comp AI > Policies > Backup, Business Continuity & DR", + }, + { + controlKey: 'a_8_24_cryptography', + controlRef: 'A.8.24 Cryptography', + whatWasTested: + 'Whether rules for the effective use of cryptography are defined and applied.', + whereToFind: 'Comp AI > Policies > Encryption & Crypto Controls', + }, +]; + +export const SEED_AUDIT_CONTROL_KEYS = SEED_AUDIT_CONTROL_DEFINITIONS.map( + (control) => control.controlKey, +); diff --git a/apps/api/src/isms/documents/internal-audit-export-data.spec.ts b/apps/api/src/isms/documents/internal-audit-export-data.spec.ts new file mode 100644 index 0000000000..aca27998a5 --- /dev/null +++ b/apps/api/src/isms/documents/internal-audit-export-data.spec.ts @@ -0,0 +1,177 @@ +import { + loadInternalAuditExtras, + mapAudits, + type AuditWithExportIncludes, +} from './internal-audit-export-data'; + +jest.mock('@db', () => ({ db: {} })); + +const extras = { + memberNames: { mem_1: 'Alex Petrisor', mem_2: 'jane@acme.io' }, +}; + +const baseAudit = { + id: 'aud_1', + documentId: 'doc_1', + reference: 'IA-2026-01', + scope: 'The whole ISMS.', + criteria: 'ISO/IEC 27001:2022 and the SoA.', + auditorName: 'Sarah Chen, Assured Compliance Ltd', + plannedStartDate: new Date('2026-05-15T00:00:00.000Z'), + plannedEndDate: new Date('2026-05-20T00:00:00.000Z'), + status: 'complete', + conclusionVerdict: 'substantially_conform', + conclusionNotes: 'Ready for Stage 2.', + signoffAuditorName: 'Sarah Chen', + signoffAuditorDate: new Date('2026-05-20T00:00:00.000Z'), + signoffSpoName: null, + signoffSpoDate: null, + signoffTopMgmtName: null, + signoffTopMgmtDate: null, + position: 0, + controls: [ + { + id: 'ac_1', + controlRef: 'Clause 6.1 Risk', + whatWasTested: 'Whether risks are identified and treated.', + whereToFind: 'Comp AI > Risks (risk register)', + result: 'observation_raised', + notes: 'See F-03.', + }, + { + id: 'ac_2', + controlRef: 'A.8.24 Cryptography', + whatWasTested: 'Whether crypto rules are applied.', + whereToFind: 'Comp AI > Policies > Encryption & Crypto Controls', + result: null, + notes: null, + }, + ], + findings: [ + { + id: 'af_1', + reference: 'F-01', + type: 'nc_minor', + clauseOrControl: 'Clause 9.1 (Monitoring)', + description: 'Three metrics unmeasured for 90 days.', + ownerMemberId: 'mem_1', + dueDate: new Date('2026-06-15T00:00:00.000Z'), + status: 'open', + closureEvidence: null, + }, + { + id: 'af_2', + reference: 'F-02', + type: 'ofi', + clauseOrControl: null, + description: 'Document the quarterly access review.', + ownerMemberId: 'mem_gone', + dueDate: null, + status: 'in_progress', + closureEvidence: null, + }, + ], +} as unknown as AuditWithExportIncludes; + +describe('mapAudits', () => { + it('maps an audit with humanized labels, dates and the conclusion sentence', () => { + const [row] = mapAudits([baseAudit], extras); + + expect(row.reference).toBe('IA-2026-01'); + expect(row.status).toBe('Complete'); + expect(row.plannedStartDate).toBe('2026-05-15'); + expect(row.plannedEndDate).toBe('2026-05-20'); + expect(row.conclusion).toBe( + 'Overall, this audit found the ISMS to substantially conform with the non-conformities recorded below to ISO/IEC 27001:2022. Corrective actions are tracked in the findings table.', + ); + expect(row.conclusionNotes).toBe('Ready for Stage 2.'); + }); + + it('maps control rows, dashing an unset result', () => { + const [row] = mapAudits([baseAudit], extras); + expect(row.controls).toEqual([ + { + controlRef: 'Clause 6.1 Risk', + whatWasTested: 'Whether risks are identified and treated.', + whereToFind: 'Comp AI > Risks (risk register)', + result: 'Observation raised', + notes: 'See F-03.', + }, + { + controlRef: 'A.8.24 Cryptography', + whatWasTested: 'Whether crypto rules are applied.', + whereToFind: 'Comp AI > Policies > Encryption & Crypto Controls', + result: '—', + notes: '', + }, + ]); + }); + + it('resolves finding owners, falling back for removed members', () => { + const [row] = mapAudits([baseAudit], extras); + expect(row.findings[0]).toMatchObject({ + reference: 'F-01', + type: 'NC minor', + ownerName: 'Alex Petrisor', + dueDate: '2026-06-15', + status: 'Open', + }); + expect(row.findings[1]).toMatchObject({ + type: 'OFI', + ownerName: 'Former member', + dueDate: '', + status: 'In progress', + }); + }); + + it('builds the three fixed sign-off slots in reference-document order', () => { + const [row] = mapAudits([baseAudit], extras); + expect(row.signoffs).toEqual([ + { role: 'Auditor', name: 'Sarah Chen', date: '2026-05-20' }, + { role: 'Information Security Manager / SPO', name: '', date: '' }, + { role: 'Top Management', name: '', date: '' }, + ]); + }); + + it('renders a null conclusion while no verdict is chosen', () => { + const [row] = mapAudits( + [ + { + ...baseAudit, + conclusionVerdict: null, + } as unknown as AuditWithExportIncludes, + ], + extras, + ); + expect(row.conclusion).toBeNull(); + }); +}); + +describe('loadInternalAuditExtras', () => { + it('resolves member display names with the name → email → placeholder fallback', async () => { + const client = { + member: { + findMany: jest.fn().mockResolvedValue([ + { id: 'mem_1', user: { name: 'Alex Petrisor', email: 'a@x.io' } }, + { id: 'mem_2', user: { name: null, email: 'jane@acme.io' } }, + { id: 'mem_3', user: null }, + ]), + }, + }; + + const result = await loadInternalAuditExtras({ + organizationId: 'org_1', + client: client as never, + }); + + expect(client.member.findMany).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + select: { id: true, user: { select: { name: true, email: true } } }, + }); + expect(result.memberNames).toEqual({ + mem_1: 'Alex Petrisor', + mem_2: 'jane@acme.io', + mem_3: 'Unknown member', + }); + }); +}); diff --git a/apps/api/src/isms/documents/internal-audit-export-data.ts b/apps/api/src/isms/documents/internal-audit-export-data.ts new file mode 100644 index 0000000000..f5058b8af6 --- /dev/null +++ b/apps/api/src/isms/documents/internal-audit-export-data.ts @@ -0,0 +1,150 @@ +import { db } from '@db'; +import type { Prisma } from '@db'; +import { conclusionSentence } from './internal-audit-defaults'; +import type { AuditExportRow, AuditSignoffExportRow } from './types'; + +/** + * Extra data the Internal Audit document (9.2) needs at export time but that + * isn't on the audit rows: display names for finding owners (findings store a + * plain memberId, no FK). Resolved once and frozen into the version snapshot + * so a historical export re-renders byte-faithfully with the names that were + * current at publish. + */ +export interface InternalAuditExtras { + /** memberId → display name (name, else email, else a placeholder). */ + memberNames: Record; +} + +type Client = Prisma.TransactionClient | typeof db; + +function memberDisplayName( + user: { name: string | null; email: string | null } | null, +): string { + return user?.name?.trim() || user?.email?.trim() || 'Unknown member'; +} + +/** Load the Internal Audit document's export extras for an organization. */ +export async function loadInternalAuditExtras({ + organizationId, + client, +}: { + organizationId: string; + client?: Client; +}): Promise { + const prisma = client ?? db; + + const members = await prisma.member.findMany({ + where: { organizationId }, + select: { id: true, user: { select: { name: true, email: true } } }, + }); + + const memberNames: Record = {}; + for (const member of members) { + memberNames[member.id] = memberDisplayName(member.user); + } + + return { memberNames }; +} + +/** The audit shape mapAudits consumes (EXPORT_DOCUMENT_INCLUDE's audits). */ +export type AuditWithExportIncludes = Prisma.IsmsAuditGetPayload<{ + include: { controls: true; findings: true }; +}>; + +export const AUDIT_STATUS_LABELS: Record = { + planned: 'Planned', + in_progress: 'In progress', + complete: 'Complete', +}; + +export const CONTROL_RESULT_LABELS: Record = { + conformity_confirmed: 'Conformity confirmed', + nonconformity_raised: 'Non-conformity raised', + observation_raised: 'Observation raised', + not_sampled: 'Not sampled this cycle', +}; + +export const FINDING_TYPE_LABELS: Record = { + nc_major: 'NC major', + nc_minor: 'NC minor', + ofi: 'OFI', + observation: 'Observation', +}; + +export const FINDING_STATUS_LABELS: Record = { + open: 'Open', + in_progress: 'In progress', + closed: 'Closed', +}; + +function formatDateYmd(date: Date | null): string | null { + return date ? date.toISOString().slice(0, 10) : null; +} + +/** The three fixed sign-off slots, in the order the reference document uses. */ +function mapSignoffs(audit: AuditWithExportIncludes): AuditSignoffExportRow[] { + return [ + { + role: 'Auditor', + name: audit.signoffAuditorName ?? '', + date: formatDateYmd(audit.signoffAuditorDate) ?? '', + }, + { + role: 'Information Security Manager / SPO', + name: audit.signoffSpoName ?? '', + date: formatDateYmd(audit.signoffSpoDate) ?? '', + }, + { + role: 'Top Management', + name: audit.signoffTopMgmtName ?? '', + date: formatDateYmd(audit.signoffTopMgmtDate) ?? '', + }, + ]; +} + +/** + * Map audit rows (with their controls and findings) into export rows, + * resolving owner names and humanizing enum labels. All audits render — a + * planned audit appears with its plan table and an empty report, which is the + * honest state of the programme. + */ +export function mapAudits( + audits: AuditWithExportIncludes[], + extras: InternalAuditExtras, +): AuditExportRow[] { + return audits.map((audit) => ({ + reference: audit.reference, + scope: audit.scope, + criteria: audit.criteria, + auditorName: audit.auditorName ?? '', + plannedStartDate: formatDateYmd(audit.plannedStartDate), + plannedEndDate: formatDateYmd(audit.plannedEndDate), + status: AUDIT_STATUS_LABELS[audit.status] ?? audit.status, + conclusion: audit.conclusionVerdict + ? conclusionSentence(audit.conclusionVerdict) + : null, + conclusionNotes: audit.conclusionNotes, + controls: audit.controls.map((control) => ({ + controlRef: control.controlRef, + whatWasTested: control.whatWasTested, + whereToFind: control.whereToFind, + result: control.result + ? (CONTROL_RESULT_LABELS[control.result] ?? control.result) + : '—', + notes: control.notes ?? '', + })), + findings: audit.findings.map((finding) => ({ + reference: finding.reference, + type: FINDING_TYPE_LABELS[finding.type] ?? finding.type, + clauseOrControl: finding.clauseOrControl ?? '', + description: finding.description, + ownerName: finding.ownerMemberId + ? (extras.memberNames[finding.ownerMemberId] ?? 'Former member') + : '', + dueDate: formatDateYmd(finding.dueDate) ?? '', + status: FINDING_STATUS_LABELS[finding.status] ?? finding.status, + closureEvidence: finding.closureEvidence ?? '', + })), + signoffs: mapSignoffs(audit), + })); +} diff --git a/apps/api/src/isms/documents/internal-audit.spec.ts b/apps/api/src/isms/documents/internal-audit.spec.ts new file mode 100644 index 0000000000..529fbf10f8 --- /dev/null +++ b/apps/api/src/isms/documents/internal-audit.spec.ts @@ -0,0 +1,352 @@ +import type { Prisma } from '@db'; +import { + auditValidationMessages, + buildInternalAuditSections, + deriveInternalAuditNarrative, + seedAuditControlsIfMissing, +} from './internal-audit'; +import { SEED_AUDIT_CONTROL_DEFINITIONS } from './internal-audit-defaults'; +import type { AuditExportRow, DocumentExportInput, IsmsPlatformData } from './types'; + +const baseInput: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: { programme: 'Acme runs an annual internal audit of the whole ISMS.' }, +}; + +const audit: AuditExportRow = { + reference: 'IA-2026-01', + scope: 'The whole ISMS as defined in the ISMS Scope Statement (Clause 4.3).', + criteria: 'ISO/IEC 27001:2022 and the Statement of Applicability.', + auditorName: 'Sarah Chen, Assured Compliance Ltd', + plannedStartDate: '2026-05-15', + plannedEndDate: '2026-05-20', + status: 'Complete', + conclusion: + 'Overall, this audit found the ISMS to substantially conform with the non-conformities recorded below to ISO/IEC 27001:2022. Corrective actions are tracked in the findings table.', + conclusionNotes: null, + controls: [ + { + controlRef: 'Clause 9.1 Monitoring', + whatWasTested: 'Whether info-security performance is being measured.', + whereToFind: 'Comp AI > ISMS > Monitoring', + result: 'Non-conformity raised', + notes: 'Three metrics overdue. See F-01.', + }, + ], + findings: [ + { + reference: 'F-01', + type: 'NC minor', + clauseOrControl: 'Clause 9.1 (Monitoring)', + description: 'Three of nine metrics have no measurement in 90 days.', + ownerName: 'Alex Petrisor', + dueDate: '2026-06-15', + status: 'Open', + closureEvidence: '', + }, + ], + signoffs: [ + { role: 'Auditor', name: 'Sarah Chen', date: '2026-05-20' }, + { role: 'Information Security Manager / SPO', name: '', date: '' }, + { role: 'Top Management', name: 'Raoul Plickat', date: '2026-05-22' }, + ], +}; + +describe('auditValidationMessages (clause 9.2 submit gate)', () => { + it('requires at least one audit', () => { + expect(auditValidationMessages({ audits: [] })).toEqual([ + 'At least one internal audit must be recorded.', + ]); + }); + + it('requires a conclusion verdict on completed audits only', () => { + const messages = auditValidationMessages({ + audits: [ + { reference: 'IA-2026-01', status: 'complete', conclusionVerdict: null }, + { reference: 'IA-2026-02', status: 'in_progress', conclusionVerdict: null }, + ], + }); + expect(messages).toEqual([ + 'Audit IA-2026-01 is complete but has no conclusion verdict.', + ]); + }); + + it('passes with a planned audit or a concluded complete audit', () => { + expect( + auditValidationMessages({ + audits: [ + { reference: 'IA-2026-01', status: 'planned', conclusionVerdict: null }, + ], + }), + ).toEqual([]); + expect( + auditValidationMessages({ + audits: [ + { + reference: 'IA-2026-01', + status: 'complete', + conclusionVerdict: 'conform', + }, + ], + }), + ).toEqual([]); + }); +}); + +describe('deriveInternalAuditNarrative', () => { + it('templates the programme paragraph with the organization name', () => { + const narrative = deriveInternalAuditNarrative({ + organizationName: 'Acme Corp', + } as IsmsPlatformData); + expect(narrative.programme).toContain('Acme Corp runs an annual internal audit'); + expect(narrative.programme).toContain('ISO/IEC 27001:2022'); + }); +}); + +describe('seedAuditControlsIfMissing', () => { + const makeTx = ( + existing: Array<{ controlKey: string | null; position: number }>, + ) => { + const tx = { + ismsAuditControl: { + findMany: jest.fn().mockResolvedValue(existing), + createMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; + return tx as unknown as Prisma.TransactionClient & typeof tx; + }; + + it('seeds all fifteen defaults into a new audit', async () => { + const tx = makeTx([]); + await seedAuditControlsIfMissing({ + tx, + auditId: 'aud_1', + documentId: 'doc_1', + }); + + const { data, skipDuplicates } = ( + tx.ismsAuditControl.createMany as jest.Mock + ).mock.calls[0][0]; + expect(skipDuplicates).toBe(true); + expect(data).toHaveLength(15); + expect(data.map((row: { controlKey: string }) => row.controlKey)).toEqual( + SEED_AUDIT_CONTROL_DEFINITIONS.map((control) => control.controlKey), + ); + expect(data[0]).toMatchObject({ + auditId: 'aud_1', + documentId: 'doc_1', + source: 'derived', + derivedFrom: `seed:${SEED_AUDIT_CONTROL_DEFINITIONS[0].controlKey}`, + position: 0, + }); + // Result deliberately unset: the auditor records it per row. + expect(data[0].result).toBeUndefined(); + }); + + it('creates only the missing seeds, after existing positions', async () => { + const tx = makeTx([ + { controlKey: 'clause_4_1_context', position: 0 }, + { controlKey: null, position: 20 }, // custom row + ]); + await seedAuditControlsIfMissing({ + tx, + auditId: 'aud_1', + documentId: 'doc_1', + }); + + const { data } = (tx.ismsAuditControl.createMany as jest.Mock).mock + .calls[0][0]; + expect(data).toHaveLength(14); + expect( + data.map((row: { controlKey: string }) => row.controlKey), + ).not.toContain('clause_4_1_context'); + expect(data[0].position).toBe(21); + }); + + it('is a no-op when every seed already exists (never deletes/overwrites)', async () => { + const tx = makeTx( + SEED_AUDIT_CONTROL_DEFINITIONS.map((control, index) => ({ + controlKey: control.controlKey, + position: index, + })), + ); + await seedAuditControlsIfMissing({ + tx, + auditId: 'aud_1', + documentId: 'doc_1', + }); + expect(tx.ismsAuditControl.createMany).not.toHaveBeenCalled(); + }); +}); + +describe('buildInternalAuditSections', () => { + it('renders the reference-document sections in order for a single audit', () => { + const sections = buildInternalAuditSections({ + ...baseInput, + audits: [audit], + }); + expect(sections.map((section) => section.heading)).toEqual([ + 'Purpose', + 'Programme', + 'Audit plan', + 'Controls Tested', + 'Findings', + 'Conclusion', + 'Sign-off', + ]); + }); + + it('suffixes per-audit headings with the reference when several audits exist', () => { + const sections = buildInternalAuditSections({ + ...baseInput, + audits: [audit, { ...audit, reference: 'IA-2027-01' }], + }); + const headings = sections.map((section) => section.heading); + expect(headings).toContain('Audit plan — IA-2026-01'); + expect(headings).toContain('Sign-off — IA-2027-01'); + }); + + it('renders the programme paragraph verbatim and an empty-audits placeholder', () => { + const sections = buildInternalAuditSections({ ...baseInput, audits: [] }); + expect(sections[1].paragraphs?.[0]?.text).toBe( + 'Acme runs an annual internal audit of the whole ISMS.', + ); + expect(sections[2].heading).toBe('Audits'); + expect(sections[2].emptyText).toBe('No internal audits recorded yet.'); + }); + + it('shows a programme placeholder when the narrative is missing or invalid', () => { + const sections = buildInternalAuditSections({ + ...baseInput, + narrative: null, + audits: [], + }); + expect(sections[1].emptyText).toBe('No audit programme recorded.'); + }); + + it('renders the audit plan as label/value rows', () => { + const sections = buildInternalAuditSections({ + ...baseInput, + audits: [audit], + }); + expect(sections[2].keyValues).toEqual([ + { label: 'Reference', value: 'IA-2026-01' }, + { + label: 'Scope', + value: + 'The whole ISMS as defined in the ISMS Scope Statement (Clause 4.3).', + }, + { + label: 'Criteria', + value: 'ISO/IEC 27001:2022 and the Statement of Applicability.', + }, + { label: 'Auditor', value: 'Sarah Chen, Assured Compliance Ltd' }, + { label: 'Planned start date', value: '2026-05-15' }, + { label: 'Planned end date', value: '2026-05-20' }, + { label: 'Status', value: 'Complete' }, + ]); + }); + + it('renders the five-column Controls Tested table', () => { + const sections = buildInternalAuditSections({ + ...baseInput, + audits: [audit], + }); + const table = sections[3].table; + expect(table?.headers).toEqual([ + 'Control reference', + 'What was tested', + 'Where to find it', + 'Result', + 'Notes', + ]); + expect(table?.rows).toEqual([ + [ + 'Clause 9.1 Monitoring', + 'Whether info-security performance is being measured.', + 'Comp AI > ISMS > Monitoring', + 'Non-conformity raised', + 'Three metrics overdue. See F-01.', + ], + ]); + }); + + it('renders the seven-column findings table, or "No findings raised."', () => { + const withFindings = buildInternalAuditSections({ + ...baseInput, + audits: [audit], + }); + expect(withFindings[4].table?.headers).toEqual([ + 'Ref', + 'Type', + 'Clause / control', + 'Description', + 'Owner', + 'Due date', + 'Status', + ]); + + const noFindings = buildInternalAuditSections({ + ...baseInput, + audits: [{ ...audit, findings: [] }], + }); + expect(noFindings[4].emptyText).toBe('No findings raised.'); + expect(noFindings[4].table).toBeUndefined(); + // No intro either: the renderers show emptyText only for empty sections. + expect(noFindings[4].intro).toBeUndefined(); + }); + + it('appends closure evidence to a finding description in the export', () => { + const sections = buildInternalAuditSections({ + ...baseInput, + audits: [ + { + ...audit, + findings: [ + { + ...audit.findings[0], + status: 'Closed', + closureEvidence: 'Restore test evidenced in task ev_123.', + }, + ], + }, + ], + }); + expect(sections[4].table?.rows[0][3]).toBe( + 'Three of nine metrics have no measurement in 90 days. — Closure evidence: Restore test evidenced in task ev_123.', + ); + }); + + it('renders the conclusion sentence plus notes, or a placeholder', () => { + const withNotes = buildInternalAuditSections({ + ...baseInput, + audits: [{ ...audit, conclusionNotes: 'Ready for Stage 2.' }], + }); + expect(withNotes[5].paragraphs?.map((paragraph) => paragraph.text)).toEqual([ + audit.conclusion, + 'Ready for Stage 2.', + ]); + + const noVerdict = buildInternalAuditSections({ + ...baseInput, + audits: [{ ...audit, conclusion: null, conclusionNotes: null }], + }); + expect(noVerdict[5].emptyText).toBe('No conclusion recorded yet.'); + }); + + it('renders the three sign-off slots with dashes for unsigned slots', () => { + const sections = buildInternalAuditSections({ + ...baseInput, + audits: [audit], + }); + expect(sections[6].table?.headers).toEqual(['Role', 'Signatory', 'Date']); + expect(sections[6].table?.rows).toEqual([ + ['Auditor', 'Sarah Chen', '2026-05-20'], + ['Information Security Manager / SPO', '—', '—'], + ['Top Management', 'Raoul Plickat', '2026-05-22'], + ]); + }); +}); diff --git a/apps/api/src/isms/documents/internal-audit.ts b/apps/api/src/isms/documents/internal-audit.ts new file mode 100644 index 0000000000..03e34de7ae --- /dev/null +++ b/apps/api/src/isms/documents/internal-audit.ts @@ -0,0 +1,285 @@ +import { z } from 'zod'; +import type { Prisma } from '@db'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { + AuditExportRow, + DocumentExportInput, + IsmsPlatformData, +} from './types'; +import { + defaultProgrammeText, + SEED_AUDIT_CONTROL_DEFINITIONS, +} from './internal-audit-defaults'; + +type Tx = Prisma.TransactionClient; + +/** + * Narrative shape persisted on the Internal Audit document (clause 9.2): the + * Programme paragraph shown at the top of the page and rendered verbatim into + * the generated document. The audits themselves live in their own registers. + */ +export const internalAuditNarrativeSchema = z.object({ + programme: z.string().trim().min(1), +}); + +export type InternalAuditNarrative = z.infer< + typeof internalAuditNarrativeSchema +>; + +/** Derive the default Programme paragraph (hardcoded default, editable). */ +export function deriveInternalAuditNarrative( + data: IsmsPlatformData, +): InternalAuditNarrative { + return { programme: defaultProgrammeText(data.organizationName) }; +} + +/** + * Clause-9.2 completeness check, shared by the submit-for-approval server gate + * and the client Submit button (internal-audit-constants.ts mirrors it). + * Requires at least one audit instance (an ISMS with no internal audit is the + * Stage-2 blocker this feature exists to remove) and a conclusion verdict on + * every completed audit (the document would otherwise render a conclusion with + * an unselected bracket). Returns unmet requirements; empty = ready. + */ +export function auditValidationMessages({ + audits, +}: { + audits: Array<{ + reference: string; + status: string; + conclusionVerdict: string | null; + }>; +}): string[] { + if (audits.length === 0) { + return ['At least one internal audit must be recorded.']; + } + return audits + .filter((audit) => audit.status === 'complete' && !audit.conclusionVerdict) + .map( + (audit) => + `Audit ${audit.reference} is complete but has no conclusion verdict.`, + ); +} + +/** + * Seed the fifteen default Controls Tested rows for an audit, idempotently by + * `controlKey`. Only creates seed rows that are missing — it NEVER deletes or + * overwrites, so re-running can never clobber the customer's edits, results, + * or notes (same guarantee as seedMetricsIfMissing). Called when an audit + * instance is created. + */ +export async function seedAuditControlsIfMissing({ + tx, + auditId, + documentId, +}: { + tx: Tx; + auditId: string; + documentId: string; +}): Promise { + const existing = await tx.ismsAuditControl.findMany({ + where: { auditId }, + select: { controlKey: true, position: true }, + }); + const existingKeys = new Set( + existing + .map((control) => control.controlKey) + .filter((key): key is string => !!key), + ); + const missing = SEED_AUDIT_CONTROL_DEFINITIONS.filter( + (control) => !existingKeys.has(control.controlKey), + ); + if (missing.length === 0) return; + + const maxPosition = existing.reduce( + (max, control) => Math.max(max, control.position), + -1, + ); + + await tx.ismsAuditControl.createMany({ + data: missing.map((control, index) => ({ + auditId, + documentId, + controlKey: control.controlKey, + controlRef: control.controlRef, + whatWasTested: control.whatWasTested, + whereToFind: control.whereToFind, + source: 'derived' as const, + derivedFrom: `seed:${control.controlKey}`, + position: maxPosition + 1 + index, + })), + // Belt-and-braces with @@unique([auditId, controlKey]): a concurrent + // create racing this seed is absorbed silently. + skipDuplicates: true, + }); +} + +// ---- Export section builder ------------------------------------------------- + +function parseProgramme(narrative: unknown): string | null { + const parsed = internalAuditNarrativeSchema.safeParse(narrative); + return parsed.success ? parsed.data.programme : null; +} + +/** Per-audit sections: plan table, Controls Tested, Findings, Conclusion, Sign-off. */ +function buildAuditSections( + audit: AuditExportRow, + suffix: string, +): IsmsExportSection[] { + const sections: IsmsExportSection[] = [ + { + heading: `Audit plan${suffix}`, + keyValues: [ + { label: 'Reference', value: audit.reference }, + { label: 'Scope', value: audit.scope }, + { label: 'Criteria', value: audit.criteria }, + { label: 'Auditor', value: audit.auditorName || '—' }, + { label: 'Planned start date', value: audit.plannedStartDate ?? '—' }, + { label: 'Planned end date', value: audit.plannedEndDate ?? '—' }, + { label: 'Status', value: audit.status }, + ], + }, + ]; + + // The renderers show emptyText only when a section has no other content, so + // the intro is included only when there are rows to introduce. + if (audit.controls.length > 0) { + sections.push({ + heading: `Controls Tested${suffix}`, + intro: + 'Each control below was sampled by the auditor. The "Where to find it" column names the location the auditor referenced. The "Result" column records the auditor\'s finding for each control. Rows marked "Not sampled this cycle" were deliberately scoped out of this audit.', + table: { + headers: [ + 'Control reference', + 'What was tested', + 'Where to find it', + 'Result', + 'Notes', + ], + rows: audit.controls.map((control) => [ + control.controlRef, + control.whatWasTested, + control.whereToFind, + control.result, + control.notes, + ]), + }, + }); + } else { + sections.push({ + heading: `Controls Tested${suffix}`, + emptyText: 'No controls recorded for this audit.', + }); + } + + if (audit.findings.length > 0) { + sections.push({ + heading: `Findings${suffix}`, + intro: + 'Non-conformities, opportunities for improvement (OFIs), and observations raised during this audit. Each is tracked to closure in Comp AI.', + table: { + headers: [ + 'Ref', + 'Type', + 'Clause / control', + 'Description', + 'Owner', + 'Due date', + 'Status', + ], + rows: audit.findings.map((finding) => [ + finding.reference, + finding.type, + finding.clauseOrControl, + // Closure evidence rides in the description cell so the exported + // record shows how a corrective action was evidenced without + // widening the reference document's seven-column table. + finding.closureEvidence + ? `${finding.description} — Closure evidence: ${finding.closureEvidence}` + : finding.description, + finding.ownerName, + finding.dueDate, + finding.status, + ]), + }, + }); + } else { + sections.push({ + heading: `Findings${suffix}`, + emptyText: 'No findings raised.', + }); + } + + sections.push( + audit.conclusion + ? { + heading: `Conclusion${suffix}`, + paragraphs: [ + { text: audit.conclusion }, + ...(audit.conclusionNotes ? [{ text: audit.conclusionNotes }] : []), + ], + } + : { + heading: `Conclusion${suffix}`, + emptyText: 'No conclusion recorded yet.', + }, + { + heading: `Sign-off${suffix}`, + table: { + headers: ['Role', 'Signatory', 'Date'], + rows: audit.signoffs.map((signoff) => [ + signoff.role, + signoff.name || '—', + signoff.date || '—', + ]), + }, + }, + ); + + return sections; +} + +/** + * Build the Internal Audit Programme, Plan and Report document (clause 9.2). + * Contents and order follow the CS-724 ticket and reference document: Purpose, + * Programme, then per audit its plan, Controls Tested, Findings, Conclusion, + * and Sign-off. `audits` (names and labels already resolved) is populated by + * loadInternalAuditExtras at export-input assembly (see + * internal-audit-export-data.ts). With a single audit the headings match the + * reference document verbatim; with several, each block carries its reference. + */ +export function buildInternalAuditSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const audits = input.audits ?? []; + const programme = parseProgramme(input.narrative); + + const sections: IsmsExportSection[] = [ + { + heading: 'Purpose', + paragraphs: [ + { + text: 'This document records the internal audit programme and the results of the internal audits of the Information Security Management System, in accordance with ISO/IEC 27001:2022, Clause 9.2. It is retained as documented information and made available to the certification body on request.', + }, + ], + }, + programme + ? { heading: 'Programme', paragraphs: [{ text: programme }] } + : { heading: 'Programme', emptyText: 'No audit programme recorded.' }, + ]; + + if (audits.length === 0) { + sections.push({ + heading: 'Audits', + emptyText: 'No internal audits recorded yet.', + }); + return sections; + } + + for (const audit of audits) { + const suffix = audits.length > 1 ? ` — ${audit.reference}` : ''; + sections.push(...buildAuditSections(audit, suffix)); + } + + return sections; +} diff --git a/apps/api/src/isms/documents/registry.ts b/apps/api/src/isms/documents/registry.ts index 759e8b0e67..4f9d1cb298 100644 --- a/apps/api/src/isms/documents/registry.ts +++ b/apps/api/src/isms/documents/registry.ts @@ -7,6 +7,11 @@ import { buildRequirementsSections } from './requirements'; import { buildObjectivesSections } from './objectives'; import { buildRolesSections } from './roles'; import { buildMonitoringSections } from './monitoring'; +import { + buildInternalAuditSections, + deriveInternalAuditNarrative, + internalAuditNarrativeSchema, +} from './internal-audit'; import { buildScopeSections, deriveScopeNarrative, @@ -35,6 +40,7 @@ const EXPORT_SECTION_BUILDERS: Record< objectives_plan: buildObjectivesSections, roles_and_responsibilities: buildRolesSections, monitoring: buildMonitoringSections, + internal_audit: buildInternalAuditSections, isms_scope: buildScopeSections, leadership_commitment: buildLeadershipSections, }; @@ -49,16 +55,22 @@ export function buildExportSections({ return EXPORT_SECTION_BUILDERS[type](input); } -/** Zod schema validating the narrative payload for each singleton document type. */ +/** + * Zod schema validating the narrative payload for each document type that + * stores one. Covers the singleton documents plus the Internal Audit document, + * whose narrative holds only the Programme paragraph (its audits live in their + * own registers, so it is NOT a narrative type). + */ export function narrativeSchemaForType( type: IsmsDocumentType, ): ZodTypeAny | null { if (type === 'isms_scope') return ismsScopeNarrativeSchema; if (type === 'leadership_commitment') return leadershipNarrativeSchema; + if (type === 'internal_audit') return internalAuditNarrativeSchema; return null; } -/** Derive the default narrative payload for a singleton document type. */ +/** Derive the default narrative payload for a document type that stores one. */ export function deriveNarrativeForType({ type, data, @@ -68,6 +80,7 @@ export function deriveNarrativeForType({ }): Record | null { if (type === 'isms_scope') return deriveScopeNarrative(data); if (type === 'leadership_commitment') return deriveLeadershipNarrative(data); + if (type === 'internal_audit') return deriveInternalAuditNarrative(data); return null; } diff --git a/apps/api/src/isms/documents/snapshot.ts b/apps/api/src/isms/documents/snapshot.ts index e810cae04a..3bb06ebe61 100644 --- a/apps/api/src/isms/documents/snapshot.ts +++ b/apps/api/src/isms/documents/snapshot.ts @@ -94,6 +94,9 @@ const TYPE_DRIFT_SOURCES: Record> = { // static defaults + customer edits + measurements); no platform snapshot // input changes its rendering, so it can never be platform-drift stale. monitoring: [], + // Internal Audit (9.2) renders from its own audits register + the Programme + // narrative (customer-owned once seeded); it never goes platform-drift stale. + internal_audit: [], isms_scope: [ 'frameworks', 'vendors', diff --git a/apps/api/src/isms/documents/types.ts b/apps/api/src/isms/documents/types.ts index 53e13db03d..17c9ec653d 100644 --- a/apps/api/src/isms/documents/types.ts +++ b/apps/api/src/isms/documents/types.ts @@ -146,6 +146,64 @@ export interface MetricExportRow { currentValue: string; } +/** The fifteen seeded Controls Tested rows and their pre-filled text (9.2). */ +export interface SeedAuditControlDefinition { + controlKey: string; + controlRef: string; + whatWasTested: string; + whereToFind: string; +} + +/** One Controls Tested row, resolved for export (result/notes humanized). */ +export interface AuditControlExportRow { + controlRef: string; + whatWasTested: string; + whereToFind: string; + /** Humanized result label ("Conformity confirmed") or a dash when unset. */ + result: string; + notes: string; +} + +/** One finding row, resolved for export (owner name frozen at build time). */ +export interface AuditFindingExportRow { + reference: string; + /** Humanized type label ("NC minor"). */ + type: string; + clauseOrControl: string; + description: string; + ownerName: string; + dueDate: string; + /** Humanized status label ("Open"). */ + status: string; + /** How a closed corrective action was evidenced; empty when not recorded. */ + closureEvidence: string; +} + +/** One sign-off slot rendered in the audit's sign-off table. */ +export interface AuditSignoffExportRow { + role: string; + name: string; + date: string; +} + +/** An audit instance, resolved for export: fields + child tables + sign-off. */ +export interface AuditExportRow { + reference: string; + scope: string; + criteria: string; + auditorName: string; + plannedStartDate: string | null; + plannedEndDate: string | null; + /** Humanized status label ("In progress"). */ + status: string; + /** Assembled conclusion sentence, or null while no verdict is chosen. */ + conclusion: string | null; + conclusionNotes: string | null; + controls: AuditControlExportRow[]; + findings: AuditFindingExportRow[]; + signoffs: AuditSignoffExportRow[]; +} + /** * The organization profile that fills the narrative parts of the Context of the * Organization document (clause 4.1) — overview table, mission, intended @@ -197,4 +255,6 @@ export interface DocumentExportInput { band?: IsmsTeamSizeBand; /** Active metrics with resolved people + values — only populated for the Monitoring document (9.1). */ metrics?: MetricExportRow[]; + /** Audit instances with resolved names — only populated for the Internal Audit document (9.2). */ + audits?: AuditExportRow[]; } diff --git a/apps/api/src/isms/isms-audit-control.service.spec.ts b/apps/api/src/isms/isms-audit-control.service.spec.ts new file mode 100644 index 0000000000..bab3d64d9e --- /dev/null +++ b/apps/api/src/isms/isms-audit-control.service.spec.ts @@ -0,0 +1,150 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsAuditControlService } from './isms-audit-control.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { + findUnique: jest.fn(), + update: jest.fn(), + }, + ismsAudit: { findFirst: jest.fn() }, + ismsAuditControl: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsAuditControlService', () => { + let service: IsmsAuditControlService; + + beforeEach(() => { + jest.clearAllMocks(); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'draft', + }); + service = new IsmsAuditControlService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { auditId: 'aud_1', controlRef: 'A.8.16 Monitoring activities' }, + }; + + it('requires the audit to belong to the org + document (type-scoped)', async () => { + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + expect(mockDb.ismsAudit.findFirst).toHaveBeenCalledWith({ + where: { + id: 'aud_1', + documentId: 'doc_1', + document: { organizationId: 'org_1', type: 'internal_audit' }, + }, + }); + }); + + it('creates a customer row (controlKey null, source manual) after the last position', async () => { + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue({ + id: 'aud_1', + documentId: 'doc_1', + }); + (mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue({ + position: 14, + }); + (mockDb.ismsAuditControl.create as jest.Mock).mockResolvedValue({}); + + await service.create(args); + + expect(mockDb.ismsAuditControl.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + auditId: 'aud_1', + documentId: 'doc_1', + controlKey: null, + controlRef: 'A.8.16 Monitoring activities', + result: null, + source: 'manual', + position: 15, + }), + }); + }); + }); + + describe('update', () => { + beforeEach(() => { + (mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue({ + id: 'ac_1', + documentId: 'doc_1', + controlKey: 'clause_4_1_context', + source: 'derived', + }); + (mockDb.ismsAuditControl.update as jest.Mock).mockResolvedValue({}); + }); + + it('records a result and flips source to manual (seeded rows editable)', async () => { + await service.update({ + controlId: 'ac_1', + organizationId: 'org_1', + dto: { result: 'conformity_confirmed' }, + }); + + expect(mockDb.ismsAuditControl.update).toHaveBeenCalledWith({ + where: { id: 'ac_1' }, + data: expect.objectContaining({ + result: 'conformity_confirmed', + source: 'manual', + }), + }); + }); + + it('clears a result back to unset with null', async () => { + await service.update({ + controlId: 'ac_1', + organizationId: 'org_1', + dto: { result: null, notes: '' }, + }); + + const { data } = (mockDb.ismsAuditControl.update as jest.Mock).mock + .calls[0][0]; + expect(data.result).toBeNull(); + expect(data.notes).toBeNull(); + }); + + it('throws NotFoundException for a row outside the organization', async () => { + (mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.update({ controlId: 'ac_x', organizationId: 'org_1', dto: {} }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('deletes any row, including seeded ones (removals stick)', async () => { + (mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue({ + id: 'ac_1', + documentId: 'doc_1', + controlKey: 'clause_4_1_context', + }); + (mockDb.ismsAuditControl.delete as jest.Mock).mockResolvedValue({}); + + const result = await service.remove({ + controlId: 'ac_1', + organizationId: 'org_1', + }); + + expect(result).toEqual({ success: true }); + expect(mockDb.ismsAuditControl.delete).toHaveBeenCalledWith({ + where: { id: 'ac_1' }, + }); + }); + }); +}); diff --git a/apps/api/src/isms/isms-audit-control.service.ts b/apps/api/src/isms/isms-audit-control.service.ts new file mode 100644 index 0000000000..3270bb05c5 --- /dev/null +++ b/apps/api/src/isms/isms-audit-control.service.ts @@ -0,0 +1,165 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocument } from './utils/document-lock'; +import type { + CreateAuditControlInput, + UpdateAuditControlInput, +} from './registers/register-registry'; + +/** + * CRUD for an audit's Controls Tested rows (clause 9.2, CS-724). The fifteen + * default rows are created idempotently by seedAuditControlsIfMissing when the + * audit is created; this service handles customer-added rows and edits. The + * ticket allows adding, editing, and removing rows freely — seeded rows are + * deletable too (nothing re-seeds an existing audit, so they stay removed). + * Editing flips source to 'manual' (records the override). + */ +@Injectable() +export class IsmsAuditControlService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateAuditControlInput; + }) { + const audit = await this.requireAudit({ + auditId: dto.auditId, + documentId, + organizationId, + }); + + return db.$transaction(async (tx) => { + await lockDocument(tx, audit.documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, auditId: audit.id })); + await invalidateApprovalIfNeeded({ tx, documentId: audit.documentId }); + return tx.ismsAuditControl.create({ + data: { + auditId: audit.id, + documentId: audit.documentId, + controlKey: null, // customer-added row + controlRef: dto.controlRef, + whatWasTested: dto.whatWasTested ?? '', + whereToFind: dto.whereToFind ?? '', + result: dto.result ?? null, + notes: dto.notes?.trim() || null, + source: 'manual', + position, + }, + }); + }); + } + + async update({ + controlId, + organizationId, + dto, + }: { + controlId: string; + organizationId: string; + dto: UpdateAuditControlInput; + }) { + const control = await this.requireControl({ controlId, organizationId }); + + return db.$transaction(async (tx) => { + // Same per-document lock submit/approve take, so an edit can't race the + // submission's completeness check. + await lockDocument(tx, control.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: control.documentId }); + return tx.ismsAuditControl.update({ + where: { id: controlId }, + data: { + controlRef: dto.controlRef ?? undefined, + whatWasTested: dto.whatWasTested ?? undefined, + whereToFind: dto.whereToFind ?? undefined, + result: dto.result === undefined ? undefined : dto.result, + notes: + dto.notes === undefined ? undefined : dto.notes?.trim() || null, + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + controlId, + organizationId, + }: { + controlId: string; + organizationId: string; + }) { + const control = await this.requireControl({ controlId, organizationId }); + await db.$transaction(async (tx) => { + await lockDocument(tx, control.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: control.documentId }); + // Findings linked to this row survive: the FK is SET NULL and each + // finding keeps its own clauseOrControl text as the frozen label. + await tx.ismsAuditControl.delete({ where: { id: controlId } }); + }); + return { success: true }; + } + + private async nextPosition({ + tx, + auditId, + }: { + tx: Prisma.TransactionClient; + auditId: string; + }) { + const last = await tx.ismsAuditControl.findFirst({ + where: { auditId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireAudit({ + auditId, + documentId, + organizationId, + }: { + auditId: string; + documentId: string; + organizationId: string; + }) { + // The audit must belong to THIS org's Internal Audit document (and to the + // document in the URL, so a row can't be attached across documents). + const audit = await db.ismsAudit.findFirst({ + where: { + id: auditId, + documentId, + document: { organizationId, type: 'internal_audit' }, + }, + }); + if (!audit) { + throw new NotFoundException('Audit not found'); + } + return audit; + } + + private async requireControl({ + controlId, + organizationId, + }: { + controlId: string; + organizationId: string; + }) { + const control = await db.ismsAuditControl.findFirst({ + where: { + id: controlId, + audit: { document: { organizationId, type: 'internal_audit' } }, + }, + }); + if (!control) { + throw new NotFoundException('Audit control row not found'); + } + return control; + } +} diff --git a/apps/api/src/isms/isms-audit-finding.service.spec.ts b/apps/api/src/isms/isms-audit-finding.service.spec.ts new file mode 100644 index 0000000000..980d1c1755 --- /dev/null +++ b/apps/api/src/isms/isms-audit-finding.service.spec.ts @@ -0,0 +1,183 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsAuditFindingService } from './isms-audit-finding.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { + findUnique: jest.fn(), + update: jest.fn(), + }, + ismsAudit: { findFirst: jest.fn() }, + ismsAuditControl: { findFirst: jest.fn() }, + member: { findFirst: jest.fn() }, + ismsAuditFinding: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsAuditFindingService', () => { + let service: IsmsAuditFindingService; + + beforeEach(() => { + jest.clearAllMocks(); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'draft', + }); + (mockDb.ismsAuditFinding.findMany as jest.Mock).mockResolvedValue([]); + service = new IsmsAuditFindingService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { + auditId: 'aud_1', + type: 'nc_minor' as const, + description: 'Three of ten access reviews had no approval evidence.', + }, + }; + + beforeEach(() => { + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue({ + id: 'aud_1', + documentId: 'doc_1', + }); + (mockDb.ismsAuditFinding.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.ismsAuditFinding.create as jest.Mock).mockResolvedValue({}); + }); + + it('creates a standalone finding with a generated F-01 reference and open status', async () => { + await service.create(args); + + expect(mockDb.ismsAuditFinding.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + auditId: 'aud_1', + documentId: 'doc_1', + reference: 'F-01', + type: 'nc_minor', + controlId: null, + status: 'open', + position: 0, + }), + }); + }); + + it('continues the per-audit sequence past deleted references', async () => { + (mockDb.ismsAuditFinding.findMany as jest.Mock).mockResolvedValue([ + { reference: 'F-01' }, + { reference: 'F-03' }, + ]); + + await service.create(args); + + expect(mockDb.ismsAuditFinding.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ reference: 'F-04' }), + }); + }); + + it('rejects a related control from another audit', async () => { + (mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.create({ + ...args, + dto: { ...args.dto, controlId: 'ac_other' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.ismsAuditControl.findFirst).toHaveBeenCalledWith({ + where: { id: 'ac_other', auditId: 'aud_1' }, + }); + expect(mockDb.ismsAuditFinding.create).not.toHaveBeenCalled(); + }); + + it('rejects an owner that is not an active org member', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.create({ + ...args, + dto: { ...args.dto, ownerMemberId: 'mem_x' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.member.findFirst).toHaveBeenCalledWith({ + where: { id: 'mem_x', organizationId: 'org_1', deactivated: false }, + }); + }); + }); + + describe('update', () => { + beforeEach(() => { + (mockDb.ismsAuditFinding.findFirst as jest.Mock).mockResolvedValue({ + id: 'af_1', + auditId: 'aud_1', + documentId: 'doc_1', + }); + (mockDb.ismsAuditFinding.update as jest.Mock).mockResolvedValue({}); + }); + + it('closes a finding with closure evidence and a cleared due date', async () => { + await service.update({ + findingId: 'af_1', + organizationId: 'org_1', + dto: { + status: 'closed', + closureEvidence: 'Restore test evidenced in task ev_123.', + dueDate: null, + }, + }); + + const { data } = (mockDb.ismsAuditFinding.update as jest.Mock).mock + .calls[0][0]; + expect(data.status).toBe('closed'); + expect(data.closureEvidence).toBe('Restore test evidenced in task ev_123.'); + expect(data.dueDate).toBeNull(); + // The server-generated reference is never updatable. + expect(data.reference).toBeUndefined(); + }); + + it('unlinks the related control with null', async () => { + await service.update({ + findingId: 'af_1', + organizationId: 'org_1', + dto: { controlId: null }, + }); + + const { data } = (mockDb.ismsAuditFinding.update as jest.Mock).mock + .calls[0][0]; + expect(data.controlId).toBeNull(); + expect(mockDb.ismsAuditControl.findFirst).not.toHaveBeenCalled(); + }); + }); + + describe('remove', () => { + it('deletes the finding', async () => { + (mockDb.ismsAuditFinding.findFirst as jest.Mock).mockResolvedValue({ + id: 'af_1', + documentId: 'doc_1', + }); + (mockDb.ismsAuditFinding.delete as jest.Mock).mockResolvedValue({}); + + const result = await service.remove({ + findingId: 'af_1', + organizationId: 'org_1', + }); + + expect(result).toEqual({ success: true }); + expect(mockDb.ismsAuditFinding.delete).toHaveBeenCalledWith({ + where: { id: 'af_1' }, + }); + }); + }); +}); diff --git a/apps/api/src/isms/isms-audit-finding.service.ts b/apps/api/src/isms/isms-audit-finding.service.ts new file mode 100644 index 0000000000..ae1f028ecf --- /dev/null +++ b/apps/api/src/isms/isms-audit-finding.service.ts @@ -0,0 +1,248 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocument } from './utils/document-lock'; +import { parseOptionalDate } from './utils/parse-optional-date'; +import type { + CreateAuditFindingInput, + UpdateAuditFindingInput, +} from './registers/register-registry'; + +/** + * CRUD for an audit's findings (clause 9.2, CS-724). Most findings are raised + * from a Controls Tested row (non-conformity / observation) and link back to + * it; standalone findings are also allowed. The reference is server-generated + * ("F-NN", per-audit sequence) and immutable. + */ +@Injectable() +export class IsmsAuditFindingService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateAuditFindingInput; + }) { + const audit = await this.requireAudit({ + auditId: dto.auditId, + documentId, + organizationId, + }); + const controlId = await this.resolveControl({ + controlId: dto.controlId, + auditId: audit.id, + }); + const ownerMemberId = await this.resolveMember({ + memberId: dto.ownerMemberId, + organizationId, + }); + const dueDate = parseOptionalDate(dto.dueDate); + + return db.$transaction(async (tx) => { + // The lock also serializes reference generation, so concurrent creates + // can never both compute the same next "F-NN". + await lockDocument(tx, audit.documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, auditId: audit.id })); + await invalidateApprovalIfNeeded({ tx, documentId: audit.documentId }); + return tx.ismsAuditFinding.create({ + data: { + auditId: audit.id, + documentId: audit.documentId, + reference: await this.nextReference({ tx, auditId: audit.id }), + type: dto.type, + controlId: controlId ?? null, + clauseOrControl: dto.clauseOrControl?.trim() || null, + description: dto.description, + ownerMemberId: ownerMemberId ?? null, + dueDate: dueDate ?? null, + status: dto.status ?? 'open', + closureEvidence: dto.closureEvidence?.trim() || null, + position, + }, + }); + }); + } + + async update({ + findingId, + organizationId, + dto, + }: { + findingId: string; + organizationId: string; + dto: UpdateAuditFindingInput; + }) { + const finding = await this.requireFinding({ findingId, organizationId }); + const controlId = await this.resolveControl({ + controlId: dto.controlId, + auditId: finding.auditId, + }); + const ownerMemberId = await this.resolveMember({ + memberId: dto.ownerMemberId, + organizationId, + }); + + return db.$transaction(async (tx) => { + // Same per-document lock submit/approve take, so an edit can't race the + // submission's completeness check. + await lockDocument(tx, finding.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: finding.documentId }); + return tx.ismsAuditFinding.update({ + where: { id: findingId }, + data: { + type: dto.type ?? undefined, + controlId: dto.controlId === undefined ? undefined : controlId, + clauseOrControl: + dto.clauseOrControl === undefined + ? undefined + : dto.clauseOrControl?.trim() || null, + description: dto.description ?? undefined, + ownerMemberId: + dto.ownerMemberId === undefined ? undefined : ownerMemberId, + dueDate: parseOptionalDate(dto.dueDate), + status: dto.status ?? undefined, + closureEvidence: + dto.closureEvidence === undefined + ? undefined + : dto.closureEvidence?.trim() || null, + position: dto.position ?? undefined, + }, + }); + }); + } + + async remove({ + findingId, + organizationId, + }: { + findingId: string; + organizationId: string; + }) { + const finding = await this.requireFinding({ findingId, organizationId }); + await db.$transaction(async (tx) => { + await lockDocument(tx, finding.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: finding.documentId }); + await tx.ismsAuditFinding.delete({ where: { id: findingId } }); + }); + return { success: true }; + } + + /** Next "F-NN" for the audit. Max + 1, so a deleted reference never recurs. */ + private async nextReference({ + tx, + auditId, + }: { + tx: Prisma.TransactionClient; + auditId: string; + }): Promise { + const existing = await tx.ismsAuditFinding.findMany({ + where: { auditId }, + select: { reference: true }, + }); + const maxSequence = existing.reduce((max, finding) => { + const match = /^F-(\d+)$/.exec(finding.reference); + return match ? Math.max(max, Number.parseInt(match[1], 10)) : max; + }, 0); + return `F-${String(maxSequence + 1).padStart(2, '0')}`; + } + + /** The linked Controls Tested row must belong to the SAME audit. */ + private async resolveControl({ + controlId, + auditId, + }: { + controlId: string | null | undefined; + auditId: string; + }): Promise { + if (controlId === undefined) return undefined; + const trimmed = controlId?.trim(); + if (!trimmed) return null; + const control = await db.ismsAuditControl.findFirst({ + where: { id: trimmed, auditId }, + }); + if (!control) { + throw new NotFoundException('Audit control row not found in this audit'); + } + return trimmed; + } + + private async resolveMember({ + memberId, + organizationId, + }: { + memberId: string | null | undefined; + organizationId: string; + }): Promise { + if (memberId === undefined) return undefined; + const trimmed = memberId?.trim(); + if (!trimmed) return null; + // Finding owners must be active People members. + const member = await db.member.findFirst({ + where: { id: trimmed, organizationId, deactivated: false }, + }); + if (!member) { + throw new NotFoundException('Active member not found in organization'); + } + return trimmed; + } + + private async nextPosition({ + tx, + auditId, + }: { + tx: Prisma.TransactionClient; + auditId: string; + }) { + const last = await tx.ismsAuditFinding.findFirst({ + where: { auditId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireAudit({ + auditId, + documentId, + organizationId, + }: { + auditId: string; + documentId: string; + organizationId: string; + }) { + const audit = await db.ismsAudit.findFirst({ + where: { + id: auditId, + documentId, + document: { organizationId, type: 'internal_audit' }, + }, + }); + if (!audit) { + throw new NotFoundException('Audit not found'); + } + return audit; + } + + private async requireFinding({ + findingId, + organizationId, + }: { + findingId: string; + organizationId: string; + }) { + const finding = await db.ismsAuditFinding.findFirst({ + where: { + id: findingId, + audit: { document: { organizationId, type: 'internal_audit' } }, + }, + }); + if (!finding) { + throw new NotFoundException('Finding not found'); + } + return finding; + } +} diff --git a/apps/api/src/isms/isms-audit.service.spec.ts b/apps/api/src/isms/isms-audit.service.spec.ts new file mode 100644 index 0000000000..10dbb611a3 --- /dev/null +++ b/apps/api/src/isms/isms-audit.service.spec.ts @@ -0,0 +1,219 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsAuditService } from './isms-audit.service'; +import { + DEFAULT_AUDIT_CRITERIA, + DEFAULT_AUDIT_SCOPE, +} from './documents/internal-audit-defaults'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + ismsAudit: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + ismsAuditControl: { + findMany: jest.fn(), + createMany: jest.fn(), + }, + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); +const currentYear = new Date().getUTCFullYear(); + +describe('IsmsAuditService', () => { + let service: IsmsAuditService; + + beforeEach(() => { + jest.clearAllMocks(); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'draft', + }); + (mockDb.ismsAudit.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsAuditControl.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsAuditControl.createMany as jest.Mock).mockResolvedValue({ + count: 15, + }); + service = new IsmsAuditService(); + }); + + describe('create', () => { + const args = { documentId: 'doc_1', organizationId: 'org_1', dto: {} }; + + it('throws NotFoundException when the internal-audit document is missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + expect(mockDb.ismsDocument.findFirst).toHaveBeenCalledWith({ + where: { id: 'doc_1', organizationId: 'org_1', type: 'internal_audit' }, + }); + }); + + it('creates an audit with template defaults and a generated reference', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.ismsAudit.create as jest.Mock).mockResolvedValue({ + id: 'aud_1', + }); + + await service.create(args); + + expect(mockDb.ismsAudit.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + documentId: 'doc_1', + reference: `IA-${currentYear}-01`, + scope: DEFAULT_AUDIT_SCOPE, + criteria: DEFAULT_AUDIT_CRITERIA, + auditorName: null, + position: 0, + }), + }); + }); + + it('seeds the fifteen default Controls Tested rows in the same transaction', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.ismsAudit.create as jest.Mock).mockResolvedValue({ + id: 'aud_1', + }); + + await service.create(args); + + const { data } = (mockDb.ismsAuditControl.createMany as jest.Mock).mock + .calls[0][0]; + expect(data).toHaveLength(15); + expect(data[0]).toMatchObject({ auditId: 'aud_1', documentId: 'doc_1' }); + }); + + it('continues the per-year sequence from the highest existing reference', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsAudit.findMany as jest.Mock).mockResolvedValue([ + { reference: `IA-${currentYear}-01` }, + { reference: `IA-${currentYear}-04` }, + ]); + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue({ + position: 3, + }); + (mockDb.ismsAudit.create as jest.Mock).mockResolvedValue({ + id: 'aud_2', + }); + + await service.create(args); + + expect(mockDb.ismsAudit.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + reference: `IA-${currentYear}-05`, + position: 4, + }), + }); + }); + + it('rejects an invalid planned date before opening the transaction', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + await expect( + service.create({ + ...args, + dto: { plannedStartDate: '2026-02-30' }, + }), + ).rejects.toThrow(BadRequestException); + expect(mockDb.ismsAudit.create).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + beforeEach(() => { + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue({ + id: 'aud_1', + documentId: 'doc_1', + }); + (mockDb.ismsAudit.update as jest.Mock).mockResolvedValue({}); + }); + + it('updates status and conclusion, leaving omitted fields untouched', async () => { + await service.update({ + auditId: 'aud_1', + organizationId: 'org_1', + dto: { status: 'complete', conclusionVerdict: 'conform' }, + }); + + const { data } = (mockDb.ismsAudit.update as jest.Mock).mock.calls[0][0]; + expect(data.status).toBe('complete'); + expect(data.conclusionVerdict).toBe('conform'); + expect(data.scope).toBeUndefined(); + expect(data.signoffAuditorName).toBeUndefined(); + }); + + it('saves sign-off slots and clears them with null/empty', async () => { + await service.update({ + auditId: 'aud_1', + organizationId: 'org_1', + dto: { + signoffAuditorName: 'Sarah Chen', + signoffAuditorDate: '2026-05-20', + signoffSpoName: null, + signoffSpoDate: '', + }, + }); + + const { data } = (mockDb.ismsAudit.update as jest.Mock).mock.calls[0][0]; + expect(data.signoffAuditorName).toBe('Sarah Chen'); + expect(data.signoffAuditorDate).toEqual( + new Date('2026-05-20T00:00:00.000Z'), + ); + expect(data.signoffSpoName).toBeNull(); + expect(data.signoffSpoDate).toBeNull(); + }); + + it('throws NotFoundException for an audit outside the organization', async () => { + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.update({ auditId: 'aud_x', organizationId: 'org_1', dto: {} }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.ismsAudit.findFirst).toHaveBeenCalledWith({ + where: { + id: 'aud_x', + document: { organizationId: 'org_1', type: 'internal_audit' }, + }, + }); + }); + }); + + describe('remove', () => { + it('deletes the audit (cascading to controls and findings)', async () => { + (mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue({ + id: 'aud_1', + documentId: 'doc_1', + }); + (mockDb.ismsAudit.delete as jest.Mock).mockResolvedValue({}); + + const result = await service.remove({ + auditId: 'aud_1', + organizationId: 'org_1', + }); + + expect(result).toEqual({ success: true }); + expect(mockDb.ismsAudit.delete).toHaveBeenCalledWith({ + where: { id: 'aud_1' }, + }); + }); + }); +}); diff --git a/apps/api/src/isms/isms-audit.service.ts b/apps/api/src/isms/isms-audit.service.ts new file mode 100644 index 0000000000..2bb105f26d --- /dev/null +++ b/apps/api/src/isms/isms-audit.service.ts @@ -0,0 +1,220 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { seedAuditControlsIfMissing } from './documents/internal-audit'; +import { + DEFAULT_AUDIT_CRITERIA, + DEFAULT_AUDIT_SCOPE, +} from './documents/internal-audit-defaults'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocument } from './utils/document-lock'; +import { parseOptionalDate } from './utils/parse-optional-date'; +import type { + CreateAuditInput, + UpdateAuditInput, +} from './registers/register-registry'; + +/** + * CRUD for the Internal Audit instances register (clause 9.2, CS-724). Every + * audit is customer-created; scope and criteria pre-fill with the template + * defaults, the reference is server-generated ("IA-YYYY-NN"), and the fifteen + * default Controls Tested rows are seeded in the same transaction. Deleting an + * audit cascades to its control rows and findings. + */ +@Injectable() +export class IsmsAuditService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateAuditInput; + }) { + await this.requireDocument({ documentId, organizationId }); + const plannedStartDate = parseOptionalDate(dto.plannedStartDate); + const plannedEndDate = parseOptionalDate(dto.plannedEndDate); + + return db.$transaction(async (tx) => { + // The lock also serializes reference generation, so concurrent creates + // can never both compute the same next "IA-YYYY-NN". + await lockDocument(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + const audit = await tx.ismsAudit.create({ + data: { + documentId, + reference: await this.nextReference({ tx, documentId }), + scope: dto.scope?.trim() || DEFAULT_AUDIT_SCOPE, + criteria: dto.criteria?.trim() || DEFAULT_AUDIT_CRITERIA, + auditorName: dto.auditorName?.trim() || null, + plannedStartDate: plannedStartDate ?? null, + plannedEndDate: plannedEndDate ?? null, + position, + }, + }); + // The default sample set the customer sees when they first open the audit. + await seedAuditControlsIfMissing({ tx, auditId: audit.id, documentId }); + return audit; + }); + } + + async update({ + auditId, + organizationId, + dto, + }: { + auditId: string; + organizationId: string; + dto: UpdateAuditInput; + }) { + const audit = await this.requireAudit({ auditId, organizationId }); + + return db.$transaction(async (tx) => { + // Same per-document lock submit/approve take, so an edit can't race the + // submission's completeness check. + await lockDocument(tx, audit.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: audit.documentId }); + return tx.ismsAudit.update({ + where: { id: auditId }, + data: { + scope: dto.scope ?? undefined, + criteria: dto.criteria ?? undefined, + auditorName: + dto.auditorName === undefined + ? undefined + : dto.auditorName?.trim() || null, + plannedStartDate: parseOptionalDate(dto.plannedStartDate), + plannedEndDate: parseOptionalDate(dto.plannedEndDate), + status: dto.status ?? undefined, + conclusionVerdict: + dto.conclusionVerdict === undefined + ? undefined + : dto.conclusionVerdict, + conclusionNotes: + dto.conclusionNotes === undefined + ? undefined + : dto.conclusionNotes?.trim() || null, + signoffAuditorName: this.optionalText(dto.signoffAuditorName), + signoffAuditorDate: parseOptionalDate(dto.signoffAuditorDate), + signoffSpoName: this.optionalText(dto.signoffSpoName), + signoffSpoDate: parseOptionalDate(dto.signoffSpoDate), + signoffTopMgmtName: this.optionalText(dto.signoffTopMgmtName), + signoffTopMgmtDate: parseOptionalDate(dto.signoffTopMgmtDate), + position: dto.position ?? undefined, + }, + }); + }); + } + + async remove({ + auditId, + organizationId, + }: { + auditId: string; + organizationId: string; + }) { + const audit = await this.requireAudit({ auditId, organizationId }); + await db.$transaction(async (tx) => { + await lockDocument(tx, audit.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: audit.documentId }); + // Cascades to the audit's control rows and findings. + await tx.ismsAudit.delete({ where: { id: auditId } }); + }); + return { success: true }; + } + + /** Three-state text field: undefined = leave, null/empty = clear. */ + private optionalText( + value: string | null | undefined, + ): string | null | undefined { + if (value === undefined) return undefined; + return value?.trim() || null; + } + + /** + * Next "IA-YYYY-NN" for the document: the year of creation plus a two-digit + * per-document, per-year sequence. Max + 1 over the surviving rows, so + * deleting an older audit never reissues its number (deleting the newest + * audit frees its number — acceptable, since audits are freely deletable + * drafts until published). Runs under the per-document lock. + */ + private async nextReference({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }): Promise { + const year = new Date().getUTCFullYear(); + const prefix = `IA-${year}-`; + const existing = await tx.ismsAudit.findMany({ + where: { documentId, reference: { startsWith: prefix } }, + select: { reference: true }, + }); + const maxSequence = existing.reduce((max, audit) => { + const sequence = Number.parseInt( + audit.reference.slice(prefix.length), + 10, + ); + return Number.isNaN(sequence) ? max : Math.max(max, sequence); + }, 0); + return `${prefix}${String(maxSequence + 1).padStart(2, '0')}`; + } + + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsAudit.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + // Scope to the Internal Audit document type: audit rows must never attach + // to another ISMS document (they'd be invisible to the Clause 9.2 export). + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId, type: 'internal_audit' }, + }); + if (!document) { + throw new NotFoundException('ISMS internal audit document not found'); + } + return document; + } + + private async requireAudit({ + auditId, + organizationId, + }: { + auditId: string; + organizationId: string; + }) { + // Type-scoped like create: an audit row can only ever live on THIS org's + // Internal Audit document (belt-and-braces with the creation guard). + const audit = await db.ismsAudit.findFirst({ + where: { + id: auditId, + document: { organizationId, type: 'internal_audit' }, + }, + }); + if (!audit) { + throw new NotFoundException('Audit not found'); + } + return audit; + } +} diff --git a/apps/api/src/isms/isms-registers.controller.spec.ts b/apps/api/src/isms/isms-registers.controller.spec.ts index eed5933b68..d1b23d1b0d 100644 --- a/apps/api/src/isms/isms-registers.controller.spec.ts +++ b/apps/api/src/isms/isms-registers.controller.spec.ts @@ -14,6 +14,9 @@ import { IsmsRoleService } from './isms-role.service'; import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; import { IsmsMetricService } from './isms-metric.service'; import { IsmsMeasurementService } from './isms-measurement.service'; +import { IsmsAuditService } from './isms-audit.service'; +import { IsmsAuditControlService } from './isms-audit-control.service'; +import { IsmsAuditFindingService } from './isms-audit-finding.service'; import { IsmsNarrativeService } from './isms-narrative.service'; jest.mock('../auth/auth.server', () => ({ @@ -54,6 +57,15 @@ jest.mock('./isms-metric.service', () => ({ jest.mock('./isms-measurement.service', () => ({ IsmsMeasurementService: class {}, })); +jest.mock('./isms-audit.service', () => ({ + IsmsAuditService: class {}, +})); +jest.mock('./isms-audit-control.service', () => ({ + IsmsAuditControlService: class {}, +})); +jest.mock('./isms-audit-finding.service', () => ({ + IsmsAuditFindingService: class {}, +})); jest.mock('./isms-narrative.service', () => ({ IsmsNarrativeService: class {}, })); @@ -105,6 +117,21 @@ describe('IsmsRegistersController', () => { remove: jest.fn(), bulkCreate: jest.fn(), }; + const auditService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const auditControlService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const auditFindingService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; const narrativeService = { save: jest.fn() }; const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; @@ -124,6 +151,9 @@ describe('IsmsRegistersController', () => { { provide: IsmsRoleAssignmentService, useValue: roleAssignmentService }, { provide: IsmsMetricService, useValue: metricService }, { provide: IsmsMeasurementService, useValue: measurementService }, + { provide: IsmsAuditService, useValue: auditService }, + { provide: IsmsAuditControlService, useValue: auditControlService }, + { provide: IsmsAuditFindingService, useValue: auditFindingService }, { provide: IsmsNarrativeService, useValue: narrativeService }, ], }) @@ -258,6 +288,68 @@ describe('IsmsRegistersController', () => { ); }); + it('dispatches audits create with an empty body (defaults server-side)', async () => { + await controller.createRow( + 'doc_1', + 'audits', + reqWith({}), + 'org_1', + 'mem_1', + ); + expect(auditService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: {}, + }); + }); + + it('dispatches audit-controls and audit-findings create with parsed dtos', async () => { + const controlBody = { auditId: 'aud_1', controlRef: 'A.8.16' }; + await controller.createRow( + 'doc_1', + 'audit-controls', + reqWith(controlBody), + 'org_1', + 'mem_1', + ); + expect(auditControlService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: controlBody, + }); + + const findingBody = { + auditId: 'aud_1', + type: 'observation', + description: 'No restore test evidenced.', + }; + await controller.createRow( + 'doc_1', + 'audit-findings', + reqWith(findingBody), + 'org_1', + 'mem_1', + ); + expect(auditFindingService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: findingBody, + }); + }); + + it('rejects an audit-findings create without a description', async () => { + await expect( + controller.createRow( + 'doc_1', + 'audit-findings', + reqWith({ auditId: 'aud_1', type: 'ofi' }), + 'org_1', + 'mem_1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(auditFindingService.create).not.toHaveBeenCalled(); + }); + it('throws BadRequestException for an unknown register', async () => { await expect( controller.createRow('doc_1', 'nope', reqWith({}), 'org_1', undefined), diff --git a/apps/api/src/isms/isms-registers.controller.ts b/apps/api/src/isms/isms-registers.controller.ts index e0a3a4e470..60496ed999 100644 --- a/apps/api/src/isms/isms-registers.controller.ts +++ b/apps/api/src/isms/isms-registers.controller.ts @@ -31,6 +31,9 @@ import { IsmsRoleService } from './isms-role.service'; import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; import { IsmsMetricService } from './isms-metric.service'; import { IsmsMeasurementService } from './isms-measurement.service'; +import { IsmsAuditService } from './isms-audit.service'; +import { IsmsAuditControlService } from './isms-audit-control.service'; +import { IsmsAuditFindingService } from './isms-audit-finding.service'; import { IsmsNarrativeService } from './isms-narrative.service'; import { createRegisterRegistry, @@ -68,9 +71,21 @@ const REGISTER_ROW_BODY = { cadence: { type: 'string' }, plan: { type: 'string' }, measurementMethod: { type: 'string' }, + // Per-register status: objectives use the first four values, audits the + // next three, and findings open/in_progress/closed. status: { type: 'string', - enum: ['not_started', 'on_track', 'at_risk', 'met'], + enum: [ + 'not_started', + 'on_track', + 'at_risk', + 'met', + 'planned', + 'in_progress', + 'complete', + 'open', + 'closed', + ], }, // Roles register (5.3) + role assignments (7.2 competence) responsibilities: { type: 'string' }, @@ -113,6 +128,47 @@ const REGISTER_ROW_BODY = { }, value: { type: 'string' }, note: { type: 'string', nullable: true }, + // Internal audit register (9.2): audits + audit-controls + audit-findings + scope: { type: 'string' }, + criteria: { type: 'string' }, + auditorName: { type: 'string', nullable: true }, + plannedStartDate: { type: 'string', nullable: true }, + plannedEndDate: { type: 'string', nullable: true }, + conclusionVerdict: { + type: 'string', + enum: ['conform', 'substantially_conform', 'not_yet_conform'], + nullable: true, + }, + conclusionNotes: { type: 'string', nullable: true }, + signoffAuditorName: { type: 'string', nullable: true }, + signoffAuditorDate: { type: 'string', nullable: true }, + signoffSpoName: { type: 'string', nullable: true }, + signoffSpoDate: { type: 'string', nullable: true }, + signoffTopMgmtName: { type: 'string', nullable: true }, + signoffTopMgmtDate: { type: 'string', nullable: true }, + auditId: { type: 'string' }, + controlRef: { type: 'string' }, + whatWasTested: { type: 'string' }, + whereToFind: { type: 'string' }, + result: { + type: 'string', + enum: [ + 'conformity_confirmed', + 'nonconformity_raised', + 'observation_raised', + 'not_sampled', + ], + nullable: true, + }, + notes: { type: 'string', nullable: true }, + type: { + type: 'string', + enum: ['nc_major', 'nc_minor', 'ofi', 'observation'], + }, + controlId: { type: 'string', nullable: true }, + clauseOrControl: { type: 'string', nullable: true }, + dueDate: { type: 'string', nullable: true }, + closureEvidence: { type: 'string', nullable: true }, position: { type: 'integer', minimum: 0 }, }, }, @@ -181,6 +237,9 @@ export class IsmsRegistersController { roleService: IsmsRoleService, roleAssignmentService: IsmsRoleAssignmentService, metricService: IsmsMetricService, + auditService: IsmsAuditService, + auditControlService: IsmsAuditControlService, + auditFindingService: IsmsAuditFindingService, private readonly measurementService: IsmsMeasurementService, private readonly narrativeService: IsmsNarrativeService, ) { @@ -193,6 +252,9 @@ export class IsmsRegistersController { roleAssignments: roleAssignmentService, metrics: metricService, measurements: this.measurementService, + audits: auditService, + auditControls: auditControlService, + auditFindings: auditFindingService, }); } diff --git a/apps/api/src/isms/isms-version.service.spec.ts b/apps/api/src/isms/isms-version.service.spec.ts index 2c25de491a..c7c4ba2b45 100644 --- a/apps/api/src/isms/isms-version.service.spec.ts +++ b/apps/api/src/isms/isms-version.service.spec.ts @@ -26,6 +26,7 @@ jest.mock('./utils/export-payload', () => ({ resolveOrgProfile: jest.fn(), resolveRolesExtras: jest.fn(), resolveMonitoringExtras: jest.fn(), + resolveInternalAuditExtras: jest.fn(), parseExportSnapshot: jest.fn(() => null), })); jest.mock('./utils/export-metadata', () => ({ diff --git a/apps/api/src/isms/isms-version.service.ts b/apps/api/src/isms/isms-version.service.ts index 6d71c6a2f1..22e3a9a1b4 100644 --- a/apps/api/src/isms/isms-version.service.ts +++ b/apps/api/src/isms/isms-version.service.ts @@ -15,6 +15,7 @@ import { buildExportInput, parseExportSnapshot, renderSnapshot, + resolveInternalAuditExtras, resolveMonitoringExtras, resolveOrgProfile, resolveRolesExtras, @@ -73,11 +74,13 @@ export class IsmsVersionService { const orgProfile = await resolveOrgProfile(document, tx); const rolesExtras = await resolveRolesExtras(document, tx); const monitoringExtras = await resolveMonitoringExtras(document, tx); + const internalAuditExtras = await resolveInternalAuditExtras(document, tx); const input = buildExportInput({ document, orgProfile, rolesExtras, monitoringExtras, + internalAuditExtras, }); const metadata = buildExportMetadata({ type: document.type, diff --git a/apps/api/src/isms/isms.module.ts b/apps/api/src/isms/isms.module.ts index d7df28867b..b82c50a8fe 100644 --- a/apps/api/src/isms/isms.module.ts +++ b/apps/api/src/isms/isms.module.ts @@ -13,6 +13,9 @@ import { IsmsRoleService } from './isms-role.service'; import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; import { IsmsMetricService } from './isms-metric.service'; import { IsmsMeasurementService } from './isms-measurement.service'; +import { IsmsAuditService } from './isms-audit.service'; +import { IsmsAuditControlService } from './isms-audit-control.service'; +import { IsmsAuditFindingService } from './isms-audit-finding.service'; import { IsmsNarrativeService } from './isms-narrative.service'; import { IsmsProfileController } from './wizard/isms-profile.controller'; import { IsmsProfileService } from './wizard/isms-profile.service'; @@ -40,6 +43,9 @@ import { AttachmentsModule } from '../attachments/attachments.module'; IsmsRoleAssignmentService, IsmsMetricService, IsmsMeasurementService, + IsmsAuditService, + IsmsAuditControlService, + IsmsAuditFindingService, IsmsNarrativeService, IsmsProfileService, ], @@ -56,6 +62,9 @@ import { AttachmentsModule } from '../attachments/attachments.module'; IsmsRoleAssignmentService, IsmsMetricService, IsmsMeasurementService, + IsmsAuditService, + IsmsAuditControlService, + IsmsAuditFindingService, IsmsNarrativeService, IsmsProfileService, ], diff --git a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts index 5ffa16ced4..1c065ec8a5 100644 --- a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts +++ b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts @@ -72,7 +72,7 @@ describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no template const result = await service.ensureSetup(dto); expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); - expect(createManyData()).toHaveLength(7); + expect(createManyData()).toHaveLength(8); // Definition-derived docs carry no templateId. expect(createManyData()[0].templateId).toBeNull(); expect(result.success).toBe(true); @@ -96,7 +96,7 @@ describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no template await service.ensureSetup(dto); - expect(createManyData()).toHaveLength(8); + expect(createManyData()).toHaveLength(9); expect(createManyData()[0].requirementId).toBeNull(); }); }); diff --git a/apps/api/src/isms/isms.service.lifecycle.spec.ts b/apps/api/src/isms/isms.service.lifecycle.spec.ts index 5184ab0772..846a3f2efd 100644 --- a/apps/api/src/isms/isms.service.lifecycle.spec.ts +++ b/apps/api/src/isms/isms.service.lifecycle.spec.ts @@ -18,6 +18,7 @@ jest.mock('@db', () => { }, member: { findFirst: jest.fn(), findMany: jest.fn() }, ismsRole: { findMany: jest.fn() }, + ismsAudit: { findMany: jest.fn() }, // lockDocument runs $executeRaw; submitForApproval wraps validate+update in a tx. $executeRaw: jest.fn(), $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), @@ -193,6 +194,57 @@ describe('IsmsService document lifecycle', () => { await service.submitForApproval(args); expect(mockDb.ismsDocument.update).toHaveBeenCalled(); }); + + it('blocks an Internal Audit doc with no audits recorded (clause 9.2)', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'internal_audit', + }); + (mockDb.ismsAudit.findMany as jest.Mock).mockResolvedValue([]); + + await expect(service.submitForApproval(args)).rejects.toThrow( + BadRequestException, + ); + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + + it('blocks an Internal Audit doc with a completed audit missing its verdict', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'internal_audit', + }); + (mockDb.ismsAudit.findMany as jest.Mock).mockResolvedValue([ + { + reference: 'IA-2026-01', + status: 'complete', + conclusionVerdict: null, + }, + ]); + + await expect(service.submitForApproval(args)).rejects.toThrow( + BadRequestException, + ); + }); + + it('allows an Internal Audit doc with a planned audit on record', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'internal_audit', + }); + (mockDb.ismsAudit.findMany as jest.Mock).mockResolvedValue([ + { reference: 'IA-2026-01', status: 'planned', conclusionVerdict: null }, + ]); + (mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + }); + + await service.submitForApproval(args); + expect(mockDb.ismsDocument.update).toHaveBeenCalled(); + }); }); describe('decline', () => { diff --git a/apps/api/src/isms/isms.service.spec.ts b/apps/api/src/isms/isms.service.spec.ts index ccb4ed9ebb..f764be4b56 100644 --- a/apps/api/src/isms/isms.service.spec.ts +++ b/apps/api/src/isms/isms.service.spec.ts @@ -1,5 +1,5 @@ import { NotFoundException } from '@nestjs/common'; -import { db } from '@db'; +import { db, Prisma } from '@db'; import { IsmsService } from './isms.service'; import type { IsmsVersionService } from './isms-version.service'; @@ -10,11 +10,15 @@ jest.mock('@db', () => ({ ismsDocument: { findMany: jest.fn(), createMany: jest.fn(), + updateMany: jest.fn(), }, ismsMetric: { findMany: jest.fn() }, + organization: { findUnique: jest.fn() }, control: { findMany: jest.fn() }, ismsDocumentControlLink: { createMany: jest.fn() }, }, + // The programme seed filters on the Prisma JSON-null sentinel. + Prisma: { AnyNull: Symbol.for('prisma.AnyNull') }, })); jest.mock('./documents/data-source', () => ({ collectPlatformData: jest.fn(), @@ -115,6 +119,43 @@ describe('IsmsService ensureSetup', () => { }); }); + it('seeds the programme on a new Internal Audit doc atomically — only while the narrative is still NULL (CS-724)', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ id: 'fw_1', requirements: [] }); + mockTemplates.mockResolvedValue([]); + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + name: 'Acme Corp', + }); + (mockDb.ismsDocument.findMany as jest.Mock) + .mockResolvedValueOnce([]) // existing-types probe + .mockResolvedValueOnce([{ id: 'doc_ia', type: 'internal_audit' }]) // created lookup + .mockResolvedValueOnce([]); // final list + + await service.ensureSetup(dto); + + // The empty-narrative filter (NULL or {}, generateNarrative's definition) + // is the concurrency guard: a narrative written between provisioning and + // this seed (concurrent setup call or an early customer edit) makes the + // update match zero rows instead of overwriting. + expect(mockDb.ismsDocument.updateMany).toHaveBeenCalledWith({ + where: { + id: 'doc_ia', + OR: [ + { draftNarrative: { equals: Prisma.AnyNull } }, + { draftNarrative: { equals: {} } }, + ], + }, + data: { + draftNarrative: { + programme: expect.stringContaining( + 'Acme Corp runs an annual internal audit', + ), + }, + }, + }); + }); + it('reports overdueMetricCount on the monitoring document row (CS-723)', async () => { const { addPeriods, periodStartFor } = jest.requireActual< typeof import('./utils/metric-periods') @@ -220,9 +261,9 @@ describe('IsmsService ensureSetup', () => { await service.ensureSetup(dto); expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); - // 1 template-driven + 7 definition fallbacks: a type shipped before its + // 1 template-driven + 8 definition fallbacks: a type shipped before its // template seed re-runs (e.g. monitoring, CS-723) still provisions. - expect(createManyData()).toHaveLength(8); + expect(createManyData()).toHaveLength(9); expect(createManyData()[0]).toMatchObject({ type: 'context_of_organization', title: 'Context of the Organization', @@ -306,9 +347,9 @@ describe('IsmsService ensureSetup', () => { await service.ensureSetup(dto); - // objectives (template) + 6 definition fallbacks; the existing + // objectives (template) + 7 definition fallbacks; the existing // context_of_organization is skipped. - expect(createManyData()).toHaveLength(7); + expect(createManyData()).toHaveLength(8); expect(createManyData()[0].type).toBe('objectives_plan'); expect( createManyData().map((doc: { type: string }) => doc.type), @@ -407,6 +448,7 @@ describe('IsmsService ensureSetup', () => { { type: 'roles_and_responsibilities' }, { type: 'objectives_plan' }, { type: 'monitoring' }, + { type: 'internal_audit' }, ]) .mockResolvedValueOnce([]); diff --git a/apps/api/src/isms/isms.service.ts b/apps/api/src/isms/isms.service.ts index ac6b470d6c..357e5dcee7 100644 --- a/apps/api/src/isms/isms.service.ts +++ b/apps/api/src/isms/isms.service.ts @@ -4,8 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { db } from '@db'; -import type { Prisma } from '@db'; +import { db, Prisma } from '@db'; import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto'; import { deriveControlLinks, resolveDocumentPlans } from './utils/ensure-setup-plan'; import { collectPlatformData } from './documents/data-source'; @@ -15,6 +14,8 @@ import { metricValidationMessages, seedMetricsIfMissing, } from './documents/monitoring'; +import { auditValidationMessages } from './documents/internal-audit'; +import { defaultProgrammeText } from './documents/internal-audit-defaults'; import { updateDraftSnapshot } from './utils/draft-snapshot'; import { EXPORT_DOCUMENT_INCLUDE } from './utils/export-payload'; import { lockDocument } from './utils/document-lock'; @@ -214,6 +215,40 @@ export class IsmsService { seedMetricsIfMissing({ tx, documentId: monitoringDoc.id }), ); } + + // Same first-load guarantee for Internal Audit (9.2): the Programme + // paragraph opens with its default text. The write is conditional on the + // narrative still being NULL (its creation state), so it is atomic: under + // concurrent setup calls — where the "created" lookup can also match a row + // the other call just created — an early customer edit can never be + // clobbered (the seed simply matches zero rows). + const internalAuditDoc = created.find( + (doc) => doc.type === 'internal_audit', + ); + if (internalAuditDoc) { + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + await db.ismsDocument.updateMany({ + where: { + id: internalAuditDoc.id, + // "Empty" matches generateNarrative's definition: NULL (the + // creation state) or an empty object — never a populated draft. + OR: [ + { draftNarrative: { equals: Prisma.AnyNull } }, + { draftNarrative: { equals: {} } }, + ], + }, + data: { + draftNarrative: { + programme: defaultProgrammeText( + organization?.name ?? 'The organization', + ), + }, + }, + }); + } } async getDocument({ @@ -250,6 +285,13 @@ export class IsmsService { objective: { select: { id: true, objective: true, target: true } }, }, }, + audits: { + orderBy: { position: 'asc' }, + include: { + controls: { orderBy: { position: 'asc' } }, + findings: { orderBy: { position: 'asc' } }, + }, + }, controlLinks: { select: { id: true, @@ -301,6 +343,11 @@ export class IsmsService { if (document.type === 'monitoring') { await this.assertMonitoringComplete({ tx, documentId }); } + // Clause 9.2: at least one audit, and a conclusion verdict on every + // completed audit (CS-724). + if (document.type === 'internal_audit') { + await this.assertInternalAuditComplete({ tx, documentId }); + } return tx.ismsDocument.update({ where: { id: documentId }, @@ -525,6 +572,25 @@ export class IsmsService { } } + private async assertInternalAuditComplete({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const audits = await tx.ismsAudit.findMany({ + where: { documentId }, + select: { reference: true, status: true, conclusionVerdict: true }, + }); + const messages = auditValidationMessages({ audits }); + if (messages.length > 0) { + throw new BadRequestException( + `This Clause 9.2 document is not ready to submit. ${messages.join(' ')}`, + ); + } + } + private async requireDocument({ documentId, organizationId, diff --git a/apps/api/src/isms/registers/register-registry.ts b/apps/api/src/isms/registers/register-registry.ts index b4d8b764f2..c653897c49 100644 --- a/apps/api/src/isms/registers/register-registry.ts +++ b/apps/api/src/isms/registers/register-registry.ts @@ -8,6 +8,9 @@ import type { IsmsRoleService } from '../isms-role.service'; import type { IsmsRoleAssignmentService } from '../isms-role-assignment.service'; import type { IsmsMetricService } from '../isms-metric.service'; import type { IsmsMeasurementService } from '../isms-measurement.service'; +import type { IsmsAuditService } from '../isms-audit.service'; +import type { IsmsAuditControlService } from '../isms-audit-control.service'; +import type { IsmsAuditFindingService } from '../isms-audit-finding.service'; /** * One generic dispatch for every ISMS register row (context issues, interested @@ -28,6 +31,20 @@ const COMPETENCE_BASIS = [ 'combination', ] as const; const METRIC_CADENCE = ['monthly', 'quarterly'] as const; +const AUDIT_STATUS = ['planned', 'in_progress', 'complete'] as const; +const AUDIT_CONCLUSION_VERDICT = [ + 'conform', + 'substantially_conform', + 'not_yet_conform', +] as const; +const AUDIT_CONTROL_RESULT = [ + 'conformity_confirmed', + 'nonconformity_raised', + 'observation_raised', + 'not_sampled', +] as const; +const AUDIT_FINDING_TYPE = ['nc_major', 'nc_minor', 'ofi', 'observation'] as const; +const AUDIT_FINDING_STATUS = ['open', 'in_progress', 'closed'] as const; const schemas = { contextIssueCreate: z.object({ @@ -176,6 +193,78 @@ const schemas = { value: z.string().trim().min(1).optional(), note: z.string().nullish(), }), + // reference is server-generated ("IA-YYYY-NN") and immutable; scope/criteria + // default to the clause-9.2 template text when omitted or blank. + auditCreate: z.object({ + scope: z.string().optional(), + criteria: z.string().optional(), + auditorName: z.string().nullish(), + plannedStartDate: z.string().nullish(), // ISO date string + plannedEndDate: z.string().nullish(), + position, + }), + auditUpdate: z.object({ + scope: z.string().trim().min(1).optional(), + criteria: z.string().trim().min(1).optional(), + // Nullish: undefined = leave as-is, null = clear. + auditorName: z.string().nullish(), + plannedStartDate: z.string().nullish(), + plannedEndDate: z.string().nullish(), + status: z.enum(AUDIT_STATUS).optional(), + conclusionVerdict: z.enum(AUDIT_CONCLUSION_VERDICT).nullish(), + conclusionNotes: z.string().nullish(), + // Sign-off slots: free-text names + ISO dates, each independently clearable. + signoffAuditorName: z.string().nullish(), + signoffAuditorDate: z.string().nullish(), + signoffSpoName: z.string().nullish(), + signoffSpoDate: z.string().nullish(), + signoffTopMgmtName: z.string().nullish(), + signoffTopMgmtDate: z.string().nullish(), + position, + }), + auditControlCreate: z.object({ + auditId: z.string(), + controlRef: z.string().trim().min(1), + whatWasTested: z.string().optional(), + whereToFind: z.string().optional(), + // Nullish: a row can be drafted before the auditor records an outcome. + result: z.enum(AUDIT_CONTROL_RESULT).nullish(), + notes: z.string().nullish(), + position, + }), + auditControlUpdate: z.object({ + controlRef: z.string().trim().min(1).optional(), + whatWasTested: z.string().optional(), + whereToFind: z.string().optional(), + result: z.enum(AUDIT_CONTROL_RESULT).nullish(), + notes: z.string().nullish(), + position, + }), + // reference is server-generated ("F-NN") and immutable. + auditFindingCreate: z.object({ + auditId: z.string(), + type: z.enum(AUDIT_FINDING_TYPE), + // Nullish: null/absent = a standalone finding not tied to a control row. + controlId: z.string().nullish(), + clauseOrControl: z.string().nullish(), + description: z.string().trim().min(1), + ownerMemberId: z.string().nullish(), + dueDate: z.string().nullish(), // ISO date string + status: z.enum(AUDIT_FINDING_STATUS).optional(), + closureEvidence: z.string().nullish(), + position, + }), + auditFindingUpdate: z.object({ + type: z.enum(AUDIT_FINDING_TYPE).optional(), + controlId: z.string().nullish(), + clauseOrControl: z.string().nullish(), + description: z.string().trim().min(1).optional(), + ownerMemberId: z.string().nullish(), + dueDate: z.string().nullish(), + status: z.enum(AUDIT_FINDING_STATUS).optional(), + closureEvidence: z.string().nullish(), + position, + }), } as const; /** One-save payload for the "Metrics due" / backfill views. */ @@ -216,6 +305,20 @@ export type UpdateMeasurementInput = z.infer; export type BulkCreateMeasurementInput = z.infer< typeof measurementBulkCreateSchema >; +export type CreateAuditInput = z.infer; +export type UpdateAuditInput = z.infer; +export type CreateAuditControlInput = z.infer< + typeof schemas.auditControlCreate +>; +export type UpdateAuditControlInput = z.infer< + typeof schemas.auditControlUpdate +>; +export type CreateAuditFindingInput = z.infer< + typeof schemas.auditFindingCreate +>; +export type UpdateAuditFindingInput = z.infer< + typeof schemas.auditFindingUpdate +>; export const ISMS_REGISTER_KEYS = [ 'context-issues', @@ -226,6 +329,9 @@ export const ISMS_REGISTER_KEYS = [ 'role-assignments', 'metrics', 'measurements', + 'audits', + 'audit-controls', + 'audit-findings', ] as const; export type IsmsRegisterKey = (typeof ISMS_REGISTER_KEYS)[number]; @@ -266,6 +372,9 @@ export interface RegisterServices { roleAssignments: IsmsRoleAssignmentService; metrics: IsmsMetricService; measurements: IsmsMeasurementService; + audits: IsmsAuditService; + auditControls: IsmsAuditControlService; + auditFindings: IsmsAuditFindingService; } /** Build the register → handler map from the injected per-register services. */ @@ -408,6 +517,54 @@ export function createRegisterRegistry( organizationId, }), }, + audits: { + create: ({ documentId, organizationId, data }) => + services.audits.create({ + documentId, + organizationId, + dto: parse(schemas.auditCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.audits.update({ + auditId: rowId, + organizationId, + dto: parse(schemas.auditUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.audits.remove({ auditId: rowId, organizationId }), + }, + 'audit-controls': { + create: ({ documentId, organizationId, data }) => + services.auditControls.create({ + documentId, + organizationId, + dto: parse(schemas.auditControlCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.auditControls.update({ + controlId: rowId, + organizationId, + dto: parse(schemas.auditControlUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.auditControls.remove({ controlId: rowId, organizationId }), + }, + 'audit-findings': { + create: ({ documentId, organizationId, data }) => + services.auditFindings.create({ + documentId, + organizationId, + dto: parse(schemas.auditFindingCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.auditFindings.update({ + findingId: rowId, + organizationId, + dto: parse(schemas.auditFindingUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.auditFindings.remove({ findingId: rowId, organizationId }), + }, }; } diff --git a/apps/api/src/isms/utils/document-types.spec.ts b/apps/api/src/isms/utils/document-types.spec.ts index 64d257afd1..a6118439a8 100644 --- a/apps/api/src/isms/utils/document-types.spec.ts +++ b/apps/api/src/isms/utils/document-types.spec.ts @@ -1,8 +1,8 @@ import { ISMS_TYPE_DEFINITIONS, matchRequirementId } from './document-types'; describe('ISMS_TYPE_DEFINITIONS', () => { - it('defines all eight foundational document types with clauses', () => { - expect(ISMS_TYPE_DEFINITIONS).toHaveLength(8); + it('defines all nine foundational document types with clauses', () => { + expect(ISMS_TYPE_DEFINITIONS).toHaveLength(9); const types = ISMS_TYPE_DEFINITIONS.map((d) => d.type); expect(types).toEqual( expect.arrayContaining([ @@ -14,6 +14,7 @@ describe('ISMS_TYPE_DEFINITIONS', () => { 'roles_and_responsibilities', 'objectives_plan', 'monitoring', + 'internal_audit', ]), ); }); @@ -25,6 +26,13 @@ describe('ISMS_TYPE_DEFINITIONS', () => { expect(monitoring?.clause).toBe('9.1'); }); + it('maps internal_audit to clause 9.2', () => { + const internalAudit = ISMS_TYPE_DEFINITIONS.find( + (d) => d.type === 'internal_audit', + ); + expect(internalAudit?.clause).toBe('9.2'); + }); + it('maps 4.2 to both interested-parties documents', () => { const clause42 = ISMS_TYPE_DEFINITIONS.filter((d) => d.clause === '4.2'); expect(clause42.map((d) => d.type)).toEqual([ diff --git a/apps/api/src/isms/utils/document-types.ts b/apps/api/src/isms/utils/document-types.ts index 55c69e94a3..149aebf739 100644 --- a/apps/api/src/isms/utils/document-types.ts +++ b/apps/api/src/isms/utils/document-types.ts @@ -73,6 +73,13 @@ export const ISMS_TYPE_DEFINITIONS: IsmsTypeDefinition[] = [ description: 'The metrics the organization monitors — what is measured, how, when, by whom, and who analyses the results (ISO 27001 clause 9.1).', }, + { + type: 'internal_audit', + clause: '9.2', + title: 'Internal Audit', + description: + 'The internal audit programme and the plan, controls tested, findings and conclusion of each internal audit of the ISMS (ISO 27001 clause 9.2).', + }, ]; /** diff --git a/apps/api/src/isms/utils/export-payload.ts b/apps/api/src/isms/utils/export-payload.ts index 2e64b032a3..c8e48ad3bb 100644 --- a/apps/api/src/isms/utils/export-payload.ts +++ b/apps/api/src/isms/utils/export-payload.ts @@ -12,6 +12,11 @@ import { mapMetrics, type MonitoringExtras, } from '../documents/monitoring-export-data'; +import { + loadInternalAuditExtras, + mapAudits, + type InternalAuditExtras, +} from '../documents/internal-audit-export-data'; import type { DocumentExportInput, IsmsOrgProfile, @@ -63,6 +68,13 @@ export const EXPORT_DOCUMENT_INCLUDE = { }, }, }, + audits: { + orderBy: { position: 'asc' }, + include: { + controls: { orderBy: { position: 'asc' } }, + findings: { orderBy: { position: 'asc' } }, + }, + }, } satisfies Prisma.IsmsDocumentInclude; export type LoadedExportDocument = Prisma.IsmsDocumentGetPayload<{ @@ -114,6 +126,18 @@ export async function resolveMonitoringExtras( }); } +/** The Internal Audit document (9.2) resolves finding-owner names; other types don't. */ +export async function resolveInternalAuditExtras( + document: LoadedExportDocument, + client?: Prisma.TransactionClient, +): Promise { + if (document.type !== 'internal_audit') return undefined; + return loadInternalAuditExtras({ + organizationId: document.organizationId, + client, + }); +} + function formatDateYmd(date: Date | null): string | null { return date ? date.toISOString().slice(0, 10) : null; } @@ -151,11 +175,13 @@ export function buildExportInput({ orgProfile, rolesExtras, monitoringExtras, + internalAuditExtras, }: { document: LoadedExportDocument; orgProfile?: IsmsOrgProfile; rolesExtras?: RolesExtras; monitoringExtras?: MonitoringExtras; + internalAuditExtras?: InternalAuditExtras; }): DocumentExportInput { return { contextIssues: document.contextIssues.map((issue) => ({ @@ -190,6 +216,9 @@ export function buildExportInput({ metrics: monitoringExtras ? mapMetrics(document.metrics, monitoringExtras) : undefined, + audits: internalAuditExtras + ? mapAudits(document.audits, internalAuditExtras) + : undefined, }; } @@ -211,11 +240,13 @@ export async function buildDraftSnapshot( const orgProfile = await resolveOrgProfile(document); const rolesExtras = await resolveRolesExtras(document); const monitoringExtras = await resolveMonitoringExtras(document); + const internalAuditExtras = await resolveInternalAuditExtras(document); const input = buildExportInput({ document, orgProfile, rolesExtras, monitoringExtras, + internalAuditExtras, }); const metadata = buildExportMetadata({ type: document.type, diff --git a/apps/api/src/isms/wizard/isms-profile.service.ts b/apps/api/src/isms/wizard/isms-profile.service.ts index 37ff28db88..be66f9dd55 100644 --- a/apps/api/src/isms/wizard/isms-profile.service.ts +++ b/apps/api/src/isms/wizard/isms-profile.service.ts @@ -34,6 +34,7 @@ const GENERATION_ORDER: Record = { roles_and_responsibilities: 5, objectives_plan: 6, monitoring: 7, + internal_audit: 8, }; const GENERATION_ORDER_DEFAULT = Object.keys(GENERATION_ORDER).length; diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx index e60edfb7c2..213d007f9e 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx @@ -8,6 +8,7 @@ import { resolveUserPermissions } from '@/lib/permissions.server'; import { auth } from '@/utils/auth'; import { ContextOfOrganizationClient } from '../components/ContextOfOrganizationClient'; import { InterestedPartiesClient } from '../components/InterestedPartiesClient'; +import { InternalAuditClient } from '../components/InternalAuditClient'; import type { ApproverOption } from '../components/IsmsApprovalSection'; import { LeadershipClient } from '../components/LeadershipClient'; import { MonitoringClient } from '../components/MonitoringClient'; @@ -33,6 +34,8 @@ interface IsmsDetailClientProps { approverOptions: ApproverOption[]; /** All active members (for the Roles member pickers); superset of approvers. */ memberOptions: ApproverOption[]; + /** Internal Auditor holder(s) from Roles (5.3) — only for the 9.2 document. */ + auditorOptions?: string[]; } const ISMS_DETAIL_CLIENTS: Record< @@ -45,6 +48,7 @@ const ISMS_DETAIL_CLIENTS: Record< objectives_plan: ObjectivesClient, roles_and_responsibilities: RolesClient, monitoring: MonitoringClient, + internal_audit: InternalAuditClient, isms_scope: ScopeClient, leadership_commitment: LeadershipClient, }; @@ -163,6 +167,39 @@ export default async function IsmsDocumentPage({ .map((p) => ({ id: p.id, name: p.user?.name ?? p.user?.email ?? 'Unknown' })) .sort((a, b) => a.name.localeCompare(b.name)); + // The Internal Audit auditor dropdown is whoever Roles (5.3) says the + // Internal Auditor is — assigned members and/or the external firm. No + // defaulting on our part: an empty list means Roles is not configured yet. + let auditorOptions: string[] = []; + if (documentType === 'internal_audit') { + const rolesDoc = setupResult.data?.documents?.find( + (doc) => doc.type === 'roles_and_responsibilities', + ); + if (rolesDoc) { + const rolesResult = await serverApi.get( + `/v1/isms/documents/${rolesDoc.id}`, + ); + const auditorRole = rolesResult.data?.roles?.find( + (role) => role.roleKey === 'internal_auditor', + ); + const memberNames = new Map(memberOptions.map((m) => [m.id, m.name])); + const holders = (auditorRole?.assignments ?? []) + .map((assignment) => memberNames.get(assignment.memberId)) + .filter((name): name is string => !!name); + const routeHolder = auditorRole?.auditRouteMemberId + ? memberNames.get(auditorRole.auditRouteMemberId) + : undefined; + const firm = auditorRole?.auditFirmName?.trim(); + auditorOptions = [ + ...new Set( + [...holders, routeHolder, firm].filter( + (name): name is string => !!name, + ), + ), + ]; + } + } + const DetailClient = ISMS_DETAIL_CLIENTS[documentType]; return ( @@ -175,6 +212,7 @@ export default async function IsmsDocumentPage({ currentMemberId={currentMember?.id ?? null} approverOptions={approverOptions} memberOptions={memberOptions} + auditorOptions={auditorOptions} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditCard.tsx new file mode 100644 index 0000000000..b8d86f89e7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditCard.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + Button, + Grid, + Heading, + HStack, + Stack, +} from '@trycompai/design-system'; +import { Edit, TrashCan } from '@trycompai/design-system/icons'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import type { IsmsAudit } from '../isms-types'; +import { AuditControlsTable } from './AuditControlsTable'; +import { AuditFields } from './AuditFields'; +import { + AuditFindingsSection, + type FindingPrefill, +} from './AuditFindingsSection'; +import { AuditSignoffCard } from './AuditSignoffCard'; +import type { ApproverOption } from './IsmsApprovalSection'; +import { + auditDetailsSchema, + type AuditControlFormValues, + type AuditDetailsFormValues, + type FindingFormValues, + type SignoffFormValues, +} from './audit-schema'; +import { + AUDIT_STATUS_LABELS, + conclusionSentence, +} from './internal-audit-constants'; +import { IsmsRegisterCard, IsmsRegisterField } from './shared'; + +/** All mutations an audit card (and its child sections) can perform. */ +export interface AuditHandlers { + onUpdateAudit: (auditId: string, values: AuditDetailsFormValues) => Promise; + onDeleteAudit: (auditId: string) => Promise; + onSaveSignoff: (auditId: string, values: SignoffFormValues) => Promise; + onCreateControl: (auditId: string, values: AuditControlFormValues) => Promise; + onUpdateControl: (controlId: string, payload: Record) => Promise; + onDeleteControl: (controlId: string) => Promise; + onCreateFinding: (auditId: string, values: FindingFormValues) => Promise; + onUpdateFinding: (findingId: string, values: FindingFormValues) => Promise; + onDeleteFinding: (findingId: string) => Promise; +} + +interface AuditCardProps extends AuditHandlers { + audit: IsmsAudit; + canEdit: boolean; + memberOptions: ApproverOption[]; + auditorOptions: string[]; +} + +function toFormValues(audit: IsmsAudit): AuditDetailsFormValues { + return { + scope: audit.scope, + criteria: audit.criteria, + auditorName: audit.auditorName ?? '', + plannedStartDate: audit.plannedStartDate?.slice(0, 10) ?? '', + plannedEndDate: audit.plannedEndDate?.slice(0, 10) ?? '', + status: audit.status, + conclusionVerdict: audit.conclusionVerdict ?? '', + conclusionNotes: audit.conclusionNotes ?? '', + }; +} + +export function AuditCard({ + audit, + canEdit, + memberOptions, + auditorOptions, + onUpdateAudit, + onDeleteAudit, + onSaveSignoff, + onCreateControl, + onUpdateControl, + onDeleteControl, + onCreateFinding, + onUpdateFinding, + onDeleteFinding, +}: AuditCardProps) { + const [isEditing, setIsEditing] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + // Set when a Controls Tested row is marked non-conformity / observation: + // the findings section opens a linked-finding form pre-filled from the row. + const [findingPrefill, setFindingPrefill] = useState( + null, + ); + + const { + control, + handleSubmit, + reset, + formState: { isDirty, isValid, isSubmitting }, + } = useForm({ + resolver: zodResolver(auditDetailsSchema), + mode: 'onChange', + defaultValues: toFormValues(audit), + }); + + useEffect(() => { + if (!isEditing) reset(toFormValues(audit)); + }, [audit, isEditing, reset]); + + const handleSave = handleSubmit(async (values) => { + try { + await onUpdateAudit(audit.id, values); + } catch { + return; + } + setIsEditing(false); + }); + + const handleDelete = async () => { + setConfirmOpen(false); + setIsDeleting(true); + try { + await onDeleteAudit(audit.id); + } catch { + // Error already surfaced via toast by the caller. + } finally { + setIsDeleting(false); + } + }; + + const headerActions = canEdit ? ( + isEditing ? ( + + + + + ) : ( + + +