diff --git a/.dockerignore b/.dockerignore index b1ba248..47b179f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,8 @@ **/node_modules **/dist artifacts +.local-appdata +.local-test-results data backups imports diff --git a/.gitignore b/.gitignore index 8b4779c..c697d99 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ Thumbs.db *.backup # Local logs +/.local-test-results/ *.log logs/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 11aadbc..f0e6d12 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,9 @@ true + + + diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index d9d2f48..4942f86 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `883ef812ee04516208b84972195b5e2a46207e44`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `2ed833f290b439a7bc91e5fd19b042c269e3bee9`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 81c5364..ede90bf 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,4121 +1,5658 @@ { "schemaVersion": 1, - "sourceRevision": "883ef812ee04516208b84972195b5e2a46207e44", + "sourceRevision": "2ed833f290b439a7bc91e5fd19b042c269e3bee9", "files": [ { "path": ".config/dotnet-tools.json", "sha256": "0d705a52e2d0a8c18776b41cd09b69955c6d265848f2fc7c913ae17e72924e07", - "bytes": 183 + "bytes": 183, + "mode": "100644" }, { "path": ".dockerignore", - "sha256": "c2ea27f6d9d98d326aa1bd05493e30641f7f33eab2c508f7a65a09b1dcb3291e", - "bytes": 186 + "sha256": "1a0e5ead63b64012f502a7832904c56567f4848125496e646aa35a63f1cd6506", + "bytes": 221, + "mode": "100644" }, { "path": ".editorconfig", "sha256": "2ab44261f0604203d49d28099ae623e7430562434dbf08d98c078b3b38c59a09", - "bytes": 989 + "bytes": 989, + "mode": "100644" }, { "path": ".env.example", "sha256": "7ee366af82c106c53da2aa2eda7b1da9a71681b2c800159161c0addc84f4dd23", - "bytes": 861 + "bytes": 861, + "mode": "100644" }, { "path": ".gitattributes", "sha256": "d3241577d687fda1ad4c3a313d9453af1d501ab6232c8ae925d5da1bcbc76a22", - "bytes": 152 + "bytes": 152, + "mode": "100644" }, { "path": ".gitignore", - "sha256": "063f777aadb4464b25b0a7e7490d23941191dc5bc1fa63a7219cd0825fd7bc90", - "bytes": 2564 + "sha256": "1b3ed68d19aefc7de74cb9c00892daecbc38fcad66b4c75086b56444702f22bb", + "bytes": 2586, + "mode": "100644" }, { "path": "CONTRIBUTING.md", "sha256": "555837ab788bf1ab8c826f928a3bfd813be1e26383bbf7ba3b14bd26074cf629", - "bytes": 1422 + "bytes": 1422, + "mode": "100644" }, { "path": "Directory.Build.props", "sha256": "1b9316a55c61a41e2bea70fe860dc4cd6dc225b7506d8b25b9b764df97609e6e", - "bytes": 427 + "bytes": 427, + "mode": "100644" }, { "path": "Directory.Build.targets", "sha256": "f2a1cc660c5c59233273cbc070145eb5892877c5de2881be6c0a2bed32e624b1", - "bytes": 391 + "bytes": 391, + "mode": "100644" }, { "path": "Directory.Packages.props", - "sha256": "4de6b529b4e61eed6b3204d0f148f325f8c432f549cde58c961cbb986ba9ccdf", - "bytes": 2270 + "sha256": "7ae6aa4eb18f5d36251492406a9131b0c907d178c23a41f6808b1981879629ca", + "bytes": 2539, + "mode": "100644" }, { "path": "ItWorx.KnowledgeHub.slnx", "sha256": "e33988c92c1615bc225920cb466069a8e6d118ab6501d564de0766304a173d27", - "bytes": 1057 + "bytes": 1057, + "mode": "100644" }, { "path": "LICENSE", "sha256": "3cb8980a332769231620c69ab23929cafc5fbb98664f19c3c623b53ded5136d4", - "bytes": 377 + "bytes": 377, + "mode": "100644" }, { "path": "MANIFEST.json", "sha256": "9e5bcade00174e302dd665220aa911c0f23d2d377c7fb5a8e0ee3cfdaec6a425", - "bytes": 901 + "bytes": 901, + "mode": "100644" }, { "path": "NuGet.Config", "sha256": "90571d3c6c59fed0ceb64eca618a17c24c0d2eb05d67fa742f696eb9cf0c54d1", - "bytes": 275 + "bytes": 275, + "mode": "100644" }, { "path": "README.md", - "sha256": "f96c14c96b82be0cda9a3b10638a88e3d8507346a62360a9e8b7924dc19d144e", - "bytes": 6085 + "sha256": "8d496d4b14cd4a52b64a448abc40a91c1f736a3ed2a769eac04c4d24aa863df7", + "bytes": 6433, + "mode": "100644" }, { "path": "SECURITY.md", "sha256": "fdfe1a8563115cff5bc83c898b6afc436494b4927690884425fc0b1372ab088d", - "bytes": 2165 + "bytes": 2165, + "mode": "100644" }, { "path": "deploy/docker/Dockerfile", - "sha256": "844bdabab7f7d7f96867c98a20a4018e88ed54116dc26c643c4dcd8505e866c8", - "bytes": 3525 + "sha256": "fbffbb204ce9d9a82c73fae581da469081b72bd1ea9ba94affe856b271bbb5e5", + "bytes": 3688, + "mode": "100644" + }, + { + "path": "deploy/observability/collector.yaml", + "sha256": "ee5a4c363a25c5d41e155100a2212b845167ba6895bae6be9981faa5fa179fe1", + "bytes": 386, + "mode": "100644" + }, + { + "path": "deploy/observability/compose.yaml", + "sha256": "13c05bed53d4861190ba18ac4b82eb229bb46ed157a7cb82eabe9c403a2bb729", + "bytes": 1952, + "mode": "100644" + }, + { + "path": "deploy/observability/grafana-datasource.yaml", + "sha256": "c34f5444422f7a61018eb9e6d33448069537e995b980d4972ced3fc1228b6865", + "bytes": 173, + "mode": "100644" + }, + { + "path": "deploy/observability/prometheus.yaml", + "sha256": "2fe4898dd5b505e6163c73a41504c87dec733a98ad5618c75ff80ab805bfe9da", + "bytes": 168, + "mode": "100644" }, { "path": "docker-compose.yml", - "sha256": "86ae75c4cb9cc9374a888c42a9e99912614f127fd738873a51ae5eea72071495", - "bytes": 4298 + "sha256": "79def4bd786305c260005ec3b0b5d3d96f0d5c7c26de2be8e4b5787fafe48ff9", + "bytes": 4603, + "mode": "100644" }, { "path": "docs/00-product-vision.md", "sha256": "e6a4cac238971be1a501cbbe115c530b8e7623071566ce7a47ecfab08bf0516d", - "bytes": 3029 + "bytes": 3029, + "mode": "100644" }, { "path": "docs/01-scope-and-non-goals.md", "sha256": "4cb84268970b866251c7e3c9cd947b4dd2aebfd099e77cf7753715be8b29a999", - "bytes": 2976 + "bytes": 2976, + "mode": "100644" }, { "path": "docs/05-non-functional-requirements.md", "sha256": "ae191ed4ebade9b90bcd45b5cbd4df6b261ef861f4eb53756956235d0cec9f3b", - "bytes": 4195 + "bytes": 4195, + "mode": "100644" }, { "path": "docs/06-system-architecture.md", "sha256": "99ca9fec4eb3262e17c7f63341bd2b3e8cb15b46e0b60ce69196bbdfc5c3b3ab", - "bytes": 5193 + "bytes": 5193, + "mode": "100644" }, { "path": "docs/07-domain-model.md", "sha256": "1429e4f543db02a325d2415eec1a7c50d3caca1afa0b670581aa4352356a0331", - "bytes": 6054 + "bytes": 6054, + "mode": "100644" }, { "path": "docs/08-database-schema.md", "sha256": "d507da527686acfe4f7bcbf0b575169c701c0c08570153466171553c3b3eb8fb", - "bytes": 6443 + "bytes": 6443, + "mode": "100644" }, { "path": "docs/09-api-contract.md", "sha256": "9ca68276ea38bb48c3dfc18a365d01beeec7f2c2d6f3de71cf7056b1a7acc64b", - "bytes": 6415 + "bytes": 6415, + "mode": "100644" }, { "path": "docs/10-search-and-indexing.md", "sha256": "1fda26e0adea0bf4ded57150d99f41c09bfbfc8f42ba071ad525ec98d4a76e8b", - "bytes": 3733 + "bytes": 3733, + "mode": "100644" }, { "path": "docs/11-markdown-and-linking.md", "sha256": "35aeb470602585faf2fe8f218c4a9dca4291bd7f7f1d8160bfa7dfbdf25ca5da", - "bytes": 4779 + "bytes": 4779, + "mode": "100644" }, { "path": "docs/12-auth-security.md", - "sha256": "8c1eb011da918e17dc363facbe191233d4c4bdedc4b755c58895eaa1d0ebbf29", - "bytes": 6738 + "sha256": "fbf59555c82aa9c77cd4a036ee474b854795c7644c9201f7dc2090ae865a6ea8", + "bytes": 7202, + "mode": "100644" }, { "path": "docs/13-attachments.md", "sha256": "aa0ad35374ec3ac984a7e14a154cf0f256c956128d8b26e8e55670735d24ae91", - "bytes": 4262 + "bytes": 4262, + "mode": "100644" }, { "path": "docs/14-revisions-and-audit.md", "sha256": "ae53e26efc9c3c19d7b47d477f2e6f0c9a0d429e2a089c59e8b603140cc05e12", - "bytes": 3462 + "bytes": 3462, + "mode": "100644" }, { "path": "docs/15-project-hub.md", "sha256": "f94b371aba66207d7cf5a7f82d7592745530a244a4f7b811e667efc99aacaa1c", - "bytes": 3561 + "bytes": 3561, + "mode": "100644" }, { "path": "docs/16-runbooks-and-adrs.md", "sha256": "6d075534795ca26e02d3bd5167c2e085411e6a13934203267e6929297755f90a", - "bytes": 2455 + "bytes": 2455, + "mode": "100644" }, { "path": "docs/17-leafwiki-migration.md", "sha256": "2d812dddf87a09bbd5494fb23109999ea199fee885877b441b1fb4776e2d22da", - "bytes": 9220 + "bytes": 9220, + "mode": "100644" }, { "path": "docs/18-backup-restore.md", - "sha256": "644a777f5d4e595f1af7e79807581a06248c182baa79117189eeeab7a227c676", - "bytes": 4762 + "sha256": "c4ae783f1f98441064a696fc02924369a267649ebcf86a2f05a29b0169ac76ee", + "bytes": 9340, + "mode": "100644" }, { "path": "docs/19-observability.md", "sha256": "62410475a7ac1c4eaea3bdc56041f9075323deb31a0e0458d3e3d0879d27876a", - "bytes": 3925 + "bytes": 3925, + "mode": "100644" }, { "path": "docs/20-ui-ux-design-system.md", "sha256": "95d30a48c4e873ec0eba70c312a7a547a31b8319ae5cd66e579db81167a3c1f0", - "bytes": 5117 + "bytes": 5117, + "mode": "100644" }, { "path": "docs/21-accessibility.md", "sha256": "d97b7cb4bc04c8392e78ce93a5d2a10b6c6b27f2ea89febfe8889a17991c516a", - "bytes": 3668 + "bytes": 3668, + "mode": "100644" }, { "path": "docs/22-testing-strategy.md", "sha256": "3640e34604bb230378f210ebdfa8993218640f73034cc1a98c0abd20a36cc7ca", - "bytes": 5038 + "bytes": 5038, + "mode": "100644" }, { "path": "docs/24-mcp-readiness.md", "sha256": "016ece2dfa1b812443d01554b9887eebf8251d9608d872677fdf49c120ac5862", - "bytes": 5511 + "bytes": 5511, + "mode": "100644" }, { "path": "docs/26-threat-model.md", "sha256": "6a4e3d1fa28a4e74afa5f202bc41fe40ed9be7ecfe4e64a03b18d4d43d0d64d9", - "bytes": 4989 + "bytes": 4989, + "mode": "100644" }, { "path": "docs/api/openapi.yaml", - "sha256": "ed9a4cc30e22bd08b106c3580be8321e693e1acce55f9948983265d88fee8aa3", - "bytes": 209363 + "sha256": "5b8fe80c77639635e49a7d555c64978dc5a3453dd8cf18984edaf59a774bb154", + "bytes": 232353, + "mode": "100644" }, { "path": "docs/assets/screenshots/02-dashboard.png", "sha256": "4f77968a98c1c617c99e652919c6855328a91e0293951011348560170cd0c88b", - "bytes": 120018 + "bytes": 120018, + "mode": "100644" }, { "path": "docs/assets/screenshots/03-knowledge.png", "sha256": "a84f7fe07708d67479c660775ea0bb6193d4c37ff9d16594b98326478337dd73", - "bytes": 190279 + "bytes": 190279, + "mode": "100644" + }, + { + "path": "docs/background-jobs.md", + "sha256": "df2300a912181cbc4e67ea613bd08aded5d4741bc4f3b81873ea1886c6ba96ae", + "bytes": 2714, + "mode": "100644" }, { "path": "docs/guides/back-ups-herstel-en-unraid-beheer.md", - "sha256": "c9144dbc479e0bc97285f8bd75ced327643450dc19ea08b59c708b91d5027781", - "bytes": 5641 + "sha256": "236221ce56c2c9db6612cb61375138fabb8662d4f69034e788599f1563d5d73b", + "bytes": 7358, + "mode": "100644" }, { "path": "docs/guides/bijlagen-en-leafwiki-import.md", "sha256": "c6a2cf6903cdf499c5a02d34459b00d13f426c08ab915dfb422cf8e376b72dce", - "bytes": 3591 + "bytes": 3591, + "mode": "100644" }, { "path": "docs/guides/documenten-toevoegen-en-intake.md", "sha256": "2352f8ef10155ddd4143c9d2525cae80a9d64676dd93cca8c07c13716716b932", - "bytes": 1650 + "bytes": 1650, + "mode": "100644" }, { "path": "docs/guides/eerste-installatie-en-aanmelden.md", "sha256": "73038719d7f1f38b5f65748387fe018e64c3877a1a1052c2b02133b529480ab7", - "bytes": 3783 + "bytes": 3783, + "mode": "100644" }, { "path": "docs/guides/operationele-runbooks-overzicht.md", "sha256": "572d982a90ac4ee02f6755bbaaafe20fae5a897365a1c6496ac1e216c5389dfc", - "bytes": 5067 + "bytes": 5067, + "mode": "100644" }, { "path": "docs/guides/pagina-s-schrijven-en-beheren.md", "sha256": "b16c8d3a553b7c1eb6a3ce9a0157eddf2cf6583fd5ff816cfc8a2310feca6ee0", - "bytes": 5103 + "bytes": 5103, + "mode": "100644" }, { "path": "docs/guides/projecten-beslissingen-en-runbooks.md", "sha256": "81ff1b257dd388b5128e4231c9374da54f7faaca32f86564d8e4887bfcb89311", - "bytes": 4428 + "bytes": 4428, + "mode": "100644" }, { "path": "docs/guides/service-accounts-en-automatisering.md", "sha256": "4656bea026e6adeffffb1dbdcdb0572a2964290c9fd122794093a241f9f4d19c", - "bytes": 7449 + "bytes": 7449, + "mode": "100644" }, { "path": "docs/guides/source-batch-removal.md", "sha256": "04d3c6ddebd650fa3a5159c7dcaf8f8c1fca83c1fbaefb0ab6375b3d9e029536", - "bytes": 2105 + "bytes": 2105, + "mode": "100644" }, { "path": "docs/guides/zoeken-geschiedenis-en-labels.md", "sha256": "0abaade24d0d88e2c05f94263a1e213680ba3f371b6ffc566abcc3c8a855c3d2", - "bytes": 2909 + "bytes": 2909, + "mode": "100644" + }, + { + "path": "docs/observability-platform.md", + "sha256": "024e18655832b00a3e9a7033da3f445d4eb83174994ef5fdaf94f5c2411cd984", + "bytes": 2624, + "mode": "100644" + }, + { + "path": "docs/platform-settings.md", + "sha256": "aecb3c59da8c668f829538049f4507e56c30c142d45161a3bdaf56870dcc340a", + "bytes": 3387, + "mode": "100644" }, { "path": "fixtures/leafwiki/README.md", "sha256": "83d8c74ad07e21f79745348d9c7b4853561cf15e3910861bc0ef4c6d410cf29f", - "bytes": 621 + "bytes": 621, + "mode": "100644" }, { "path": "fixtures/markdown-import/Projecten/Voorbeeldproject.md", "sha256": "8073c9e060711df344c89992ae1d164c29cccd2362658f0281802bd873b7c3be", - "bytes": 162 + "bytes": 162, + "mode": "100644" }, { "path": "fixtures/markdown-import/School/Linux/Bestandsrechten.md", "sha256": "102d22fe7096f0249200f9a492741bfbfc3940d8f488a7ffd766f9f85b4cf22f", - "bytes": 126 + "bytes": 126, + "mode": "100644" }, { "path": "fixtures/markdown-import/School/Linux/Shell-operators.md", "sha256": "40ad436d12a4a5b89855a9fce472bb269f8b72cb57f4f2207a5c1e3b8f4d9573", - "bytes": 202 + "bytes": 202, + "mode": "100644" }, { "path": "fixtures/markdown-import/assets/architecture.svg", "sha256": "172e951e869735f4ca68f3aa68945bdff7e7868561292266908c77bfc61efc77", - "bytes": 575 + "bytes": 575, + "mode": "100644" }, { "path": "fixtures/markdown-import/source-manifest.json", "sha256": "7028b1284fd7e2910dcbc08cf5e50515ab405eab66b006decb7556736800abdc", - "bytes": 140 + "bytes": 140, + "mode": "100644" }, { "path": "global.json", "sha256": "6cd80ed6f7a93e76c20e47164e3bfedfddc1b42b33519b2cd2dee6febff4e836", - "bytes": 111 + "bytes": 111, + "mode": "100644" }, { "path": "package.json", "sha256": "54f467b65e7c45cd7e16d65e36dadef42c8d14f6b9c0a105a57703caa6d8c64b", - "bytes": 1042 + "bytes": 1042, + "mode": "100644" }, { "path": "pnpm-lock.yaml", - "sha256": "128ec7f17a519778986a9d7890ec2da74fd6362fbeaae241df6ff0589be42b22", - "bytes": 139775 + "sha256": "5139471372511d950cccb7dffd9346c9d8bca5592dbd99f96a54c359b4c78872", + "bytes": 140522, + "mode": "100644" }, { "path": "pnpm-workspace.yaml", "sha256": "974c5c67f9c2429b38eeea3cc1a7c8d78e5c24833c647c5c69e6a7b369d53931", - "bytes": 150 + "bytes": 150, + "mode": "100644" }, { "path": "scripts/check-openapi-coverage.mjs", - "sha256": "064f649140e7c72234680881bf59051053d4df8fb36a2eb3d84712a31b328508", - "bytes": 4122 + "sha256": "3f43d6b228336be887144e67175b03614792e5f08967069fae5f5c3d09046cd4", + "bytes": 2748, + "mode": "100644" }, { "path": "scripts/doctor.mjs", "sha256": "32f7f374e277ef82e1cf66c4f05d029d6c60bb7ac9170ad71f7bc4886f5ec48c", - "bytes": 3401 + "bytes": 3401, + "mode": "100644" }, { "path": "scripts/generate-release-manifest.mjs", - "sha256": "5d801b61d2a805c5d4b20af8520193b90821296f9e5cc7851f12767a91113a52", - "bytes": 5138 + "sha256": "cb2efde98ed83182a340a9c426cfcfadec84167cad8f713636c7f89e1a735405", + "bytes": 5981, + "mode": "100644" }, { "path": "scripts/lib/exec.mjs", "sha256": "17b303d2fec7c6b99eb687036fac2658b2fdfa63cc3f60517b3cd96786210c0c", - "bytes": 3493 + "bytes": 3493, + "mode": "100644" + }, + { + "path": "scripts/lib/openapi-routes.mjs", + "sha256": "b8bbe97f1e11572587655f548260684c7f72efafdcf993fb57165eac443c13b4", + "bytes": 1216, + "mode": "100644" + }, + { + "path": "scripts/lib/product-backlog.mjs", + "sha256": "4f3b0fb8abbeca20b453ff386774ee42aad4ee2d2e71427e1a5cfb44795dc8bd", + "bytes": 2576, + "mode": "100644" + }, + { + "path": "scripts/lib/release-gates.mjs", + "sha256": "82d3c3edac64849e8777a6d19189d67539f60137a4056a0f506a1951f97c69f0", + "bytes": 260, + "mode": "100644" + }, + { + "path": "scripts/lib/route-map.mjs", + "sha256": "d08f5c5d9347ae97e19adccf0e4982df0a5ae25e722a5847ef048162f1fae3a1", + "bytes": 8948, + "mode": "100644" }, { "path": "scripts/validate-openapi.mjs", "sha256": "d59e7aa97e5c3bf17438804453306900d986f07dab811ee59e03af62a11383ba", - "bytes": 2209 + "bytes": 2209, + "mode": "100644" }, { "path": "scripts/validate-public-source.mjs", - "sha256": "c6610b35e3938330ae4deda6bfd5b7f4c97ce547b4a94cf9a39faecc8da6c053", - "bytes": 1238 + "sha256": "3ae5d677cff57dc431f0d000c223f9efd1648c69e74b0b1a0a4bde207d57e76a", + "bytes": 3043, + "mode": "100644" }, { "path": "scripts/verify.mjs", - "sha256": "3e47ed409ecb43b8a1e6237d60a80d8b3d261a5e5657cd8c1dbd215fd855ef1a", - "bytes": 8548 + "sha256": "d327338d63c2f5a016e5e7d0e0c621d43f808ba682eed1843e8ab3f1ad7df469", + "bytes": 11998, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/AuthorizationPolicies.cs", "sha256": "3db009c090e34b8386c9d9e2169f0546b4e545e4b35c46d8b50e868ff126da1f", - "bytes": 401 + "bytes": 401, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessAuthorizationHandler.cs", "sha256": "4e29ba7d31db43611a201c3a5332ab2a5746e983303fa25c33ec56d489377916", - "bytes": 2212 + "bytes": 2212, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessEndpointExtensions.cs", "sha256": "60f2165830bfe08f13cdc13e1c5d26a414665d16f7bc97b488216851c29b8335", - "bytes": 1517 + "bytes": 1517, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessRequirement.cs", "sha256": "e55e3427da9661f13c333c885df4757548ff96f0ce58de9d47babbb8d9075592", - "bytes": 938 + "bytes": 938, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/ServiceTokenAuthenticationHandler.cs", "sha256": "95e281204f0c2f621f9f919000464073e3a7f318029fced25e7ed12cc57c5701", - "bytes": 3810 + "bytes": 3810, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleAuthorizationHandler.cs", "sha256": "265e80c23e957b90828f9c144a1541ea67c6639bbbf2626910618e36b805127a", - "bytes": 1500 + "bytes": 1500, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleRequirement.cs", "sha256": "a69cc4bb1d2a982e7dbdf352f5a65b43eeccd0185d4a56739746635750b79b7e", - "bytes": 533 + "bytes": 533, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/JobWorker.cs", + "sha256": "988b673691e5117646605acfd7f9164f97d94670a380865bc8c78381caf3cde0", + "bytes": 20861, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/OperationalTelemetryBackgroundService.cs", + "sha256": "a511ac85b6a9acf78faa9a13ae0453157cbe2aab2b574ab6f7be3939ca4a93e7", + "bytes": 2014, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/StagingSessionCleanupBackgroundService.cs", "sha256": "a9627543eeb86d8e75e2c926fb0624efc99ba9679649184df2ad33c72480372b", - "bytes": 4372 + "bytes": 4372, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs", - "sha256": "df0965011d7d97a6d03dbaf24a6c0bf8c05ed6be616a6bfbf78efdd090d04bfd", - "bytes": 1150 + "sha256": "d86fc65ef2acdd9367acc8c96298da5164791e448e8af3464635ac5bf31613ba", + "bytes": 1210, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminAuditEndpoints.cs", + "sha256": "7bbdbaff9e3794cbbe2a1cee6c5344d82f2e57427756ab5252885590f5bb8634", + "bytes": 1086, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminEndpoints.cs", "sha256": "9ae490206424d5e4646bcef9a15df7938a082757ac39f1eb02d6dec9d325eeb0", - "bytes": 9494 + "bytes": 9494, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AttachmentsEndpoints.cs", "sha256": "7e4eef5e2d14040b4dfb602580794e20a72ca3d643b90054beabe68aefa1c8c8", - "bytes": 10712 + "bytes": 10712, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs", - "sha256": "cf9ff5fdb2cf00b815e3e042259a6dafc761c3439ffbe4d257df1aa6dcf223db", - "bytes": 5598 + "sha256": "d07a2b79dda0707d711666c6c97d07aca7313ba4b5d12e69551bbec0ba969d18", + "bytes": 5837, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs", - "sha256": "f2fc4717f4376f10b51a16092b70fad1698a476b84d3fa84ff8e1aaf426c31d2", - "bytes": 2288 + "sha256": "4280b0353a2f71a15b90c6b2c674c1b87376f206a3e1bf222dbe161d8386ef6f", + "bytes": 7029, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DataSourcesEndpoints.cs", + "sha256": "2134492af8e1fcd4a39dbcd8fbf884e6331d17d3147d03820083abf01e817b76", + "bytes": 5834, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DecisionsEndpoints.cs", "sha256": "eed41b036ef80a94909f30ecc748d0cd8c8b5410909faacdd93917d7290ef67d", - "bytes": 10299 + "bytes": 10299, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DraftsEndpoints.cs", "sha256": "c814c4bf4724ba1eaa8671bdf2f12eed0498933abb985b37c4cdce2b1fcfc917", - "bytes": 4641 + "bytes": 4641, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ImportsEndpoints.cs", "sha256": "7e5e95248d3fcb690e9675b8dfe2e2cfb97810c513a47ca39171f2711644f230", - "bytes": 13588 + "bytes": 13588, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/InfrastructureEndpoints.cs", "sha256": "de8923f90ef62278f439bc6d7084133901b2b0dbb59984ae726430a829bd2b0e", - "bytes": 30756 + "bytes": 30756, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs", - "sha256": "5fe075a816488b48149563041728724ffa22a9bb0bf1aa7261172d2dd11ffc8b", - "bytes": 26519 + "sha256": "017f865d012d6c603b293c9a83b57ebd7e7bffe030e042d94efa2707774d1193", + "bytes": 27349, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/JobsEndpoints.cs", + "sha256": "f985804dfb8d1263e957c69802931809bc9c14573d0f485e925c47d2a58bcfeb", + "bytes": 12964, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PagesEndpoints.cs", "sha256": "a043f77d8b7449bdb853f6326c021ffd358e5f3bd1a7af10643457d25db5488e", - "bytes": 26245 + "bytes": 26245, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PlatformEndpoints.cs", + "sha256": "615fed1bad4c585a48153912834d76ea3fef4b90052e876e543e1d11736228b9", + "bytes": 11493, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ProjectsEndpoints.cs", "sha256": "bb715300847727e2e4905ccb652d28da4b167822d35827d611523bc4abf23d51", - "bytes": 30000 + "bytes": 30000, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RagPublicationEndpoints.cs", "sha256": "07dc72cab1fec0b8a8cf3915f741d5a21d73eb52d9e3b198e684499e92fcb7d0", - "bytes": 3338 + "bytes": 3338, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RunbooksEndpoints.cs", "sha256": "c39f45548034c21e2fcc7a9bf2cca469201c4382a0516419216413fa1a7da0be", - "bytes": 9571 + "bytes": 9571, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs", - "sha256": "e2b81a8d0245ee93e92c35bed5fcd9a9f14bb471e7829410e8006ba38326a45a", - "bytes": 1968 + "sha256": "e3c4b7f8219ac2f28b0d31a8005b7f8d8af2d0ab63c2559350f3d8fbe9aecf62", + "bytes": 2067, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ServiceAccountsEndpoints.cs", "sha256": "ac42ebec42c9cc8ddf5e55af651e1789084c514705157974bdcc7c0ebc3da452", - "bytes": 6356 + "bytes": 6356, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SetupEndpoints.cs", "sha256": "a2936b5f2be938e7564e6a6ce7001e9c3f7d600a320c6ce34bdf0535ed958150", - "bytes": 3324 + "bytes": 3324, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SourceBatchPurgeEndpoints.cs", "sha256": "0c0be832f9bc5eaf0352c40b396c97f56ff4d0ec1560409f85e68dc69802207e", - "bytes": 3933 + "bytes": 3933, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/TemplatesEndpoints.cs", "sha256": "786f88c698d0c4e6544e196eb28d4bbdf68ebc46575bf8170e86467ec36ce606", - "bytes": 972 + "bytes": 972, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/UnifiedSearchEndpoints.cs", + "sha256": "30bb74b28623d99948fabcace4e893e57a400c4b69cb950aee8ed1162483b12a", + "bytes": 3648, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/WorkspaceEndpoints.cs", "sha256": "f32d69e9783cb3f7dd4dd1c238e7ea2d20af117820ba9c5c3626f41024cc02b8", - "bytes": 1398 + "bytes": 1398, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj", - "sha256": "7402928d3cde210690a5bd89bea05a432642346948a7c4b2ac2bcc424ea8a414", - "bytes": 619 + "sha256": "ec4d90148629b37868bffe3c3c3378ded0bb710ea1069541c179958b0507eae1", + "bytes": 872, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Knowledge/ETagHelper.cs", "sha256": "f8068b81563a5035edcc674ed7489b4aedb5fec69fac2665121c80cb0af6d75d", - "bytes": 593 + "bytes": 593, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs", - "sha256": "ae0c3bbec69d2b667e8576f3b6384601fd003816e3cf133e3f05fde1ae5fbedb", - "bytes": 3035 + "sha256": "b03953c881b9d43f9e8a2ca14b5083e85fdaa88adcdb5cde34e1159ca0425624", + "bytes": 5993, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Program.cs", - "sha256": "d3a6283ed7f30fe1a98d84fd934a28a0d2758b42eb4170bfb62d22778f74f6da", - "bytes": 29884 + "sha256": "112b628d5285a808296acfc97e5a52e63ef43e4fc6df87033443447f0f5954e1", + "bytes": 32882, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Properties/launchSettings.json", "sha256": "ba7320d62133875f99cd089fc4f9f67c24c0d4d116c1df173f9d04261723b8f1", - "bytes": 383 + "bytes": 383, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/AntiforgeryEndpointFilter.cs", "sha256": "6a307bcf239614f2d7fdcee0cd1928d17a64cbc666e45d9d9d44f7a593a261fd", - "bytes": 1365 + "bytes": 1365, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/CorrelationIdMiddleware.cs", "sha256": "d93726d49e9cf09e92c41628c6edd772c0e9d35726f043ee1570dfe9d74d90f9", - "bytes": 1232 + "bytes": 1232, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/IdempotencyEndpointFilter.cs", "sha256": "d2836a29097f6fd895e3713ea2d4ce68bcd4708133a3d2128a36cd626e6a4da0", - "bytes": 8448 + "bytes": 8448, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs", - "sha256": "2dcceb1f6a0c5e6193135c93d9f34269c32cffb3c5d7fecf31a2c7f8a05ebaf2", - "bytes": 406 + "sha256": "4e63c03737cf82a2e3ef111b9b67a2322667a4f1edaf5e8892d17b550034c351", + "bytes": 503, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/SecurityHeadersMiddleware.cs", "sha256": "e4392e92f0f6ed46b470580e7f9db6d3f80d80852cb21bcf63dbeaf8f569b99e", - "bytes": 1153 + "bytes": 1153, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/ServiceTokenFailureRateLimiter.cs", "sha256": "558bf8257f101c35bdde678dd10dea3419ccc956456d878270b0866f643f7e6a", - "bytes": 1120 + "bytes": 1120, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/StrictAntiforgeryEndpointFilter.cs", + "sha256": "e596a295e30d7b1c8186a302027a3536b17404a97f773375707ce5cf492301ae", + "bytes": 1032, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/appsettings.Development.json", "sha256": "4c9be8a8257311c69a2649815b0fb934e150a432a207232830b1978ffb552599", - "bytes": 146 + "bytes": 146, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/appsettings.json", "sha256": "428f07dbd12b22ef80df16777488b2400dd7704fe8602985e53ef4903498f784", - "bytes": 341 + "bytes": 341, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Api/wwwroot/index.html", "sha256": "a43bdd8b4f68310aaa40b88f311f139f8e957d31131c6994ddae5186c9ba5da1", - "bytes": 1100 + "bytes": 1100, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/AssemblyMarker.cs", "sha256": "3ef21317427731204ba6383e07e26c34cabec72408aceba7213a00d7ce5e7d21", - "bytes": 83 + "bytes": 83, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/AttachmentContentLock.cs", "sha256": "5f052641b844097a85939b65db9b4b1619c24ae857b9ac367f34026683fe616f", - "bytes": 2953 + "bytes": 2953, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentBlobStore.cs", "sha256": "169751656a77b6263c739a62c48053f46d847f2f8636a43b426b582553682025", - "bytes": 2251 + "bytes": 2251, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentRepository.cs", "sha256": "4451d0e709937e60402d1e1eb0d28e8f1444e6f471f050b9770a875f357d1cec", - "bytes": 6662 + "bytes": 6662, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/IContentSignatureDetector.cs", "sha256": "627c43eafa0b8ee5b7d26eef87f247e1bf15cdff98d5829739b394ae7f4fd7a1", - "bytes": 706 + "bytes": 706, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/OrphanedBlobGarbageCollector.cs", "sha256": "aa969b9f390d2c89663bfd7a2f4014a35146f5ac0ef98c80f37420266cbbf7fe", - "bytes": 3023 + "bytes": 3023, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetAttachmentContentUseCase.cs", "sha256": "bdde195b4b3cf8565a63a66b5699f7415be2403217cb0db4b386d837eced5ed8", - "bytes": 6321 + "bytes": 6321, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetPageAttachmentsUseCase.cs", "sha256": "ce94b1ff8f776a305dfca75d10a981252b402250e8a04b07511f03370dceac9f", - "bytes": 852 + "bytes": 852, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/UploadAttachmentUseCase.cs", "sha256": "f7ec6391e001dc256cb2f3cec4ee1bbb04975c6f5aaa951c160938c3eb2160a7", - "bytes": 10766 + "bytes": 10766, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Audit/AuditEventRecord.cs", "sha256": "5c992ef4d82463c7aa230d9514acf5ef542feabfc7057510beba78eaaf7855e0", - "bytes": 589 + "bytes": 589, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Audit/IActivityEventReader.cs", + "sha256": "0b97a6471bd394adbb7df868c9eb6e6e536f909569eaf78d03c11da40c95428e", + "bytes": 301, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs", - "sha256": "a5569e83b8ce6304699a40ae661a91f42ac05f4bb06681c9709764d290b729c1", - "bytes": 394 + "sha256": "0de8a4df88b5a54573c0ac159bfc4866e3e956af76ae6af4bf15f1c3dc364fdd", + "bytes": 535, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventRecorder.cs", "sha256": "78a58a0357e1cb466dd315e6277d549999657638fb7cc54a36ef4deb307f50bb", - "bytes": 444 + "bytes": 444, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/IIdempotencyStore.cs", "sha256": "0cde1245c250d21bc794a2fb5268ffe722680392b8ff36292ac13447496c2c1e", - "bytes": 328 + "bytes": 328, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/IOutboxRepository.cs", "sha256": "79c6588e2eb89d2cac68f49da398db19dc2689848894c3e9d3c3332b10ff9ded", - "bytes": 868 + "bytes": 868, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountRepository.cs", "sha256": "e244f7f8c7a6c1e914c22a82b66b853d631fd050be0e91cc8b175753db629ce9", - "bytes": 1210 + "bytes": 1210, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountTokenIssuer.cs", "sha256": "c7bbdfbadc891447c23470922740cd4a0eef6135139aa11be863b60ee37bd241", - "bytes": 783 + "bytes": 783, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/AuthenticateServiceTokenUseCase.cs", "sha256": "f171e19da253eef77b8309866ea4fa6ea02cd3d6dcd5833675360bf89f16ed5e", - "bytes": 1957 + "bytes": 1957, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/CreateServiceAccountUseCase.cs", "sha256": "14aa3f4a5b6c8267fa3bf85a1a2d8b51f976f773e69f2b82807aa86a3085264a", - "bytes": 2086 + "bytes": 2086, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/IssueServiceAccountTokenUseCase.cs", "sha256": "2e65d12054e16fdb94603141a6816590c9bed8a18bc240b5fc56ea1789fe1ec9", - "bytes": 2102 + "bytes": 2102, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/ListServiceAccountsUseCase.cs", "sha256": "7e81c9212e31b1a789862b6a0a2ebed98b75dfaec97b25f12a651ca2ad32a57e", - "bytes": 847 + "bytes": 847, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/RevokeServiceAccountTokenUseCase.cs", "sha256": "e9c1e46770cdee378c5c53d15918ff5b6be14979843c582897a1bd9963f953ca", - "bytes": 1496 + "bytes": 1496, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Common/OperationResult.cs", "sha256": "40a9d990d5abf09548777fdebc83a7b7cb051ec0a3cb611b6406248c3cf4b588", - "bytes": 2157 + "bytes": 2157, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Common/SafeUrlValidator.cs", "sha256": "7598bbd83634d0adef18905057e7b7d090a4383d93f8277569be5e73b1704d57", - "bytes": 1877 + "bytes": 1877, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/IDecisionRepository.cs", "sha256": "31846cb8dc01a99eb6b1e1e365d0e53815c753ddd86ec74bf6df24ca86bc0528", - "bytes": 1461 + "bytes": 1461, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/AcceptDecisionUseCase.cs", "sha256": "380d185bdcb8f75b19357381b00a614af4c6dd8bc8c61fb848c633bf24e62c18", - "bytes": 2676 + "bytes": 2676, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/CreateDecisionUseCase.cs", "sha256": "2af5f9d1ef695fe55b7e868a22e4becb10be9fe5a18d584a01432c631a7314b2", - "bytes": 4739 + "bytes": 4739, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/GetDecisionUseCase.cs", "sha256": "a2b6838be3852fcd569b4ebad889ee1ff19f1cb10aab9125595834fc3da6c310", - "bytes": 1733 + "bytes": 1733, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/ListDecisionsUseCase.cs", "sha256": "d1d010447371772234b4b5614de785c20496f769fba848f0fd64fa45bae501d0", - "bytes": 408 + "bytes": 408, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/RejectDecisionUseCase.cs", "sha256": "6d6ba3ccd487075294d7e6d7e236a36c5494f4e76e8dc8fc0e4b8d1be626825b", - "bytes": 1823 + "bytes": 1823, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/WithdrawDecisionUseCase.cs", "sha256": "053009f9ceed5c3ed37f498c76ad41c3aba8b01e05af29c00e157784621e429a", - "bytes": 1834 + "bytes": 1834, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IInstallationRepository.cs", "sha256": "4c26debcd4a65fb6b51c0c00546db301ffa987ff480e16c52dcd74ce30ebca0c", - "bytes": 606 + "bytes": 606, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IRecoveryTokenService.cs", "sha256": "110bdbd3e32d9dcaf621f8052e46c138d8c8802343d1fa5a9398583e2bef9ab0", - "bytes": 869 + "bytes": 869, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IUserAccountManager.cs", "sha256": "bc5a2df56e72356bcc927e0eaa475f610e02e83d1be761e9a304882b9ee9fbdf", - "bytes": 2360 + "bytes": 2360, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceMembershipRepository.cs", "sha256": "d79749756802ba301e594004a186b09913e3968379a6b4ec13b8b769d9f8a0a5", - "bytes": 797 + "bytes": 797, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceRepository.cs", "sha256": "63063de6138b02fe8da2ee91952dabdeb56d20e8b5254a0b9277a1a2dc41463c", - "bytes": 332 + "bytes": 332, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/PasswordSignInOutcome.cs", "sha256": "b9bce7ac37bc066b40442596779dda20dc5a425020600957bfb02e9f977aa6bd", - "bytes": 754 + "bytes": 754, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CompleteFirstRunSetupUseCase.cs", "sha256": "0ada0ffd1ed5d1bba526d838e03700097d93cb61f6e964a8680f9718d92104f6", - "bytes": 4002 + "bytes": 4002, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CreateWorkspaceUserUseCase.cs", "sha256": "67e4ace3828f54705bef1a11e8606260df337eae3d28af5511d0e22246c9f007", - "bytes": 3926 + "bytes": 3926, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/GetSessionUseCase.cs", "sha256": "4b37fb46bdc5f4ae882386765c6227078c9ef26fcf1d59614137f0f2ad8bba86", - "bytes": 1382 + "bytes": 1382, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/IssueRecoveryTokenUseCase.cs", "sha256": "058c0591cdcae1dab22b94a829df0b4e145bca0875d57f0a3dce7bae8a9c6e30", - "bytes": 1998 + "bytes": 1998, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/ListWorkspaceUsersUseCase.cs", "sha256": "9e55c003c5cc3af1fce51873e765ba8c31b321530f0f4ea577e2fc265bb1c435", - "bytes": 1358 + "bytes": 1358, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LoginUseCase.cs", "sha256": "4572268de71f0ccfdd4d163c30dc7dafaeab03ba9cb93f3c33d61d7154881a96", - "bytes": 2413 + "bytes": 2413, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LogoutUseCase.cs", "sha256": "33a815b571dcd78ecac9e7150227efb3b06a110243c644fc631193ecfa469ad1", - "bytes": 1044 + "bytes": 1044, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/RedeemRecoveryTokenUseCase.cs", "sha256": "00e1c5844497d977201a8756a92feecfab6d13703268dd921fb954a99b41ed0c", - "bytes": 2508 + "bytes": 2508, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/SearchWorkspaceMembersUseCase.cs", "sha256": "3ac25d40f39b1d8cee9cb91327931286e0ef092721604ce0a181b1c28cecf103", - "bytes": 2378 + "bytes": 2378, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/UpdateWorkspaceUserUseCase.cs", "sha256": "2318386d4ccdefe2defeab18f74300eba7ab75c3528dfaf3a37852b35feb4113", - "bytes": 4992 + "bytes": 4992, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccount.cs", "sha256": "32d9da06a848de668d529b30f673fa63b6d11575c0cfc3cf734d5a3f143a3dcd", - "bytes": 391 + "bytes": 391, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccountCreationResult.cs", "sha256": "16cf1937eeeaad9ba00c103603b53fa01ab0d455067033ca04a31c19c4d3800b", - "bytes": 777 + "bytes": 777, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/IInfrastructureRepository.cs", "sha256": "76a63ad8237342f288d4fa044d1690eee2c28e322bff0896272426a42b19a15a", - "bytes": 10201 + "bytes": 10201, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/InactiveInventorySourceException.cs", + "sha256": "af93ed374e1b22791c9bbcd9e8106f95c691bea066ecccebea00565a96a0eb70", + "bytes": 286, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/StaleInventorySnapshotException.cs", + "sha256": "5524ade30d863972ffb789343d5b4a8c5115bb09ef852b49996accc29e9c9f8f", + "bytes": 243, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ImportCuratedInventoryPackageUseCase.cs", "sha256": "0ffdb581e76d059119540c4086d02f92b65b4d1f8d46cdaabce5561d9da1277c", - "bytes": 4729 + "bytes": 4729, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/InfrastructureQueryUseCases.cs", "sha256": "77cc129af02331fa02d35807e6b02a093d2984eeb746205a4c9c4b72b2ee2479", - "bytes": 3006 + "bytes": 3006, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ManageManualOverrideUseCases.cs", "sha256": "476c9e83c81ff5a3633bea2dfe5279a771b25350c2713cdf191cd96d2c9517ef", - "bytes": 4275 + "bytes": 4275, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ReviewInfrastructureServiceUseCase.cs", "sha256": "d62b15b0e0a23e7ec65b31db2ceaa4f129decb097c836ab71f1ad3dbfb3d82a5", - "bytes": 3156 + "bytes": 3156, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs", - "sha256": "55812fe5d58a27c07b60099f908c311eee649c74a3d157010c01c7459c5fdbf8", - "bytes": 2562 + "sha256": "ea299c2f30ab6d9c40e3b0e93eb8cc0aa8945c3dcca9a39a40180585cdd9f7f2", + "bytes": 7154, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/UpdateInfrastructureEditorialProfileUseCase.cs", "sha256": "9c647c6e0968ef15df4dd5c700b97810acc26b8805d431e9ccd58afe6eb9875f", - "bytes": 4143 + "bytes": 4143, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/EditorialProfileValidator.cs", "sha256": "aa9ed54576f518b99b2480dd1391fe67ac1f649dd1fb6e22dde4246921144e8a", - "bytes": 6018 + "bytes": 6018, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/ManualOverrideCatalog.cs", "sha256": "8410ac1c9291cd046b5d3696d0bdc15b8f1a30a70e94a49c2e4b985c703e3746", - "bytes": 9952 + "bytes": 9952, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IIngestionProposalRepository.cs", "sha256": "e64d13b80e62517db336eaff31a4fbfd6405af7b1388c5b2b8c44e49ba16446a", - "bytes": 2043 + "bytes": 2043, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IIngestionSourceRetentionRepository.cs", "sha256": "96643e0f3b0fb52dbef38de02be7e83072e4ba88284bbd725e3b61ca617fda45", - "bytes": 810 + "bytes": 810, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IngestionMetrics.cs", "sha256": "965723358264952bde89f5f3bf8c2e681c7f91dcfb1597d21bbf2055316ac171", - "bytes": 2450 + "bytes": 2450, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IngestionProposalOptions.cs", "sha256": "54067a87bfd56997cad12bd8c4c546ed50f1c5457f8081c9bf2a4d437709433b", - "bytes": 466 + "bytes": 466, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/ApplyIngestionProposalAnalysisUseCase.cs", "sha256": "c73fe271e1577e21a1a9c51280740a26a53387e6280f19b02bfc08d64889c700", - "bytes": 2265 + "bytes": 2265, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CleanupExpiredIngestionSourcesUseCase.cs", "sha256": "4c65635361dd334535fff1d9da4988dcd787937550f035d07d7978f1f021d523", - "bytes": 2950 + "bytes": 2950, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CreateIngestionProposalUseCase.cs", - "sha256": "dca9aa6442bf72a0fa0edc4a6bb39f05fed72cccc225ce1c62192782762de4e0", - "bytes": 1922 + "sha256": "d207f31757ce5e0d8040c1d8d74b1e3ffd2beac9c03c8ed853b7b65dce09c09a", + "bytes": 2032, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/DecideIngestionProposalUseCase.cs", "sha256": "f41e850b331cd3d7a651d29901c859cb0f8088598317f36831c3460f800203b9", - "bytes": 2191 + "bytes": 2191, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/PatchIngestionProposalUseCase.cs", "sha256": "dff564af0d629db55a6391a821811ce3f8baa4b17b4f8b76de5df0e5a9a299cf", - "bytes": 1743 + "bytes": 1743, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/PublishIngestionProposalUseCase.cs", "sha256": "8a4db702b0ec89e892b529d90140d8a420c2676ded8570588ac8640a7ee76fea", - "bytes": 9843 + "bytes": 9843, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/RetryIngestionProposalUseCase.cs", "sha256": "25a7f13f19dde4cba8acdf979e01bf5364ad8e097704814dfb7bb714e72da38b", - "bytes": 1619 + "bytes": 1619, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/StageIngestionProposalSourceUseCase.cs", "sha256": "89834979bb9e99e5925f1e00340e7ffa60bb4bc45558ec63e8c4b28a71b7c280", - "bytes": 6435 + "bytes": 6435, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/SubmitManualIngestionProposalUseCase.cs", "sha256": "407ed475aa9acb2624f13bad2882bba956918ff55bdb87cd97d63d42eae06247", - "bytes": 2219 + "bytes": 2219, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/ItWorx.KnowledgeHub.Application.csproj", "sha256": "6fcf2e217eceaca7f4fe66e4ff9fad4492a20436eeb929fab36981597788aa18", - "bytes": 447 + "bytes": 447, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Jobs/BackgroundJobRepositoryModels.cs", + "sha256": "ac6211649bb0c1577be1efc897523873db68d4cdddf42fa497a7af0620b64e0e", + "bytes": 691, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Jobs/IBackgroundJobRepository.cs", + "sha256": "596db5a5873b95612085a069577f5e2844c701faa8be73af61b23fb5436d92ac", + "bytes": 3140, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Jobs/UseCases/CancelBackgroundJobUseCase.cs", + "sha256": "1737be46ba2c02fbf2a85d79e288efa12029f8d7ec4846e00ba32c1f91926ffb", + "bytes": 2594, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Jobs/UseCases/QueueBackgroundJobUseCase.cs", + "sha256": "af2b79090dacbb06e4e3cd01b370a387fed42bc9f90bfb226117c3ff296e267e", + "bytes": 5515, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IDraftRepository.cs", "sha256": "d5c38dae5d89d09136ca02d1ece1a0493a4ad8592fe5ad420c572abf91f0d098", - "bytes": 377 + "bytes": 377, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IMarkdownRenderer.cs", "sha256": "d9e331a4f1b280a79d51e68bf9da02908825d3ed5c980025c2fec82f5d0aac8e", - "bytes": 1084 + "bytes": 1084, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IPageCreationSessionRepository.cs", "sha256": "a82f935e8343e1db94f246c06e6f001b582d30203403bd372a3ca53cc7a78919", - "bytes": 3106 + "bytes": 3106, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IPageRepository.cs", "sha256": "2fc8d89ac9628d24c1753806f9f747ec2aeb7d45e5ac7efde078f6eb3cf15eb3", - "bytes": 6486 + "bytes": 6486, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/ISearchRepository.cs", "sha256": "095d2892449217568233006abe22d7602b1be3e554bb932fa16b08f3efab2a4f", - "bytes": 1318 + "bytes": 1318, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/ITemplateProvider.cs", "sha256": "1ba878b702d1ea4642396e6e47a44ec29627d5ddaa66c4b87ee23595eda50b6d", - "bytes": 517 + "bytes": 517, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/InternalLinkExtractor.cs", "sha256": "74258e29a94fd1309082996ec1e8d10424f8a88b9d98319a43f59c92e99d3351", - "bytes": 1661 + "bytes": 1661, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/LinkResolutionService.cs", "sha256": "d32c39156f3eda7eaf038c73cf063b0203dfb98f1ee079a56659f72ea3142235", - "bytes": 2533 + "bytes": 2533, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/PageCreationSessionOptions.cs", "sha256": "ea7f80be0cd1674f169e760abc5f7cb177bf63274b7f29a68bc64a17d7892f32", - "bytes": 1274 + "bytes": 1274, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/RagPublicationOptions.cs", "sha256": "c9f125d8493ed26bbea1dc7192e3d8aa70d85f9e95b103efc7fe91a605473e68", - "bytes": 976 + "bytes": 976, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/BeginPageCreationSessionUseCase.cs", "sha256": "8e96da26afb281a9efa053998774d01e1826d59eeb8e61ab0b386ac6c4a6b2f2", - "bytes": 2033 + "bytes": 2033, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CleanupExpiredPageCreationSessionsUseCase.cs", "sha256": "1cce2f7eda3098b8c0c27c0e1e220805a900232f0a00c5193aefceafc8bece7b", - "bytes": 5871 + "bytes": 5871, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CommitPageCreationSessionUseCase.cs", "sha256": "287357ace9fb69e7fb7ad00ec4396d8be4a0e91be355c6d7704ef75ca906b20d", - "bytes": 6728 + "bytes": 6728, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CreatePageUseCase.cs", "sha256": "61ff5e39043ea5c479ff23af297f266f14b80082c5c686298097bf51cd67fc89", - "bytes": 4904 + "bytes": 4904, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/DiscardDraftUseCase.cs", "sha256": "6c8aef55d862add0530606e0dce38d4ff73ff15497edf02f3652732935ceacc1", - "bytes": 547 + "bytes": 547, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/DiscardPageCreationSessionUseCase.cs", "sha256": "dee3bfc35b34b29a2d84178c6a35c1d0410d32f3648e845604c7475b9aaa8c25", - "bytes": 3554 + "bytes": 3554, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetDraftUseCase.cs", "sha256": "85cf8209151c0f9557c743cfc05446452ba2c46954f8c189d44445dde1978704", - "bytes": 374 + "bytes": 374, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageBacklinksUseCase.cs", "sha256": "c4d762c7f642448378a38e97167cc94b259f98c43bf1c9068fe06820cc09f103", - "bytes": 518 + "bytes": 518, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageChildrenUseCase.cs", "sha256": "5fe86dc39e9230cd1adbf51026a7f33a421c3e599304982837c6d5f876bf37b5", - "bytes": 1210 + "bytes": 1210, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageRevisionUseCase.cs", "sha256": "eeda2fab0ec8e0fe6b787255b18222c24a3499f4de7b852dc89e1086d2a04dd3", - "bytes": 1151 + "bytes": 1151, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageRevisionsUseCase.cs", "sha256": "db0765c8b84980553fd4b9de2c811dd62d9349c5cac781ec7a136216d7a545c4", - "bytes": 520 + "bytes": 520, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageUseCase.cs", "sha256": "9d0366f4f71129de6af0c942bc9efa72ea0531d028ab2de5f2a97db5f4257b56", - "bytes": 2473 + "bytes": 2473, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetRagPublicationManifestUseCase.cs", "sha256": "dd3589364fbc315264167e1e9df70119ae82ea6dc4d4e1211cfa3fc09dec1bfb", - "bytes": 4655 + "bytes": 4655, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/MovePageUseCase.cs", "sha256": "2685636fccb94a4337e66f640ab6eaa37b91f12266f757d247748bf94fb9cc21", - "bytes": 3887 + "bytes": 3887, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/PageOperationResult.cs", "sha256": "579fbfaba1f53de86d92d49ebbb3680c9b956609055bc01cc9028fdf8dd62102", - "bytes": 585 + "bytes": 585, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/PurgeTrashUseCase.cs", "sha256": "aabe86a5d7e2e2efee14bd464bd1a5eca48090b5bff55459f7acc20a970d602c", - "bytes": 1764 + "bytes": 1764, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/RestorePageRevisionUseCase.cs", "sha256": "4cefc5d99ea4bf1a1e10605e6db864269cc5f8c5b9ad2c5119e414aebd0826c7", - "bytes": 3565 + "bytes": 3565, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/RestorePageUseCase.cs", "sha256": "f7325338cd0ad757642e2e0bb1a0aa287612b030cb03120b296de7172eace5c7", - "bytes": 1626 + "bytes": 1626, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SaveDraftUseCase.cs", "sha256": "eabbac408ee7d47a4c56db8adb330e9ab98c67125175efa980af168b7288ac46", - "bytes": 2651 + "bytes": 2651, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SearchPagesUseCase.cs", - "sha256": "ce2309db46b1e3316c219b9db203644681036013a273bc22d631c0d568bd0912", - "bytes": 1222 + "sha256": "8d88b35c356d881d6f98e4d87822e1c68b8440eb0c85bb9a67f868d76af0eb8f", + "bytes": 1472, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/StagingCleanupStatusTracker.cs", "sha256": "acbbb2dc8ebf08d3ac8896238c3e03c515bad9629a8e05637e20bef2e025d771", - "bytes": 1823 + "bytes": 1823, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/TrashPageUseCase.cs", "sha256": "85e74e58ac34c28014b4bae9ee7f848d1fd20f0e6c8078db7ce85d67574db3e0", - "bytes": 2253 + "bytes": 2253, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/UpdatePageUseCase.cs", "sha256": "02aab6d8f94f1012a1687ff9de0fc77ddc3850b63f74d6d37335721649901aed", - "bytes": 3837 + "bytes": 3837, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/IImportRepository.cs", "sha256": "83b956d65957c4fa2c71eb0f059bc82629f4e32c3c752a3b50ea87cc1eed4bad", - "bytes": 1226 + "bytes": 1226, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiAdapter.cs", "sha256": "a1835af08a327d02b4f31c1c763b24723badc55f19412159b85e2a68afefcfd0", - "bytes": 907 + "bytes": 907, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiAdapterCatalog.cs", "sha256": "e34952eab50fb35bdf03c9b42412dc00fcb88819949a051062f4d56f58da5d58", - "bytes": 738 + "bytes": 738, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiSourceReader.cs", "sha256": "f731e0dbbe01fb698ba66cbc91019caacb0fd10889bf5235bdc01b9c2e2c731e", - "bytes": 770 + "bytes": 770, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/IPageRollbackRepository.cs", "sha256": "90c8b8d348237ae017690079a0b7b356de7c43c1dd6203f03e0dc9c7b27ee0f4", - "bytes": 1379 + "bytes": 1379, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ISourceBatchPurgeRepository.cs", "sha256": "35645610dd085dcf62b3eed3c525f1aa489bb30841f8d088cbe2451cec1c3ba3", - "bytes": 1891 + "bytes": 1891, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ImportPlan.cs", "sha256": "c8f28923504b4b37088be90cdf30b9569dd6baf7b192901d8960e2cdc867bcfa", - "bytes": 1326 + "bytes": 1326, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/SourceFingerprint.cs", "sha256": "8427511ae1133a325897e6ed32adbe08343564ffbaeee0e9318eed59a95e32fb", - "bytes": 1092 + "bytes": 1092, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/ExecuteImportUseCase.cs", "sha256": "0694273f21367c96db08de251b66e94f4bcc6a5a0d836d69933a789f348310d4", - "bytes": 16963 + "bytes": 16963, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/GetImportJobUseCase.cs", "sha256": "cf7b7abc17c7cea7b6133471c54c734d549c40f4b0d3b9cfe91eb961c295fe07", - "bytes": 328 + "bytes": 328, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/PreflightImportUseCase.cs", "sha256": "2039c5825aed65800f05cad7551574b7941d50595e369248d05c530271aaffe5", - "bytes": 3950 + "bytes": 3950, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/PurgeSourceBatchUseCase.cs", "sha256": "280f07754ce7e95d1e48f8087240a6cd05273fac6f20f543135d960f454d8443", - "bytes": 3712 + "bytes": 3712, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/RollbackImportUseCase.cs", "sha256": "db4135809d0987c75ef96080d2f0f0059b102a4244e2cc266a2253741b1b3c44", - "bytes": 3524 + "bytes": 3524, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/BackupManifest.cs", "sha256": "f476252932d2087793fda9732070df1fce6eeb223d0721f0a5be6839b9788339", - "bytes": 1912 + "bytes": 1912, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/IBackupEvidenceStore.cs", + "sha256": "235dda6bbdf29db3ce08d98ef2d861f1e72c3afb6404ecad60ae95416507cd3d", + "bytes": 875, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/IBackupService.cs", "sha256": "b2adaca6da7c2ab5eb13f2e896bb4781eb4ef4b8eff201e676f7f7049e5d15c2", - "bytes": 861 + "bytes": 861, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/IOperationalTelemetryReader.cs", + "sha256": "5e83d04e3f045579927009dde2515790e776c4f58709d209deba9cfe7193a091", + "bytes": 467, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/IRestoreService.cs", - "sha256": "6f6117c1d9446cd040b2d2ec9972d0cc685a46acc9e0cb24fadcba73764fda64", - "bytes": 1428 + "sha256": "86bcb62f33bdbd27b0a5b7b810c45ee9ecda29362eff0e0a750d0b528183d119", + "bytes": 1463, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/PlatformMetrics.cs", + "sha256": "37ac545b9ffb9579fc59dbcb5175bb606828c2d4d059af81eee8c5179ad1afca", + "bytes": 4578, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/CreateBackupUseCase.cs", - "sha256": "d886a29d1f2cdd1a4d18325b9fb5939ba246134acba1988defaa1603665c6af5", - "bytes": 2448 + "sha256": "d8f98372e9462547d917e6a418b985a9235255f2be2ac1c43a9f7ce3fd0a63e8", + "bytes": 2599, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/ListBackupsUseCase.cs", "sha256": "1eb784e06252b3972feff812182af9d4522811d687e968797480a7d46e0a609f", - "bytes": 283 + "bytes": 283, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Platform/PlatformSettingsPort.cs", + "sha256": "f68a90e389585f06da2cff7dc84f09c27443ebd176227f7ea59b9330c942f861", + "bytes": 756, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Platform/UseCases/PlatformSettingsUseCases.cs", + "sha256": "9521112c5284007683390241d1a7a51d849d8cb48af5ace8a1296b0fce46bd79", + "bytes": 6030, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Platform/UseCases/UserPreferencesUseCases.cs", + "sha256": "dafff56b2b7603ae5cb382f9ab3376c2b7ad6c813e2c479fc5600815bc39fa0d", + "bytes": 6353, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Platform/UserPreferencesPort.cs", + "sha256": "2480e40d3d5445cbf81d83704744054f5e3306489aba6a225824108b63cf1f6b", + "bytes": 592, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/IProjectRepository.cs", "sha256": "3f9d37422e3731da61e322e90e8b4445a3ab8af631c4fed2570101b2e1ad4057", - "bytes": 3712 + "bytes": 3712, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/ChangeProjectStatusUseCase.cs", "sha256": "9a0772a2218ac888756db4e91121c8a45b71893c93a61aa3ef6f29dca3c93182", - "bytes": 2663 + "bytes": 2663, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectMilestoneUseCase.cs", "sha256": "0c09cc7ef1c26c54eb2aa1ccf4eeaaaea736b7d97d98f0fd1bf25c0091d8e5c3", - "bytes": 2450 + "bytes": 2450, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectUpdateUseCase.cs", "sha256": "4e8ce6e9cba700d21a65363a31e5d700ee26b9de4a0f20ecf695d8a14b9af518", - "bytes": 4478 + "bytes": 4478, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectUseCase.cs", "sha256": "69e53bc155147aeaa21a4d159bf8ed4b44ec613e9f405acde8e5eb1dc65b00bd", - "bytes": 2213 + "bytes": 2213, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectCatalogTotalsUseCase.cs", "sha256": "88abc2cdabbd8ab52ac6515be07e5728149643edfbc1c999fd552901156a0b64", - "bytes": 327 + "bytes": 327, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectContextUseCase.cs", "sha256": "bc905fd81a6cb60fd06e2a9fa3386b8f33e67dce940ca6afbe5b94f7a8566df3", - "bytes": 5133 + "bytes": 5133, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectMilestonesUseCase.cs", "sha256": "09833d12ba6bd56dc9c57a9e37dcd1e59e2a4d3618a31143b17ac5a2ba3bb2c7", - "bytes": 372 + "bytes": 372, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectOverviewUseCase.cs", "sha256": "9cea4423d980307ffe13bb8b611570c58589ac0eea9e0945cf769b31e99d0e9a", - "bytes": 2560 + "bytes": 2560, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectUpdatesUseCase.cs", "sha256": "f51f1b0ddd26fb25e12241b811918beb5a7e112035e77c3a0582258d43d2b398", - "bytes": 351 + "bytes": 351, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/LinkProjectDocumentUseCase.cs", "sha256": "e3773e0f32e5b50e8d07edfb2f914b9323658341c863e0bb369efa83c68b7431", - "bytes": 2401 + "bytes": 2401, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/ListProjectsUseCase.cs", "sha256": "ebbda87d1e31869b747cc1af865e143ee1f677404087f8e0fefced637f74b344", - "bytes": 1348 + "bytes": 1348, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/SetProjectIconUseCase.cs", "sha256": "d2c113d6100d3b743751a45ca988c2805329fc68587cb68a502fdd8093ff0f46", - "bytes": 7312 + "bytes": 7312, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UnlinkProjectDocumentUseCase.cs", "sha256": "51f38dd52346faf46d366c1379380aac687ffdbee9794a805ed5fa7f50330e50", - "bytes": 1499 + "bytes": 1499, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UpdateProjectMilestoneUseCase.cs", "sha256": "782396194553e59f8f1b951e1fa625308148731097f4853dea55cb700e1c7bae", - "bytes": 2715 + "bytes": 2715, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UpdateProjectUseCase.cs", "sha256": "35579002f88c1168bc476ea593245cd6c8688a135c5fb6785014b472ee45c217", - "bytes": 3852 + "bytes": 3852, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/IRunbookRepository.cs", "sha256": "ba66358f0e161dd1d95433e02ea1f7df332769e62ab37cb375890af58f922b0f", - "bytes": 1097 + "bytes": 1097, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/CreateRunbookUseCase.cs", "sha256": "c973e3b96d49a8cae500322dd9555c8921c4c2ba5f15f045c4865a5fc6dfc8b2", - "bytes": 3671 + "bytes": 3671, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/GetRunbookUseCase.cs", "sha256": "106a91784b742056050472790e2539b7c94885c455c50664a2984528bd140ded", - "bytes": 1017 + "bytes": 1017, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/ListRunbooksUseCase.cs", "sha256": "576a4f923122935ce1e99909a435145a8bc038fc6fef709a4e3c141168ea691f", - "bytes": 404 + "bytes": 404, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/UpdateRunbookProfileUseCase.cs", "sha256": "c86fede92f9b9399bb29270cf6c6bedd6e6c01956fe3903f835505d309e2a0c1", - "bytes": 2800 + "bytes": 2800, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/ValidateRunbookUseCase.cs", "sha256": "e095597b190139c1f01afb412e3bbccbdf57d4d19232e9f641755f8108f9010c", - "bytes": 2271 + "bytes": 2271, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Sources/DataSourceUseCases.cs", + "sha256": "85ff0cc3f7d26811e8e35d70d315582166a7e734ebdfa74e5fe60713114c6985", + "bytes": 4262, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Sources/IDataSourceRepository.cs", + "sha256": "0cc75b5e2c33f409cbda5e38facaafc28f20997c6ab1b032f64150560259d54b", + "bytes": 645, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/UnifiedSearch/IUnifiedSearchRepository.cs", + "sha256": "db709d4c92d7df02a10dc1b2365edf478eab44f94c4973041b6a719ab9114e2a", + "bytes": 1097, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/UnifiedSearch/UnifiedSearchUseCase.cs", + "sha256": "91ae52ac21af63762f7120a664b1dc20817c45b6ca823787565b952b3f1d3e78", + "bytes": 1130, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Activity/ActivityContracts.cs", - "sha256": "4bc4ea6c5217e87fe3fb4bf26b84aa87abc0c8234d35d5c6c89bc1c27ee35927", - "bytes": 343 + "sha256": "e14f47cf9e84c92f797b85f7323e6720dc5db08500f5c8f3485a80b2cddee1a7", + "bytes": 708, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/CreateUserRequest.cs", "sha256": "1e356619562a6ce95c8e6525f6b484aab6515352864e76f0311a21b860fbc7bc", - "bytes": 169 + "bytes": 169, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/IssueRecoveryTokenResponse.cs", "sha256": "ae7aab2126f1fa2fce016cc1a73a3871b5b1ff2b080a95fc06b1730012dd0c63", - "bytes": 324 + "bytes": 324, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/StagingCleanupContracts.cs", "sha256": "cf36a275ba811632edb9e2af14329f42678349e4a24408bd368e3b584e752110", - "bytes": 712 + "bytes": 712, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/UpdateUserRequest.cs", "sha256": "12c0b7551e3f8c040fc461f2414ce5c804d210d267450cc26f33471eac22bf13", - "bytes": 301 + "bytes": 301, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/UserSummaryResponse.cs", "sha256": "d54783845d869bb03edd751388db17097fb2e1cee09265bc84b42f728eee36d6", - "bytes": 244 + "bytes": 244, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/AssemblyMarker.cs", "sha256": "f4a5b737454da7c8883e71d39daa1cec302805c67c02067589b5a70fb7d29b83", - "bytes": 81 + "bytes": 81, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Attachments/AttachmentSummary.cs", "sha256": "f4eb12211104379392e8c844bd1075e7dd12532e7eff5a5fe518373969519bfb", - "bytes": 361 + "bytes": 361, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Auth/CsrfTokenResponse.cs", "sha256": "c74718c14d73ad6a24bfdf14ea04cb295c3f71ab7363bd8fdaf7ec5b1b19aaa2", - "bytes": 410 + "bytes": 410, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Auth/LoginRequest.cs", "sha256": "d908627bea430ae93cc8ae3ac301186b8ac209b15131e97b00ac0670188f5df4", - "bytes": 113 + "bytes": 113, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Auth/RecoveryRedeemRequest.cs", "sha256": "1e285478dabd029e15ddc1501f43a7ac284b1ad64b6d37f177aac317d3a9d18a", - "bytes": 125 + "bytes": 125, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Auth/SessionResponse.cs", "sha256": "739afe6252275fef45aedaaf40a6fab23670c7ec024811e593c06c6fc21340b0", - "bytes": 809 + "bytes": 809, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Automation/ServiceAccountContracts.cs", "sha256": "c13217dfe25cb7bb62ca4fd3cd6b67ed6279c8e28caeae067678fdc3fa35197d", - "bytes": 997 + "bytes": 997, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Decisions/DecisionContracts.cs", "sha256": "7bf010e3dcafb232403b32bf1a8c2ead0fcd47cfc2ffc1eb4be56da4c73d6c95", - "bytes": 1021 + "bytes": 1021, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Errors/ProblemTypes.cs", "sha256": "1df111e70e84d889c6a88dd393f8b3a31a61c89197a8b6da98478f62f2f6b685", - "bytes": 3238 + "bytes": 3238, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Identity/WorkspaceMemberContracts.cs", "sha256": "85690c34c02fd490b422a6ef36f4716ea91e6ef744471bf6dcda89a1aa59a457", - "bytes": 640 + "bytes": 640, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Infrastructure/InfrastructureContracts.cs", "sha256": "b67a8c57a3da1573bb1e1f723f73b82a0e9ae00d07676b9908e819d191e0d3af", - "bytes": 11056 + "bytes": 11056, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Ingestion/IngestionProposalContracts.cs", "sha256": "ea3963276b4f7c8fb79da9e4f792f0ad610386897ca858d9a2985bf374f1bea8", - "bytes": 4684 + "bytes": 4684, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/ItWorx.KnowledgeHub.Contracts.csproj", "sha256": "5542d279fb7b4b0a15516a8170928a519520f07e48a2b05dd64bc6dde047a2f8", - "bytes": 210 + "bytes": 210, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Jobs/BackgroundJobContracts.cs", + "sha256": "3a2b87f12099b5672023f750627dc9b0bab4a7aa4866ff7d20c83b0f5a45d8b9", + "bytes": 905, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/BacklinkEntry.cs", "sha256": "56662869008a175eeaf6c441795169ec7ebd04915b5436b8fcc1bef82e806266", - "bytes": 222 + "bytes": 222, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/BreadcrumbEntry.cs", "sha256": "611d410c9a47ec2a521de24c8dec02a1a12e187f08d75ef4f6d9b80c5d02aead", - "bytes": 113 + "bytes": 113, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/CreatePageRequest.cs", "sha256": "ac033b1317750b538bef5552fecfebffe0890f30be11a0265aabb40531e3ce3f", - "bytes": 248 + "bytes": 248, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/DraftResponse.cs", "sha256": "c51e6129f22f115d669551b58414a567a52ad8b9c726846b02c68157bf989fd6", - "bytes": 437 + "bytes": 437, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/MovePageRequest.cs", "sha256": "e4c3ebd7e8191a4f825a0e9012ce313df700f74b821999e47545a110a02b0898", - "bytes": 424 + "bytes": 424, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageChildrenResponse.cs", "sha256": "746653eb1c04cc12ba44e798d0fc359d8e4b355dd82f1d4e2e0c0b2615762d1c", - "bytes": 334 + "bytes": 334, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageCreationSessionContracts.cs", "sha256": "897d00594961f9ce7b9b774e0c205bd881990e80fdb5fea4be88900f9d37f463", - "bytes": 445 + "bytes": 445, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageDetail.cs", "sha256": "faec98cc4e610e1135fb06c85795a96e26feebc824f2d9d90c965c8d857ce978", - "bytes": 572 + "bytes": 572, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageRevisionDetailResponse.cs", "sha256": "d7dc3c6532a6c3e4b57d5c0574011fbc74aae19fdd372a9d9c73e6da5dfb4fb9", - "bytes": 322 + "bytes": 322, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageRevisionSummary.cs", "sha256": "3f00e35bb671fa0971d7c8a748ae6b0bffb47b1681c38d212f2550d8b784bcaf", - "bytes": 269 + "bytes": 269, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageSummary.cs", "sha256": "a309477cb676953b85a72b37e13887527ba2bf608bd1e47842b33979868d1573", - "bytes": 263 + "bytes": 263, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PreviewMarkdownContracts.cs", "sha256": "fc4da9a3ce8c742f65de38e26e8afe2fbb0c4faac3b972592a851d01a16b3467", - "bytes": 182 + "bytes": 182, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/RagPublicationManifestResponse.cs", "sha256": "5346c835998a19c444dfb9d80b537fe4986c8ad3d3c5d7ba17189d4e0e909c9e", - "bytes": 936 + "bytes": 936, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/SaveDraftRequest.cs", "sha256": "352fedb03b728326bb0d22cb2d651f460f62e2af6d74a80f38a25baf473a06d0", - "bytes": 321 + "bytes": 321, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/SearchResultItem.cs", "sha256": "f05fb3b832078aeec40e8f5f87f5f640dc50dfb1a616333c91a87af518eb3843", - "bytes": 810 + "bytes": 810, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/TemplateSummary.cs", "sha256": "3bb262dbfaefb9f1e8f311f0cf60b431a7975f4679a01e745e84fdecc1d8676d", - "bytes": 156 + "bytes": 156, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/TrashPurgeResponse.cs", "sha256": "bba32ede9a5e639fcf48d0e323e0aa671f98bca1bf785932cb44d21e2b1dd757", - "bytes": 110 + "bytes": 110, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/UpdatePageRequest.cs", "sha256": "b546926daa3406c6f904f7d9280059e7f8e342d3bd7bfb9aa294e47ad2a99243", - "bytes": 251 + "bytes": 251, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Migration/ImportContracts.cs", "sha256": "839822a46c728e20a3608980c58ddcaeedd3ee80b0bffa0961d4a177459693be", - "bytes": 1554 + "bytes": 1554, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Migration/SourceBatchPurgeContracts.cs", "sha256": "db74e5a27f7b9eb96db75977b1c8fb1c1cb81ac748ce11afdac442fe593f45de", - "bytes": 1549 + "bytes": 1549, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Operations/BackupContracts.cs", - "sha256": "0a16876805be758d255c1281681aa2ab2568991bca0ed70e793f6007cfe43cd8", - "bytes": 629 + "sha256": "237c75a2bdacc1a7c5ef796086bf5bdd4c2a5aac7727ae9681f41958ca4b5f91", + "bytes": 1512, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Platform/PlatformSettingsContracts.cs", + "sha256": "fdc59df2bad639a275037b2d371c6eed03829ee92ad8ee6a03857679829b06fa", + "bytes": 1554, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Platform/UserPreferencesContracts.cs", + "sha256": "65387b9ccff983e6072053ce278dcb54dc7c225f97cc6588b7d90835586fbb2a", + "bytes": 936, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Projects/ProjectContextResponse.cs", "sha256": "6477193e420877603bd5b816fdc1848c0816e379836364b139cb6e160ccbc88f", - "bytes": 1216 + "bytes": 1216, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Projects/ProjectContracts.cs", "sha256": "ca89b57a4da134415dff9e9191c06cab536044e26543fd5d6bccece9a2dc9dd1", - "bytes": 3723 + "bytes": 3723, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Runbooks/RunbookContracts.cs", "sha256": "c08ec512028ecb7dcc8509e66c9a5bcfcdda3c7de5509be2ce0b7f90a923306e", - "bytes": 1227 + "bytes": 1227, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Setup/CompleteSetupRequest.cs", "sha256": "0519daa08e5afb46950b1010fdf705021e6a8ee8ced8e4cfd923f63d598ac869", - "bytes": 181 + "bytes": 181, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Setup/CompleteSetupResponse.cs", "sha256": "576c45d58aa08f4945a791e92fd87890a152122487b1234f0a1ece0b8bd1aaf0", - "bytes": 123 + "bytes": 123, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Setup/SetupStatusResponse.cs", "sha256": "bea9df35265d90e0ef6aa7275d4de2dea74df6cf471f327bb217dde8204b7c6d", - "bytes": 378 + "bytes": 378, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Sources/DataSourceContracts.cs", + "sha256": "80a4da6ec0b6bd7b913317d98caac7ce13ad8ae23d3c8b13e0a76d9a2cc4f931", + "bytes": 968, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Contracts/SystemInfo/SystemInfoResponse.cs", "sha256": "95f2116184cc51e9e4318f3ab08a07573fc19b4ab5f1bf2d010d0b755743a539", - "bytes": 1312 + "bytes": 1312, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/UnifiedSearch/UnifiedSearchContracts.cs", + "sha256": "d8865a2fe37301f573baf8e8eadd69b32b53c1f9180fd385889558bb6121580a", + "bytes": 811, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/AssemblyMarker.cs", "sha256": "a05f0f51f88ad56ba31b86cc9ad254115b28ba0c7776b63207b2d041a32793d4", - "bytes": 78 + "bytes": 78, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentBlob.cs", "sha256": "ecefe43f3758398bd29fca7b8f37723f27f753f0ffd4841135f3f8533f3b022d", - "bytes": 2746 + "bytes": 2746, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentDeliveryPolicy.cs", "sha256": "987b84cb6ffed6f57958b124dc7f0a4b81c65a1337716245345efa346cf26054", - "bytes": 1019 + "bytes": 1019, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentReference.cs", "sha256": "5977374a5a431337210a4498af29141a8b9fb750524261ac127c8c9b3b9b77f5", - "bytes": 2659 + "bytes": 2659, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentStorageState.cs", "sha256": "ade532e021f82f04b9e3eb725fd405c42dde1ed4ec0ee2a8bbc15d206e764f8f", - "bytes": 157 + "bytes": 157, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentUsage.cs", "sha256": "5691f6f401e7c5bf897d955dda36d27f9233e6888fe4755774eeff8e5ceb3c5a", - "bytes": 4405 + "bytes": 4405, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/SafeFilename.cs", "sha256": "e8a0c5afd12f72cc616b60b95d8391a900e3cc1525b2519820ebf4e4b8c98cd1", - "bytes": 2614 + "bytes": 2614, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/IdempotencyRecord.cs", "sha256": "e0927a081094b00f1c8fae7c71cde1a755333c66d861cf38c3e28e153ea7810f", - "bytes": 1874 + "bytes": 1874, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/OutboxMessage.cs", "sha256": "e9291d6cf512d09f20341c215d022cecfef36b9e0cf714ac200694a539208c4d", - "bytes": 2651 + "bytes": 2651, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/Scopes.cs", "sha256": "a5ccffaa44939bb2e3acca675f2d6346f6d442ef2b841954925e6166501f9105", - "bytes": 2512 + "bytes": 2512, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/ServiceAccount.cs", "sha256": "eb751d7ce9ed17247c209eeacc83ce4a2864e6c12e1a65286a96569ac5122f4d", - "bytes": 1834 + "bytes": 1834, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/ServiceAccountToken.cs", "sha256": "e34a7581c0dbe2d15cbb235a3dd15077ff99e249e8437ae7997e3f16bcfa4f9d", - "bytes": 3278 + "bytes": 3278, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Decisions/DecisionRecord.cs", "sha256": "cfe4edd47b28ca717b78d0954b22d867bebf656109e6691552b4c37002d0a46f", - "bytes": 4633 + "bytes": 4633, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Decisions/DecisionStatus.cs", "sha256": "dc1328d16d9a7a8f13a5f87c8f3d94cc89c16786e5140153dca43b26ef438939", - "bytes": 239 + "bytes": 239, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/DisplayName.cs", "sha256": "8550e2ada53afa05f916d12f6e83fcebb6f3b3f0a793f91602926246de76cd74", - "bytes": 858 + "bytes": 858, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/EmailAddress.cs", "sha256": "b342db1a3db0bdf2110d03b30b18e5b36ec536822051d5e9543a72e3bc319542", - "bytes": 1826 + "bytes": 1826, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/Installation.cs", "sha256": "e0d1fa5b4125bd73d95263974a0d1afe892a9d9a7df537d4eb8ab3fc24568d9d", - "bytes": 2341 + "bytes": 2341, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/Workspace.cs", "sha256": "3fcf1385bb5a80a376b3529039c5eb219446c845dbe6bb8ffca846f6095b2174", - "bytes": 1474 + "bytes": 1474, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceMembership.cs", "sha256": "c00334d1e17f1a62e23d19acc0a4191fab2952205a58c9d80fea7ece4e39b463", - "bytes": 1430 + "bytes": 1430, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceName.cs", "sha256": "af534c66b9b76a2cd45cd1c7b276c7d92dbf2d17b4a9a5be3660a74047d0026d", - "bytes": 2116 + "bytes": 2116, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceRole.cs", "sha256": "4a7230d011b27ed6e366889bde4b146c6fb92cd77150df7fdcb25834b040c7ea", - "bytes": 446 + "bytes": 446, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/EditorialKnowledge.cs", "sha256": "47389ad0d4855b4a64582c115eb6ede83fb2ff6aefcc62a14d2a2c3d363e9a3d", - "bytes": 5828 + "bytes": 5828, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/FileSystemPathBoundary.cs", "sha256": "ce8f080a83bb1f3cbb8fc429b2b6691403a54affc7fb0e9656d0653fe44bf1c8", - "bytes": 770 + "bytes": 770, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEntities.cs", - "sha256": "434ee1885e8d967368d33fe6b5c308b24df613b6581b8efd71dd0095e3f459ba", - "bytes": 17140 + "sha256": "9382ebf750547104ff21a41f7f085c62d5ea674fc8a80e7e2df568e3a0ae83d3", + "bytes": 17279, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEnums.cs", "sha256": "47f813ce30ef5878952c9df1326beba835ca936cf48b7604455063fcd11de0b6", - "bytes": 546 + "bytes": 546, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InventoryChange.cs", "sha256": "d222ccd7b6bcacd168747226f70646edfd1809182c6a233ca06d1a0d9269ef07", - "bytes": 1597 + "bytes": 1597, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InventorySnapshot.cs", "sha256": "4914ec4d49146202cf17153227807ec86e4ba05223fe61e045298f30bdcf367f", - "bytes": 2086 + "bytes": 2086, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Ingestion/IngestionProposal.cs", "sha256": "a7e971fa288b598972ed63e7e6fcae511e4022ac84f45228301ad650b25b2eb4", - "bytes": 18472 + "bytes": 18472, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Ingestion/IngestionProposalStatus.cs", "sha256": "52723bdb96db4b61fe608e8470979bdc483762e8b175fd533519f5d20a95c341", - "bytes": 3075 + "bytes": 3075, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/ItWorx.KnowledgeHub.Domain.csproj", "sha256": "c1b5edb028e42d9dc83f9c8a32e440919de008aac3b8c72ea66cf03facfa5101", - "bytes": 204 + "bytes": 204, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJob.cs", + "sha256": "73532892384942d071df5853e65ccb61c17939a1bdee638add6954d8a3957c20", + "bytes": 8343, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobItem.cs", + "sha256": "2f599140b2c9a91ff3dc23653a57a506f2aa58e60dbc5357d8132b81026943db", + "bytes": 1294, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobItemState.cs", + "sha256": "0f6383827ddaed4369d41219f44989946f31992bd680953154664dece05068ac", + "bytes": 151, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobStatus.cs", + "sha256": "340beb1b4ae330ddb4891842d4906cc908183d54bc5499596064c7c70050e5ae", + "bytes": 170, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobType.cs", + "sha256": "c5e333666627b7714039a6e734cf72a53fb4413786abcb7e92ff0173a2dc78b3", + "bytes": 108, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/DocumentStatus.cs", "sha256": "e5303d9d38a684b7de41a8ae270c17d31fa627c9d70d9139dcb16361cf850674", - "bytes": 169 + "bytes": 169, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/DocumentType.cs", "sha256": "56a5c151c61ecdb52dab6a17ce152abf89188871c4e2bf81f67bbc969bd94ce5", - "bytes": 312 + "bytes": 312, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/Draft.cs", "sha256": "2a1109080f1439b27545e949cd77461703054479060ce2b0fb73d5642ffa94c9", - "bytes": 4107 + "bytes": 4107, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/Page.cs", "sha256": "d2f7d5f6dbbeb753af9301362b41b1133dd237632e29d4f0ce6f0b3fc4754b01", - "bytes": 8197 + "bytes": 8197, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageCreationSession.cs", "sha256": "5026b0d587523d0157a248a503f1f76e6f0c75d3e7913855061e0a2eaac2b9e3", - "bytes": 4083 + "bytes": 4083, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageCreationSessionStatus.cs", "sha256": "03ff59010c07406bfef7c1b93783fbc7293e88353f1802b8e048768ae5a50e99", - "bytes": 130 + "bytes": 130, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageId.cs", "sha256": "970f79f6918ad8ebb5ff6cae3850b5b0d1b15495c3c63f56e3ae9711b5de4b70", - "bytes": 217 + "bytes": 217, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageLink.cs", "sha256": "a6eb37c26feef0bb118b8a6bb97c0c6d5ee00e41cef780ed4f0112548572b2de", - "bytes": 2059 + "bytes": 2059, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageRevision.cs", "sha256": "f32c1083728aeab7e45440d094530b1b156e3803b954db30edd599c3d1b97e62", - "bytes": 2337 + "bytes": 2337, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageSlug.cs", "sha256": "b1a9ceebcd83a0c45521c49bf887855187510bc6030d41c4e0e76435734aaf4d", - "bytes": 1781 + "bytes": 1781, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageTitle.cs", "sha256": "8dced8ff8e2394cbd1723b04753fb4bae8751d2e05d029415b0f12ee5820e4ef", - "bytes": 847 + "bytes": 847, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/RagPublicationTags.cs", "sha256": "6cd1f7a8853dcd579944cdbd97849f4796070aace98f273c9c20ddf97b24476f", - "bytes": 4389 + "bytes": 4389, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/SortKey.cs", "sha256": "07216e4576150ac96453edce8489eafae9807c4005553a1f24eb5ce32b13359c", - "bytes": 2283 + "bytes": 2283, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportItem.cs", "sha256": "b6a264874c6060c721cbd5d2d85c3fdf9d77e26421e40db4efefd698cdd5e1e5", - "bytes": 2426 + "bytes": 2426, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportItemStatus.cs", "sha256": "67f25fc74696ab90e2fe83b4dd1fefc49fd9212f9bb654dcc18ad2e4525d3327", - "bytes": 167 + "bytes": 167, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportJob.cs", "sha256": "88e87ef0fe96a2e4714d1c18d682bba8ce3198b020546d3cff5f482413047c9e", - "bytes": 4771 + "bytes": 4771, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportJobStatus.cs", "sha256": "ad88031f5a4c809fd64f9064a560bdc229162102f3b5b7e5f7fbd71fb5c56ef7", - "bytes": 193 + "bytes": 193, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportProvenance.cs", "sha256": "463f94642167b95f696a6532c4e179f3923074ebfda103373adda7b2b7c36fd4", - "bytes": 1545 + "bytes": 1545, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportSourceKind.cs", "sha256": "6b9e638c34d51b1415b946f497e4b8662c7717d6ea78bbbf930d227cdb50bfc9", - "bytes": 154 + "bytes": 154, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportTargetEntityType.cs", "sha256": "c36e8ac9d38dda7afc4302bb2cff47ac5354ebfe45b670b193fde6c1825481d2", - "bytes": 130 + "bytes": 130, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Platform/PlatformSettings.cs", + "sha256": "438139260e7ede04c0f113f0b507de7e2a8b628644b22948e57844b1e1a599c3", + "bytes": 4918, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Platform/UserPreferences.cs", + "sha256": "239a989ce00f42067bb4eb1c92f26f041bace9d6d5d848f113f08625c820033a", + "bytes": 3517, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/Project.cs", "sha256": "03e9e860ce6a9397ca18dd86dc6b62f130e332d5c23a8032ef3548be6f635211", - "bytes": 9175 + "bytes": 9175, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectDocument.cs", "sha256": "a5145414554707f68ddcd2bf91b3df44ddb3ade631b31beb16453892c0190a54", - "bytes": 1006 + "bytes": 1006, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectDocumentRole.cs", "sha256": "47bb9f654e0fd5bd2307a9618af47b94a12a4ce3521c5384f94c797d6d2c26ca", - "bytes": 376 + "bytes": 376, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectId.cs", "sha256": "31235810ecf15eb9ca0740b1204e925543621e436ded4314571dbd921c97441d", - "bytes": 222 + "bytes": 222, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectLifecycleStatus.cs", "sha256": "8957f929650518ecd5ef2046be900c64279a7004f55e1393afcfaa0f45c3f3f4", - "bytes": 292 + "bytes": 292, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectMilestone.cs", "sha256": "6671768e30927ccbbebaf38eafae0b88df86f9d852f6415453e5ece7a7a25fa9", - "bytes": 3003 + "bytes": 3003, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectMilestoneState.cs", "sha256": "42e3cd8ea2ffcb7916c5cdeff2183a26eb283d285e619cc9ddd9754eb401579d", - "bytes": 160 + "bytes": 160, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectPriority.cs", "sha256": "6f6a4a8f8d9a39450c91e3bf33bbaa7a78a635a6907b3ce254f93c19f8bb3a42", - "bytes": 140 + "bytes": 140, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectStatus.cs", "sha256": "8d7595bb44c0924012313cc4dc9df304d50d9e994c351c83c88e533956100ecc", - "bytes": 218 + "bytes": 218, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdate.cs", "sha256": "933822f5954364c5b7e7cd232bd5698f160e637336e25f0c6a7f09924baa814d", - "bytes": 4819 + "bytes": 4819, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdateCategory.cs", "sha256": "2921d236d8f031297dde42b451d2ffdf7b9ff1b0ff9cb11200d4c980482336ff", - "bytes": 349 + "bytes": 349, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdateVerificationStatus.cs", "sha256": "420c57ec370dc23bc594fc8f537fa3a4d401ebfd89bce72050556830abfbc1ee", - "bytes": 280 + "bytes": 280, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookProfile.cs", "sha256": "566f89057f31288c3cb81163b5798a71705ecb0b04de4b577d7a07b39e26191b", - "bytes": 4252 + "bytes": 4252, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookRiskLevel.cs", "sha256": "423720b7fa907e67ac45a6abf6dfa9b7fc83c15beda7636797b4d37f5bdb2278", - "bytes": 141 + "bytes": 141, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookStatus.cs", "sha256": "e44594d3827cf7d4d1f3ed46e5bf4ca8cd1673bfc0900a0a736337b794c5c35b", - "bytes": 128 + "bytes": 128, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Sources/DataSource.cs", + "sha256": "e95db09757348ec2b9b89aa3d045516159173e950b536c555fc6a68e50be1163", + "bytes": 3922, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/AssemblyMarker.cs", "sha256": "dec74426a34424ca6e10fcc7affc2849e6120ad25c9b77b896dcb13b9c95b187", - "bytes": 86 + "bytes": 86, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/AttachmentRepository.cs", "sha256": "bdbcf291789a0883fdbf1c2a8873199f8104089f9c4e33e5b5c11b899d3cc0fd", - "bytes": 9017 + "bytes": 9017, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/ContentSignatureDetector.cs", "sha256": "6ee1b2111bb1131b6cbc8c29875a1940c235638b52400e75a543a33b0f1e1851", - "bytes": 7577 + "bytes": 7577, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/FileSystemAttachmentBlobStore.cs", "sha256": "48cefcf9f330754b91a3dd4e08aa7e952cff5b60deb5b9c446bb0b2a6bd07af4", - "bytes": 5330 + "bytes": 5330, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventReader.cs", - "sha256": "8b6328d93c07595bb79ff9986ff3890a6ade9d1bfdaa85a12378b3b6400772d3", - "bytes": 856 + "sha256": "feaa914dd9cdb2da8c4c3c90aad8eda9d0f774a21c6b1205a3a1dafea50cbfda", + "bytes": 2620, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventRecorder.cs", "sha256": "966fb8fb60c8e261176937fe66b6c884c77e0bdc691c1aaa8b6e9e3a1deb91ac", - "bytes": 2179 + "bytes": 2179, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/IdempotencyStore.cs", "sha256": "b3e3dd6e6f6c8c53fcb977721d6d5190fa5e374ae3efc9fc4c835d1052250d20", - "bytes": 808 + "bytes": 808, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/OutboxRepository.cs", "sha256": "9b3b6ae4048ca463e8e961f215a4665d0a6b6b2790e2939ce51b19e23ac12241", - "bytes": 826 + "bytes": 826, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/ServiceAccountRepository.cs", "sha256": "927a4d3eb6def6dbdbd5029e8089623648fb340cdc4b1cd0ad44acd744548f55", - "bytes": 2306 + "bytes": 2306, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/ServiceAccountTokenIssuer.cs", "sha256": "eee6c5691a639633d5c7341cc745dadaee30ac7162cef2a8bd76e9274a312846", - "bytes": 2918 + "bytes": 2918, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Decisions/DecisionRepository.cs", "sha256": "92039bc9ce0e1bab64416243d97eedce439f5751ba3b8b7d3ff93a4b12704153", - "bytes": 5646 + "bytes": 5646, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/EnabledAccountConfirmation.cs", "sha256": "a441d6a4061acc96969f603ceaca9b9acece218526fbbb06ce78429deb000474", - "bytes": 670 + "bytes": 670, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/InstallationDefaults.cs", "sha256": "1e440d241431058efd72216fcad091116b579f282a8c5543ebbccf063be28b80", - "bytes": 383 + "bytes": 383, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/InstallationRepository.cs", "sha256": "2120ba96a9784122b52aaf3d09c4b4cd50aba8e67518da6f6a746253f0be6478", - "bytes": 2079 + "bytes": 2079, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/RecoveryTokenService.cs", "sha256": "7a09fc6f31b4a6e60eb1b74182ea87257987d28490f89072c3c4af22339a8028", - "bytes": 2095 + "bytes": 2095, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/UserAccountManager.cs", "sha256": "de5e9405a6686f5eb9563b9678ad196b1d06b733d8104d3c3c94674e899fdf9c", - "bytes": 10406 + "bytes": 10406, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/WorkspaceMembershipRepository.cs", "sha256": "af615654cfa076009db969ba3293bd5f0c1b5b108b970b61cbfdbf374af7d7ef", - "bytes": 2278 + "bytes": 2278, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/WorkspaceRepository.cs", "sha256": "d2fb2e9500d076ba6a8d2f0c096303e47b270c4d04e54cefead0d6fdf72cc577", - "bytes": 1219 + "bytes": 1219, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureKnowledge/InfrastructureRepository.cs", - "sha256": "14a5ad07630b4a5021b60adbe4f21b2fc1fe0ebc20cfc7d82148aed8a955fc8b", - "bytes": 46297 + "sha256": "87a2bced7abd3e2f6b84cc8117e49cbcaff1c87c13febc378187b13c99689102", + "bytes": 47647, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureServiceCollectionExtensions.cs", - "sha256": "6e3152184b8ec84c84554e30d482f8577708bdbf01c897166ae992bb99cfc3be", - "bytes": 12268 + "sha256": "64c065ad32602578c419e27bf83a4a44345f5dd91f1506477d1159424446cc04", + "bytes": 13733, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Ingestion/IngestionProposalRepository.cs", "sha256": "ee7db685377749130961f50567e548b46230f7cf44ebaf7e1605205ea53204ff", - "bytes": 9800 + "bytes": 9800, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/ItWorx.KnowledgeHub.Infrastructure.csproj", "sha256": "f43ad4df8cd0ccdd59ab35f4d360a7375ff41b90c09e146648ac2082b300eb98", - "bytes": 1977 + "bytes": 1977, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Jobs/BackgroundJobRepository.cs", + "sha256": "3f4c5cbab1071659cf572f64e7c6c59c3b9b69f5e2a7d451a095d932d0ad095e", + "bytes": 20526, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/DraftRepository.cs", "sha256": "f26a54519b8438f431267290615f4c5af8997319601db0ce4517ee41894871b8", - "bytes": 972 + "bytes": 972, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/ManagedMarkdownControlMarkers.cs", "sha256": "46ca709375e7bfbe6d8a05d4b784dc4cb1fcd82cc62265850af7a9f941c28317", - "bytes": 1993 + "bytes": 1993, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/MarkdownRenderer.cs", "sha256": "b47b6f266968c087b999971df7d6a6d66158a0ab25b78c767e05b1d09b8dafbf", - "bytes": 5785 + "bytes": 5785, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/PageCreationSessionRepository.cs", "sha256": "718fd2fb95f8a0aefe3590a2adf5ba82515669f26b748566e249d439e7e8b774", - "bytes": 2508 + "bytes": 2508, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/PageRepository.cs", "sha256": "5adbc6a1bb178d4b6782b8744813448d3b9ed34bd87ec2621a26f794db2ed831", - "bytes": 14455 + "bytes": 14455, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/SearchRepository.cs", "sha256": "dead86bcaa367b2a2145527e80985db94876261223584bd66ee5670cd8065cea", - "bytes": 10486 + "bytes": 10486, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/TemplateProvider.cs", "sha256": "03d16448b0e6ea94bc181d4c053b27dc2bb9b6d55b89ff8448910a68bbaa91ce", - "bytes": 3347 + "bytes": 3347, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/FileSystemLeafWikiSourceReader.cs", "sha256": "c662b430a273061149f81e14b74c1dafda25c5b2d11a36798d6272b739c66793", - "bytes": 9995 + "bytes": 9995, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/FilesystemMarkdownAdapter.cs", "sha256": "2cc4ac904a933ded459ee5a822da7c5c633a064c2fcb9b50b370419dd30896e5", - "bytes": 11751 + "bytes": 11751, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/ImportRepository.cs", "sha256": "448523af343769e2e995b676f6a4b891cfafb5273dd8876eaa66f37c78e8f32c", - "bytes": 2572 + "bytes": 2572, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/LeafWikiAdapterCatalog.cs", "sha256": "95669a90fbc68d314885fe92cdbba82c3565666507c4ff27ee7594cca67db62e", - "bytes": 1033 + "bytes": 1033, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/LeafWikiV5FilesystemAdapter.cs", "sha256": "e6e350cc024a29e0515a711271d8a1fcc98bdd029079397345c21617dffe8acf", - "bytes": 28263 + "bytes": 28263, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/PageRollbackRepository.cs", "sha256": "96fe7f7961355bd629ab1518e1d5c8994c775cb9cee8f487590be785039f9fb6", - "bytes": 3146 + "bytes": 3146, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/SourceBatchPurgeRepository.cs", "sha256": "3155060c6ed9dff25439e293ed1aae0a9b1e4e6e5badab7122a58dc880f85d40", - "bytes": 15355 + "bytes": 15355, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/SyntheticSqliteAdapter.cs", "sha256": "385ba973d2d6e7352c9926f7f0692f3683baa9c56c485b58b129de6d7088791a", - "bytes": 7007 + "bytes": 7007, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupPaths.cs", - "sha256": "06307665479009f869538cd8b02c067603f9d84bd38e7b247ba45e34e11bf9e8", - "bytes": 343 + "sha256": "f66f37abe68bd45f548ce3525cbb5e3fd5792971e149b3d818d06d9a701a0469", + "bytes": 688, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupStorageSafety.cs", + "sha256": "4374075bd9a9d14759caed6933f6480e6ead4dda540862d7b80bd95371dff121", + "bytes": 4301, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/DatabaseHealthCheck.cs", "sha256": "e499b5449be4ffc6b7e21986fd5793d635ae741030b3b2e260b90379999dc088", - "bytes": 1142 + "bytes": 1142, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/DatabaseMetricsInterceptor.cs", + "sha256": "4f9a186c1e87ec0036f867d938e00582e6aeeeb9a99d6b35debc12114e26bf2a", + "bytes": 1986, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/FileBackupEvidenceStore.cs", + "sha256": "88d5d97b47db3ea893e9710cd52ba4cb9c80fca21ea04687a729d4a6c661a60a", + "bytes": 14506, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/OperationalTelemetryReader.cs", + "sha256": "19466618e539962b0ce503042369a4166ef11b078233af003b8b7ce5182291b3", + "bytes": 1712, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresBackupService.cs", - "sha256": "6bfd16d3e2d9624d88e316624277ab407c14e88ed9e5f2879d9c1ccd14b45551", - "bytes": 13845 + "sha256": "e2ce688c1ed527cead2e54960443fee8e18bca099ea0d82ec7b26ac4a3fa7bdd", + "bytes": 16839, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresRestoreService.cs", - "sha256": "79112a12d80277c4d6fbf3b97ad747ff855d309ac74a8ea0e00c71a8c81aa84c", - "bytes": 8451 + "sha256": "fe4a8b96ca8ded3cbb1a26b10a1af5204e9904e005af7d4b33da5ee7f340ad72", + "bytes": 24904, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/ReleaseManifest.cs", "sha256": "a66d471872764249ce540121d7d54a6e0702172c83fb929074e3f879547d2a62", - "bytes": 2732 + "bytes": 2732, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/StorageHealthCheck.cs", "sha256": "37d4178f1d789531b1d4f2f835f2dfff90a4648240592666a3af8b503d57b7c7", - "bytes": 2146 + "bytes": 2146, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/TableDiscovery.cs", - "sha256": "51a30c4605eb0e6b1743136244244d7d3394e0ff27c1eaabdaabffe6b3e05e2d", - "bytes": 2951 + "sha256": "116d5f2209adea9be9b4f0e416fb327087f0cf01727745ba2a22545dd419fcb7", + "bytes": 4724, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/ApplicationUser.cs", "sha256": "57b170cc56a1fe90f4c9e37501a8c1425438a67b96f4dfde8bb84247e2bbcdc2", - "bytes": 1021 + "bytes": 1021, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/AuditEventEntity.cs", "sha256": "b042afc922bc2b8000432db5de1f026c459fb058e3b3c78ba29049d8b43bed47", - "bytes": 561 + "bytes": 561, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs", "sha256": "bc17d8afb8769c1ca7370b4179001589a4cf53f056c198674c91589f842619ff", - "bytes": 555 + "bytes": 555, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentBlobConfiguration.cs", "sha256": "f1988ee304c77826c3bb7a35d0aa651a076510ce5ac00614336fd1158c98c6a8", - "bytes": 977 + "bytes": 977, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentReferenceConfiguration.cs", "sha256": "ab47d614d0ef5ac38902dcf87bd20944bf07de1f364c5ad9d100f25361139f87", - "bytes": 1322 + "bytes": 1322, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentUsageConfiguration.cs", "sha256": "e0389efa13e83ab9badc513e8bca45040b53aeb4a301e0aa4cc47ebcbd49f688", - "bytes": 2519 + "bytes": 2519, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AuditEventConfiguration.cs", "sha256": "0a4a4be8d48fd2797c1eda1a6139f539612ee677a81deef3a5bdf941d353f91e", - "bytes": 1417 + "bytes": 1417, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/BackgroundJobConfiguration.cs", + "sha256": "5e3313d095a93929dbfa3a6ec154b80f0201b874ae36c7d6113c7afcc6bd5a95", + "bytes": 3481, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DataSourceConfiguration.cs", + "sha256": "e17b75d63d0b09cbb101aa281026ff4fde820acca263a26b9f7d70cab1af0a5d", + "bytes": 1280, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DecisionRecordConfiguration.cs", "sha256": "c42b89b33d71a403bda2a1bb7844d6d009202cd50fd59847aa82b621f6f35983", - "bytes": 2965 + "bytes": 2965, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DraftConfiguration.cs", "sha256": "a111afe705c1f33797330454707d7b68b88202b9e0b36fd558fedfd37bc59042", - "bytes": 1568 + "bytes": 1568, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/EditorialKnowledgeConfigurations.cs", "sha256": "4e8616e91984d44f3ee1a395cb7dfe44e7bf0506fd6d6fb266712148a64020f3", - "bytes": 3352 + "bytes": 3352, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/IdempotencyRecordConfiguration.cs", "sha256": "df0f4317843b4914024bcc39baae7dfb04edecc7087037f90cb1a6cbc0f08a7d", - "bytes": 1335 + "bytes": 1335, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportItemConfiguration.cs", "sha256": "85ddc9aeaa4042bd19b5980e9537d00b1bb691c5a1250ff47efe5cc178c5db80", - "bytes": 994 + "bytes": 994, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportJobConfiguration.cs", "sha256": "5d51453c46dc48edb0a26d71bff00210b3035d20e09263c0a86bf09c6d60074b", - "bytes": 1048 + "bytes": 1048, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportProvenanceConfiguration.cs", "sha256": "02d19a1b14b8f92e32bbb0472f34713e1d0a57db36c9dc077cfa6132c5292755", - "bytes": 842 + "bytes": 842, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InfrastructureHostConfiguration.cs", "sha256": "d8ef0e853e5329e992aa1560fe789db801c301c12213a044ee62030e0d55102d", - "bytes": 1287 + "bytes": 1287, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InfrastructureServiceConfiguration.cs", "sha256": "b9887878c9bf6532589ef559195dfbcd6f2e4cab6087dde383d0a8a59e209d27", - "bytes": 2412 + "bytes": 2412, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/IngestionProposalConfiguration.cs", "sha256": "f8f46a14e05b3d5115d0de16d78dd210f7ffdaa05f9d2b88e4cb44c207f910c0", - "bytes": 5750 + "bytes": 5750, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InstallationConfiguration.cs", "sha256": "a75e1227e8c2eebd4df8f4872d69a680ec6ffb548e615f2239025e9e357897d8", - "bytes": 1344 + "bytes": 1344, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InventoryChangeConfiguration.cs", "sha256": "ef9bcca4ffe7f2eea6c657e1d9f4ee849dbf00861858f40f881cddeb86bc2921", - "bytes": 1172 + "bytes": 1172, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InventorySnapshotConfiguration.cs", "sha256": "ededf6ec0e844622722cb8d28fedd94c7fd8bcebe95e41c0c9126929e3db5a73", - "bytes": 1155 + "bytes": 1155, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs", "sha256": "178b4ebc339a04c0fd17bf2dacc32faf0a7f65588eb598234a25df52883cce0d", - "bytes": 1834 + "bytes": 1834, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageConfiguration.cs", "sha256": "1d056c3ff24bc47122f32d34b499bc39c59144ff3c53f525270d924c49250f62", - "bytes": 3568 + "bytes": 3568, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageCreationSessionConfiguration.cs", "sha256": "bc1e8dbba8f117d40c30d2e169e214a1f74e90fd0177bfe2fdf3e7ff08de8124", - "bytes": 1582 + "bytes": 1582, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageLinkConfiguration.cs", "sha256": "627189435806fd6c614e42d90a3cf109e7fe1d856030397406042fec1ed8a797", - "bytes": 883 + "bytes": 883, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageRevisionConfiguration.cs", "sha256": "8c34f8ad4df1aaf8025c6ce555b29416f5efdd4aedb3fc3cfa38e7c7886481a6", - "bytes": 1434 + "bytes": 1434, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PlatformSettingsConfiguration.cs", + "sha256": "02614063609fe8d3beddae59e7fa790af66e26383d141365cf25cc4a0a6d433b", + "bytes": 2175, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs", "sha256": "eeacfc9a485dfe616a8e65650339947fc95c519687aaa4ca8b997ff21edb533e", - "bytes": 3194 + "bytes": 3194, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectDocumentConfiguration.cs", "sha256": "701f12b8a5a5e2c4e66819bb85a4d05bba01b2edc670e94feaf195445ecc1fc6", - "bytes": 957 + "bytes": 957, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectMilestoneConfiguration.cs", "sha256": "d72e435cd6789339af8e2df1600ad3cafdb2659111427a9e7fd0e32a1bf5448c", - "bytes": 1717 + "bytes": 1717, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectUpdateConfiguration.cs", "sha256": "257f9614c0f39a5afc22c32a9bbff5bd011cf4f165075c3e00d64ef0b4087a58", - "bytes": 2204 + "bytes": 2204, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/RecoveryTokenConfiguration.cs", "sha256": "5a6aa1649427a14b0016587de43635fdc02f18a7a4bb796f6f25d9b0c812f380", - "bytes": 771 + "bytes": 771, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/RunbookProfileConfiguration.cs", "sha256": "17dfe164b07a80469317ec35770796da5d838f73f865da4f62b44091e547422c", - "bytes": 2007 + "bytes": 2007, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceAccountConfiguration.cs", "sha256": "05f7566a384f6efc66187098b28337a9a6dea49df36cb2ee2d9df01d821485ed", - "bytes": 1157 + "bytes": 1157, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceAccountTokenConfiguration.cs", "sha256": "aa4983c755881dae8aaf57ad27fb7a5ea730fd7b89f9af69823f0bd4e96566d9", - "bytes": 1447 + "bytes": 1447, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceFactConfigurations.cs", "sha256": "72102ccde04a119dc184406db54846de68518273aacd16e8939c0fc56fc4217b", - "bytes": 6176 + "bytes": 6176, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/UserPreferencesConfiguration.cs", + "sha256": "24834f741ecf0df4d01b1401f5844aa7b98307ad3f46a9ba1351262badd30a44", + "bytes": 1819, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs", "sha256": "3e3622cb241a137cbe553aeee321c218cd428ce628a30c0b971144965f35675f", - "bytes": 853 + "bytes": 853, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/WorkspaceMembershipConfiguration.cs", "sha256": "e330a5532015c1475523aa70754004096228b7812728d633fa926cf112ecbf47", - "bytes": 878 + "bytes": 878, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/DatabaseConnectionOptions.cs", "sha256": "082d951535b6df7a8e8b1d922ea63c310a026bf6c458b859bc36eb9ab6c59cc6", - "bytes": 371 + "bytes": 371, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/DatabaseConnectionStringFactory.cs", "sha256": "ef652423b5f1d8a4235138fb8baf8247194661ec01ffff2bc5d4fc62068275ed", - "bytes": 1463 + "bytes": 1463, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContext.cs", - "sha256": "b456897a42d4576917c1ebc6936a5e740c99b63464be2a2d7b7a2c2dc83a4b5f", - "bytes": 5958 + "sha256": "d500ba8e1a622a935526ed4810b572a8d6c86a48f77ac1e5597ac5745ac6461f", + "bytes": 6357, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContextFactory.cs", - "sha256": "7776afa1cdb9451c39fb52d37168b6472579ef4cfc130841c8691a208f6b5c0d", - "bytes": 1077 + "sha256": "48901804e66912e7ddd8b40052798611a53bac9c20b6438c80bb9c2e785d5b2e", + "bytes": 1075, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731162849_InitialCreate.Designer.cs", "sha256": "46effc45cdea0b166c8dbb8462ba6b5232d770fc70216eaaa09033d053da0d70", - "bytes": 19665 + "bytes": 19665, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731162849_InitialCreate.cs", "sha256": "a76b3a2bfe8039d55a5818cb9d65e441865a427c4df1d282b36755bbe2aefcc8", - "bytes": 14084 + "bytes": 14084, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731185224_AddKnowledgePages.Designer.cs", "sha256": "ced1eb0c7275dc843b538e9034a41593d3b242b9ce1ade9d02fd0b5e1ad0c02e", - "bytes": 31845 + "bytes": 31845, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731185224_AddKnowledgePages.cs", "sha256": "9aab5aed585fe58984584f768384f1860f205ee9a9c088087280e1784f29ad99", - "bytes": 8884 + "bytes": 8884, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731201729_AddDiscoveryAndSearch.Designer.cs", "sha256": "a352e67918091c2ef60acda11093a55146727e196269d1a1059eab80c0cf6ea8", - "bytes": 32054 + "bytes": 32054, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731201729_AddDiscoveryAndSearch.cs", "sha256": "e9d73c49bfa24d1888227586aa32926ebef9992577d4094f67c158e5c3fc6b8e", - "bytes": 4767 + "bytes": 4767, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731215422_AddAttachmentsAndImports.Designer.cs", "sha256": "6e6a1d792e4633ecd308a01e94338d05d2aedbeadbf1bd448916007e82952c93", - "bytes": 45859 + "bytes": 45859, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731215422_AddAttachmentsAndImports.cs", "sha256": "28e0db6377e9b5a0b5531eb95935dc84fde278e10745bb2a77ad51d4dc5a770b", - "bytes": 10562 + "bytes": 10562, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801013144_AddProjectHubAdrsRunbooks.Designer.cs", "sha256": "1206414497c45936abe96a6993686391eb41ba9b6a1c735cae649e261b0dcfde", - "bytes": 65369 + "bytes": 65369, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801013144_AddProjectHubAdrsRunbooks.cs", "sha256": "8b6a267309ec4be99f1b44c37db82e4a4393ee1abbf77a61c2498f21d9e5fa5c", - "bytes": 14044 + "bytes": 14044, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801023652_AddAutomationAndOutbox.Designer.cs", "sha256": "e766c4626516d705d8904eda31eda68bea1280cfcbfbb6d8c3aa2ee7ae738ca1", - "bytes": 74306 + "bytes": 74306, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801023652_AddAutomationAndOutbox.cs", "sha256": "02526db84cc8b7ca30a43479470a791cd6e33f94ef55bd01d3f78a0e99cf1a05", - "bytes": 6832 + "bytes": 6832, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260802040639_AddStructuredInfrastructureKnowledge.Designer.cs", "sha256": "cff9c22c72cb7490cb6ca224197a277cee0cdbde2d2ca833d3542958e3d2afa9", - "bytes": 111371 + "bytes": 111371, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260802040639_AddStructuredInfrastructureKnowledge.cs", "sha256": "0e4bb6d2ae5e18407cbfb4b74286dfdd5a833cb83d74f452241c7be6c1b8d787", - "bytes": 32958 + "bytes": 32958, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803193236_AddPageCreationStaging.Designer.cs", "sha256": "f99e8cdee431be06981b36242da01e6c8ff2011d62962ad656922bfc93f3646d", - "bytes": 114381 + "bytes": 114381, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803193236_AddPageCreationStaging.cs", "sha256": "5eca746e697518fc9ead6e8d09a06902bf61ce550fb6901d6fa7796dce246387", - "bytes": 4251 + "bytes": 4251, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803195722_ExtendDraftMetadata.Designer.cs", "sha256": "02b05c4c45aadb2b9c0eb720b6e9776f155ce1f95ff135caa382618002483630", - "bytes": 115243 + "bytes": 115243, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803195722_ExtendDraftMetadata.cs", "sha256": "b2f9b13872c641de3d58b54be8319af635a568ba03feeb16c30750ef89080825", - "bytes": 1857 + "bytes": 1857, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260804005007_AddInfrastructureServicesDetectedNameIndex.Designer.cs", "sha256": "b94f1d13a804ee35a3286aff631699fc0a78478ea941c647e87fe771ad005f23", - "bytes": 115460 + "bytes": 115460, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260804005007_AddInfrastructureServicesDetectedNameIndex.cs", "sha256": "a8b9c3465d2c3626fb2998bb4439a05db68e2b7773c5a2431fbfc754be4fbb2a", - "bytes": 914 + "bytes": 914, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920120000_AddIngestionProposals.Designer.cs", "sha256": "7288bc485eb105a5971a8120f282ca2f245e1d3bb6a84eca735fc6f4cf650684", - "bytes": 126857 + "bytes": 126857, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920120000_AddIngestionProposals.cs", "sha256": "399049a315e5cac2131e964614e42bfc70945de108dbd2a959d03a4dfb2141c1", - "bytes": 7707 + "bytes": 7707, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920155218_AddIngestionProposalAttachmentUsages.Designer.cs", "sha256": "3e54b8a5e4aaa81bd7f588ec74de3f95cff335a870c8dfdae562d8c963967ad3", - "bytes": 127635 + "bytes": 127635, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920155218_AddIngestionProposalAttachmentUsages.cs", "sha256": "f145293b34186da861314af8d9d11ecb75fb32e5263618642c9deaead44cf351", - "bytes": 2618 + "bytes": 2618, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920173238_AddIngestionPublication.Designer.cs", "sha256": "8201e6ef926b33da0511d4814850be3bcfa0e19fd9c369015b0e307e843df3ca", - "bytes": 131112 + "bytes": 131112, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920173238_AddIngestionPublication.cs", "sha256": "4474288b4b762af30080d27526a9bdf1a952bd2b903c868b01c38cf489967fdc", - "bytes": 4559 + "bytes": 4559, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260923221837_AddPageTagsGinIndex.Designer.cs", "sha256": "1909debeadb1dad39a1c8e4823eef079af2db468032705dc883accc3ab9d39cc", - "bytes": 131298 + "bytes": 131298, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260923221837_AddPageTagsGinIndex.cs", "sha256": "72288097d2c02207e82ed7efbb18ea1565b1d75066faa9ec1b113bd30715c40c", - "bytes": 784 + "bytes": 784, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260924182209_AddProjectIcons.Designer.cs", "sha256": "03035c0b25976008d9f10d560a667bfe01e4aae285e2506ab156eb86a3913f69", - "bytes": 132691 + "bytes": 132691, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260924182209_AddProjectIcons.cs", "sha256": "29718e07eab4ace7c338b69b31536ca97a9c8c5d01ba49a6078f409b5becf900", - "bytes": 3832 + "bytes": 3832, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002001948_PlatformFoundations.Designer.cs", + "sha256": "1880003749f7f29e42dac56a8ebdafcac66891db792ba891501b190a907ef8ee", + "bytes": 146120, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002001948_PlatformFoundations.cs", + "sha256": "cf3dba546a5af0b71ad4f1379fb5588fec3cb65c5cf6ca28ce546f7a48d1be42", + "bytes": 9216, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002095318_AddBackgroundJobs.Designer.cs", + "sha256": "894b3c7cd75335887c58810ba0d324825e801738b7c740622ae24d7d8cdde86d", + "bytes": 156109, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002095318_AddBackgroundJobs.cs", + "sha256": "54ffa2a246929bdf07f70cc5524902c3e9eea3e7777bff8cc93945d23256bfd2", + "bytes": 7846, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/KnowledgeHubDbContextModelSnapshot.cs", - "sha256": "0eb1a59c19a2ac9afa587c53d46b9f41a0f836925b84756c72d7cbbf3102a786", - "bytes": 132595 + "sha256": "0ffc566cf3dfa8733b3b0c0f4124746b064a281a419d10ddbee31310757a60e5", + "bytes": 156009, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/RecoveryTokenEntity.cs", "sha256": "8b191dd8b4d388b8cfcdfd8549cc3c8f3aa515451ebc95afce5e38bdfbe342a1", - "bytes": 569 + "bytes": 569, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/SchemaGuard.cs", "sha256": "6903b8571b5ee9d6048a74fb3473c20950a8a9754715af113d8890a9085b313f", - "bytes": 2566 + "bytes": 2566, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Platform/PlatformSettingsRepository.cs", + "sha256": "543d76bbfefacf9a507d12c8b818e09b1e1230301b26533e4ea60661d04b5d60", + "bytes": 3569, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Platform/UserPreferencesRepository.cs", + "sha256": "5ec4647a9e0a6612434df659d603d2af4304adc1e5a713028ad28a10f953b6c3", + "bytes": 1478, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Projects/ProjectRepository.cs", "sha256": "6b69dde4243a86e437c14145fdd08f102547d4a86f0c5e8c78672840f594b1cc", - "bytes": 13959 + "bytes": 13959, + "mode": "100644" }, { "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Runbooks/RunbookRepository.cs", "sha256": "d5b25afe37ce718ace1a370e57eba4880749b7f6f1be8518630a7a93d30643d7", - "bytes": 5290 + "bytes": 5290, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Sources/DataSourceRepository.cs", + "sha256": "dea10ea010610de9b6d79b3bc8f804dcf8e516a19e7cd9b9c9a7042b188edf28", + "bytes": 2454, + "mode": "100644" + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/UnifiedSearch/UnifiedSearchRepository.cs", + "sha256": "a415a318c459673a17274f692989e058afb6a80a0137b0ce121d839e8570904d", + "bytes": 26978, + "mode": "100644" }, { "path": "src/frontend/eslint.config.mjs", "sha256": "f2b1ded6fa9f74260438527be5969e1ae9d708df596ed6bba09e6b9ca51e93e3", - "bytes": 976 + "bytes": 976, + "mode": "100644" }, { "path": "src/frontend/index.html", "sha256": "01a6f25ed87a6b2ebab9a6ae9a1d2ede2baffb73785930595aaff02fa8f2520a", - "bytes": 598 + "bytes": 598, + "mode": "100644" }, { "path": "src/frontend/package.json", - "sha256": "ace9c70d5fe5ae6076d4f52494b3fd41fe2c44c4bca518c4233bf6a2a450605c", - "bytes": 1826 + "sha256": "8cd83d71ceba97615ab0cfb20dae581d75767b4be13b545da8868165750e4b0d", + "bytes": 1867, + "mode": "100644" }, { "path": "src/frontend/public/favicon.svg", "sha256": "37fd61466b3a818994e57d56ab8625e7803b94775ccff349b2a05e9fe737c198", - "bytes": 302 + "bytes": 302, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/blockpilot-autonomous.png", "sha256": "427889b0183ad56c4895dcdc3acb9b458b98ad61fa61b388d3f8c4504ebe0496", - "bytes": 55470 + "bytes": 55470, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/chimera.png", "sha256": "7c9eadb384e0b2832e426045dfdbda4f1ed298ae5d3605c8beb90040172eb292", - "bytes": 353492 + "bytes": 353492, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/devrunbook.png", "sha256": "15bccb4ec53777e93ad3c07ac6aa83878898f61f9ae73f63ee415dd936cd029e", - "bytes": 15050 + "bytes": 15050, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/dockdeck.png", "sha256": "b15cc1330d5d79639eca6efe88af796f2448d9bad71eee61fbd339092a673b0a", - "bytes": 100522 + "bytes": 100522, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/evidencevault.png", "sha256": "ff41dabc59bc8c0ce24abe8188b99c1d629a59f971aae41c3e5d9ad116900e20", - "bytes": 23068 + "bytes": 23068, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/geointel.png", "sha256": "3521f712a10796ddaa88c6ad6368997c4c2d30f06324df884c31c22e4b639575", - "bytes": 25979 + "bytes": 25979, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/gifted.png", "sha256": "04856261a6de7124fac925f4d992e9e29042d7a43f0cd74f02f1a6f2e17cb8b5", - "bytes": 17248 + "bytes": 17248, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/infrabinder.png", "sha256": "a29c0c974fa31a7afb234e5fb61ee68c3e000dafdf0ccb1a6ce05e3354d38277", - "bytes": 13330 + "bytes": 13330, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/itworx-mcp-hub.png", "sha256": "10082dc4d75e3fe93a46137b76ef036d95e04af5ea9aaab491f1fffcbca1d1c7", - "bytes": 1242042 + "bytes": 1242042, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/knowledge-hub.svg", "sha256": "37fd61466b3a818994e57d56ab8625e7803b94775ccff349b2a05e9fe737c198", - "bytes": 302 + "bytes": 302, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/ludarium.png", "sha256": "29862374bd95218010a21722fe82e43c469f7180c41d753421000f71843a49ce", - "bytes": 34655 + "bytes": 34655, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/lumaops.svg", "sha256": "b978309cbf85b0fad32793337244805d7451ed330e12b8673ca15007f016f584", - "bytes": 577 + "bytes": 577, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/mcop.png", "sha256": "486a6e8d5a5a3cb840b888cf0e5396701de2b4e5f2cabf9a5c02ba4abc595c26", - "bytes": 10754 + "bytes": 10754, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/mobilityops.svg", "sha256": "3d92feb153ec1cd1b6a523cbcb9b20a1c010eae6da4cd0c4e5cc9a47d4b8b8cd", - "bytes": 344 + "bytes": 344, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/pokemon-legacy-rom-builder.png", "sha256": "967ed6d5806f12d36950a39893ab6580d58cbc334e2b289413b130d53f578e38", - "bytes": 103981 + "bytes": 103981, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/portfolio-itworx-tech.png", "sha256": "8b20df10effac8e8fc404db829275b23b437d54d1c42c57600bce1d66d072d1e", - "bytes": 54726 + "bytes": 54726, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/project-launchpad.png", "sha256": "35bc3d8be877c3cc5d4a702be7c7562cff73fdf3991e421b2758dccf129bc53c", - "bytes": 7293 + "bytes": 7293, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/projectbrain.png", "sha256": "83bcd21e2f2aeb09cb2543d3812a84ad9bd841168cfed7b9b937c25e08231b8e", - "bytes": 29208 + "bytes": 29208, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/psforge.png", "sha256": "82bcafb6f1afa6eef9352febb7106170a63a60b4678c25b1a55b4d95bac94855", - "bytes": 7379 + "bytes": 7379, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/quizmonitor.png", "sha256": "bb6b1b4553fa23ae0c61d7026d946e33237a26595e16ac34ed16093ee9cd486b", - "bytes": 3091 + "bytes": 3091, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/ragcore.svg", "sha256": "3cb8ee1ba0b9223ba4324baac06821de74efc6f0185a884b451738ea1cc45fb8", - "bytes": 568 + "bytes": 568, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/spoortracker.png", "sha256": "240e218c23e97b6ab0aa2a70d3c837d4e88b763ee250ac92765cf0a52183e600", - "bytes": 4602 + "bytes": 4602, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/unraid-appops-gateway.png", "sha256": "1c388d073c49546182cb6ff2587df04601f8e2d32eb9f1038bb1a4a6fca63de8", - "bytes": 6567 + "bytes": 6567, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/vacatureradar.svg", "sha256": "e5fa24a32a3206e44526b1e5e12dabbc3bf0d5968ef4110cb7d451a4b155aa62", - "bytes": 464 + "bytes": 464, + "mode": "100644" }, { "path": "src/frontend/public/project-icons/widefrog.png", "sha256": "f97ff062faba1acc2d4ad280f00b87a7f7ca54f0e3565d405fda8137aaddae6c", - "bytes": 8546 + "bytes": 8546, + "mode": "100644" }, { "path": "src/frontend/src/App.test.tsx", "sha256": "6d3682d2a5489a85d802a5111d0ce5918e4479e1f37971a6c6a966af0deb901c", - "bytes": 4991 + "bytes": 4991, + "mode": "100644" }, { "path": "src/frontend/src/App.tsx", - "sha256": "8b29bb586d679c01fd1b3e8a945cc60078df050dcf81e368dc4c49141131d6f4", - "bytes": 2789 + "sha256": "3aa6b596ec0e2f6b93cdaf7cc3c89bf4b54d28a776e1e2a42f0e810e11a9be9c", + "bytes": 2918, + "mode": "100644" }, { "path": "src/frontend/src/api/activityTypes.ts", "sha256": "807fc7880567fe41cee14eddeb7f1fa839527b13f8dbed7bfb0f7de09a93ca00", - "bytes": 289 + "bytes": 289, + "mode": "100644" + }, + { + "path": "src/frontend/src/api/adminQueries.ts", + "sha256": "13dfd53119de80f04ef07fa505431e98cf23d1162fdbd6b153c5a2e6d185c00d", + "bytes": 3979, + "mode": "100644" }, { "path": "src/frontend/src/api/adminTypes.ts", - "sha256": "5e1f23d7ae48e94e70f7ce3445dd6804d80873bd927bb562883a9b2b7cef155b", - "bytes": 1976 + "sha256": "590f66b7f783e00e4b51770dab90103f40244992d700efa2e3dd9796e34b5b01", + "bytes": 2138, + "mode": "100644" }, { "path": "src/frontend/src/api/attachmentTypes.ts", "sha256": "028e1f1be74d91d1f9d3c39e024cf5e694a68219ce59f914e0aac9992fdc3afe", - "bytes": 290 + "bytes": 290, + "mode": "100644" }, { "path": "src/frontend/src/api/client.test.ts", "sha256": "fb8feafa96a8e619f7a79b2b647167169e3bf7e50e2cb6f4d9ca4c9333364ae7", - "bytes": 2241 + "bytes": 2241, + "mode": "100644" }, { "path": "src/frontend/src/api/client.ts", "sha256": "e6bb3514a6ee97dc7b198994628dcaf281a7b723f4bd0ffc07bff423a23c3768", - "bytes": 6411 + "bytes": 6411, + "mode": "100644" + }, + { + "path": "src/frontend/src/api/domainQueries.ts", + "sha256": "65be533be00600457739cc2936b50f8c934cd8ef85489df376c2ad07f3338bf1", + "bytes": 5266, + "mode": "100644" }, { "path": "src/frontend/src/api/infrastructureTypes.ts", "sha256": "13fa7ba0d356cd081b59840a7f586b48141ccdee7e18548853ce33c1157e0339", - "bytes": 5955 + "bytes": 5955, + "mode": "100644" }, { "path": "src/frontend/src/api/intake.test.ts", "sha256": "cac6ce0a0fc9b21f6190b9f19b68a94b7fcdd791b2fc12288765904f8544fde4", - "bytes": 4765 + "bytes": 4765, + "mode": "100644" }, { "path": "src/frontend/src/api/intake.ts", "sha256": "62ce3e9604296f68aca3515f96ced52fb870e6a59d52134737bc6150561fcd0b", - "bytes": 4441 + "bytes": 4441, + "mode": "100644" }, { "path": "src/frontend/src/api/intakeTypes.ts", "sha256": "ad1037654fbfacd3116fa7862362ec5fb8031086ea4800aaddb5087bae98147e", - "bytes": 3656 + "bytes": 3656, + "mode": "100644" }, { "path": "src/frontend/src/api/knowledgeTypes.ts", "sha256": "212d1a540875ce687a25e864966ecd785b6a0a84332529ae7e0bd80b10eedd1c", - "bytes": 4101 + "bytes": 4101, + "mode": "100644" + }, + { + "path": "src/frontend/src/api/platformTypes.ts", + "sha256": "2a35b609dd3de5568c02252e59f4f25b283a59d5e0f8e2fc76ac5107055ce3b5", + "bytes": 5249, + "mode": "100644" }, { "path": "src/frontend/src/api/projectTypes.ts", "sha256": "fb8dda2261fd54dcedecec55bf8b2898cc82f356ee47736366500aca5e30dea2", - "bytes": 7785 + "bytes": 7785, + "mode": "100644" }, { "path": "src/frontend/src/api/setupTypes.ts", "sha256": "8a64981191ec496492e45b194829ae2d57c42004c075ea8f9505b0764b3fe38a", - "bytes": 82 + "bytes": 82, + "mode": "100644" }, { "path": "src/frontend/src/api/types.ts", "sha256": "58be4ccc1003181ba995c13ae1ba30c0f11e6a9fcddc423e9307f2356cc21f71", - "bytes": 617 + "bytes": 617, + "mode": "100644" + }, + { + "path": "src/frontend/src/api/unifiedSearchTypes.ts", + "sha256": "d02fdf1a919d3a9b4b100f377ed1ced1f7c3c1080ef837b731054c7c52b8ccbe", + "bytes": 762, + "mode": "100644" }, { "path": "src/frontend/src/auth/SessionContext.tsx", "sha256": "bd75bb6e09b78b04aef7a4f621482ec4a3344e2bd29c130dc89b2e27d7742182", - "bytes": 1467 + "bytes": 1467, + "mode": "100644" }, { "path": "src/frontend/src/auth/session-context.ts", "sha256": "4e242cfebd7d9d9f3c5b8bbd1940fcb29fa6f24b6f3cbd526e654929104617f7", - "bytes": 617 + "bytes": 617, + "mode": "100644" }, { "path": "src/frontend/src/components/AuthShell.tsx", "sha256": "3222e264ae7524802cc4e3cdcb86403c1de591d91439406a68daeb2eb9c6955d", - "bytes": 1756 + "bytes": 1756, + "mode": "100644" + }, + { + "path": "src/frontend/src/components/CachedDataNotice.tsx", + "sha256": "bf955cc45f09bf698fadd0257e0cd82a18cc41ce895dba3a7178055c99b34d82", + "bytes": 383, + "mode": "100644" }, { "path": "src/frontend/src/components/CommandPalette.test.tsx", - "sha256": "728f321a25070b3da081d2f1c4c19c144eefdc23a18a612a0a1497c242e7fa42", - "bytes": 5663 + "sha256": "8c8042342f4909342abcacfe8ffebdafea518ea49389f7f159356a5c44bb8aae", + "bytes": 5602, + "mode": "100644" }, { "path": "src/frontend/src/components/CommandPalette.tsx", - "sha256": "22aee35c90ef6006073562b5ae9fe0255d213199ca538d4fd3a2c3158543870f", - "bytes": 12265 + "sha256": "cf933deb7b7209ccad0cbedc166df299b87a98bfec0c993e8ab0629ff2f35ea4", + "bytes": 12301, + "mode": "100644" }, { "path": "src/frontend/src/components/DataTable.test.tsx", "sha256": "a98eed66bd060fddd584add5efeebd37003728274b8680c2d90d8ced17274db3", - "bytes": 7384 + "bytes": 7384, + "mode": "100644" }, { "path": "src/frontend/src/components/DataTable.tsx", "sha256": "f92e216bb04fda99b680f5710eabe6acea8dab9c8423f1ec555b8e51327041fd", - "bytes": 10019 + "bytes": 10019, + "mode": "100644" + }, + { + "path": "src/frontend/src/components/DeploymentEvidence.tsx", + "sha256": "20421738abedb7289216b8c93a9772e47bd0bd92b71278190548a8f8323b57c1", + "bytes": 576, + "mode": "100644" }, { "path": "src/frontend/src/components/EntityDialogs.test.tsx", "sha256": "6a33f0c9db1191da1daa93f24d19f0dea1a86084a3b167e31370ced081420ed3", - "bytes": 5999 + "bytes": 5999, + "mode": "100644" }, { "path": "src/frontend/src/components/InventoryReviewPanel.test.tsx", "sha256": "2e6e4b7c6d727d2b0a606ba6a735995de4a29280d8e4622afc4d2bf586865cbb", - "bytes": 3174 + "bytes": 3174, + "mode": "100644" }, { "path": "src/frontend/src/components/InventoryReviewPanel.tsx", "sha256": "d88c29419f5b8bdd662d0759eac9f62ec5c7deb00ff19fc573e676d368457118", - "bytes": 7844 + "bytes": 7844, + "mode": "100644" }, { "path": "src/frontend/src/components/LeafWikiImportPanel.test.tsx", "sha256": "740584be9dc12f063deeae9a54677708f16b898644c6cb66c8f628d487c5c47d", - "bytes": 3927 + "bytes": 3927, + "mode": "100644" }, { "path": "src/frontend/src/components/LeafWikiImportPanel.tsx", "sha256": "a69c67c12a754da08d0db5d48ef80e7b36cfc1b74085a0d2f3ce79c0cab7685f", - "bytes": 9547 + "bytes": 9547, + "mode": "100644" }, { "path": "src/frontend/src/components/MarkdownContent.test.tsx", "sha256": "88280950c55bf2de0e1b96a60d12138b4fa9289acd6da30642825b1934977230", - "bytes": 3694 + "bytes": 3694, + "mode": "100644" }, { "path": "src/frontend/src/components/MarkdownContent.tsx", "sha256": "b44e224cbc05ed2b56a1edd3121e3e22e127f0539f0e498449a7ab82831e7e41", - "bytes": 2037 + "bytes": 2037, + "mode": "100644" }, { "path": "src/frontend/src/components/OwnerPicker.test.tsx", "sha256": "b627f7d665333aec494e34764f3ea195238abce270b6c69b69767613c9177de2", - "bytes": 3913 + "bytes": 3913, + "mode": "100644" }, { "path": "src/frontend/src/components/OwnerPicker.tsx", "sha256": "143d2a2d96d21b46ba41bdacd147a49d21fc3e50b448721ed0ca802ab5b49da5", - "bytes": 7310 + "bytes": 7310, + "mode": "100644" }, { "path": "src/frontend/src/components/PageActions.test.tsx", "sha256": "2aff591fb173a4eb6887bf0640ae2dc131ac513d930ed0cb0d23e2912045f49d", - "bytes": 3984 + "bytes": 3984, + "mode": "100644" }, { "path": "src/frontend/src/components/PageActions.tsx", "sha256": "23dca85a1aa79f6ca19200488af593808dfa32ea3c5d143fa7f2f5578c896c13", - "bytes": 10032 + "bytes": 10032, + "mode": "100644" }, { "path": "src/frontend/src/components/ProductPrimitives.tsx", "sha256": "ac8b7aa028c792b649a0df3a6da651aa1818377bfc7746cc3ccbda2a0697feec", - "bytes": 2090 + "bytes": 2090, + "mode": "100644" }, { "path": "src/frontend/src/components/ProjectDialogs.tsx", "sha256": "d385c5b17c611b391c11facb9966f7cb0d0d076b23715ce7b4837de96047ad51", - "bytes": 11824 + "bytes": 11824, + "mode": "100644" }, { "path": "src/frontend/src/components/ProjectWorkspaceActions.test.tsx", "sha256": "5cc188e83132f23d8d778dc70abc7037024ced268254708b9a8adc1599b90335", - "bytes": 6183 + "bytes": 6183, + "mode": "100644" }, { "path": "src/frontend/src/components/ProjectWorkspaceActions.tsx", "sha256": "96af22a2cc25df2dd5e1a7f775a3ae5e302b5530f87fc9684708f17aa62864cd", - "bytes": 15540 + "bytes": 15540, + "mode": "100644" }, { "path": "src/frontend/src/components/RunbookCreateDialog.tsx", "sha256": "06c60b1d14d60cc2638b7ba9480e7af1c25766f0ead85de82b26d1042dea6503", - "bytes": 4779 + "bytes": 4779, + "mode": "100644" }, { "path": "src/frontend/src/components/ServiceOverrideManager.test.tsx", "sha256": "6b5596796a4733d85bf726166ecbaca1db92df9e1973db9cb42ec460e8808e47", - "bytes": 5987 + "bytes": 5987, + "mode": "100644" }, { "path": "src/frontend/src/components/ServiceOverrideManager.tsx", "sha256": "b3be450753cec5ae662070bf98a1bd2fe87da4c4c33fa2035a922bb509b2af13", - "bytes": 7269 + "bytes": 7269, + "mode": "100644" }, { "path": "src/frontend/src/components/Tooltip.test.tsx", "sha256": "7e45a31916ef6effa4118bccfd7bed3db9a852e8942440cab95421632e571994", - "bytes": 1784 + "bytes": 1784, + "mode": "100644" }, { "path": "src/frontend/src/components/Tooltip.tsx", "sha256": "098b59565771d08f2889adc2f2aee97201bbd1d09b65fd780d1c2e0e38e65fe1", - "bytes": 1781 + "bytes": 1781, + "mode": "100644" + }, + { + "path": "src/frontend/src/components/deployment-evidence.ts", + "sha256": "ccae462b9697ff2ab1fcffb0cd81ef542d21c5cb7907aa8277e1290031c8ae24", + "bytes": 3155, + "mode": "100644" + }, + { + "path": "src/frontend/src/components/deploymentEvidence.test.ts", + "sha256": "671970d0fa010cb41f0e185eac4ba0b99db44350e9d91076bfda77860a0d2991", + "bytes": 1627, + "mode": "100644" }, { "path": "src/frontend/src/components/infrastructureFormat.ts", "sha256": "241bd9adb63e516c94dfcdf6c70686119373c28ae1e1010c4beb50d3c150e360", - "bytes": 203 + "bytes": 203, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/EmptyState.tsx", "sha256": "c917f29ea427db8e1e277bb2e26983900838b07f683c12918cec1cbe75dae3d7", - "bytes": 351 + "bytes": 351, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ErrorState.tsx", "sha256": "b0fbecc2cb873e9b95d2337131a70b82d20214587d2967e4298ab618f16ff667", - "bytes": 812 + "bytes": 812, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/IngestionProgress.tsx", "sha256": "32fa02e1e6ff61ed931faa9b553503eda2e06034a395e39eec9bf9807a6938c5", - "bytes": 305 + "bytes": 305, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/IngestionQueueCard.test.tsx", "sha256": "11a68093a22e9a174cae284486cf3403841b345b5456ff5a94ed1bf2d72c6788", - "bytes": 2801 + "bytes": 2801, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/IngestionQueueCard.tsx", "sha256": "5aee3f46fc0905e632b2b07a1e701df2ce78b9d8561adca4eacbc69b4977d25b", - "bytes": 1770 + "bytes": 1770, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/IngestionQueueFilters.test.tsx", "sha256": "cbb5167071678c7c9ae5d4ef1a81bd6829726c8490e1e5d7dd23323608d6cc3b", - "bytes": 1650 + "bytes": 1650, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/IngestionQueueFilters.tsx", "sha256": "a3d75b8682675caf5982b78f1079cea30a2f465acf656da0fa2f01197d2231cc", - "bytes": 4069 + "bytes": 4069, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/IngestionQueueTable.tsx", "sha256": "7c76c4b5f8ce417bbdd72dfa2a48267e05730f208140dd86a3678e4835806ff1", - "bytes": 3185 + "bytes": 3185, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/LoadingState.tsx", "sha256": "cd112058bd9a9d8a329550f65d6fd5f28734713db4021fe940379081618e2b12", - "bytes": 377 + "bytes": 377, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ManualDocumentIntake.test.tsx", "sha256": "7fbfcc3386745a14a56e35688be7d6a54182f357d9e550109bbff7e93fd9575b", - "bytes": 5532 + "bytes": 5532, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ManualDocumentIntake.tsx", "sha256": "9d85e521d7e16df96fbda9a966065a358ae98b530ff33410484bb7cee56829b8", - "bytes": 5817 + "bytes": 5817, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/MobileReviewPanels.tsx", "sha256": "bc0b380967993edc9eeedd4bbdf645a311c24a94d8c4368ebe8ddd07bf7c7487", - "bytes": 1501 + "bytes": 1501, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ProposalContentDiff.tsx", "sha256": "0550fa213f6430377152d961d96a85ab7f1ea4b8348099ccae4fbd1020aa54d2", - "bytes": 1009 + "bytes": 1009, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ProposalDecisionPane.tsx", "sha256": "e2cd6555c96bdd8d005d8ed7be26afc0bd37cb447ee0175b645dc038c503ecb8", - "bytes": 2296 + "bytes": 2296, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ProposalEvidencePane.tsx", "sha256": "4dc0b3f27cbaf31de0b6ee9ec776d71980afdfc5aa47ce424f839edd57b6f71e", - "bytes": 1950 + "bytes": 1950, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ProposalMetadataForm.tsx", "sha256": "5103c6d0c51de98bcdaee5e70e730778d71d1f30366ef5940358b74a8f32dd66", - "bytes": 2897 + "bytes": 2897, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ProposalQueuePane.tsx", "sha256": "5c91a162bf39479bed3c5625447400665c5f8eb6ab70a0dad96eb4c12215744d", - "bytes": 766 + "bytes": 766, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ProposalReviewHistory.tsx", "sha256": "cda9d69ac4a9c34f833e940405ec54f089efc891f639e5c4d5515e5e00d4047c", - "bytes": 825 + "bytes": 825, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ProposalSourceList.tsx", "sha256": "ad0b7fd032e523ec6291809a162afe2e03810a2efa3029dcc1c25fbe0beeb6f9", - "bytes": 759 + "bytes": 759, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/StatusBadge.test.tsx", "sha256": "d83abe2cabfce64565861ab20c48a0d2b34d47db24e17dcd15cfb48764d43e73", - "bytes": 1548 + "bytes": 1548, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/StatusBadge.tsx", "sha256": "e410ebf5f9095d2cbca33285d0e9f094d746ebdeaa4228a62bc75a3dbead8970", - "bytes": 1872 + "bytes": 1872, + "mode": "100644" }, { "path": "src/frontend/src/components/ingestion/ingestionQueueFilterState.ts", "sha256": "2df507e0a54c9b7f9848a1975f1c06646073f1669d02a14c407daa64169d33fb", - "bytes": 2205 + "bytes": 2205, + "mode": "100644" }, { "path": "src/frontend/src/components/statusLabels.ts", "sha256": "9711673b4cf102dc790e12ec1179bebe002f48ffee33efbfb9e8b019e645ddda", - "bytes": 3679 + "bytes": 3679, + "mode": "100644" }, { "path": "src/frontend/src/design-system/auth.css", "sha256": "6012643644c8dd2d67fbea51a69a61f09ff3fae9a1570d414804a602171a0b78", - "bytes": 5675 + "bytes": 5675, + "mode": "100644" }, { "path": "src/frontend/src/design-system/base.css", "sha256": "42df86a609b82bafe3c0c1585db94e0bc8736f76091bf9eea645c2a53ed90ee3", - "bytes": 2261 + "bytes": 2261, + "mode": "100644" }, { "path": "src/frontend/src/design-system/components.css", "sha256": "05513d987cf9dca9bd0a460c0e5063e26d9cf18b35430f4e063d21ac0c64f4fb", - "bytes": 24623 + "bytes": 24623, + "mode": "100644" }, { "path": "src/frontend/src/design-system/documents.css", "sha256": "6e12961e364e8d117354a9ab4cac7a46162d2d1252848e2eb43abf892ed7672b", - "bytes": 7042 + "bytes": 7042, + "mode": "100644" }, { "path": "src/frontend/src/design-system/editor.css", "sha256": "7c19d13c29f92be98317067bc9465fb9cabf3ddba4f9004056029147d2b427d0", - "bytes": 6999 + "bytes": 6999, + "mode": "100644" }, { "path": "src/frontend/src/design-system/forms.css", "sha256": "264238dd76215afeb69e62e0e4949535876a8fdab17ba63445f99b7da753f777", - "bytes": 9072 + "bytes": 9072, + "mode": "100644" }, { "path": "src/frontend/src/design-system/responsive.css", - "sha256": "229fcc6e8a67078daabb36aac5e3265ca0b06892500e5f7ea901a3ae8e82c4bf", - "bytes": 8672 + "sha256": "6531dbfe75470968b56bc9df73d1e6fe987838f256b20ac3936451acfc8a0c79", + "bytes": 9291, + "mode": "100644" }, { "path": "src/frontend/src/design-system/shell.css", "sha256": "3f6eeab9771ece799ef486bf9c8593070f4f8f20be0abce9c924f006195b0a93", - "bytes": 10347 + "bytes": 10347, + "mode": "100644" }, { "path": "src/frontend/src/design-system/tokens.css", "sha256": "3aa66166f06719569ec3906dd52495075adef217c1942b65402c9f18b936cf8b", - "bytes": 5566 + "bytes": 5566, + "mode": "100644" }, { "path": "src/frontend/src/design-system/typography.test.ts", "sha256": "e683614e7a558a6cebbf73a01588bfd0d2595f367ce9f117c93b0995a5714a53", - "bytes": 6757 + "bytes": 6757, + "mode": "100644" }, { "path": "src/frontend/src/design-system/workspaces.css", - "sha256": "410521fdbfe7ab23425f988055096845f7e396a759d9493b1ce9fd0dfcc6d0a4", - "bytes": 44803 + "sha256": "9f3274f209abbce164bfeef99fcce3adfd52b560525989a53652dee280354395", + "bytes": 48585, + "mode": "100644" }, { "path": "src/frontend/src/hooks/useDocumentTitle.ts", "sha256": "5534a1472a981c98c3408fe53cf97ceb2d76b5bebb74129472859345b565f847", - "bytes": 266 + "bytes": 266, + "mode": "100644" }, { "path": "src/frontend/src/hooks/useMobileViewport.ts", "sha256": "54f806bac73c6cfd476bebf94e18887361945d0336494496a120c4bc5d42951c", - "bytes": 2223 + "bytes": 2223, + "mode": "100644" }, { "path": "src/frontend/src/hooks/useOnlineStatus.ts", "sha256": "97eb14b3e9cc86cb963e7a2b64853034352a96bfbd33e8bb19a7664759265e72", - "bytes": 1303 + "bytes": 1303, + "mode": "100644" }, { "path": "src/frontend/src/layouts/AppShell.accessibility.test.tsx", "sha256": "1a384b988564c29da990b1fbe3bf807d7eb0095f8069d21d73c915fb5e36a20d", - "bytes": 2544 + "bytes": 2544, + "mode": "100644" }, { "path": "src/frontend/src/layouts/AppShell.tsx", - "sha256": "06bda7a6984a8e88a30d8d5f433d204c275a9af55c68b00c4b8db8b0a9533dd6", - "bytes": 25060 + "sha256": "16fdfe23d9af0fc7e97455a1b42b3d9dbf3aa6522abb32941fd9a8ae99cc1bbc", + "bytes": 24684, + "mode": "100644" }, { "path": "src/frontend/src/layouts/active-entity-context.ts", "sha256": "6473ee840c649799c8154d2b8905e2ae05862c4fc4da945d621dcd7fa6cf2527", - "bytes": 2152 + "bytes": 2152, + "mode": "100644" }, { "path": "src/frontend/src/layouts/context-shortcuts.test.ts", "sha256": "9e989f1b92093062d9477a4c7e2159f6f630f7c9d0d5ab088f1a97d4f4a1ba9e", - "bytes": 1636 + "bytes": 1636, + "mode": "100644" }, { "path": "src/frontend/src/layouts/context-shortcuts.ts", "sha256": "97c42c2b64bb077e569abfc059cd2f805f82593197ace022e3094150ae640e2d", - "bytes": 903 + "bytes": 903, + "mode": "100644" }, { "path": "src/frontend/src/lib/clipboard.ts", "sha256": "77c402ff2e4534f29128088e558329c1cf3036a3b0f2a96677a6c3bc70188653", - "bytes": 1977 + "bytes": 1977, + "mode": "100644" }, { "path": "src/frontend/src/lib/uuid.test.ts", "sha256": "aea10ca1598b963b6240675a7aa3a71618109eefc5f324cd0d5c6dcd2280e5b8", - "bytes": 1334 + "bytes": 1334, + "mode": "100644" }, { "path": "src/frontend/src/lib/uuid.ts", "sha256": "542adb8514eaa15bfdcc257a1609a68fdefd94c8b005246397e0104846ecd9cb", - "bytes": 2106 + "bytes": 2106, + "mode": "100644" }, { "path": "src/frontend/src/main.tsx", - "sha256": "a5323c4abcc96b43a2a1e83bb3988f84be503075a0d90a7daf46f083b79807f9", - "bytes": 925 + "sha256": "4f20f3838b5baf770522d9d13919a47a216f8b9751a662704fe3387b2a825b23", + "bytes": 1667, + "mode": "100644" }, { "path": "src/frontend/src/notifications/ErrorBoundary.tsx", "sha256": "193ccfccca3d3dd18db64d309ad0a2f13956a5fa0d1e5d5b99ad03bd64e39d9f", - "bytes": 3274 + "bytes": 3274, + "mode": "100644" }, { "path": "src/frontend/src/notifications/OfflineBanner.test.tsx", "sha256": "324dc3e70ccb0342335d8b1e44271e910b21317163b89e4c45c950c508c8fe13", - "bytes": 1898 + "bytes": 1898, + "mode": "100644" }, { "path": "src/frontend/src/notifications/OfflineBanner.tsx", "sha256": "0c16dabe2000c8d775ed8b838768a6a6b02ab322e07529ea118a56e1290475b7", - "bytes": 1287 + "bytes": 1287, + "mode": "100644" }, { "path": "src/frontend/src/notifications/ToastProvider.tsx", "sha256": "6ed0217d7c21867ba7f2b278a9de2c7c8494b23f1d08fdf8923874dc14d5bc70", - "bytes": 3622 + "bytes": 3622, + "mode": "100644" }, { "path": "src/frontend/src/notifications/toast-context.ts", "sha256": "b4fbe6217feab3d02e3a49b45b5e6d5383ce8600e432202deedbaf659ec7e1b7", - "bytes": 2542 + "bytes": 2542, + "mode": "100644" }, { "path": "src/frontend/src/pages/AdminPage.courseTagging.test.tsx", "sha256": "1fd3d7478bda0f4d7dbaa7a26ba6320265235d43488e426123c58231bf8dd01c", - "bytes": 11619 + "bytes": 11619, + "mode": "100644" }, { "path": "src/frontend/src/pages/AdminPage.test.tsx", "sha256": "fdbce01e913a1faf3af6a2cd0728e4846a20f6bea302bccfd2409b7a9de8192b", - "bytes": 15685 + "bytes": 15685, + "mode": "100644" }, { "path": "src/frontend/src/pages/AdminPage.tsx", "sha256": "c401bd59c461cc4d9c6771afcd115a2982321a257155800139251368d810992a", - "bytes": 38145 + "bytes": 38145, + "mode": "100644" }, { "path": "src/frontend/src/pages/LoginPage.test.tsx", "sha256": "0735439984705d1b1e91c40a56bfef67b6161a7050dca5a24c6c2f419acea2fd", - "bytes": 7510 + "bytes": 7510, + "mode": "100644" }, { "path": "src/frontend/src/pages/LoginPage.tsx", "sha256": "e31525d48251bb4b21ab50ecfe941440e52a1b58fd019b61a7ebd219b03cb2be", - "bytes": 10718 + "bytes": 10718, + "mode": "100644" }, { "path": "src/frontend/src/pages/SetupPage.test.tsx", "sha256": "58684772100475407c40e9450d6aee7484f146148feaf77ff260f7c3c7a10546", - "bytes": 1611 + "bytes": 1611, + "mode": "100644" }, { "path": "src/frontend/src/pages/SetupPage.tsx", "sha256": "370e52b926012a516e2413bf5b48ba292ed6d496796ec5ec03cc25728d4a75c8", - "bytes": 4681 + "bytes": 4681, + "mode": "100644" }, { "path": "src/frontend/src/pages/StatusScreens.tsx", "sha256": "be2ca9e5abf4c90aaff36c76a520b682b6966e73e44bfb1258fcf9fb63c18bc1", - "bytes": 535 + "bytes": 535, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminAccessPage.tsx", + "sha256": "fe29275afb2f37d1a5ae20142adea7eacbdb0305d434b78359e72abedf260fbd", + "bytes": 8312, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminAuditPage.test.tsx", + "sha256": "654aeea03b1bf9c115dee34a0fb1bddc3d94e439f51c4e3a44ccf4b27c620816", + "bytes": 2760, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminAuditPage.tsx", + "sha256": "9f3d8e389e5ea3500eed5a044817825aeb628de18017d203e517d05278f0c905", + "bytes": 5449, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminBackupsPage.test.tsx", + "sha256": "ba97ee5af2a1c0ad48bbef9b631c5ea911dd085134347e3fa819d5e51b28e766", + "bytes": 3931, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminBackupsPage.tsx", + "sha256": "6d0b3dc80e1bb073a2e8685ba447fc6200b1867fb162e90e0e08976dbc6db351", + "bytes": 10448, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminIntegrationsPage.test.tsx", + "sha256": "6d09b4af8d2e7c4fc25b28dc960ebf14d6ca83810e554757a52e746058ce08a7", + "bytes": 5646, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminIntegrationsPage.tsx", + "sha256": "46764d4fb4f52e5eb31ff13dacb896887287693c9409bf9d2dc505ebc3e5d8bf", + "bytes": 12874, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminInventoryPage.tsx", + "sha256": "b6bd6fa9ff2bf4d79015fad6f6c8a33d1159ab316e1166e82732b8ebc43182cd", + "bytes": 3924, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminJobsPage.test.tsx", + "sha256": "18a737dacb9a927c38dbb2cb5f81881c43fa4c9193d13e380720a206f651463a", + "bytes": 9314, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminJobsPage.tsx", + "sha256": "6637f59491551c2f22a16636b1e94056bb67ccabd7c5ff524e11dc4cf757b251", + "bytes": 12508, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminKnowledgePage.tsx", + "sha256": "1152ee4191bcde19bf25e31f2be021a62719d0aea6e598d1962716410a4284c0", + "bytes": 686, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminOverviewPage.tsx", + "sha256": "908f9b030be214008256d4f4372f10c261b9acddefa00d2ef6ae8643c7956600", + "bytes": 7273, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminPlatformPage.tsx", + "sha256": "c287d14a9fe432c2b2b77e44794bcfd836c7e2a20fcbb0229eb6216e81f654ec", + "bytes": 11110, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminSystemPage.test.tsx", + "sha256": "3e27523dafde4b580f5fff5f12014d1d707a50949ba2da60c5833bff647e6592", + "bytes": 3680, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminSystemPage.tsx", + "sha256": "70d53a98f3c216c71c6b62566b8b91a029d342dce74c8e47168c91e7f5a2502c", + "bytes": 7586, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminTaxonomiesPage.test.tsx", + "sha256": "bc5cd845472adca29d64db2121ae3e481dcff4cf2b029c0704d47517284c06d8", + "bytes": 2452, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminTaxonomiesPage.tsx", + "sha256": "627b60002990b86a144583407b30ec4fd5d5345e54f704655ed016ca973bd3fe", + "bytes": 4603, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminUsersPage.tsx", + "sha256": "53773d4a212865aa95e6bdc05d25af17865cfd45e3ba3dbf49fec15b8bd06062", + "bytes": 7754, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/AdminWorkspaceLayout.tsx", + "sha256": "7e0e6798eae46f32f46c1e348a376d24d467beb59a3af46dfc67b1f6a3237d31", + "bytes": 1176, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/admin/CourseTaggingJobForm.tsx", + "sha256": "87a1565733b70ea5899bd05649e08bea34eda2229e97d6b95ba1826119f79d44", + "bytes": 4074, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/Attachments.test.tsx", "sha256": "41f060d518de44e15322c6d25cf81ff4e9ad7cb420bcc8613d6a0c786320824b", - "bytes": 4158 + "bytes": 4158, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/Attachments.tsx", "sha256": "934ea510c84d1ad0a56c136eb95aefebc5ab0d592368e9780aaaf963d7168194", - "bytes": 4605 + "bytes": 4605, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/Backlinks.test.tsx", "sha256": "fc6119da886624491d0ee025ec60dcebc19554ecaa8ec51b276fcd23d24043c9", - "bytes": 1970 + "bytes": 1970, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/Backlinks.tsx", "sha256": "b52a36b30790a00b541bc57026e0a54b42474e2550829b795de5e1af61a73fd6", - "bytes": 1887 + "bytes": 1887, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/KnowledgeTree.test.tsx", "sha256": "393ca8197144c518c4b192b9af05e2313300e12c1c508f3d673a9ca4916546e7", - "bytes": 12505 + "bytes": 12505, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/KnowledgeTree.tsx", "sha256": "669cc8f5ec3dbb6058a1f121b0de55a9bf51d390151807500cae6430a9033bdf", - "bytes": 16468 + "bytes": 16468, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/PageEditorPage.insecureContext.test.tsx", "sha256": "fcd6c673d5c323756a63c495eb965a4f4703053a1db8abd9c47fc4032832e2ab", - "bytes": 2441 + "bytes": 2441, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/PageEditorPage.test.tsx", - "sha256": "fc4013443bd4312fb2ad01c21b6a127e60a85baed40860f97fe296391271e80e", - "bytes": 23744 + "sha256": "6a426e4906772be6ccf9ed027eac825569b92247b10ce29b1fde5832b2b6993c", + "bytes": 27316, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/PageEditorPage.tsx", - "sha256": "ad31836a900aa7c9b149fd675853d77d91e050c1d3f8955709a563b89703f636", - "bytes": 31286 + "sha256": "620ad79022c59c8cd5e13b8d93ff5d4d5c3093f58892bf31fbd8c99014f0392f", + "bytes": 32643, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/PageViewPage.test.tsx", "sha256": "0c67d1d09d4e641ff18ee92e2753598c5eee2d3cb46686e6469addfe6fff3069", - "bytes": 6567 + "bytes": 6567, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/PageViewPage.tsx", "sha256": "65625b32065bccea4057e351f2e9d6ce9b877b25f49fd38f724159ca514ae350", - "bytes": 9717 + "bytes": 9717, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/RevisionHistory.test.tsx", "sha256": "5d6b17aa5e1ac7a45bbfaf2f04648628abe58228626a94b196510461de95466d", - "bytes": 3850 + "bytes": 3850, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/RevisionHistory.tsx", "sha256": "7f93dc9acebc576ef42264c65f1147683983c469891b5999695bea8dfebf69d1", - "bytes": 7536 + "bytes": 7536, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/SearchPage.test.tsx", "sha256": "a08aa1bbed51daac290eab427f35727d77383ef3942da2bbeba0a7818038d2f3", - "bytes": 7571 + "bytes": 7571, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/SearchPage.tsx", "sha256": "20926e56e1cd7fdd619dcd79b681a29783b4ce9f5a7b09436432ea8ee0cedea9", - "bytes": 7207 + "bytes": 7207, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/editorAppearance.ts", "sha256": "a6fe89a104c018d5ef61160201bb9c4ce963ae7668120e4954750ae44d23ff5d", - "bytes": 5922 + "bytes": 5922, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/knowledgeMetadata.test.ts", "sha256": "06ba8598a6dc76c48f9de131c476d860b643ffa2205d95e1d02eafccbbabd40a", - "bytes": 943 + "bytes": 943, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/knowledgeMetadata.ts", "sha256": "fa54f6da13302e2864741fc53f16506139349f82b333cf33bd88e7085166fff2", - "bytes": 1653 + "bytes": 1653, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/recentKnowledge.test.ts", "sha256": "242769a93daae7022bcac98adf30d4ba57a1750b94f787a03673a052b838896b", - "bytes": 972 + "bytes": 972, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/recentKnowledge.ts", "sha256": "972d03910c80f9b5eeaf9721665061ba60ec38f46ad989821430c532bcc3cf99", - "bytes": 1095 + "bytes": 1095, + "mode": "100644" }, { "path": "src/frontend/src/pages/knowledge/revisionDiff.ts", "sha256": "3b3a7b54cdde2c7c9cab6bfbb34c36e66720f9031d9612e25d4fdc259bb5522a", - "bytes": 941 + "bytes": 941, + "mode": "100644" }, { "path": "src/frontend/src/pages/projects/DecisionDetailView.tsx", - "sha256": "6a7d2dc4fd079f547ca3240d477aef8247e44bfec6138d9f3a9a174d1f0714b4", - "bytes": 9339 + "sha256": "45ebc7cbf71f047201c5c793f72bfbb0cac4e0a2e53437ef676cc43b33eaf28c", + "bytes": 9722, + "mode": "100644" }, { "path": "src/frontend/src/pages/projects/RunbookDetailView.test.tsx", "sha256": "c82229b148f7557c2ceb3e1b290465bb42147aef3e43f96795c24abddf6f9c08", - "bytes": 3529 + "bytes": 3529, + "mode": "100644" }, { "path": "src/frontend/src/pages/projects/RunbookDetailView.tsx", "sha256": "b152ab77859438ee2093f482b606dbd4df42cf6c88b3603d3c964e4e758d4f2c", - "bytes": 10943 + "bytes": 10943, + "mode": "100644" }, { "path": "src/frontend/src/pages/projects/formHelpers.ts", "sha256": "50f1bc388997fc5696f8076e1b6fa756fdc39e9def166b4f12b47e5bfcb2c592", - "bytes": 464 + "bytes": 464, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ActivityPage.test.tsx", - "sha256": "e831b5392ccfe398e9fac6f834f128b7104ab1c5597d2ba0003980b7016e1b5c", - "bytes": 2017 + "sha256": "addf0ab0f2353684bab226348c527933bd9584b38fc2e2e75e1f0c0b85840d08", + "bytes": 2107, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ActivityPage.tsx", - "sha256": "9de80f11748165a4366d260842b1f10e94741086cfdb90fa3dd965d5e3a8a6c7", - "bytes": 5491 + "sha256": "e6c97582cbd43bbc9e73fac62c867e687413c9c59aa4bffbfccae07858e947c3", + "bytes": 4951, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/AdminRoutePage.tsx", "sha256": "eed028b2549cb07d6e3bf24fecc433bb34614accdf7edae8f0e4de77ad676039", - "bytes": 202 + "bytes": 202, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/DashboardPage.test.tsx", "sha256": "e5f9c7de670616a02598f89e475c022a94b8ea2d58190a77082ee28561e04176", - "bytes": 9522 + "bytes": 9522, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/DashboardPage.tsx", "sha256": "dbcb0cdab67c76d2049c3b4c4d2b9ffc6e1a8115ea92c150164d857b4c1418f8", - "bytes": 11706 + "bytes": 11706, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/DecisionWorkspacePage.tsx", "sha256": "aa0a3085bda7b9126c863e23c339d537903b23ca451e79789979bd3ac851b9c9", - "bytes": 1199 + "bytes": 1199, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/InfrastructureOverviewPage.test.tsx", - "sha256": "ee6f69ada39849a55ffff66a8810e81e3aef34cb3104346175b14bd54fe0f265", - "bytes": 3110 + "sha256": "7fb1a7a69f679bde28a9a15a98347974c58851bffa6d2dddf1a0583334a7dc46", + "bytes": 3231, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/InfrastructureOverviewPage.tsx", - "sha256": "81d5bd6d68ce719e61db421941d2c3c92a8691c88bfcd6d3dc733a414929ff2f", - "bytes": 5289 + "sha256": "e7313de4cca4b39fd31e2691b0b8337e6fe9b16883264c321253cf811bc649ab", + "bytes": 4709, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/IngestionQueuePage.test.tsx", "sha256": "1235119a2619473f50d8c3f388fb7e04976794ddab5c6c556d9a02f73c875966", - "bytes": 14414 + "bytes": 14414, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/IngestionQueuePage.tsx", "sha256": "c2256a0f9d4f935884dc98ccca91e0778c7564070ae4f62702c210ec94eb88f3", - "bytes": 6873 + "bytes": 6873, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/IngestionReviewPage.test.tsx", "sha256": "495c6094bb36d99767864bd0c361982be0534fba08146edd5e9fea791959adb8", - "bytes": 25611 + "bytes": 25611, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/IngestionReviewPage.tsx", "sha256": "a49e5aa2bf1b7f509e437b4ccacaea9ea2698a7fa8f5770a29402d8d3527d0ce", - "bytes": 11124 + "bytes": 11124, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/KnowledgeEditorRoute.tsx", "sha256": "7c3cb59c3bfe5841dd7f82505446da3d5822c379479c0bf3c227baeecba681d0", - "bytes": 2380 + "bytes": 2380, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/KnowledgeRoutePage.test.tsx", "sha256": "633e803a017dfedd9646df3682ff51b9feddf33aae26ab25f36b28ee7333fef8", - "bytes": 7081 + "bytes": 7081, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/KnowledgeRoutePage.tsx", "sha256": "78ecf930f452ef844a8236ba62cf97865add07ae8be3bd1f888a5c5a9b6a219c", - "bytes": 9898 + "bytes": 9898, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/NotFoundPage.tsx", "sha256": "2de25eefa9cde911f01e142bf92a4685b48c163bc6a45049181ba3a17a2d1f88", - "bytes": 573 + "bytes": 573, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ProjectWorkspacePage.tsx", - "sha256": "b15eae6398795dd9b7f06697903e22e6c79b17a9dce8fd60b63ea30d5fba62d0", - "bytes": 15139 + "sha256": "b4b979fad45582953d101a6312799d29c625a2b32723a91c60fb8c06f6e47d62", + "bytes": 14038, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ProjectsOverviewPage.test.tsx", - "sha256": "5e9029619595cef9711220cfebd17ccf1e34744d14f5a1b2697bfbc1114af677", - "bytes": 9715 + "sha256": "43fbc3d898abddd9e1aef4d9dc0948665723371bd103914dc755bdcb1e5a5cfe", + "bytes": 10674, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ProjectsOverviewPage.tsx", - "sha256": "fe84386f4c339db910f592a459311b4dc5308f4734699796285aedf967998af8", - "bytes": 17151 + "sha256": "0f30ad59ba40d1ce39b28780e6120922672fc8a605ec95f71715ecf91d688ed1", + "bytes": 15292, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/RouteStates.tsx", "sha256": "65ab0884f4f3882adfbc0a5ebdb41a3a5bc933efcad1aa36f48aa005034070b7", - "bytes": 1666 + "bytes": 1666, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/RunbookWorkspacePage.tsx", "sha256": "d21f3610ed654aea55cdcaa5aa8b60b162ad8c7b65f591d2724686cad52920fc", - "bytes": 991 + "bytes": 991, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/RunbooksOverviewPage.test.tsx", - "sha256": "a431231392ca97c22bb153e617faed724ef521df76f53220f9daae2fe7dc3c05", - "bytes": 1567 + "sha256": "411e91d05083a9a3bc43c4d5df68d590e41d37b1dea8a5669c6f4537db7c7544", + "bytes": 1657, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/RunbooksOverviewPage.tsx", - "sha256": "609827d6cda62adf1945355b2b30dc94b76b8525f45f4c77f231ab965918b1d1", - "bytes": 4010 + "sha256": "616944e41168a11c96d115dab18e132c396935f26e3aa2cb87a0526574ab37ae", + "bytes": 3833, + "mode": "100644" + }, + { + "path": "src/frontend/src/pages/v2/SearchRoutePage.test.tsx", + "sha256": "5b3dc4782b113fcea02c560727f4dcbd044d4ed54d1c342cd44083c6dc1f01e9", + "bytes": 4262, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/SearchRoutePage.tsx", - "sha256": "bf6cb16f4c0773011538f5c93640fcce12022ed4b1d8ffa541a6e7d8932bb3b5", - "bytes": 352 + "sha256": "a89da30c4636d39d76127b7b2b2c6f3ac5e17dac95e8b81df8e564be69f13904", + "bytes": 5057, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ServiceDetailPage.tsx", - "sha256": "15210ed67f9edc0d5448e58959e9f5ccbf728fdf7161e0e5aa7e6c61f96261df", - "bytes": 20205 + "sha256": "501fa79f9dd2795eca580cc0ec8e48c572fc6660c7309c3c1d5f759b0bb2da56", + "bytes": 20480, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ServicesCatalogPage.test.tsx", - "sha256": "a86a4a185a4d48a65d3d5b35073b97eef3977c944c82722ea454234186e264f1", - "bytes": 9277 + "sha256": "0fdc2b9be694fb53a6e4e465a7589565365f76ea9876f20fe60559a48530f3a5", + "bytes": 9566, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ServicesCatalogPage.tsx", - "sha256": "186446a5ccf3bc61093c161ed9d0cca58fabdd63979b73bb011a9f67e163557c", - "bytes": 16560 + "sha256": "373052505fbafc468330b29cc278f41d393d2a8d148092f7adc4afbcd36fe29f", + "bytes": 13723, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/SettingsPage.tsx", - "sha256": "a19495a86a45e7a3a1854e014b092fb45eef17124e0216a4f73288be4d68f143", - "bytes": 8044 + "sha256": "118377b7de685aa4d7217bb254d6022b1c1baa3546c5f8f2b97aeff6623d650a", + "bytes": 11702, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/StoragePage.test.tsx", "sha256": "6e7065424c4d29325973217ce32d33ec0b24cf4b6d571769d856fbfe113b2b22", - "bytes": 2528 + "bytes": 2528, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/StoragePage.tsx", "sha256": "12f21266029100c803416d72c98d6fe2ec3692355a643397e1779fc699ab1410", - "bytes": 8109 + "bytes": 8109, + "mode": "100644" }, { "path": "src/frontend/src/pages/v2/ingestionProposalMutations.ts", "sha256": "cf55775adf60969d96dce338bd7f1eaafb33bcd11cfe245e21e6aa7c6766e11d", - "bytes": 1401 + "bytes": 1401, + "mode": "100644" }, { "path": "src/frontend/src/preferences/PreferencesContext.test.tsx", - "sha256": "3c6253589bd474435c344d27591b5e0946805112ae1a3ae697c41ad2701cae66", - "bytes": 7534 + "sha256": "496fc3990f98cc93168b6d774ec1ad1d1f18a097359dc47b6daea57f33d5db16", + "bytes": 16388, + "mode": "100644" }, { "path": "src/frontend/src/preferences/PreferencesContext.tsx", - "sha256": "5919e3b6097439653a077fb43a6a374262f6486d9189d61dbc092d08cb20a017", - "bytes": 6486 + "sha256": "4c52b7569ce1ae699ace02faf6778257cadc15e717ffa306a495f48d278ee0a2", + "bytes": 19098, + "mode": "100644" }, { "path": "src/frontend/src/preferences/preferences-context.ts", - "sha256": "28391797b9529468d52ec0573a61be0c1170cacd00cfd8f4c9221b1315b2cf20", - "bytes": 1538 + "sha256": "8ff4d8b4e1ed045514fdd42b2ce0d9b8bf20ec3f7428732051b9d7d924df0fb3", + "bytes": 2108, + "mode": "100644" + }, + { + "path": "src/frontend/src/query/QueryProvider.tsx", + "sha256": "8e03b7186056188babf635894f91e7a37d1f025744a50567138beb7941f05191", + "bytes": 521, + "mode": "100644" + }, + { + "path": "src/frontend/src/query/queryClient.test.ts", + "sha256": "79eb2ca94474827f179e127203d63b2fe60dbee79421cd97fa63d36ce4ac3cdb", + "bytes": 1639, + "mode": "100644" + }, + { + "path": "src/frontend/src/query/queryClient.ts", + "sha256": "dfa12620c2fd6a73942c289879a900e2ca339834865779d4a86c041ec0cb1ecd", + "bytes": 660, + "mode": "100644" + }, + { + "path": "src/frontend/src/query/testPreferencesApi.ts", + "sha256": "1135c06bf3c7dd88090af3323e81a68b9b257fb48cf9c509cd8a67d7b89169c3", + "bytes": 1439, + "mode": "100644" }, { "path": "src/frontend/src/router/AuthenticatedRoutes.test.tsx", - "sha256": "1c2b4a125d1f95c22dd255d80119733ca30ae25b015946090046e77ed02ba0dc", - "bytes": 9912 + "sha256": "635cd2b08adef4b3e02ffa1e97b534854f68ffa6dd44b1ae449e98847cbbf753", + "bytes": 10796, + "mode": "100644" }, { "path": "src/frontend/src/router/AuthenticatedRoutes.tsx", - "sha256": "20462a0607f21728be97e6467d0bc5b7ee3879f6ef7bf12f84b904cd2ccf366a", - "bytes": 3962 + "sha256": "7f73391029a3767c7e2610f80b1a912de8d039b3a3d3ea75afd4f49e2728db4e", + "bytes": 5786, + "mode": "100644" + }, + { + "path": "src/frontend/src/test-utils/request.ts", + "sha256": "867487599d14f4483587a8c4d27ba6d735076df9936b12d48d35cc19b1db0b68", + "bytes": 622, + "mode": "100644" }, { "path": "src/frontend/src/test/motionTestUtils.tsx", "sha256": "7a68f4f27f462770e4ef86618e238c7eed82a51a566a0f4cace6c35226197692", - "bytes": 2159 + "bytes": 2159, + "mode": "100644" }, { "path": "src/frontend/src/test/setup.ts", "sha256": "8782a831c3470856266e93f37c39c071950f68958639143049685386a30628d1", - "bytes": 1044 + "bytes": 1044, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/ProjectArtwork.tsx", "sha256": "47a32cc9b5d9604d08c84b9d8d1a2989d71a4372cfd10e3db02130c909f08edf", - "bytes": 1542 + "bytes": 1542, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/ProjectSignature.test.tsx", "sha256": "27c77afbdb5671eadf466fa85a9a1ba71bdf0bb5fd0d7c2ff6f3ee6865c54b71", - "bytes": 5743 + "bytes": 5743, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/ProjectSignature.tsx", "sha256": "f7bd4c57b82f9be0b5b93e1c74e495e0519a7061207e59d6d90276b7764d6ec8", - "bytes": 2570 + "bytes": 2570, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/ServerMap.test.tsx", "sha256": "9c152f41578f7c1229dbcce75f3838d25b4968f1759521260573fada01e46faf", - "bytes": 7736 + "bytes": 7736, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/ServerMap.tsx", "sha256": "e092478b3a5e32c78b15a664ba936c2fc807bf821780a94e75161d44982d261d", - "bytes": 4998 + "bytes": 4998, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/ServiceTopology.test.tsx", "sha256": "87f1fa37d22494b14bd2cee6d2978c30bba4e9e77ef0533096f8c9adb11f46e0", - "bytes": 5579 + "bytes": 5579, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/ServiceTopology.tsx", "sha256": "6b8d77a77debdf198ea2c69a8f4912ce4aba842671fd6cb2a6e5795a536beb58", - "bytes": 2969 + "bytes": 2969, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/hostLabel.ts", "sha256": "0cf72bffc447e4fa8271e7c2f8304f57d9fd3d4c7b7cb6299449940471b43a21", - "bytes": 834 + "bytes": 834, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/projectArtworkGeometry.ts", "sha256": "8b8b8124736263343b4d2c63882eb5230606395a2e322b0a6128397006e3b5b1", - "bytes": 996 + "bytes": 996, + "mode": "100644" }, { "path": "src/frontend/src/visualizations/projectLogoSource.ts", "sha256": "88c5123b49d0fdd36f8d20bfddc875e7fa1326154702233a8c7c0d843afd244e", - "bytes": 2123 + "bytes": 2123, + "mode": "100644" }, { "path": "src/frontend/src/vite-env.d.ts", "sha256": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5", - "bytes": 38 + "bytes": 38, + "mode": "100644" }, { "path": "src/frontend/tsconfig.app.json", "sha256": "787debd064cab0f699e9ded5a1a8d46236a802dd35756871f7d3da65064b7133", - "bytes": 696 + "bytes": 696, + "mode": "100644" }, { "path": "src/frontend/tsconfig.json", "sha256": "770b4140bbb581e2dfd9ea9946ffc9c75a1d86ba7d2db5f77c83e37cbdf9d808", - "bytes": 119 + "bytes": 119, + "mode": "100644" }, { "path": "src/frontend/tsconfig.node.json", "sha256": "da44988fbbab51979e9e52dd5f6a9854ff1511880082f4e17e15dc7bbdca067a", - "bytes": 457 + "bytes": 457, + "mode": "100644" }, { "path": "src/frontend/vite.config.ts", "sha256": "47e30c5389e6189649e55601974c5184b9af9a1a0d8715c320639d7e22f1f553", - "bytes": 1194 + "bytes": 1194, + "mode": "100644" }, { "path": "src/frontend/vitest.config.ts", - "sha256": "c10fc444e6bd01da9d219f418203e0d483673dacdb91281e0a3e15681872a717", - "bytes": 912 + "sha256": "ccd6f7959900ebf9a6064f4b852f440a921895672af194b55b7bb570b17105d0", + "bytes": 1080, + "mode": "100644" }, { "path": "tests/backend/Directory.Build.props", "sha256": "b0a567b357d9616ffcadbe8b8170643fa98392a0c427ee27deb3cc03eb35646b", - "bytes": 578 + "bytes": 578, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/ForwardedHeadersTrustTests.cs", "sha256": "9aace185b11e25f193677eb235d7f84fb75337217c3d1da79ed00a5d7001a0a7", - "bytes": 4654 + "bytes": 4654, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/GlobalUsings.cs", "sha256": "50436eb3a60162e0cfad749eddfb06f8908f821a90ca44532df946a53e071359", - "bytes": 20 + "bytes": 20, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/HealthEndpointTests.cs", "sha256": "bcb11a815d34a01f5ad5d76e65640a1e2d39e0092786ac83a1fd335f20cadf93", - "bytes": 1702 + "bytes": 1702, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/ItWorx.KnowledgeHub.Api.Tests.csproj", "sha256": "ac500693872ad89e1f82ab15342c895bf4c0427e4db6fbbf3a4bad0bec211ef8", - "bytes": 719 + "bytes": 719, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/KnowledgeHubApiFactory.cs", "sha256": "2be914fd004cdc46b61dbe7c6726c672cb729b2f6a8eab834fd6ee85e67b3b94", - "bytes": 1145 + "bytes": 1145, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/LeafWikiAdapterLifetimeTests.cs", + "sha256": "4955eb3000685d79af97413d88705cab8f710e4143907eec9c6b6717efb47b1b", + "bytes": 923, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/PlatformEndpointAuthorizationTests.cs", + "sha256": "9d6a9701f80d16bb1b35b1eb52f8491cab27723b5438c29062043709e8412a44", + "bytes": 834, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/RestoreModeRunnerTests.cs", - "sha256": "a929db8f9b7cb5624392ec42c63160965cce78e50081e01ba83bc72f545a0766", - "bytes": 3535 + "sha256": "01f727a3152f64856bf0bbd0634b0f2a8483d5418c0b635faf541e111ad56f61", + "bytes": 6792, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/SecurityHeaderTests.cs", "sha256": "455e6322739575e1f3ba4a67c88ec1781e7dc245bdfbe20616a23fb1b7b37657", - "bytes": 2011 + "bytes": 2011, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/GlobalUsings.cs", "sha256": "50436eb3a60162e0cfad749eddfb06f8908f821a90ca44532df946a53e071359", - "bytes": 20 + "bytes": 20, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/ItWorx.KnowledgeHub.Architecture.Tests.csproj", "sha256": "43044be136fbcca44499bb237f9a3a655c0f7d7499cab9ce0fa1681652ff4c4e", - "bytes": 511 + "bytes": 511, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/ProjectReferenceTests.cs", "sha256": "25f0e49444022866c57c246f4fa171c97e11844c0c09a4d2c3a2b9ea5aa173c9", - "bytes": 2520 + "bytes": 2520, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/AttachmentUsageTests.cs", "sha256": "6f8bcb85df3b59b9a4432c304f1329d8053a74a13dc402e8d21d06257d997c31", - "bytes": 473 + "bytes": 473, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/BackgroundJobTests.cs", + "sha256": "c78ec0760bae2d298b307a2b2c31c23328f7fc3714cfae357135f301ccfa9802", + "bytes": 4089, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/DecisionRecordTests.cs", "sha256": "35ca6db2abf98f84530244b94fd8d10789c333a8b98fd522df1f700aae51a954", - "bytes": 2919 + "bytes": 2919, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/DraftTests.cs", "sha256": "630fa8c6ae9a01e5819bf01cb42a7bf749bf6c1d9d6046c201755a84aacd273c", - "bytes": 2281 + "bytes": 2281, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/EmailAddressTests.cs", "sha256": "441cdd5399caf8ef23b05d7f803b99c4c71c19b8183f2af78217f322954b9c16", - "bytes": 875 + "bytes": 875, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/FileSystemPathBoundaryTests.cs", "sha256": "2ad617bdc1cd392a5f784aa11bb8dbf1d97710a0331187fd85bd17d2b6f48e21", - "bytes": 881 + "bytes": 881, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/GlobalUsings.cs", "sha256": "50436eb3a60162e0cfad749eddfb06f8908f821a90ca44532df946a53e071359", - "bytes": 20 + "bytes": 20, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/InfrastructureKnowledgeTests.cs", - "sha256": "37b87f9661a15c45e6c8da318354190cd68a51f6f9bdc0ce8429e5473df477c6", - "bytes": 2301 + "sha256": "a48be3828c4d71f2d07c27d4bba83741a69c4b69bc47a8da3f5e8f39d4e6bac4", + "bytes": 2655, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/IngestionProposalTests.cs", "sha256": "d1fc168870abbb9fb2ea09a164ca8a687ba5e9a4bf750c13fa7c876cbc581389", - "bytes": 16179 + "bytes": 16179, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/InstallationTests.cs", "sha256": "2c2761d5a92acc7328bb3f84a283c8f4b516ec06c07daf334f66098f5f6803be", - "bytes": 957 + "bytes": 957, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ItWorx.KnowledgeHub.Domain.Tests.csproj", "sha256": "c44e6c3b9ab1dea0f22d4533e74fc89adcbe039118a384e164d844803bbeaf54", - "bytes": 876 + "bytes": 876, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageCreationSessionTests.cs", "sha256": "cc0b2cade55bae47275eca75a76eecc042eb9a1a6af5113d38ef91bc5a744fc5", - "bytes": 2934 + "bytes": 2934, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageTests.cs", "sha256": "1c1187908b6c9e2f05e04e5dae5d8cbded3a6debc99eaf4b18039efaa48d4edc", - "bytes": 4642 + "bytes": 4642, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageTitleTests.cs", "sha256": "c720221a2a8b2cb7cb91508acfec97edcd624774456d1f5cea2bd5656fd83c31", - "bytes": 781 + "bytes": 781, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ProjectTests.cs", "sha256": "6baa1ac26163e3de2463d57c2003ddf1826c8ec94ab4264fddb78ccc337e99b7", - "bytes": 3113 + "bytes": 3113, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/RunbookProfileTests.cs", "sha256": "30259da4f5ffbd219fa28d4741e0d78f05a3598d824aa7b62aa0e45cdc72ef77", - "bytes": 1291 + "bytes": 1291, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/SafeFilenameTests.cs", "sha256": "fc102dd3034d014bb6fc342a5ab9d85b5b772a94ca4bfbd225fdf9300765b6de", - "bytes": 1934 + "bytes": 1934, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ServiceAccountTests.cs", "sha256": "139ea348309adae5783b7e3f223944909e68d56b9c12f17cc037f7d3e289a40d", - "bytes": 3896 + "bytes": 3896, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/SortKeyTests.cs", "sha256": "94ca257b33bb77184fe1d067d8b421fc13175c7b481598886d21e33f24f55262", - "bytes": 1141 + "bytes": 1141, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/Sources/DataSourceTests.cs", + "sha256": "242759504c9155cc14a63ba627537b4984bbc345ae9b026455cfa92039ace4c0", + "bytes": 2257, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/WorkspaceNameTests.cs", "sha256": "6452cc4936d52ec4bae52d2b750de955b50faac1d3d7f6e1a7e7c9c996697357", - "bytes": 1034 + "bytes": 1034, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Attachments/AttachmentUploadTests.cs", "sha256": "00b15bf1134d2d8095ea3168678fb50a3fa081fe28baff9899049b3fc7166ce3", - "bytes": 12430 + "bytes": 12430, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Attachments/StagedAttachmentAuthorizationTests.cs", "sha256": "b9f8221062632a2d7338d93eba9feeb7b4da6f749cea477c3f160286cff9194d", - "bytes": 12107 + "bytes": 12107, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/ConcurrentLoginTests.cs", "sha256": "ef8b668d88ed9a53d9db952421a01e4fde541ca305613b7905d9fd5aa5b4545e", - "bytes": 1176 + "bytes": 1176, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/CsrfTests.cs", "sha256": "1fd06004af33335e7a5e6c78ea6b495e1a3f1bac7cd4e27f40c567d91509a8a7", - "bytes": 1942 + "bytes": 1942, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/LoginLogoutLockoutTests.cs", "sha256": "3fd9d8fdec4a169bae6e8e620ba6e57a6f135e19dab4e97dd973b4b4dacb3d09", - "bytes": 5513 + "bytes": 5513, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/RecoveryTests.cs", "sha256": "79570d35497ea473070d36cfd7873e0406b0c7272642c0f581b1216d2f6eb3b1", - "bytes": 6817 + "bytes": 6817, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/SessionExpiryTests.cs", "sha256": "4ddbb62765e695fc0b6f2bd7dbc0e521aead5d08d6fcb64ac5723dfb5ce3e134", - "bytes": 1403 + "bytes": 1403, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/WorkspaceMemberSearchTests.cs", "sha256": "e06e00a180859067661a417ee930f9710bdbb4991ce38a337e111c3f1f0748ce", - "bytes": 3933 + "bytes": 3933, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Authorization/RoleAuthorizationTests.cs", - "sha256": "fd4269fdae83874b088c5a91177e6bcf4f34004d01229e0d218d3a4fd8ba833c", - "bytes": 5290 + "sha256": "6fe1de8392d956d02a4867ec9490cc4ab86e76ddd1832c97aaee5b5988dddd1c", + "bytes": 5279, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/IdempotencyTests.cs", "sha256": "444abd821152e08670d1bda0b6c0e27ada696f85bb1abf2ee9968fc459d02040", - "bytes": 4400 + "bytes": 4400, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/OutboxTests.cs", "sha256": "0c3d6780ab8f5983b26d8064350e231685f5d3c429b55bc75e8a4ef58726d9f8", - "bytes": 4302 + "bytes": 4302, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ProjectContextTests.cs", "sha256": "c291aac579f522c775f480c4576f1d6434e44fd362e571bfaf13d6a2a1c1b19d", - "bytes": 4781 + "bytes": 4781, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ServiceAccountTests.cs", "sha256": "176001168511217b11b766324de9060087d6510e76e7b4f3543c339e06068cca", - "bytes": 19737 + "bytes": 19737, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ServiceAccountTokenIssuerTests.cs", "sha256": "8f50ed1460b7d8d3b57bb61d70483aa5c7ce1052892c342c9a0311bbc9014748", - "bytes": 2342 + "bytes": 2342, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/GlobalUsings.cs", "sha256": "50436eb3a60162e0cfad749eddfb06f8908f821a90ca44532df946a53e071359", - "bytes": 20 + "bytes": 20, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/KnowledgeHubIntegrationFactory.cs", - "sha256": "d8a4803a44bf92699a33010106c4626d4b9c0e368c5817a24c4c0cec02dba11c", - "bytes": 6309 + "sha256": "3c2f04933568944fd668ad87f7889ab3bd5be2b9d8631c1be2378b8d22a5a03c", + "bytes": 6636, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/OwnerSetupTestBase.cs", "sha256": "81a3e9a3653433178f44d47c064c57db81e1a6473ec2f6a2eb4a4aaad2b5fa11", - "bytes": 1599 + "bytes": 1599, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/PostgresContainerFixture.cs", - "sha256": "e2e309740077baed94d23fba8c3b46076ac7c1cb7024cfd839e09bce7f4cbbdf", - "bytes": 2249 + "sha256": "28cd5848662edb7bbfb9f05980bb879c8ab2ea0c527d36d6a505dc59aeb87623", + "bytes": 2627, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/PostgresTestGroup.cs", "sha256": "acad3cf4cc8f0bada688837f4b5babf750cbf5b7d6701f3f0071df6978a43fa1", - "bytes": 225 + "bytes": 225, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/SetupHelper.cs", "sha256": "8d07df84d6fa27625e06ec65355036c3b09aae03b87f38a98d80e59df70e6d0b", - "bytes": 5230 + "bytes": 5230, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/EditorialProfileValidationTests.cs", "sha256": "5ddbc9f870f008e1d977bb65170e3b8bc709957969c9884c48bcd5233917cf51", - "bytes": 11578 + "bytes": 11578, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/ExpandedManualOverrideCatalogTests.cs", "sha256": "c869f264f0c2006c031b32f07448b0f63878d0a40405a2426468ed78d3706d1c", - "bytes": 9307 + "bytes": 9307, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureCatalogReleaseScaleTests.cs", "sha256": "63d7471175327a5953e0541514adae8e761fcf8ba0a087dd36938fef2f8a5af7", - "bytes": 12611 + "bytes": 12611, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureCatalogScaleTests.cs", "sha256": "70bf7e2bf08bbe3593a091be680a173f3bca87083f400ee819874123d22ee6a8", - "bytes": 8715 + "bytes": 8715, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructurePaginationTests.cs", "sha256": "8606278c03d5c3bc09bcf27e62b1c570d59379c1e16bfe93dfccf5d88ab501a3", - "bytes": 12149 + "bytes": 12149, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureSynchronizationTests.cs", - "sha256": "8551e55a4bcc65e88e0e43336f0fad44f2062cc0030b129c92be59f4778065e2", - "bytes": 30073 + "sha256": "0eb042f8f37edea1d5e88c0751856b9b59ffeaf58a89c9570a2b6f1b55ca1898", + "bytes": 35041, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/ManualOverrideCatalogTests.cs", "sha256": "352183037e11e4ebd2123798c73324d6faeb79fe26b111c1e2aaf5e4f8143da9", - "bytes": 9483 + "bytes": 9483, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionObservabilityTests.cs", "sha256": "b4425360c31ddc9af65e6dc025b07ebecb301aa6b0a6f7add272199aed0e87e2", - "bytes": 9748 + "bytes": 9748, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPilotAvailabilityTests.cs", "sha256": "7599a942d9cc6d948bfa7942db9e324ffb63a340d6a3b45e67dfccdd901934bc", - "bytes": 2838 + "bytes": 2838, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPilotContractTests.cs", "sha256": "8f60385ccdcb312d188801ad0e61c22497ec6cadcebff7220ae6334abe160a60", - "bytes": 15287 + "bytes": 15287, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalApiTests.cs", "sha256": "8643b011fa8621b68aee41bf8e54bbb8eb5085f06954b51013c9708fde7b642a", - "bytes": 43019 + "bytes": 43019, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalAuthorizationTests.cs", "sha256": "87a1b67f6beaef4151a141cc622eb6abf557b6424da096bec0e83603988a9156", - "bytes": 9371 + "bytes": 9371, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalEtagContractTests.cs", "sha256": "570749cd19e820ad837fd622860904ec170650b65441a80273a8d74657183a48", - "bytes": 4535 + "bytes": 4535, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalPersistenceTests.cs", "sha256": "d07601fbdf2d7f405e59a8d0c81aa374dc7ea8b198a1297e028780e2bde2de9d", - "bytes": 8420 + "bytes": 8420, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalPrivacyTests.cs", "sha256": "114d88cc28609bb30eda2167e7d0d4c258f2feafc42730813d9bac4462d9c595", - "bytes": 11741 + "bytes": 11741, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalSourceCompensationTests.cs", "sha256": "dce305a08f045cf9d33e567da1344d677d98a1338241a93643a314fdeced9097", - "bytes": 12190 + "bytes": 12190, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPublicationTests.cs", "sha256": "ed579474d0a6ff66696baac5a55ca0602d2d19c0d8abea2363a143a78b03e898", - "bytes": 20199 + "bytes": 20199, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionSourceRetentionPersistenceTests.cs", "sha256": "f40ed6fc2d1012e0f1333ee26747aeb465be2b3665f17d6876b2b46da9dac4b2", - "bytes": 9354 + "bytes": 9354, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionSourceRetentionTests.cs", "sha256": "ebc00d50c7a4ab35d7422ad27ca21eb2ae23f520d373e671647649cb293d9cef", - "bytes": 9840 + "bytes": 9840, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/ItWorx.KnowledgeHub.IntegrationTests.csproj", "sha256": "89b9209c9a6f02cdc697b25fc57cc385d65e64c08552101ed960791c9d18e060", - "bytes": 1834 + "bytes": 1834, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Jobs/BackgroundJobConsistencyTests.cs", + "sha256": "ebe3c11f8fc9b502429b5427450fb1041fec91e1042bec98a002be7d25a7974e", + "bytes": 12399, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Jobs/BackgroundJobWorkerTests.cs", + "sha256": "431c7d52b1ff318835f6b5e95189519aff0495de3bd5f6df8e3bc1aa20601557", + "bytes": 11083, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Jobs/BackgroundJobsApiTests.cs", + "sha256": "38c960297cb42aa5430623a970eb176ae342365d7be8e3c09ade188bbdf0ca95", + "bytes": 29783, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/BacklinksTests.cs", "sha256": "b4d44afd5f5ef42260c7e658a04aea2569c2fc326ace5a88a94bb9de20df2a9c", - "bytes": 3646 + "bytes": 3646, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/DraftAutosaveTests.cs", "sha256": "2a3052b4b69f9928e5164183104f9b07992cb4c1fcd715b466199efe900f71de", - "bytes": 4500 + "bytes": 4500, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/InternalLinkResolutionTests.cs", "sha256": "515ea3013a1863ccd1879040875bce5ae8532ce93694e2db339875208a200f5f", - "bytes": 4975 + "bytes": 4975, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/MarkdownSanitizationTests.cs", "sha256": "8d4e180987c311f388b2c93c74689f15f8f58b0b7d56ece98350b334c27fa648", - "bytes": 10526 + "bytes": 10526, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageConcurrencyTests.cs", "sha256": "b1d6a793a66b0403a2808b5c33cee011e67461bdefce33bc351419351b9370bd", - "bytes": 3835 + "bytes": 3835, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageCreationStagingTests.cs", "sha256": "aaa2b6c1edd544baae60e0f9ecc562275072c60a0f5678f730f2e11989641b33", - "bytes": 13089 + "bytes": 13089, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageCrudTests.cs", "sha256": "2e8b63081fc159a3623af57ef6f0daff6fb668dccbf9f08a10fa2c2cd134ebb7", - "bytes": 9087 + "bytes": 9087, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageTreeScaleTests.cs", "sha256": "6b066fc33c57ad51d1498bf1482ab79f0f885a98c65c99df0935153fd2006073", - "bytes": 4162 + "bytes": 4162, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/RagPublicationManifestTests.cs", "sha256": "b39ca25e0b74d490280b3900d35b2468c822c9104676dadce6f882ddc7a8f7f4", - "bytes": 13618 + "bytes": 13618, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/RevisionsTests.cs", "sha256": "fca318dbe303eb869cda7dbbbfc900c5e9baa8a08af0a70fecc060ec4d3d68d6", - "bytes": 5046 + "bytes": 5046, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchFullScaleTests.cs", - "sha256": "bc93cf09fab0c37c5689bbdc33aa51af6de99858e72eec955181a2d4bad112be", - "bytes": 21113 + "sha256": "6baa425d6723a88f2e0698ea6363cfe4af803c6a4b844c19420fcb114e3609c3", + "bytes": 21637, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchScaleTests.cs", "sha256": "8febc36432eaae5642c762018c06c62873c5221a3288f2c86ffe8a468d73b62f", - "bytes": 4075 + "bytes": 4075, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchTests.cs", "sha256": "ed8ffb2b302f238d9e21d52c7ff55e99af3b9659d97fe6a085e70cb56ae41761", - "bytes": 6270 + "bytes": 6270, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/StagingSessionCleanupTests.cs", "sha256": "0fe285c8e68ca30d56042e6c6afae953dd7f54dc6762c38766a8500bcc4f0e86", - "bytes": 15574 + "bytes": 15574, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/TemplateTests.cs", "sha256": "72376ccc16b54beb2c9a035c070fd3b26dfaa6f980f5b49d73447eb18cb3fb66", - "bytes": 2842 + "bytes": 2842, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/TrashPurgeTests.cs", "sha256": "7c6ea2e7ceebf0542fc8ec040569f635e24213097d3794a3a317670105ecdcc5", - "bytes": 4606 + "bytes": 4606, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/CutoverRehearsalTests.cs", "sha256": "dd6aadc78a9f2416d801ebe4d1049a28310e9c836bf1c2d21668422c3a9b175a", - "bytes": 13214 + "bytes": 13214, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/InPlaceUpgradeRehearsalTests.cs", "sha256": "6be5365f778870dd38a63d9552ccb672da9e181737380b73e1e74828b6a523f1", - "bytes": 9921 + "bytes": 9921, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/LeafWikiImportTests.cs", "sha256": "7bb69f38364e6854c7c533b1d15a7b2365feee742139ca69fddaa7439c14f57a", - "bytes": 27489 + "bytes": 27489, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/LeafWikiV5ImportTests.cs", "sha256": "5839381bc9b01d3d9124f98aae1ee8085f74cbd8d690f235293eb5e1d68b12fa", - "bytes": 15377 + "bytes": 15377, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/SourceBatchPurgeTests.cs", "sha256": "0c6190b595bef291b3f8df07652ba5d004fd01a94433a27b3bbd58aecf219edb", - "bytes": 8874 + "bytes": 8874, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/ActivityFeedTests.cs", - "sha256": "5439e76efa08f3588fad4f3dde3ace611a789aecdc1292b5557ecf3c58b8988c", - "bytes": 1472 + "sha256": "4fb20ddfab5c58821883ea6f76888ba2f2ece0033dad47049a683bd4ceffcd4f", + "bytes": 2861, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/BackupEvidenceTests.cs", + "sha256": "2dfb388042098a921bdadfe077774a71fb83b17bda7cee159525d0cb8c3439d6", + "bytes": 8333, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/BackupServiceTests.cs", - "sha256": "a533eabdb6a606bc9d9db6ed33910b97c0e80ac572b50bfd5f54327921ff914c", - "bytes": 4196 + "sha256": "2815b7f3833d8f0eae746bfd3da9d823a8b00b226a68d6366fd810ae8043a085", + "bytes": 8268, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/DataSourceApiTests.cs", + "sha256": "5c9c675febda722a0cf84ab7c27cbc50c2890c01dd549c5424ecbd92a8c980c6", + "bytes": 3951, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/HealthCheckTests.cs", "sha256": "ce0a7755a62645880fc0619089c004cb33db0ca7f3bb45eda09f4d0a14566557", - "bytes": 5519 + "bytes": 5519, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/LoginRedactionTests.cs", "sha256": "fa1438b0483dd76b2485377b7e9ce40b11e77d4ebbbe46dc57239ec3a551329a", - "bytes": 2449 + "bytes": 2449, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/OtlpExportTests.cs", + "sha256": "849256e3e06d889a8f74999ee8be74c5b8ad1f5c8829d41760c59376694e5e83", + "bytes": 2576, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/PlatformMetricsTests.cs", + "sha256": "0d7a37d580f8e9fb9c79746a51db508d010555186379d18ddf73b7e97dd62c0a", + "bytes": 1308, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/RestoreServiceTests.cs", - "sha256": "e18e516bd4586574b39d880ce1e52cf989d25c5b37242405b7283bac64c0a923", - "bytes": 8438 + "sha256": "0f8b0228a3aa7c568c0a52d4952e757698e8d68f45bbd8bdf9da073edbc9c463", + "bytes": 20974, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/SchemaGuardTests.cs", "sha256": "15a5441e5248229d69d6c0af53c10b2b2b4287fb8a60a0caa2c3bb7827bf06af", - "bytes": 4448 + "bytes": 4448, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/SystemInfoTests.cs", - "sha256": "52fee800671424b2c7123602e7ec9ec1bb743118c68ae190d93e3068ad9ba537", - "bytes": 3119 + "sha256": "802713cf37854709dae5bbb15d02cb69852809d89f2229b62e326d539240c9b4", + "bytes": 3499, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/TableIdentifierSafetyTests.cs", + "sha256": "8b76dd13abf8a3c1951502d76eabb5190b71bfdbe4878ab5a063503e12e8d28f", + "bytes": 9818, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Platform/PlatformMigrationTests.cs", + "sha256": "8ceebe49b39a4e4dff41ad732c465b9d2af7f104e86831fdcc107616f2d9a9c8", + "bytes": 2374, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Platform/PlatformSettingsApiTests.cs", + "sha256": "cb79bd3435f9c50cf635a8cc60ba43a2e5df81140c4d8b561a636a47681a423d", + "bytes": 5316, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Platform/UserPreferencesApiTests.cs", + "sha256": "12743f53c55c2ebce1df06799af1e2ddcae6009d14cdca3e295428887b433cdd", + "bytes": 7004, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/DecisionTests.cs", "sha256": "add1093964043ae40de795fedd3f9c5d9fa2af54dd30264dba796b131ab8f002", - "bytes": 7772 + "bytes": 7772, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/ProjectHubTests.cs", "sha256": "9934bfd60c1286730ea9e315da1641c706e5b804d9b09c99e2cfbdfad9c82e6d", - "bytes": 18936 + "bytes": 18936, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/RunbookTests.cs", "sha256": "1c03d35c0f293c89947ebec214bcc0a1fd04b75a472d67c2c0ed66c880bda430", - "bytes": 6683 + "bytes": 6683, + "mode": "100644" }, { "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Setup/FirstRunSetupTests.cs", "sha256": "65e49d37e0256c2ec14ed2ea94f782beb8da177a0585409ba1a128dff87bc8b5", - "bytes": 2617 + "bytes": 2617, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/UnifiedSearch/UnifiedSearchScaleTests.cs", + "sha256": "94249bb2b44f72710a861feb7e03005dda27067c35a3c7cce02f6ebfb21b57f8", + "bytes": 5758, + "mode": "100644" + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/UnifiedSearch/UnifiedSearchTests.cs", + "sha256": "59c3ca3e2906a2715fc28b1bd64aaec35f74d2a51eac55c4697f69c331f9e0f5", + "bytes": 19341, + "mode": "100644" }, { "path": "tests/e2e/global-setup.ts", "sha256": "87142c3c6dc78d3cff7b0bfb4ef76ec7bd6c35eb052023a408f8003cf2aaef61", - "bytes": 4101 + "bytes": 4101, + "mode": "100644" }, { "path": "tests/e2e/package.json", - "sha256": "9f9484c32e1edfdb76206fef167b5517b9a06b55bc3d6de548d0040889912b2b", - "bytes": 398 + "sha256": "8d74629efb8a0c226085e81e05f881b9242eb998f32cdc1465427a43371e67c2", + "bytes": 413, + "mode": "100644" }, { "path": "tests/e2e/playwright.config.ts", - "sha256": "ad749af57a54caa7ae3161b9b9a0e69daa692eae7ea0a730a63a0d72f601f4f0", - "bytes": 944 + "sha256": "942c3bd6293e6b2771697bcb669a94834429ec16e065affe06e65f6348dcc8cd", + "bytes": 1379, + "mode": "100644" }, { "path": "tests/e2e/specs/attachments.spec.ts", "sha256": "c05f2645157e15a72c4ce6288ed1244248b14e27c7d4fc10b3f26d104068beaa", - "bytes": 3024 + "bytes": 3024, + "mode": "100644" }, { "path": "tests/e2e/specs/auth-journey.spec.ts", "sha256": "f89bb871fe6494a9a786c992e39da15c7a2b4bb45e61c2d2668f9aab69ed82ae", - "bytes": 2576 + "bytes": 2576, + "mode": "100644" }, { "path": "tests/e2e/specs/baseline.spec.ts", "sha256": "3fad54bff5595955a16898aabfadef48451b5bf89a39f039cd35c6db74bc032b", - "bytes": 773 + "bytes": 773, + "mode": "100644" }, { "path": "tests/e2e/specs/discovery.spec.ts", - "sha256": "1ab37f9fb35e3b470e585a4fefb2324b4791e96fc358b8fb745f667260583893", - "bytes": 5462 + "sha256": "0bde0c4eb49179260e2eeaf94fe6d13429d38830d07e57dd61ed800a33d1120e", + "bytes": 6001, + "mode": "100644" }, { "path": "tests/e2e/specs/ingestion-review.spec.ts", - "sha256": "1536ee1e6260d9f20f0be89007b6af2fdfd8b9974f0e9a30a0a0a0f1a82fc7ef", - "bytes": 4100 + "sha256": "5ec75d1f124d2d0335e514769091a2259c75cb75129add25ed64a4cbc90d6a67", + "bytes": 4212, + "mode": "100644" }, { "path": "tests/e2e/specs/knowledge-editing.spec.ts", "sha256": "ff9c1ba0974d9860c3e34ff9b2109c9c2b03e2529acfe84240b711afa6c02e19", - "bytes": 7003 + "bytes": 7003, + "mode": "100644" + }, + { + "path": "tests/e2e/specs/platform-productization.spec.ts", + "sha256": "ee4e1871dd9e57231b32d1506d9fd8fa637e08c935cb2a3b1a7644d9fed9a7ed", + "bytes": 6616, + "mode": "100644" }, { "path": "tests/e2e/specs/project-hub.spec.ts", "sha256": "eb5d70422430b58cbfbfb354928bc9581b140793486c901f9ae2896434994bfc", - "bytes": 9315 + "bytes": 9315, + "mode": "100644" }, { "path": "tests/e2e/specs/support/credentials.ts", "sha256": "439e2847b04fb7c2858b5e441391a1de808ee826b9603299271e3d76b8e033c9", - "bytes": 411 + "bytes": 411, + "mode": "100644" }, { "path": "tests/e2e/specs/support/v2.ts", "sha256": "b4f8131748087dae86b4746ba80cd7581a00d9a1737abfb65b72fbebb407c4d6", - "bytes": 2960 + "bytes": 2960, + "mode": "100644" }, { "path": "tests/e2e/specs/v2-editor-leafwiki-floor.spec.ts", - "sha256": "f24f4203bf9f6f2f5e9a159112010c910b7e1662265fe6144bd3de51e878aee2", - "bytes": 10847 + "sha256": "f4e09179e3192e3359e0467c60946f8e49bf2ff21b251bb95f7c2c695b58e3b4", + "bytes": 11350, + "mode": "100644" }, { "path": "tests/e2e/specs/v2-infrastructure-review.spec.ts", - "sha256": "5718cd25ced1e7b8ce204b41d70d166662a07dfa5afb06ea65de718a5c9e5850", - "bytes": 9358 + "sha256": "64ba86233675ab1fae9dca82063317339eb2d3e94efc943903a1c3b4d1755b7a", + "bytes": 9378, + "mode": "100644" }, { "path": "tests/e2e/specs/v2-m5-workspace-fidelity.spec.ts", - "sha256": "a73b3c05027b1f7cd54daaaacc08a18288056d080d75cd4e66245a5427cf5be1", - "bytes": 7430 + "sha256": "3c219de21e7d5affbb21fe068f4865f790679c791eff2e3c901dd6a5e0623363", + "bytes": 7440, + "mode": "100644" }, { "path": "tests/e2e/specs/v2-m6-motion-and-matrices.spec.ts", - "sha256": "8288720d427ca6e2ababd538df25cf7eec95a9897119da22863745d4c08ba4ab", - "bytes": 6928 + "sha256": "8a6ff943998177465a6f5a6090ec770607d6e2004e1a5db81f3b83fe38efb51b", + "bytes": 8029, + "mode": "100644" }, { "path": "tests/e2e/specs/v2-m7-admin-actions.spec.ts", - "sha256": "33b59a667459a76ee1f574244b6f1e0cc479f1f689d90d8cbac43c979d76e0d8", - "bytes": 10604 + "sha256": "918818bfb3a3e5ef917923824f74f2f6b2a1fc6e0276788a4a370018a77e9457", + "bytes": 11704, + "mode": "100644" }, { "path": "tests/e2e/specs/v2-m8-accessibility-and-fidelity.spec.ts", - "sha256": "a1de19825eb55b3103f3174797c909e3a393e3b079dad034de71f4c0801c4cb5", - "bytes": 8986 + "sha256": "e4e55625075e96b29b51d5f70869f32bd45a985c742c163578b7696ae652281c", + "bytes": 8996, + "mode": "100644" }, { "path": "tests/e2e/specs/v2-r3-daily-ux-completeness.spec.ts", "sha256": "1787d051c085de0cdc8e9cbdeeffdd6786eb23add8b1eb12e35207fbec195ad2", - "bytes": 6706 + "bytes": 6706, + "mode": "100644" }, { "path": "tests/e2e/specs/v2-routing-and-fidelity.spec.ts", - "sha256": "fc6823cf7cffdee2deea7c63f951c4098b6a66f799a03961788a8e802b53c2e6", - "bytes": 13598 + "sha256": "89742264ffa0b700afc105199d680def9b5e6738c2b05dcd72aba9fa202bb0da", + "bytes": 13798, + "mode": "100644" }, { "path": "tests/e2e/specs/v3-intake-pilot.spec.ts", "sha256": "98f62bf9087c5463e15094e543c192fa1045ee5d28a8af14812c5e62ce6cc4db", - "bytes": 10361 + "bytes": 10361, + "mode": "100644" }, { "path": "tools/leafwiki-inspector/README.md", "sha256": "24bae3b3a276cf7e84f3908c8e5f3b7b0920d377dba65f355e5b9d219fe689e9", - "bytes": 1359 + "bytes": 1359, + "mode": "100644" }, { "path": "tools/leafwiki-inspector/inspect.py", "sha256": "10239ed5ed307bb0ac7b6d03900e1a6335aec1f7d58364b444174cd0f2d106d0", - "bytes": 19648 + "bytes": 19648, + "mode": "100644" }, { "path": "tools/leafwiki-inspector/tests/test_inspect.py", "sha256": "d4d508b7760d9b83326ab90798685dcf474576c2598f626b4f822986ac6aa8f2", - "bytes": 4098 + "bytes": 4098, + "mode": "100644" } ] } diff --git a/README.md b/README.md index 32a6f57..1b285fa 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ this repository. - Track projects, milestones, updates, architecture decisions and runbook validation. - Import LeafWiki content through a read-only, preflighted and reversible workflow. - Create scoped, revocable service-account tokens for the versioned `/api/v1` API. +- Synchronize personal preferences across devices and manage versioned platform policy. +- Follow durable course-tagging and backup jobs with live progress and cancellation. +- Search knowledge, projects, services, runbooks and decisions from one workspace. +- Manage source freshness and inspect recorded deployment, recovery and telemetry evidence. - Back up the database and attachments together, then verify a clean-target restore. ## Screenshots diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index f3fb013..70a2a82 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -57,8 +57,10 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080 \ DOTNET_EnableDiagnostics=0 \ Build__Commit=${BUILD_COMMIT} +# Explicitly refresh OpenSSL too: the pinned base may predate security +# updates even when curl's already-satisfied dependencies would keep it. RUN apt-get update \ - && apt-get install --yes --no-install-recommends curl ca-certificates \ + && apt-get install --yes --no-install-recommends curl ca-certificates libssl3t64 openssl \ && rm -rf /var/lib/apt/lists/* \ && groupadd --gid 10001 knowledgehub \ && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin knowledgehub \ diff --git a/deploy/observability/collector.yaml b/deploy/observability/collector.yaml new file mode 100644 index 0000000..e659865 --- /dev/null +++ b/deploy/observability/collector.yaml @@ -0,0 +1,24 @@ +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + +processors: + memory_limiter: + check_interval: 1s + limit_mib: 128 + batch: + timeout: 5s + +exporters: + prometheus: + endpoint: 0.0.0.0:8889 + metric_expiration: 2m + +service: + pipelines: + metrics: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [prometheus] diff --git a/deploy/observability/compose.yaml b/deploy/observability/compose.yaml new file mode 100644 index 0000000..7ac8bc4 --- /dev/null +++ b/deploy/observability/compose.yaml @@ -0,0 +1,47 @@ +services: + app: + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318 + OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf + OTEL_METRIC_EXPORT_INTERVAL: "15000" + + otel-collector: + image: ${KNOWLEDGE_HUB_COLLECTOR_IMAGE:-otel/opentelemetry-collector-contrib:0.162.0-amd64@sha256:340885ab6f46822374f00c58c067396b53b8d612559842e8821585567579d9e3} + restart: unless-stopped + command: ["--config=/etc/otelcol-contrib/config.yaml"] + volumes: + - ./deploy/observability/collector.yaml:/etc/otelcol-contrib/config.yaml:ro + read_only: true + security_opt: ["no-new-privileges:true"] + networks: [knowledge-hub-internal] + + prometheus: + image: prom/prometheus:v3.14.0@sha256:5ce7540c3c00ef4ab0c9d2c995c6a5b9c421f44b4a115d97a2c7af3b1c21cbb0 + restart: unless-stopped + command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--storage.tsdb.retention.time=7d"] + volumes: + - ./deploy/observability/prometheus.yaml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + read_only: true + security_opt: ["no-new-privileges:true"] + networks: [knowledge-hub-internal] + + grafana: + image: grafana/grafana:12.4.0@sha256:b0ae311af06228bcfd4a620504b653db80f5b91e94dc3dc2a5b7dab202bcde20 + restart: unless-stopped + environment: + GF_SECURITY_ADMIN_USER: ${KNOWLEDGE_HUB_GRAFANA_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${KNOWLEDGE_HUB_GRAFANA_PASSWORD:?Set a private Grafana password for observability} + GF_USERS_ALLOW_SIGN_UP: "false" + volumes: + - ./deploy/observability/grafana-datasource.yaml:/etc/grafana/provisioning/datasources/knowledge-hub.yaml:ro + - grafana-data:/var/lib/grafana + ports: + - "127.0.0.1:${KNOWLEDGE_HUB_GRAFANA_PORT:-13000}:3000" + read_only: true + security_opt: ["no-new-privileges:true"] + networks: [knowledge-hub-internal] + +volumes: + prometheus-data: + grafana-data: diff --git a/deploy/observability/grafana-datasource.yaml b/deploy/observability/grafana-datasource.yaml new file mode 100644 index 0000000..08d76c5 --- /dev/null +++ b/deploy/observability/grafana-datasource.yaml @@ -0,0 +1,8 @@ +apiVersion: 1 +datasources: + - name: Knowledge Hub Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/deploy/observability/prometheus.yaml b/deploy/observability/prometheus.yaml new file mode 100644 index 0000000..ece3cf9 --- /dev/null +++ b/deploy/observability/prometheus.yaml @@ -0,0 +1,7 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s +scrape_configs: + - job_name: itworx-knowledge-hub + static_configs: + - targets: [otel-collector:8889] diff --git a/docker-compose.yml b/docker-compose.yml index db8db13..ab54df6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,10 @@ services: KnowledgeHub__Paths__Backups: /var/lib/knowledge-hub/backups KnowledgeHub__Paths__Keys: /var/lib/knowledge-hub/keys Build__Commit: ${BUILD_COMMIT:-development} + # Explicit opt-in export to the operator's collector. Empty disables export. + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_METRIC_EXPORT_INTERVAL: ${OTEL_METRIC_EXPORT_INTERVAL:-30000} TZ: ${TZ:-Europe/Brussels} ports: - "${KNOWLEDGE_HUB_PORT:-1240}:8080" diff --git a/docs/12-auth-security.md b/docs/12-auth-security.md index 516bf45..c09cfe2 100644 --- a/docs/12-auth-security.md +++ b/docs/12-auth-security.md @@ -186,6 +186,13 @@ At minimum isolate policies for: Return a generic response that does not confirm account/content existence. +The knowledge and unified-search endpoints share a 60-request/minute budget per +effective remote IP. Job creation and the compatibility backup-creation endpoint +share a 10-request/minute budget. Both use zero-length queues and return 429. +Forwarded addresses are accepted only from configured trusted proxies. Durable +job admission additionally enforces per-actor, workspace and rolling daily budgets +inside PostgreSQL; see [background jobs](background-jobs.md). + ## Security verification Release requires: diff --git a/docs/18-backup-restore.md b/docs/18-backup-restore.md index 2ba13b5..2033843 100644 --- a/docs/18-backup-restore.md +++ b/docs/18-backup-restore.md @@ -16,7 +16,8 @@ A full backup set contains: 5. Product, schema and container/image versions. 6. File inventory with sizes and SHA-256 hashes. 7. Backup job metadata and timestamps. -8. Encryption metadata when operator-managed encryption is enabled. +8. A signed integrity receipt authenticating the exact manifest. +9. Encryption metadata when operator-managed encryption is enabled. Do not include runtime session secrets or plaintext API tokens. @@ -42,13 +43,46 @@ mutable files. Deletions use retention, preventing a blob from disappearing duri 5. copy/link/archive required blobs without following unsafe paths; 6. create manifest; 7. verify dump readability and every file hash; -8. optionally encrypt with an operator-supplied mechanism; -9. atomically publish completed backup; -10. apply retention only after a verified new backup exists; -11. record audit and administrator-visible result. +8. authenticate the manifest hash with HMAC-SHA-256; +9. optionally encrypt with an operator-supplied mechanism; +10. atomically publish completed backup; +11. apply retention only after a verified new backup exists; +12. record audit and administrator-visible result. A failed backup never replaces the last known-good backup. +New backup directories and files use private Unix permissions (0700 and 0600). +Permission failures stop creation before publication. On Windows, provision and +verify restrictive NTFS ACLs for backups, keys and restore targets before use; +Unix mode checks do not enforce Windows ACLs. HMAC authenticates backups but +does not encrypt their contents, so archive/share access must remain restricted. + +## Backup authenticity key and recovery + +The `.integrity.json` receipt beside a backup is not trusted by itself. The application +authenticates its backup ID, exact manifest hash, completion time and verification result +with HMAC-SHA-256. The random 32-byte key is stored at +`KnowledgeHub:Paths:Keys/backup-integrity.key`; in the supplied Compose deployment this +is `/var/lib/knowledge-hub/keys/backup-integrity.key`. This location is outside +`KnowledgeHub:Paths:Backups`, so someone who can rewrite a backup and its receipt cannot +create a valid signature. The application creates the key when integrity evidence is +first written and restricts it to the service account on Unix hosts. + +Treat this key as essential recovery material. Copy the exact key file into an encrypted, +access-controlled escrow location separate from the backup storage, and include it in +the operator's disaster-recovery procedure. To restore on a replacement host, recover +the key to the configured Keys directory before verifying or restoring backups. Never +put the key in a backup directory or in the same backup archive as the signed receipts. +Protect access to the escrow copy as carefully as access to the backups. + +If the key is missing or unreadable, backup integrity is reported as unknown and restore +is refused. Do not replace a lost key with a newly generated one and expect existing +backups to validate: a new key authenticates only backups created after it. Recover the +original key from escrow first. If no copy exists, the existing backups cannot be +cryptographically authenticated by the application and must not be treated as verified. +Backups made before authenticated receipts were introduced also remain unverified; create +a new backup with this version before relying on application-verified restore. + ## Retention defaults Configurable starting policy: @@ -65,13 +99,26 @@ source. Restore is deliberately separate from normal application startup. -1. Select backup. -2. Verify manifest, hashes, version compatibility and encryption. -3. Create clean PostgreSQL database and empty target directories. -4. Restore database. +1. Select backup. Copy read-only media to a writable recovery directory first, + preserving the manifest, signed receipts and file bytes. Recover the exact + `backup-integrity.key` from protected external escrow into the configured Keys + directory; never take it from the backup archive. The application acquires an + exclusive storage lease there to prevent retention from removing a restore source, + and writes its restore receipt there. Keep the original media unchanged. +2. Use the backup's recorded application image to create a clean PostgreSQL + database at exactly the manifest's schema version. Create an existing, empty + target attachments directory on writable storage with room for the full payload. +3. Copy the manifest and every listed database and attachment file into a private + staging directory inside the target attachments volume. Verify the HMAC against + the staged manifest bytes and verify each staged file's size and SHA-256 before + opening or changing the target database. Restore reads only from this verified + snapshot, so source-volume changes after verification cannot change restored bytes. +4. Restore database. The restore service refuses a target whose latest applied + migration differs from the authenticated manifest, before inserting any rows. 5. Restore attachment blobs by hash. -6. Start the recorded/current compatible application in maintenance mode. -7. Apply supported migrations. +6. Verify the restored data with the recorded application version. +7. Upgrade through supported migrations using the intended newer image, then + repeat integrity checks before promotion. 8. Run integrity checks: - FK/schema; - user/membership; @@ -87,6 +134,21 @@ Restore is deliberately separate from normal application startup. Never test restore by overwriting the only production instance. +Pass target PostgreSQL credentials through `--target-connection-file=`, which +reads the connection string from a protected file. On Unix, the file must be readable +only by the restore account (mode `0600` or `0400`); the restore command rejects files +with group or other access. On Windows, apply an ACL that grants access only to the +restore operator/service account. Do not put the connection string in command-line +arguments. Restore errors deliberately omit provider exception details because they +can contain connection data. + +The real-backup drill also requires the exact integrity key recovered from protected +external escrow. Pass only its file path through +`KNOWLEDGE_HUB_BACKUP_INTEGRITY_KEY_FILE`; the script copies the key with mode `0600` +into its temporary working directory, uses that directory as the restore Keys path, +and removes the temporary copy during cleanup. The key contents are never placed in an +argument or printed by the script. + ## Backup encryption The application may integrate an operator-supplied encryption command or library after @@ -125,11 +187,12 @@ isolated restore proves it is necessary and the operator has assessed newer writ - create backup with representative data; - verify manifest; +- reject a forged manifest and matching unsigned/invalid integrity receipt; - corrupt dump and blob tests; - clean-target restore; - compare logical counts and hashes; - restore older supported schema then migrate; -- missing key/encrypted backup failure; +- missing integrity key or encryption key failure; - interrupted backup cleanup; - retention policy; - no secret inclusion; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 92dc9a6..5073b0d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: ItWorx Knowledge Hub API - version: 0.1.0-draft + version: 1.0.0-rc.1 description: | Versioned API for the ItWorx Knowledge Hub web client and scoped automation. This is not an MCP transport. The future MCP service will consume this API. @@ -29,6 +29,9 @@ tags: - name: Activity - name: Workspace - name: Ingestion proposals + - name: Preferences + - name: DataSources + - name: Jobs security: - cookieAuth: [] - bearerAuth: [] @@ -777,6 +780,59 @@ paths: $ref: "#/components/schemas/SearchResponse" "401": $ref: "#/components/responses/Unauthorized" + /unified-search: + get: + tags: [Search] + operationId: searchWorkspaceContent + description: >- + Human reader session only. Searches visible knowledge, projects, reviewed + services, runbooks and decisions in the current workspace. Trashed content + and hidden or archived services are excluded. Counts cover all five kinds; + `kind` filters the returned page. A `tag` filters exact, case-insensitive + page tags on knowledge, runbook and decision results and excludes projects + and services. An empty query returns no results unless `tag` is supplied. The + endpoint shares the search rate limit of 60 requests per effective remote IP + per minute. An invalid `kind` returns 422. + parameters: + - name: q + in: query + schema: + type: string + maxLength: 500 + - name: kind + in: query + schema: + type: string + enum: [knowledge, projects, services, runbooks, decisions] + - name: tag + in: query + description: Exact, case-insensitive page tag; applies to knowledge, runbooks and decisions. + schema: + type: string + maxLength: 64 + - $ref: "#/components/parameters/Cursor" + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + responses: + "200": + description: Permission-filtered, ranked results and complete grouped counts + content: + application/json: + schema: + $ref: "#/components/schemas/UnifiedSearchResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + description: Invalid kind + "429": + description: Search request budget exhausted /rag/publication-manifest: get: tags: [Rag] @@ -3077,6 +3133,180 @@ paths: application/problem+json: schema: $ref: "#/components/schemas/ProblemDetails" + /me/preferences: + get: + tags: [Preferences] + operationId: getMyPreferences + security: [{ cookieAuth: [] }] + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/UserPreferences" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + put: + tags: [Preferences] + operationId: updateMyPreferences + security: [{ cookieAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UserPreferencesRequest" } } } } + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/UserPreferences" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /admin/platform-settings: + get: + tags: [Admin] + operationId: getPlatformSettings + security: [{ cookieAuth: [] }] + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformSettings" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + put: + tags: [Admin] + operationId: updatePlatformSettings + security: [{ cookieAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformSettingsRequest" } } } } + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformSettings" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /admin/data-sources: + get: + tags: [DataSources] + operationId: listDataSources + security: [{ cookieAuth: [] }] + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/DataSourceList" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [DataSources] + operationId: registerDataSource + security: [{ cookieAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/DataSourceRegistration" } } } } + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/DataSource" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /admin/data-sources/{id}: + put: + tags: [DataSources] + operationId: configureDataSource + security: [{ cookieAuth: [] }] + parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/DataSourceConfiguration" } } } } + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/DataSource" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /admin/data-sources/{id}/observations: + post: + tags: [DataSources] + operationId: reportDataSourceObservation + security: [{ cookieAuth: [] }] + parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/DataSourceObservation" } } } } + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/DataSource" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /admin/backups/status: + get: + tags: [Backups] + operationId: getBackupStatus + security: [{ cookieAuth: [] }] + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/BackupStatus" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /admin/backups/{backupId}/protection: + put: + tags: [Backups] + operationId: setBackupProtection + security: [{ cookieAuth: [] }] + parameters: [{ name: backupId, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/BackupProtectionRequest" } } } } + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/BackupEvidence" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /admin/audit-log: + get: + tags: [Admin] + operationId: listAdminAuditLog + security: [{ cookieAuth: [] }] + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/AdminAuditList" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /jobs: + get: + tags: [Jobs] + operationId: listBackgroundJobs + security: [{ cookieAuth: [] }] + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/BackgroundJobList" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /jobs/course-tagging: + post: + tags: [Jobs] + operationId: queueCourseTaggingJob + security: [{ cookieAuth: [] }] + parameters: [{ $ref: "#/components/parameters/RequiredIdempotencyKey" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/QueueCourseTaggingJobRequest" } } } } + responses: + "202": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/BackgroundJob" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /jobs/backups: + post: + tags: [Jobs] + operationId: queueBackupJob + security: [{ cookieAuth: [] }] + parameters: [{ $ref: "#/components/parameters/RequiredIdempotencyKey" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/QueueBackupJobRequest" } } } } + responses: + "202": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/BackgroundJob" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /jobs/{jobId}: + get: + tags: [Jobs] + operationId: getBackgroundJob + security: [{ cookieAuth: [] }] + parameters: [{ name: jobId, in: path, required: true, schema: { type: string, format: uuid } }] + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/BackgroundJob" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /jobs/{jobId}/cancel: + post: + tags: [Jobs] + operationId: cancelBackgroundJob + security: [{ cookieAuth: [] }] + parameters: [{ name: jobId, in: path, required: true, schema: { type: string, format: uuid } }] + responses: + "200": { description: Persisted result, content: { application/json: { schema: { $ref: "#/components/schemas/BackgroundJob" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + /jobs/{jobId}/events: + get: + tags: [Jobs] + operationId: streamBackgroundJobEvents + security: [{ cookieAuth: [] }] + parameters: [{ name: jobId, in: path, required: true, schema: { type: string, format: uuid } }] + responses: + "200": { description: Job and progress Server-Sent Events with heartbeat and terminal close, content: { text/event-stream: { schema: { type: string } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } components: securitySchemes: cookieAuth: @@ -3248,6 +3478,194 @@ components: schema: $ref: "#/components/schemas/ProblemDetails" schemas: + UserPreferencesRequest: + type: object + required: [theme, density, primaryNavigationCollapsed, contextPanelOpen, navigationOrder, dashboardWidgetOrder, hiddenDashboardWidgets, defaultLandingPage, catalogView, contextPanelWidth, navigationWidth] + properties: + theme: { type: string, enum: [system, light, dark] } + density: { type: string, enum: [comfortable, compact] } + primaryNavigationCollapsed: { type: boolean } + contextPanelOpen: { type: boolean } + navigationOrder: { type: array, items: { type: string } } + dashboardWidgetOrder: { type: array, items: { type: string } } + hiddenDashboardWidgets: { type: array, items: { type: string } } + defaultLandingPage: { type: string } + catalogView: { type: string, enum: [grid, list] } + contextPanelWidth: { type: integer, minimum: 256, maximum: 440 } + navigationWidth: { type: integer, minimum: 192, maximum: 320 } + UserPreferences: + allOf: + - $ref: "#/components/schemas/UserPreferencesRequest" + - type: object + required: [schemaVersion, eTag, isDefault, updatedAt] + properties: + schemaVersion: { type: integer, enum: [1] } + eTag: { type: string } + isDefault: { type: boolean } + updatedAt: { type: [string, "null"], format: date-time } + PlatformSettingsRequest: + type: object + required: [displayName, primaryLocale, timeZone, ingestionProposals, backupPolicyExpectation, inventoryFreshnessPolicy] + properties: + displayName: { type: string, minLength: 1, maxLength: 200 } + primaryLocale: { type: string } + timeZone: { type: string } + ingestionProposals: { type: object, required: [enabled, maximumSourceCount, maximumSourceBytes, failedRetryLimit], properties: { enabled: { type: boolean }, maximumSourceCount: { type: integer, minimum: 1, maximum: 20 }, maximumSourceBytes: { type: integer, format: int64 }, failedRetryLimit: { type: integer, minimum: 1, maximum: 3 } } } + backupPolicyExpectation: { type: object, required: [expectedIntervalHours, scheduleDescription, dailyRetentionCount, weeklyRetentionCount, monthlyRetentionCount, protectedBackupCount, restoreVerificationMaxAgeDays], properties: { expectedIntervalHours: { type: integer }, scheduleDescription: { type: string }, dailyRetentionCount: { type: integer }, weeklyRetentionCount: { type: integer }, monthlyRetentionCount: { type: integer }, protectedBackupCount: { type: integer }, restoreVerificationMaxAgeDays: { type: integer } } } + inventoryFreshnessPolicy: { type: object, required: [warningAfterHours], properties: { warningAfterHours: { type: integer } } } + PlatformSettings: + allOf: + - $ref: "#/components/schemas/PlatformSettingsRequest" + - type: object + required: [schemaVersion, eTag, isDefault, updatedAt] + properties: + schemaVersion: { type: integer, enum: [1] } + eTag: { type: string } + isDefault: { type: boolean } + updatedAt: { type: [string, "null"], format: date-time } + backupPolicyExpectation: { type: object, description: Includes enforcement External; configured schedule is an expectation, not a scheduler run. } + DataSourceRegistration: + type: object + required: [sourceKey, displayName, kind, expectedIntervalHours] + properties: + sourceKey: { type: string, maxLength: 200 } + displayName: { type: string, maxLength: 120 } + kind: { type: string, enum: [Inventory, Gitea, KnowledgeClient, ManualIntake, LeafWiki, CuratedPackage] } + expectedIntervalHours: { type: integer, minimum: 1, maximum: 8760 } + DataSourceConfiguration: + type: object + required: [displayName, enabled, expectedIntervalHours] + properties: + displayName: { type: string, maxLength: 120 } + enabled: { type: boolean } + expectedIntervalHours: { type: integer, minimum: 1, maximum: 8760 } + DataSourceObservation: + type: object + required: [success] + properties: + success: { type: boolean } + recordCount: { type: [integer, "null"], format: int64 } + errorCode: { type: [string, "null"] } + correlationId: { type: [string, "null"], maxLength: 100 } + DataSource: + type: object + required: [id, sourceKey, displayName, kind, enabled, state, configurationState, syncAuthority, expectedIntervalHours, eTag, actions] + properties: + id: { type: string, format: uuid } + sourceKey: { type: string } + displayName: { type: string } + kind: { type: string } + enabled: { type: boolean } + state: { type: string, enum: [Unknown, Current, Stale, Failed, Disabled] } + configurationState: { type: string } + syncAuthority: { type: string } + expectedIntervalHours: { type: integer } + lastAttemptAt: { type: [string, "null"], format: date-time } + lastSuccessAt: { type: [string, "null"], format: date-time } + lastFailureAt: { type: [string, "null"], format: date-time } + dataObservedAt: { type: [string, "null"], format: date-time } + recordCount: { type: [integer, "null"], format: int64 } + lastErrorCode: { type: [string, "null"] } + lastCorrelationId: { type: [string, "null"] } + eTag: { type: string } + actions: { type: array, items: { type: string } } + DataSourceList: + type: object + required: [items, nextCursor] + properties: + items: { type: array, items: { $ref: "#/components/schemas/DataSource" } } + nextCursor: { type: [string, "null"] } + BackupProtectionRequest: + type: object + required: [protected] + properties: + protected: { type: boolean } + BackupEvidence: + type: object + required: [protected, protectionVersion] + properties: + protected: { type: boolean } + protectionVersion: { type: integer } + integrityVerifiedAt: { type: [string, "null"], format: date-time } + restoreVerifiedAt: { type: [string, "null"], format: date-time } + lastRestoreSucceeded: { type: [boolean, "null"] } + BackupStatus: + type: object + required: [state, scheduleAuthority, scheduleDescription, restoreConfidence, storedBackupBytes, protectedCount, expectedIntervalHours, applicationRetentionCount, dailyRetentionExpectation, weeklyRetentionExpectation, monthlyRetentionExpectation, restoreVerificationMaxAgeDays] + properties: + state: { type: string, enum: [Unknown, Current, Stale] } + scheduleAuthority: { type: string, enum: [External] } + scheduleDescription: { type: string } + nextScheduledRun: { type: [string, "null"], format: date-time } + nextExpectedBy: { type: [string, "null"], format: date-time } + lastSuccessfulBackupAt: { type: [string, "null"], format: date-time } + lastIntegrityVerifiedAt: { type: [string, "null"], format: date-time } + lastVerifiedRestoreAt: { type: [string, "null"], format: date-time } + restoreConfidence: { type: string, enum: [Unknown, Stale, Verified] } + storedBackupBytes: { type: integer, format: int64, description: Sum of stored backup manifest sizes; not filesystem capacity. } + protectedCount: { type: integer } + expectedIntervalHours: { type: integer } + applicationRetentionCount: { type: integer } + dailyRetentionExpectation: { type: integer } + weeklyRetentionExpectation: { type: integer } + monthlyRetentionExpectation: { type: integer } + restoreVerificationMaxAgeDays: { type: integer } + AdminAuditList: + type: object + required: [items, nextCursor] + properties: + items: { type: array, items: { $ref: "#/components/schemas/AdminAuditItem" } } + nextCursor: { type: [integer, "null"], format: int64 } + AdminAuditItem: + type: object + required: [id, actorKind, source, action, summary, occurredAt] + properties: + id: { type: integer, format: int64 } + actorKind: { type: string } + actorId: { type: [string, "null"], format: uuid } + source: { type: string } + action: { type: string } + targetType: { type: [string, "null"] } + targetId: { type: [string, "null"] } + summary: { type: string } + occurredAt: { type: string, format: date-time } + correlationId: { type: [string, "null"] } + QueueCourseTaggingJobRequest: + type: object + required: [rootPageId, tag] + properties: + rootPageId: { type: string, format: uuid } + tag: { type: string } + QueueBackupJobRequest: { type: object, description: Empty object; the job snapshots the current server-side backup policy. } + BackgroundJob: + type: object + required: [id, workspaceId, actorId, type, status, createdAt, cancelRequested, progress, total, processed, succeeded, skipped, failed, errors, version] + properties: + id: { type: string, format: uuid } + workspaceId: { type: string, format: uuid } + actorId: { type: string, format: uuid } + type: { type: string } + status: { type: string, enum: [queued, running, succeeded, partially_succeeded, failed, cancelled] } + createdAt: { type: string, format: date-time } + startedAt: { type: [string, "null"], format: date-time } + completedAt: { type: [string, "null"], format: date-time } + cancelRequestedAt: { type: [string, "null"], format: date-time } + cancelRequested: { type: boolean } + progress: { type: integer, minimum: 0, maximum: 100 } + total: { type: integer } + processed: { type: integer } + succeeded: { type: integer } + skipped: { type: integer } + failed: { type: integer } + errors: { type: array, items: { type: object, properties: { code: { type: string }, message: { type: string }, itemId: { type: [string, "null"], format: uuid } } } } + result: { type: [object, "null"] } + correlationId: { type: [string, "null"] } + version: { type: integer, format: int64 } + BackgroundJobList: + type: object + required: [items] + properties: + items: { type: array, items: { $ref: "#/components/schemas/BackgroundJob" } } CreateIngestionProposalRequest: type: object required: [manifest] @@ -5243,6 +5661,41 @@ components: $ref: "#/components/schemas/SearchResultItem" nextCursor: type: [string, "null"] + UnifiedSearchResponse: + type: object + required: [items, counts, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/UnifiedSearchResultItem" + counts: + $ref: "#/components/schemas/UnifiedSearchCounts" + nextCursor: + type: [string, "null"] + UnifiedSearchCounts: + type: object + required: [knowledge, projects, services, runbooks, decisions, total] + properties: + knowledge: { type: integer, minimum: 0 } + projects: { type: integer, minimum: 0 } + services: { type: integer, minimum: 0 } + runbooks: { type: integer, minimum: 0 } + decisions: { type: integer, minimum: 0 } + total: { type: integer, minimum: 0 } + UnifiedSearchResultItem: + type: object + required: [kind, id, title, summary, snippet, updatedAt, href] + properties: + kind: + type: string + enum: [knowledge, projects, services, runbooks, decisions] + id: { type: string, format: uuid } + title: { type: string } + summary: { type: string } + snippet: { type: string } + updatedAt: { type: string, format: date-time } + href: { type: string, description: Same-origin relative route to the result. } RagPublicationManifestItem: type: object description: >- diff --git a/docs/background-jobs.md b/docs/background-jobs.md new file mode 100644 index 0000000..49d2b0d --- /dev/null +++ b/docs/background-jobs.md @@ -0,0 +1,43 @@ +# Durable background jobs + +Course tagging and manual backup creation run as PostgreSQL-backed jobs. The admin +workspace shows persisted progress and supports cancellation. SSE sends the same +safe snapshot as the polling endpoint and closes on a terminal state; the browser +falls back to polling after an interrupted stream. + +Only an enabled workspace administrator can create or execute a job. Mutation +endpoints require cookie authentication and CSRF validation. The worker rechecks +the actor's membership before work and before each course page. An idempotency key +is scoped to the workspace and actor; reusing it with a different payload returns +409. Retrying the same payload returns the existing job, including at capacity. +The job row and its `job.queued` audit event commit together. Cancellation requests +and their `job.cancel_requested` audit event also commit together; repeating a +running-job cancellation returns the current request without adding another audit +event. + +The database serializes admission per workspace. Defaults under `KnowledgeHub:Jobs` +are `MaxPendingPerWorkspace=10`, `MaxPendingPerActor=3`, and +`MaxCreatedPerDay=100` (a rolling 24-hour window). Capacity rejection returns 429 +before inserting a job or page snapshot. HTTP creation also has a shared budget +of ten requests per effective remote IP per minute. Only configured trusted +proxies can affect that address. These limits bound distinct-key floods across +application restarts and replicas as well as ordinary bursts. + +Course tagging snapshots at most `MaxCoursePages=100000` page IDs in PostgreSQL. +The worker updates one current page at a time with its ETag, preserves content, +skips existing tags, and records safe per-page failures. A lease is renewed while +work is active; expired leases can be reclaimed with bounded retries. Default +worker concurrency is two. Cancellation stops future items and preserves the +already completed outcomes. + +Terminal jobs retain their counters, bounded errors, result, actor and audit trail. +Their page-ID recovery snapshots are removed in the same transaction as the final +state. This limits large item-table growth without discarding the job history. +The daily admission budget bounds growth of that compact history. Source data is +never removed by this maintenance. + +The job `Version` is a monotonic optimistic-concurrency token for persisted worker +updates and SSE event IDs. If a cancellation update wins a race with a tracked worker +save, the worker retries against the current version while keeping the cancellation +request. SSE always emits a terminal event and closes when it observes a terminal +job, including when that snapshot has the same version as the last progress event. diff --git a/docs/guides/back-ups-herstel-en-unraid-beheer.md b/docs/guides/back-ups-herstel-en-unraid-beheer.md index fcba059..d48b01e 100644 --- a/docs/guides/back-ups-herstel-en-unraid-beheer.md +++ b/docs/guides/back-ups-herstel-en-unraid-beheer.md @@ -65,20 +65,46 @@ af. ### Stap 2 — herstellen +Maak eerst een **beschrijfbare herstelkopie** van de gekozen back-up; herstel neemt een +exclusieve opslaglease en schrijft achteraf een herstelbewijs naast het manifest. Geef +de CLI de doelconnectie via een door de operator beheerd geheimenbestand, niet via een +argument met het wachtwoord erin. Op Unix moet dat bestand alleen voor de restoregebruiker +leesbaar zijn (`chmod 600` of `chmod 400`); op Windows beperk je de ACL tot de restore- +operator/serviceaccount. Het bestand bevat één PostgreSQL-connectiereeks. De +integriteitssleutel moet eveneens beschikbaar zijn in de geconfigureerde Keys-map. +Haal de exacte `backup-integrity.key` uit beschermde externe escrow; herstel vertrouwt +geen sleutel die alleen in de back-upkopie zit. De restore verifieert de manifest- +handtekening en kopieert de vermelde bestanden eerst naar een privé-stagingmap binnen +het lege doelbijlagenvolume. Reserveer daar vrije ruimte voor ten minste de volledige +back-upinhoud. De restore gebruikt uitsluitend die geverifieerde snapshot. + ```bash docker run --rm \ --network itworx-knowledge-hub_knowledge-hub-internal \ - -v knowledge-hub-backups:/var/lib/knowledge-hub/backups:ro \ + -v :/var/lib/knowledge-hub/backups:rw \ -e KnowledgeHub__Paths__Backups=/var/lib/knowledge-hub/backups \ + -v :/var/lib/knowledge-hub/keys:ro \ + -e KnowledgeHub__Paths__Keys=/var/lib/knowledge-hub/keys \ + -v :/run/secrets/restore-target-connection:ro \ -v :/var/lib/knowledge-hub/attachments \ itworx/knowledge-hub:local \ dotnet ItWorx.KnowledgeHub.Api.dll \ --restore \ --backup-id= \ - --target-connection="Host=;Port=5432;Database=;Username=;Password=" \ + --target-connection-file=/run/secrets/restore-target-connection \ --target-attachments=/var/lib/knowledge-hub/attachments ``` +De echte herstel-drill haalt de back-up via SSH op, maar leest de integriteitssleutel +uit een lokaal door de operator beheerd escrowbestand. Geef alleen het bestandspad mee; +de drill maakt tijdens uitvoering een tijdelijke kopie met modus `0600` en verwijdert +die bij opruimen: + +```bash +KNOWLEDGE_HUB_BACKUP_INTEGRITY_KEY_FILE=/secure/escrow/backup-integrity.key \ + bash scripts/v2-r4-real-backup-restore-drill.sh +``` + De doeldatabase moet **leeg** zijn — herstel weigert expliciet als er al rijen in staan, om nooit per ongeluk bestaande gegevens te vermengen met herstelde gegevens. @@ -86,7 +112,8 @@ Het proces rapporteert per tabel het aantal herstelde versus verwachte rijen, he aantal herstelde bijlagen, en sluit af met `GESLAAGD` of `MISLUKT` (afsluitcode 0 respectievelijk 1). Een `MISLUKT`-resultaat laat expliciet zien welke tabel niet overeenkomt — er wordt nooit stilzwijgend een onvolledig herstel als -geslaagd gerapporteerd. +geslaagd gerapporteerd. Providerfoutdetails worden niet afgedrukt omdat die +connectiegegevens kunnen bevatten. ## Opstartgedrag en migraties diff --git a/docs/observability-platform.md b/docs/observability-platform.md new file mode 100644 index 0000000..41c85c2 --- /dev/null +++ b/docs/observability-platform.md @@ -0,0 +1,16 @@ +# Operationele metrieken + +De applicatie exporteert alleen naar OTLP als `OTEL_EXPORTER_OTLP_ENDPOINT` expliciet is gezet. De optionele Compose-overlay `deploy/observability/compose.yaml` schakelt een Collector, Prometheus en Grafana in op een intern Docker-netwerk. Grafana bindt uitsluitend aan de loopback-interface van de host; kies een privaat beheerderswachtwoord via `KNOWLEDGE_HUB_GRAFANA_PASSWORD` buiten Git. Start met beide Compose-bestanden in hetzelfde project nadat de configuratie en opslaglocaties zijn gecontroleerd. + +De Collector neemt OTLP/HTTP op poort 4318 aan en biedt de metrieken aan Prometheus op 8889. Grafana leest Prometheus als geprovisioneerde gegevensbron. De applicatie heeft vaste labels voor operatie, uitkomst, jobstatus en bewijstype. SQL, paginatitels, actoren, zoektermen, tokens en bron-URL's zijn geen metrieklabels. + +De overlay gebruikt vastgelegde image-digests. De collector-default is de +beschikbare amd64-build van 0.162.0 voor de Unraid-host. Kies op een andere +architectuur expliciet een geverifieerde image via `KNOWLEDGE_HUB_COLLECTOR_IMAGE`. +De ongekwalificeerde tag `0.162.0` was tijdens de controle niet beschikbaar. + +Gebruik in Prometheus onder meer `platform_operations_total`, `platform_operation_duration_seconds`, `platform_jobs`, `platform_queue_age_seconds` en `platform_evidence_age_seconds`. Een ontbrekende meting betekent onbekend: na een databasefout worden oudere snapshots uit de gauges verwijderd. De ouderdom van backups telt pas als het nieuwste opgeslagen manifest een geldig integriteitsbewijs heeft. De `inventory`-ouderdom is gebaseerd op de capturetijd van de nieuwste snapshot, niet op de latere importtijd. De applicatie toont het beleid voor externe backups als verwachting, zonder een planner-run te verzinnen. + +Controleer na start een echte taak of zoekactie, vraag dan binnen een export- en scrape-interval `platform_operations_total` op via de Prometheus API en controleer de tijdreeks ook in Grafana. Een lege tijdreeks is geen bewijs van een gezonde pipeline. De geïsoleerde geautomatiseerde OTLP-test verstuurt een echte protobuf-aanvraag naar een lokale receiver en controleert dat private labels ontbreken; de volledige Collector → Prometheus → Grafana-pipeline moet in de beoogde omgeving apart worden geverifieerd. + +Bronnen voor de gebruikte exporterconfiguratie: [OpenTelemetry Collector Prometheus exporter](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/exporter/prometheusexporter/README.md) en [OTLP exporter configuratie](https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/). diff --git a/docs/platform-settings.md b/docs/platform-settings.md new file mode 100644 index 0000000..4713eb2 --- /dev/null +++ b/docs/platform-settings.md @@ -0,0 +1,60 @@ +# Voorkeuren en platformbeheer + +## Persoonlijke instellingen + +Op `/settings` kies je thema, datadichtheid, panelen, landingspagina en de volgorde +van navigatie en dashboardwidgets. Aangemelde gebruikers bewaren deze keuzes bij +hun account. Het scherm toont laden, opslaan, gesynchroniseerd, offline of een +versieconflict. Bij een conflict kun je de servervoorkeuren laden of je lokale +keuze opnieuw aanbieden. Gebruik de verplaatsknoppen met toetsenbord of touch; +slepen is een extra mogelijkheid. + +## Beheerwerkruimten + +`/admin` bevat het overzicht. De afzonderlijke onderdelen behandelen gebruikers, +toegang, integraties, inventaris, kennis, classificaties, achtergrondtaken, +back-ups, audit, platforminstellingen en systeeminformatie. Alleen bevoegde +beheerders hebben toegang. Een verborgen navigatielink vervangt geen API-controle. + +Op `/admin/platform` bewaar je weergavenaam, locale, tijdzone, intakebeleid, +inventarisouderdom en verwachtingen voor externe backups. Verouderde formulieren +kunnen geen nieuwere serverversie overschrijven. Laad bij een conflict de actuele +waarden opnieuw voordat je wijzigingen opnieuw toepast. + +De backupplanning en dagelijkse, wekelijkse en maandelijkse bewaaraantallen zijn +verwachtingen over operatorbeheer. Dit scherm start geen externe planner en voert +geen externe retentie uit. Handmatige backups verschijnen als duurzame taken op +`/admin/jobs`; de lokale backupdienst hanteert daarnaast haar geconfigureerde +retentie en respecteert beschermde backups. Zie [backup en herstel](18-backup-restore.md). + +## Externe bronnen + +Op `/admin/integrations` registreer je een unieke broncode, type, naam en verwacht +synchronisatie-interval. Inventarisbronnen worden automatisch geregistreerd bij +een geslaagde synchronisatie. Configuratie en waarnemingen zijn afzonderlijk: +een ingeschakelde bron is nog geen bewijs dat de laatste run geslaagd is. + +De broncode `inventory:default` is gereserveerd voor de inventarisimporter en kan +niet handmatig worden geregistreerd. Uitschakelen van deze bron blokkeert volgende +imports. De importer controleert en vergrendelt de bron binnen dezelfde transactie +als de inventariswijziging: een eerder bevestigde uitschakeling wordt ook door een +reeds gestarte aanvraag gerespecteerd. + +Een operator kan een geslaagde of mislukte externe run rapporteren met een +bekende recordtelling en correlatie-ID. Een onbekend aantal blijft leeg. Zo'n +rapportage is geen uitgevoerde verbindingstest. De hub voert externe synchronisatie +niet zelf uit en bewaart hier geen bronwachtwoorden. De interface toont de laatste +poging, laatste geslaagde poging en het tijdstip waarop gegevens zijn waargenomen. + +## Taakvoortgang en operationeel bewijs + +Een cursustag- of back-uptaak blijft op de server bestaan als je het tabblad sluit. +De detail-URL bevat het taak-ID. Annuleren stopt toekomstig werk zodra dat veilig +kan; reeds afgeronde pagina-updates blijven bewaard. Fouten en aantallen komen +uit opgeslagen serverstatus. Zie [achtergrondtaken](background-jobs.md). + +Systeem- en deploymentinformatie tonen uitsluitend gemelde versie- en +runtimegegevens. Ontbrekend bewijs blijft onbekend. Gebruik het broncommit, de +image-identiteit en de toegepaste migraties samen wanneer je een release of +herstel beoordeelt. Een gezonde applicatiecontrole bewijst op zichzelf niet dat +de productieversie gelijk is aan de nieuwste repositoryversie. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 944d57e..7ba4024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.16 version: 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-query': + specifier: ^5.104.0 + version: 5.104.0(react@19.2.8) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -854,6 +857,14 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tanstack/query-core@5.104.0': + resolution: {integrity: sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==} + + '@tanstack/react-query@5.104.0': + resolution: {integrity: sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -1259,6 +1270,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2829,6 +2841,13 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tanstack/query-core@5.104.0': {} + + '@tanstack/react-query@5.104.0(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.104.0 + react: 19.2.8 + '@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tanstack/table-core': 8.21.3 diff --git a/scripts/check-openapi-coverage.mjs b/scripts/check-openapi-coverage.mjs index 9949ae2..e7684b2 100644 --- a/scripts/check-openapi-coverage.mjs +++ b/scripts/check-openapi-coverage.mjs @@ -15,6 +15,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import yaml from "js-yaml"; +import { extractRoutesFromSource } from "./lib/openapi-routes.mjs"; const endpointsDir = "src/backend/ItWorx.KnowledgeHub.Api/Endpoints"; const openApiPath = "docs/api/openapi.yaml"; @@ -32,42 +33,7 @@ function listEndpointFiles(dir) { } function extractRoutesFromFile(filePath) { - const source = fs.readFileSync(filePath, "utf8"); - const routes = []; - - // MapGroup("/api/v1/xyz") establishes a prefix for every Map* call chained off the - // returned variable within the same file — every endpoint file in this codebase uses - // exactly this one-group-per-file pattern (verified by inspection), so a single - // "most recent MapGroup literal in this file" tracking variable is sufficient and - // avoids needing a real C# parser. - const groupMatch = source.match(/MapGroup\(\s*"([^"]+)"\s*\)/); - const groupPrefix = groupMatch ? groupMatch[1] : ""; - - const mapCallPattern = /\.Map(Get|Post|Put|Delete|Patch)\(\s*"([^"]*)"/g; - let match; - while ((match = mapCallPattern.exec(source)) !== null) { - const method = match[1].toUpperCase(); - const routeLiteral = match[2]; - const fullPath = groupPrefix + routeLiteral; - routes.push({ method, path: normalizeRoute(fullPath), file: filePath }); - } - - return routes; -} - -// Normalizes an ASP.NET route template's typed route constraints -// (`{attachmentId:guid}`) to OpenAPI's untyped `{attachmentId}` style, and strips the -// leading `/api/v1` prefix to match how docs/api/openapi.yaml keys its paths (relative -// to the documented `servers[0].url`). -function normalizeRoute(route) { - let normalized = route.replace(/\{([a-zA-Z0-9_]+)(:[a-zA-Z]+)?\}/g, "{$1}"); - if (normalized.startsWith("/api/v1")) { - normalized = normalized.slice("/api/v1".length); - } - if (normalized === "") { - normalized = "/"; - } - return normalized; + return extractRoutesFromSource(fs.readFileSync(filePath, "utf8"), filePath); } function isExcluded(routePath) { diff --git a/scripts/generate-release-manifest.mjs b/scripts/generate-release-manifest.mjs index 1a55070..b5b8606 100644 --- a/scripts/generate-release-manifest.mjs +++ b/scripts/generate-release-manifest.mjs @@ -24,6 +24,12 @@ import { spawnSync } from "node:child_process"; const repoRoot = process.cwd(); const outputPath = process.argv[2] ?? path.join(repoRoot, "release-manifest.json"); +const semverIdentifier = "(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)"; +const semverPattern = new RegExp( + `^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` + + `(?:-(${semverIdentifier}(?:\\.${semverIdentifier})*))?` + + `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$` +); function sha256File(relativePath) { const fullPath = path.join(repoRoot, relativePath); @@ -88,6 +94,18 @@ function readOpenApiVersion() { } function readSemanticVersion() { + const releaseTag = nonEmpty(process.env.RELEASE_TAG); + if (releaseTag) { + const version = releaseTag.startsWith("v") ? releaseTag.slice(1) : ""; + if (!version || !semverPattern.test(version)) { + throw new Error(`RELEASE_TAG must be a v-prefixed semantic version; received '${releaseTag}'.`); + } + // Docker's web-build stage intentionally has no .git directory. A validated + // release tag is therefore the authoritative semantic version for tagged + // images, while the full tag remains separately recorded as releaseTag. + return version; + } + const versionFile = path.join(repoRoot, "VERSION"); if (fs.existsSync(versionFile)) { return fs.readFileSync(versionFile, "utf8").trim(); diff --git a/scripts/lib/openapi-routes.mjs b/scripts/lib/openapi-routes.mjs new file mode 100644 index 0000000..4655208 --- /dev/null +++ b/scripts/lib/openapi-routes.mjs @@ -0,0 +1,23 @@ +/** Extract literal Minimal API routes and their receiver-specific MapGroup prefix. */ +export function extractRoutesFromSource(source, file) { + const prefixes = new Map([["app", ""]]); + const groupPattern = /\b(?:var|const|RouteGroupBuilder)\s+(\w+)\s*=\s*(\w+)\.MapGroup\(\s*"([^"]+)"\s*\)/g; + for (const match of source.matchAll(groupPattern)) { + const parent = prefixes.get(match[2]); + if (parent === undefined) throw new Error(`Unknown MapGroup receiver ${match[2]} in ${file}`); + prefixes.set(match[1], parent + match[3]); + } + const routes = []; + const mapPattern = /\b(\w+)\.Map(Get|Post|Put|Delete|Patch)\(\s*"([^"]*)"/g; + for (const match of source.matchAll(mapPattern)) { + const prefix = prefixes.get(match[1]); + if (prefix === undefined) throw new Error(`Unknown route receiver ${match[1]} in ${file}`); + const path = (prefix + match[3]).replace(/\{([a-zA-Z0-9_]+)(:[a-zA-Z]+)?\}/g, "{$1}"); + if (!path.startsWith("/api/v1")) { + if (!path.startsWith("/health")) throw new Error(`Unexpected non-API route ${path} in ${file}`); + continue; + } + routes.push({ method: match[2].toUpperCase(), path: path.slice("/api/v1".length) || "/", file }); + } + return routes; +} diff --git a/scripts/lib/product-backlog.mjs b/scripts/lib/product-backlog.mjs new file mode 100644 index 0000000..1fdb12b --- /dev/null +++ b/scripts/lib/product-backlog.mjs @@ -0,0 +1,50 @@ +export function validateProductBacklog(backlog, plan) { + const errors = []; + if (backlog?.schemaVersion !== 2) errors.push("Product backlog schemaVersion must be 2."); + if (!Array.isArray(backlog?.statusValues)) errors.push("Product backlog statusValues must be an array."); + if (!Array.isArray(backlog?.items)) { + errors.push("Product backlog items must be an array."); + return errors; + } + const milestoneById = new Map((plan?.milestones ?? []).map((milestone) => [milestone.id, milestone])); + const itemIds = new Set(); + const statuses = new Set(backlog.statusValues ?? []); + for (const item of backlog.items) { + if (!item || typeof item !== "object") { + errors.push("Product backlog contains an invalid item."); + continue; + } + if (typeof item.id !== "string" || !item.id || itemIds.has(item.id)) { + errors.push(`Product backlog item has a missing or duplicate ID: ${item.id ?? "(missing)"}.`); + } + itemIds.add(item.id); + if (!statuses.has(item.status)) errors.push(`${item.id} has an invalid status: ${item.status}.`); + const milestone = milestoneById.get(item.milestone); + if (!milestone) { + errors.push(`${item.id} references an unknown milestone: ${item.milestone}.`); + continue; + } + if (item.status === "complete" && (!Array.isArray(item.evidence) || item.evidence.length === 0)) { + errors.push(`${item.id} is complete without evidence references.`); + } + if (item.status === "todo" && milestone.status === "complete" && item.postMilestone !== true) { + errors.push(`${item.id} is todo in a completed milestone without an explicit postMilestone marker.`); + } + if (item.status === "partial" && (!item.notes || !Array.isArray(item.evidence) || item.evidence.length === 0)) { + errors.push(`${item.id} is partial without notes and evidence references.`); + } + if (item.status === "blocked_external") { + const acceptance = milestone.acceptance?.find((criterion) => criterion.id === item.acceptance); + if (!item.externalOwner || !acceptance?.external || acceptance.met !== false) { + errors.push(`${item.id} must reference an unmet external acceptance criterion and owner.`); + } + } + if (item.status === "complete" && milestone.status !== "complete") { + const acceptance = milestone.acceptance?.find((criterion) => criterion.id === item.acceptance); + if (!acceptance || acceptance.met !== true) { + errors.push(`${item.id} completes an unfinished milestone without a met acceptance criterion.`); + } + } + } + return errors; +} diff --git a/scripts/lib/release-gates.mjs b/scripts/lib/release-gates.mjs new file mode 100644 index 0000000..b2bc53a --- /dev/null +++ b/scripts/lib/release-gates.mjs @@ -0,0 +1,8 @@ +import { commandExists } from "./exec.mjs"; + +export function requireReleaseTool(command, exists = commandExists) { + if (!exists(command)) { + throw new Error(`${command} is required for the release gate but was not found on PATH.`); + } + return command; +} diff --git a/scripts/lib/route-map.mjs b/scripts/lib/route-map.mjs new file mode 100644 index 0000000..770a65d --- /dev/null +++ b/scripts/lib/route-map.mjs @@ -0,0 +1,234 @@ +function skipQuoted(source, start) { + const quote = source[start]; + let escaped = false; + for (let index = start + 1; index < source.length; index += 1) { + const character = source[index]; + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === quote) { + return index + 1; + } + } + return source.length; +} + +function readRouteTag(source, start) { + const closing = source[start + 1] === "/"; + const nameStart = start + (closing ? 2 : 1); + const nameMatch = /^[A-Za-z_$][\w$.-]*/u.exec(source.slice(nameStart)); + if (!nameMatch || nameMatch[0] !== "Route") return null; + + let curlyDepth = 0; + let quote = null; + let escaped = false; + for (let index = nameStart + nameMatch[0].length; index < source.length; index += 1) { + const character = source[index]; + if (quote !== null) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = null; + continue; + } + if (character === "\"" || character === "'" || character === "`") { + quote = character; + } else if (character === "{") { + curlyDepth += 1; + } else if (character === "}" && curlyDepth > 0) { + curlyDepth -= 1; + } else if (character === ">" && curlyDepth === 0) { + const text = source.slice(nameStart + nameMatch[0].length, index); + return { + closing, + selfClosing: !closing && /\/\s*$/u.test(text), + attributes: closing ? "" : text.replace(/\/\s*$/u, ""), + end: index + 1 + }; + } + } + return { error: "AuthenticatedRoutes.tsx contains an unterminated tag." }; +} + +function findStaticPath(attributes) { + const pathAttribute = /(?:^|\s)path\s*=\s*(?:"([^"]*)"|'([^']*)')/u.exec(attributes); + if (pathAttribute) return { value: pathAttribute[1] ?? pathAttribute[2] }; + if (/(?:^|\s)path\s*=\s*\{/u.test(attributes)) return { error: "dynamic" }; + return null; +} + +function routePath(parentPath, childPath) { + if (childPath === "*") return parentPath ? `${parentPath.replace(/\/$/u, "")}/*` : "*"; + if (childPath.startsWith("/")) return childPath.replace(/\/{2,}/gu, "/"); + const joined = `${parentPath || ""}/${childPath}`.replace(/\/{2,}/gu, "/"); + return joined || "/"; +} + +function routeFeatures(attributes) { + const features = new Set(); + for (const match of attributes.matchAll(/\bfeatures\s*\?\.\s*([A-Za-z_$][\w$]*)/gu)) { + features.add(match[1]); + } + return [...features].sort(); +} + +function extractRouteStructure(source) { + const errors = []; + const routeStack = []; + const records = []; + + for (let index = 0; index < source.length;) { + if (source.startsWith("//", index)) { + const newline = source.indexOf("\n", index + 2); + index = newline < 0 ? source.length : newline + 1; + continue; + } + if (source.startsWith("/*", index)) { + const commentEnd = source.indexOf("*/", index + 2); + index = commentEnd < 0 ? source.length : commentEnd + 2; + continue; + } + if (source[index] === "\"" || source[index] === "'" || source[index] === "`") { + index = skipQuoted(source, index); + continue; + } + + if (source[index] === "<" && (source[index + 1] === "/" || /[A-Za-z_$]/u.test(source[index + 1] ?? ""))) { + const tag = readRouteTag(source, index); + if (tag?.error) { + errors.push(tag.error); + break; + } + if (tag) { + if (tag.closing) { + if (routeStack.length === 0) errors.push("AuthenticatedRoutes.tsx closes a without a matching opening tag."); + else routeStack.pop(); + } else { + const parent = routeStack.at(-1); + const parentPath = parent?.path ?? ""; + const pathAttribute = findStaticPath(tag.attributes); + const hasIndex = /(?:^|\s)index(?:\s|=|$)/u.test(tag.attributes); + const localFeatures = routeFeatures(tag.attributes); + const features = [...new Set([...(parent?.features ?? []), ...localFeatures])].sort(); + const administrator = Boolean(parent?.administrator) + || /\bsession\.role\s*===?\s*["']Administrator["']/u.test(tag.attributes); + + if (pathAttribute?.error === "dynamic") { + errors.push("AuthenticatedRoutes.tsx uses a dynamic Route path; route-map validation requires a static path."); + } + if (hasIndex && pathAttribute) errors.push("AuthenticatedRoutes.tsx declares a Route with both index and path."); + + let fullPath = null; + if (hasIndex) fullPath = parentPath || "/"; + else if (pathAttribute && !pathAttribute.error) fullPath = routePath(parentPath, pathAttribute.value); + + if (fullPath !== null) { + records.push({ + path: fullPath, + featureGates: features, + auth: administrator ? "administrator" : "authenticated", + depth: routeStack.length, + parentPath + }); + } + + if (!tag.selfClosing) { + routeStack.push({ + path: fullPath ?? parentPath, + features, + administrator + }); + } + } + index = tag.end; + continue; + } + } + index += 1; + } + + if (routeStack.length > 0) errors.push("AuthenticatedRoutes.tsx contains an unclosed tag."); + + const uniqueRecords = []; + for (const record of records) { + const priorIndex = uniqueRecords.findIndex((candidate) => candidate.path === record.path); + if (priorIndex < 0) { + uniqueRecords.push(record); + continue; + } + + const prior = uniqueRecords[priorIndex]; + const isNestedSamePath = record.parentPath === record.path && record.depth > prior.depth; + if (isNestedSamePath) { + uniqueRecords[priorIndex] = record; + } else { + errors.push(`AuthenticatedRoutes.tsx declares duplicate route path ${record.path}.`); + } + } + + return { records: uniqueRecords, errors }; +} + +export function extractAuthenticatedRoutes(source) { + return extractRouteStructure(source).records; +} + +export function extractAuthenticatedRoutePaths(source) { + return extractAuthenticatedRoutes(source).map((route) => route.path); +} + +function mappedFeatureGates(route) { + if (Array.isArray(route?.featureGates)) return [...route.featureGates].sort(); + return typeof route?.featureGate === "string" ? [route.featureGate] : []; +} + +export function validateRouteMap(routeMap, authenticatedSource, sessionSource) { + const errors = []; + const { records, errors: routeErrors } = extractRouteStructure(authenticatedSource); + errors.push(...routeErrors); + const actualPaths = records.map((route) => route.path); + const mappedRoutes = Array.isArray(routeMap?.routes) ? routeMap.routes : []; + const mappedPaths = mappedRoutes.map((route) => route?.path); + + if (new Set(mappedPaths).size !== mappedPaths.length) { + errors.push("product/route-map.json declares duplicate paths."); + } + const missing = actualPaths.filter((routePath) => !mappedPaths.includes(routePath)); + const stale = mappedPaths.filter((routePath) => !actualPaths.includes(routePath)); + if (missing.length > 0) errors.push(`Route map is missing: ${missing.join(", ")}.`); + if (stale.length > 0) errors.push(`Route map has stale entries: ${stale.join(", ")}.`); + + if (routeMap?.sourceOfTruth !== "src/frontend/src/router/AuthenticatedRoutes.tsx") { + errors.push("Route map sourceOfTruth must identify AuthenticatedRoutes.tsx."); + } + const states = routeMap?.sessionStates; + const expectedStates = ["loading", "error", "setupRequired", "unauthenticated"]; + if (!states || typeof states !== "object" || Array.isArray(states)) { + errors.push("Route map must document the pre-route session states."); + } else { + const actualStates = Object.keys(states).sort(); + if (JSON.stringify(actualStates) !== JSON.stringify([...expectedStates].sort())) { + errors.push(`Route map session states must be ${expectedStates.join(", ")}.`); + } + for (const [condition, screen] of Object.entries(states)) { + if (typeof screen !== "string" || !sessionSource.includes(`<${screen}`)) { + errors.push(`Session screen is not present in App.tsx: ${condition}.`); + } + } + } + + for (const actualRoute of records) { + const mappedRoute = mappedRoutes.find((route) => route?.path === actualRoute.path); + if (!mappedRoute) continue; + if (mappedRoute.auth !== actualRoute.auth) { + errors.push(`${actualRoute.path} must be marked ${actualRoute.auth}-only.`); + } + const expectedFeatures = actualRoute.featureGates; + const mappedFeatures = mappedFeatureGates(mappedRoute); + if (JSON.stringify(expectedFeatures) !== JSON.stringify(mappedFeatures)) { + const expected = expectedFeatures.length > 0 ? expectedFeatures.join(", ") : "none"; + errors.push(`${actualRoute.path} feature-gate registry must be ${expected}.`); + } + } + return errors; +} diff --git a/scripts/validate-public-source.mjs b/scripts/validate-public-source.mjs index 49b3ea4..50cb4de 100644 --- a/scripts/validate-public-source.mjs +++ b/scripts/validate-public-source.mjs @@ -28,4 +28,49 @@ if (!Array.isArray(manifest.files) || manifest.files.length === 0) { process.exit(1); } -console.log(`Public source structure passed (${manifest.files.length} allowlisted files).`); +const markdownFiles = []; +function walkMarkdown(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if ([".git", "node_modules", "__pycache__"].includes(entry.name)) continue; + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) walkMarkdown(fullPath); + else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) markdownFiles.push(fullPath); + } +} + +function stripCode(markdown) { + const withoutFences = markdown.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/g, (block) => + block.replace(/[^\n]/g, " ")); + return withoutFences.replace(/`[^`\n]*`/g, (span) => " ".repeat(span.length)); +} + +walkMarkdown(process.cwd()); +const errors = []; +const linkPattern = /\[[^\]]+\]\(([^)]+)\)/g; +for (const markdownFile of markdownFiles) { + const content = stripCode(fs.readFileSync(markdownFile, "utf8")); + for (const match of content.matchAll(linkPattern)) { + let target = match[1].trim(); + if (target.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) continue; + target = target.split("#", 1)[0].split("?", 1)[0]; + if (!target) continue; + try { + target = decodeURIComponent(target); + } catch { + errors.push(`Could not URL-decode link in ${path.relative(process.cwd(), markdownFile)}: ${target}`); + continue; + } + const resolved = path.resolve(path.dirname(markdownFile), target); + if (!fs.existsSync(resolved)) { + errors.push(`Broken local Markdown link in ${path.relative(process.cwd(), markdownFile)}: ${match[1]}`); + } + } +} + +if (errors.length > 0) { + console.error("Public source validation failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log(`Public source structure passed (${manifest.files.length} allowlisted files, ${markdownFiles.length} Markdown files, local links resolved).`); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index da33f07..fb274fe 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -3,7 +3,8 @@ import fs from "node:fs"; import process from "node:process"; import { spawnSync } from "node:child_process"; -import { commandExists, run } from "./lib/exec.mjs"; +import { capture, commandExists, run } from "./lib/exec.mjs"; +import { requireReleaseTool } from "./lib/release-gates.mjs"; const flags = new Set(process.argv.slice(2)); const structureOnly = flags.has("--structure-only"); @@ -11,6 +12,24 @@ const fast = flags.has("--fast"); const ci = flags.has("--ci"); const release = flags.has("--release"); const publicSourceExport = fs.existsSync("PUBLIC_SOURCE_EXPORT.md"); +const releaseImageReference = process.env.RELEASE_IMAGE_REFERENCE ?? "itworx/knowledge-hub:local"; +let releaseImageId = null; +// Test hosts use fixed configuration and do not need appsettings hot reload. +// Avoid consuming the shared Linux host's inotify quota for hundreds of factories. +const backendTestEnvironment = { DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE: "false" }; + +function splitImageReference(reference) { + const tagSeparator = reference.lastIndexOf(":"); + if (tagSeparator <= reference.lastIndexOf("/") || tagSeparator === 0 || /\s/.test(reference)) { + throw new Error(`Release image reference must include a repository and tag: '${reference}'.`); + } + const repository = reference.slice(0, tagSeparator); + const tag = reference.slice(tagSeparator + 1); + if (!repository || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag)) { + throw new Error(`Release image reference has an invalid repository or tag: '${reference}'.`); + } + return { repository, tag }; +} function findWorkingPython() { // On some Windows setups `python3` resolves via PATH to the Microsoft Store @@ -39,12 +58,22 @@ async function section(name, action) { } try { + // Fail before starting a long release matrix when a mandatory scanner is + // unavailable. CI provides these through the pinned release workflow setup; + // local release verification must install the same tools explicitly. + const releaseGitleaks = release ? requireReleaseTool("gitleaks") : null; + const releaseTrivy = release ? requireReleaseTool("trivy") : null; + await section("Bundle structure", async () => { if (publicSourceExport) { await run(process.execPath, ["scripts/validate-public-source.mjs"]); } else { await run(process.execPath, ["scripts/validate-bundle.mjs"]); await run(process.execPath, ["scripts/test-safety-hook.mjs"]); + await run(process.execPath, ["scripts/validate-product-state.mjs"]); + await run(process.execPath, ["scripts/validate-route-map.mjs"]); + await run(process.execPath, ["--test", "scripts/tests/route-map.test.mjs", "scripts/tests/product-backlog.test.mjs", "scripts/tests/validate-public-source.test.mjs", "scripts/tests/validate-bundle.test.mjs", "scripts/tests/v2-r4-real-backup-restore-drill.test.mjs", "scripts/tests/cleanup-temporary-resources.test.mjs"]); + await run(python, ["-m", "unittest", "scripts.tests.test_public_source_pipeline"]); } }); @@ -108,7 +137,7 @@ try { "trx", "--results-directory", "artifacts/test-results" - ]); + ], { env: backendTestEnvironment }); }); await section("Frontend install and verification", async () => { @@ -149,16 +178,60 @@ try { if (ci || release) { await section("Production image build", async () => { - await run("docker", ["compose", "build", "app"], { - env: { - POSTGRES_PASSWORD: "verification-only-not-for-production", - BUILD_COMMIT: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? "verification" + const buildCommit = process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? "verification"; + if (release) { + const { repository, tag } = splitImageReference(releaseImageReference); + await run("docker", [ + "build", + "--file", + "deploy/docker/Dockerfile", + "--tag", + releaseImageReference, + "--build-arg", + `BUILD_COMMIT=${buildCommit}`, + "--build-arg", + `RELEASE_TAG=${process.env.RELEASE_TAG ?? ""}`, + "--build-arg", + `IMAGE_REPOSITORY=${repository}`, + "--build-arg", + `IMAGE_TAG=${tag}`, + "." + ]); + releaseImageId = await capture("docker", [ + "image", + "inspect", + "--format", + "{{.Id}}", + releaseImageReference + ]); + if (!/^sha256:[0-9a-f]{64}$/i.test(releaseImageId)) { + throw new Error(`Docker returned an invalid image ID for '${releaseImageReference}'.`); } - }); + const imageIdPath = "artifacts/release-verification/image-id.txt"; + fs.mkdirSync("artifacts/release-verification", { recursive: true }); + fs.writeFileSync(imageIdPath, `${releaseImageId}\n`, "utf8"); + console.log(`Captured immutable release image ID in ${imageIdPath}.`); + } else { + await run("docker", ["compose", "build", "app"], { + env: { + POSTGRES_PASSWORD: "verification-only-not-for-production", + BUILD_COMMIT: buildCommit + } + }); + } }); } if (release) { + await section("Release evidence integrity tests", async () => { + await run(process.execPath, [ + "--test", + "scripts/tests/release-gates.test.mjs", + "scripts/tests/generate-release-evidence.test.mjs", + "scripts/tests/generate-release-manifest.test.mjs" + ]); + }); + await section("Full-scale search/tree suite (release-gate only)", async () => { await run("dotnet", [ "test", @@ -167,8 +240,12 @@ try { "Release", "--no-build", "--filter", - "Category=ReleaseScale" - ]); + "Category=ReleaseScale", + "--logger", + "trx", + "--results-directory", + "artifacts/test-results" + ], { env: backendTestEnvironment }); }); await section(".NET dependency vulnerability scan", async () => { @@ -195,32 +272,24 @@ try { }); await section("Secret scan", async () => { - if (!commandExists("gitleaks")) { - throw new Error("gitleaks is required for the release secret scan but was not found on PATH."); - } - - await run("gitleaks", ["detect", "--source", ".", "--redact"]); + await run(releaseGitleaks, ["detect", "--source", ".", "--redact"]); }); - if (commandExists("trivy")) { - await section("Container vulnerability scan", async () => { - await run("trivy", [ - "image", - "--severity", - "CRITICAL,HIGH", - "--exit-code", - "1", - "--scanners", - "vuln", - "itworx/knowledge-hub:local" - ]); - }); - } else { - console.warn( - "\ntrivy not found on PATH — container vulnerability scan skipped. Install " + - "trivy before a real release; this is not a substitute for the scan." - ); - } + await section("Container vulnerability scan", async () => { + if (!releaseImageId) { + throw new Error("The release image ID was not captured after the production image build."); + } + await run(releaseTrivy, [ + "image", + "--severity", + "CRITICAL,HIGH", + "--exit-code", + "1", + "--scanners", + "vuln", + releaseImageId + ]); + }); console.warn( "\nRelease verification still requires the milestone-defined E2E (desktop + " + diff --git a/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/JobWorker.cs b/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/JobWorker.cs new file mode 100644 index 0000000..c9aa60c --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/JobWorker.cs @@ -0,0 +1,461 @@ +using System.Text.Json; +using ItWorx.KnowledgeHub.Application.Identity; +using ItWorx.KnowledgeHub.Application.Jobs; +using ItWorx.KnowledgeHub.Application.Jobs.UseCases; +using ItWorx.KnowledgeHub.Application.Knowledge; +using ItWorx.KnowledgeHub.Application.Knowledge.UseCases; +using ItWorx.KnowledgeHub.Application.Operations; +using ItWorx.KnowledgeHub.Application.Operations.UseCases; +using ItWorx.KnowledgeHub.Domain.Identity; +using ItWorx.KnowledgeHub.Domain.Jobs; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ItWorx.KnowledgeHub.Api.BackgroundServices; + +/// Executes durable jobs with bounded worker concurrency. PostgreSQL leases +/// allow another process to resume work after a crash; each course page is handled as +/// an independent, idempotent ETag-guarded update. +public sealed partial class JobWorker( + IServiceScopeFactory scopeFactory, + BackgroundJobsOptions options, + TimeProvider timeProvider, + PlatformMetrics metrics, + ILogger logger) : BackgroundService +{ + private long lastMetricsRefreshMilliseconds; + private int metricsRefreshInProgress; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + options.Validate(); + if (!options.Enabled) + { + LogDisabled(); + return; + } + var workers = Enumerable.Range(0, options.MaxConcurrency) + .Select(_ => RunWorkerAsync(stoppingToken)); + await Task.WhenAll(workers); + } + + private async Task RunWorkerAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + using var scope = scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var leaseToken = Guid.NewGuid(); + Guid? jobId; + try + { + var now = timeProvider.GetUtcNow(); + jobId = await repository.TryClaimNextAsync( + leaseToken, now, now.AddSeconds(options.LeaseSeconds), stoppingToken); + await RefreshMetricsAsync(repository, force: jobId is not null, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + LogWorkerPollFailed(exception.GetType().Name); + try { await Task.Delay(TimeSpan.FromMilliseconds(options.PollMilliseconds), timeProvider, stoppingToken); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + continue; + } + + if (jobId is null) + { + try { await Task.Delay(TimeSpan.FromMilliseconds(options.PollMilliseconds), timeProvider, stoppingToken); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + continue; + } + + var claimedJob = await repository.FindLeasedAsync(jobId.Value, leaseToken, stoppingToken); + if (claimedJob is null) + continue; + + using var leaseLost = new CancellationTokenSource(); + using var userCancellation = new CancellationTokenSource(); + using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource( + stoppingToken, leaseLost.Token); + using var monitorCancellation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + var monitor = MonitorLeaseAsync( + jobId.Value, leaseToken, leaseLost, userCancellation, monitorCancellation.Token); + var measurement = metrics.Start("job"); + + try + { + await ExecuteLeasedJobAsync( + scope.ServiceProvider, jobId.Value, leaseToken, operationCancellation.Token, + userCancellation.Token, stoppingToken); + var completed = await repository.FindAsync(claimedJob.WorkspaceId, jobId.Value, stoppingToken); + if (completed?.Status == BackgroundJobStatus.Cancelled) + measurement.Cancel(); + else if (completed?.Status == BackgroundJobStatus.Succeeded) + measurement.Complete(completed.Processed); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Leave the durable running row leased. A different worker will recover + // it when the lease expires; explicit user cancellation is checked below. + if (userCancellation.IsCancellationRequested && !leaseLost.IsCancellationRequested) + { + try + { + await repository.CancelLeasedJobAsync(jobId.Value, leaseToken, timeProvider.GetUtcNow(), CancellationToken.None); + measurement.Cancel(); + } + catch (Exception exception) + { + LogJobStateWriteFailed(jobId.Value, exception.GetType().Name); + } + } + } + catch (OperationCanceledException) when (userCancellation.IsCancellationRequested && !leaseLost.IsCancellationRequested) + { + try + { + await repository.CancelLeasedJobAsync(jobId.Value, leaseToken, timeProvider.GetUtcNow(), CancellationToken.None); + measurement.Cancel(); + } + catch (Exception exception) + { + LogJobStateWriteFailed(jobId.Value, exception.GetType().Name); + } + } + catch (OperationCanceledException) when (leaseLost.IsCancellationRequested) + { + LogLeaseLost(jobId.Value); + } + catch (Exception exception) + { + var retry = await RetryTimeAsync(repository, jobId.Value, leaseToken, CancellationToken.None); + try + { + await repository.RetryLeasedJobAsync( + jobId.Value, leaseToken, timeProvider.GetUtcNow(), retry, + "processing_retry", "De tijdelijke verwerking is mislukt; de taak wordt opnieuw geprobeerd.", + CancellationToken.None); + } + catch (Exception stateException) + { + LogJobStateWriteFailed(jobId.Value, stateException.GetType().Name); + } + LogJobExecutionFailed(jobId.Value, exception.GetType().Name); + } + finally + { + measurement.Dispose(); + monitorCancellation.Cancel(); + try { await monitor; } + catch (OperationCanceledException) { } + } + + await RefreshMetricsAsync(repository, force: true, stoppingToken); + } + } + + private async Task ExecuteLeasedJobAsync( + IServiceProvider services, + Guid jobId, + Guid leaseToken, + CancellationToken operationToken, + CancellationToken userCancellationToken, + CancellationToken hostToken) + { + var jobs = services.GetRequiredService(); + var job = await jobs.FindLeasedAsync(jobId, leaseToken, operationToken); + if (job is null) + return; + + if (!await IsActorAdministratorAsync(services, job, operationToken)) + { + await jobs.FailLeasedJobAsync(jobId, leaseToken, timeProvider.GetUtcNow(), + "actor_unavailable", "De uitvoerder is niet langer actief als beheerder.", operationToken); + return; + } + + if (await jobs.IsCancellationRequestedAsync(jobId, leaseToken, operationToken)) + { + await jobs.CancelLeasedJobAsync(jobId, leaseToken, timeProvider.GetUtcNow(), operationToken); + return; + } + + if (job.Type == BackgroundJobType.Backup) + { + using var backupCancellation = CancellationTokenSource.CreateLinkedTokenSource(operationToken, userCancellationToken); + await ExecuteBackupAsync(services, job, leaseToken, backupCancellation.Token, operationToken); + return; + } + + if (job.Type == BackgroundJobType.CourseTagging) + await ExecuteCourseTaggingAsync(services, job, leaseToken, operationToken); + } + + private async Task ExecuteBackupAsync( + IServiceProvider services, + BackgroundJob job, + Guid leaseToken, + CancellationToken backupCancellationToken, + CancellationToken stateCancellationToken) + { + var jobs = services.GetRequiredService(); + var backups = services.GetRequiredService(); + var summary = await backups.ExecuteAsync( + new CreateBackupCommand(job.ActorId, job.CorrelationId), backupCancellationToken); + var result = JsonSerializer.Serialize(new + { + backupId = summary.BackupId, + createdAt = summary.CreatedAt, + sizeBytes = summary.TotalSizeBytes, + tableCount = summary.TableCount, + attachmentCount = summary.AttachmentCount, + schemaVersion = summary.SchemaVersion + }); + await jobs.RecordJobOutcomeAsync( + job.Id, leaseToken, BackgroundJobItemState.Succeeded, null, null, + stateCancellationToken); + await jobs.CompleteLeasedJobAsync(job.Id, leaseToken, result, timeProvider.GetUtcNow(), stateCancellationToken); + } + + private async Task ExecuteCourseTaggingAsync( + IServiceProvider services, + BackgroundJob job, + Guid leaseToken, + CancellationToken operationToken) + { + using var payload = JsonDocument.Parse(job.PayloadJson); + var rootPageId = payload.RootElement.GetProperty("rootPageId").GetGuid(); + var tag = payload.RootElement.GetProperty("tag").GetString()!; + var jobs = services.GetRequiredService(); + var pages = services.GetRequiredService(); + var updatePage = services.GetRequiredService(); + + while (true) + { + operationToken.ThrowIfCancellationRequested(); + if (await jobs.IsCancellationRequestedAsync(job.Id, leaseToken, operationToken)) + { + await jobs.CancelLeasedJobAsync(job.Id, leaseToken, timeProvider.GetUtcNow(), operationToken); + return; + } + + var currentJob = await jobs.FindLeasedAsync(job.Id, leaseToken, operationToken); + if (currentJob is null) + return; + if (currentJob.Processed >= currentJob.Total) + { + await jobs.CompleteLeasedJobAsync(job.Id, leaseToken, JsonSerializer.Serialize(new + { + rootPageId, + tag, + total = currentJob.Total, + processed = currentJob.Processed, + succeeded = currentJob.Succeeded, + skipped = currentJob.Skipped, + failed = currentJob.Failed + }), timeProvider.GetUtcNow(), operationToken); + return; + } + + if (!await IsActorAdministratorAsync(services, currentJob, operationToken)) + { + await jobs.FailLeasedJobAsync(job.Id, leaseToken, timeProvider.GetUtcNow(), + "actor_unavailable", "De uitvoerder is niet langer actief als beheerder.", operationToken); + return; + } + + var item = await jobs.FindNextPageItemAsync(job.Id, leaseToken, operationToken); + if (item is null) + throw new InvalidOperationException("The job snapshot is incomplete."); + + await jobs.MarkPageItemProcessingAsync(job.Id, leaseToken, item.Ordinal, timeProvider.GetUtcNow(), operationToken); + var page = await pages.FindAsync(item.PageId, operationToken); + if (page is null || page.WorkspaceId != job.WorkspaceId || page.IsTrashed) + { + await jobs.RecordPageItemOutcomeAsync( + job.Id, leaseToken, item.Ordinal, BackgroundJobItemState.Failed, + "page_unavailable", "Pagina is niet langer beschikbaar.", item.PageId, + timeProvider.GetUtcNow(), operationToken); + continue; + } + + if (page.Tags.Any(existing => string.Equals(existing, tag, StringComparison.OrdinalIgnoreCase))) + { + await jobs.RecordPageItemOutcomeAsync( + job.Id, leaseToken, item.Ordinal, + item.InitiallyTagged ? BackgroundJobItemState.Skipped : BackgroundJobItemState.Succeeded, + null, null, null, timeProvider.GetUtcNow(), operationToken); + continue; + } + + if (page.Tags.Count >= Domain.Knowledge.Page.MaxTagCount) + { + await jobs.RecordPageItemOutcomeAsync( + job.Id, leaseToken, item.Ordinal, BackgroundJobItemState.Skipped, + null, null, null, timeProvider.GetUtcNow(), operationToken); + continue; + } + + var etag = await pages.GetETagAsync(page.Id, operationToken); + if (etag is null) + { + await jobs.RecordPageItemOutcomeAsync( + job.Id, leaseToken, item.Ordinal, BackgroundJobItemState.Failed, + "page_unavailable", "Pagina is niet langer beschikbaar.", page.Id, + timeProvider.GetUtcNow(), operationToken); + continue; + } + + var tags = page.Tags.Append(tag).ToArray(); + var result = await updatePage.ExecuteAsync( + new UpdatePageCommand( + page.Id, + page.TitleValue, + page.Markdown, + page.DocumentType.ToString(), + page.DocumentStatus.ToString(), + $"Cursustag {tag} toegevoegd", + tags, + etag, + job.ActorId, + job.CorrelationId), + operationToken); + + if (result.Status == PageOperationStatus.Success) + { + await jobs.RecordPageItemOutcomeAsync( + job.Id, leaseToken, item.Ordinal, BackgroundJobItemState.Succeeded, + null, null, null, timeProvider.GetUtcNow(), operationToken); + } + else + { + var conflict = result.Status == PageOperationStatus.ConcurrencyConflict; + await jobs.RecordPageItemOutcomeAsync( + job.Id, leaseToken, item.Ordinal, BackgroundJobItemState.Failed, + conflict ? "concurrency_conflict" : "page_update_failed", + conflict ? "Pagina is tijdens de taak gewijzigd; de wijziging is overgeslagen." + : "De pagina kon niet worden bijgewerkt.", + page.Id, timeProvider.GetUtcNow(), operationToken); + } + } + } + + private static async Task IsActorAdministratorAsync( + IServiceProvider services, + BackgroundJob job, + CancellationToken cancellationToken) + { + var accounts = services.GetRequiredService(); + var memberships = services.GetRequiredService(); + var account = await accounts.FindByIdAsync(job.ActorId, cancellationToken); + if (account is not { Enabled: true }) + return false; + var role = await memberships.GetRoleAsync(job.WorkspaceId, job.ActorId, cancellationToken); + return role == WorkspaceRole.Administrator; + } + + private async Task MonitorLeaseAsync( + Guid jobId, + Guid leaseToken, + CancellationTokenSource leaseLost, + CancellationTokenSource userCancellation, + CancellationToken cancellationToken) + { + var lastRenewal = timeProvider.GetUtcNow(); + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(1), timeProvider, cancellationToken); + using var scope = scopeFactory.CreateScope(); + var jobs = scope.ServiceProvider.GetRequiredService(); + if (await jobs.IsCancellationRequestedAsync(jobId, leaseToken, cancellationToken)) + userCancellation.Cancel(); + + var now = timeProvider.GetUtcNow(); + if (now - lastRenewal >= TimeSpan.FromSeconds(options.LeaseRenewSeconds)) + { + if (!await jobs.RenewLeaseAsync(jobId, leaseToken, now.AddSeconds(options.LeaseSeconds), cancellationToken)) + { + leaseLost.Cancel(); + return; + } + lastRenewal = now; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception exception) + { + // A transient telemetry/lease read failure does not cancel work before + // the lease can expire. The database remains authoritative on writes. + LogLeaseMonitorFailed(jobId, exception.GetType().Name); + } + } + } + + private async Task RefreshMetricsAsync(IBackgroundJobRepository repository, bool force, CancellationToken cancellationToken) + { + var nowMilliseconds = timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); + if (!force && nowMilliseconds - Interlocked.Read(ref lastMetricsRefreshMilliseconds) < 10_000) + return; + if (Interlocked.CompareExchange(ref metricsRefreshInProgress, 1, 0) != 0) + return; + try + { + var statistics = await repository.GetQueueStatisticsAsync(cancellationToken); + var oldestAge = statistics.OldestQueuedAt is { } oldest + ? Math.Max(0, (timeProvider.GetUtcNow() - oldest).TotalSeconds) + : (double?)null; + metrics.SetQueueState(statistics.Queued, statistics.Running, statistics.Failed, oldestAge); + Interlocked.Exchange(ref lastMetricsRefreshMilliseconds, nowMilliseconds); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception exception) + { + LogJobMetricsRefreshFailed(exception.GetType().Name); + } + finally + { + Volatile.Write(ref metricsRefreshInProgress, 0); + } + } + + private async Task RetryTimeAsync( + IBackgroundJobRepository repository, Guid jobId, Guid leaseToken, CancellationToken cancellationToken) + { + var attempt = (await repository.FindLeasedAsync(jobId, leaseToken, cancellationToken))?.AttemptCount ?? 1; + var seconds = attempt switch { 1 => 2, 2 => 10, _ => 30 }; + return timeProvider.GetUtcNow().AddSeconds(seconds); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Background job poll failed ({ErrorType}).")] + private partial void LogWorkerPollFailed(string errorType); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Background job {JobId} failed ({ErrorType}).")] + private partial void LogJobExecutionFailed(Guid jobId, string errorType); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Background job {JobId} lost its processing lease.")] + private partial void LogLeaseLost(Guid jobId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Background job {JobId} state write failed ({ErrorType}).")] + private partial void LogJobStateWriteFailed(Guid jobId, string errorType); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Background job {JobId} lease monitoring failed ({ErrorType}).")] + private partial void LogLeaseMonitorFailed(Guid jobId, string errorType); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Background job metrics refresh failed ({ErrorType}).")] + private partial void LogJobMetricsRefreshFailed(string errorType); + + [LoggerMessage(Level = LogLevel.Information, Message = "Background job worker is disabled by configuration.")] + private partial void LogDisabled(); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/OperationalTelemetryBackgroundService.cs b/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/OperationalTelemetryBackgroundService.cs new file mode 100644 index 0000000..5961646 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/OperationalTelemetryBackgroundService.cs @@ -0,0 +1,41 @@ +using ItWorx.KnowledgeHub.Application.Operations; + +namespace ItWorx.KnowledgeHub.Api.BackgroundServices; + +public sealed partial class OperationalTelemetryBackgroundService( + IServiceScopeFactory scopeFactory, PlatformMetrics metrics, TimeProvider clock, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + // Do not race initial database migration during host startup. + await Task.Delay(TimeSpan.FromSeconds(30), clock, stoppingToken); + using var scope = scopeFactory.CreateScope(); + var snapshot = await scope.ServiceProvider.GetRequiredService() + .ReadAsync(stoppingToken); + var now = clock.GetUtcNow(); + metrics.SetQueueState(snapshot.QueuedJobs, snapshot.RunningJobs, snapshot.FailedJobs, + Age(snapshot.OldestQueuedJobAt, now)); + metrics.SetOperationalAges(Age(snapshot.OldestPendingIngestionAt, now), + Age(snapshot.LatestInventoryCaptureAt, now), Age(snapshot.LatestVerifiedBackupAt, now)); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception) + { + // Omit stale gauges after a failed read, rather than exporting old success. + metrics.ClearSnapshots(); + LogReadFailed(); + } + } + } + + private static double? Age(DateTimeOffset? value, DateTimeOffset now) => + value is { } time && time <= now ? (now - time).TotalSeconds : null; + + [LoggerMessage(Level = LogLevel.Warning, Message = "Operationele metriekmeting mislukt; waarden blijven onbekend tot de volgende geslaagde meting.")] + private partial void LogReadFailed(); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs index faae588..77c02d3 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs @@ -18,13 +18,14 @@ public static class ActivityEndpoints } private static async Task ListAsync( - IAuditEventReader reader, + IActivityEventReader reader, [FromQuery] int? limit, + [FromQuery] long? before, CancellationToken cancellationToken) { - var items = await reader.ListRecentAsync(limit ?? 100, cancellationToken); - return Results.Ok(new ActivityListResponse(items.Select(item => new ActivityItemResponse( + var page = await reader.ListAsync(limit ?? 100, before, cancellationToken); + return Results.Ok(new ActivityListResponse(page.Items.Select(item => new ActivityItemResponse( item.Id, item.ActorKind, item.ActorId, item.Source, item.Action, item.TargetType, - item.TargetId, item.Summary, item.OccurredAt)).ToArray())); + item.TargetId, item.Summary, item.OccurredAt)).ToArray(), page.NextCursor)); } } diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminAuditEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminAuditEndpoints.cs new file mode 100644 index 0000000..4c2eb5b --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminAuditEndpoints.cs @@ -0,0 +1,22 @@ +using ItWorx.KnowledgeHub.Api.Authorization; +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Contracts.Activity; +using Microsoft.AspNetCore.Mvc; + +namespace ItWorx.KnowledgeHub.Api.Endpoints; + +public static class AdminAuditEndpoints +{ + public static IEndpointRouteBuilder MapAdminAuditEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/v1/admin/audit-log", async ( + IAuditEventReader reader, [FromQuery] int? limit, [FromQuery] long? before, CancellationToken cancellationToken) => + { + var page = await reader.ListPageAsync(limit ?? 50, before, cancellationToken); + return Results.Ok(new AdminAuditListResponse(page.Items.Select(item => new AdminAuditItemResponse( + item.Id, item.ActorKind, item.ActorId, item.Source, item.Action, item.TargetType, + item.TargetId, item.Summary, item.OccurredAt, item.CorrelationId)).ToArray(), page.NextCursor)); + }).WithTags("Admin").RequireAuthorization(AuthorizationPolicies.RequireAdministrator); + return app; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs index c1b799f..4113904 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs @@ -2,12 +2,12 @@ using ItWorx.KnowledgeHub.Api.Security; using ItWorx.KnowledgeHub.Application.Identity; using ItWorx.KnowledgeHub.Application.Identity.UseCases; using ItWorx.KnowledgeHub.Application.Ingestion; +using ItWorx.KnowledgeHub.Application.Platform; using ItWorx.KnowledgeHub.Contracts.Auth; using ItWorx.KnowledgeHub.Contracts.Errors; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; namespace ItWorx.KnowledgeHub.Api.Endpoints; @@ -40,18 +40,19 @@ public static class AuthEndpoints private static async Task> GetSessionAsync( GetSessionUseCase useCase, - IOptions ingestionOptions, + IPlatformSettingsReader platformSettings, CancellationToken cancellationToken) { var session = await useCase.ExecuteAsync(cancellationToken); - return TypedResults.Ok(ToSessionResponse(session, ingestionOptions.Value)); + var ingestion = (await platformSettings.GetEffectiveAsync(cancellationToken)).Values.IngestionProposals; + return TypedResults.Ok(ToSessionResponse(session, ingestion)); } private static async Task, ProblemHttpResult>> LoginAsync( [FromBody] LoginRequest request, LoginUseCase loginUseCase, GetSessionUseCase sessionUseCase, - IOptions ingestionOptions, + IPlatformSettingsReader platformSettings, HttpContext httpContext, CancellationToken cancellationToken) { @@ -65,7 +66,8 @@ public static class AuthEndpoints { case PasswordSignInStatus.Success: var session = await sessionUseCase.ExecuteAsync(cancellationToken); - return TypedResults.Ok(ToSessionResponse(session, ingestionOptions.Value)); + var ingestion = (await platformSettings.GetEffectiveAsync(cancellationToken)).Values.IngestionProposals; + return TypedResults.Ok(ToSessionResponse(session, ingestion)); case PasswordSignInStatus.LockedOut: return TypedResults.Problem( @@ -124,7 +126,7 @@ public static class AuthEndpoints }; } - private static SessionResponse ToSessionResponse(SessionInfo session, IngestionProposalOptions ingestionOptions) => new( + private static SessionResponse ToSessionResponse(SessionInfo session, ItWorx.KnowledgeHub.Domain.Platform.IngestionProposalPolicyValues ingestionOptions) => new( session.Authenticated, session.SetupRequired, session.UserId, diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs index d15438c..fbe4222 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs @@ -1,8 +1,11 @@ +using System.Globalization; using System.Security.Claims; using ItWorx.KnowledgeHub.Api.Authorization; using ItWorx.KnowledgeHub.Api.Security; +using ItWorx.KnowledgeHub.Application.Audit; using ItWorx.KnowledgeHub.Application.Operations; using ItWorx.KnowledgeHub.Application.Operations.UseCases; +using ItWorx.KnowledgeHub.Application.Platform; using ItWorx.KnowledgeHub.Contracts.Operations; using Microsoft.AspNetCore.Http.HttpResults; @@ -22,31 +25,93 @@ public static class BackupsEndpoints .RequireAuthorization(AuthorizationPolicies.RequireAdministrator); group.MapGet("", ListAsync); + group.MapGet("/status", StatusAsync); + group.MapPut("/{backupId}/protection", ProtectAsync) + .AddEndpointFilter(); group.MapPost("", CreateAsync) + .RequireRateLimiting(RateLimitPolicies.JobCreation) .AddEndpointFilter(); return app; } - private static async Task> ListAsync(ListBackupsUseCase useCase, CancellationToken cancellationToken) + private static async Task> ListAsync(ListBackupsUseCase useCase, IBackupEvidenceStore evidence, CancellationToken cancellationToken) { var backups = await useCase.ExecuteAsync(cancellationToken); - return TypedResults.Ok(new BackupListResponse(backups.Select(ToResponse).ToList())); + var responses = new List(backups.Count); + foreach (var backup in backups) + responses.Add(ToResponse(backup, await evidence.GetAsync(backup.BackupId, cancellationToken))); + return TypedResults.Ok(new BackupListResponse(responses)); } private static async Task> CreateAsync( - CreateBackupUseCase useCase, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken) + CreateBackupUseCase useCase, IBackupEvidenceStore evidence, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken) { var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string; var actorId = Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!); var summary = await useCase.ExecuteAsync(new CreateBackupCommand(actorId, correlationId), cancellationToken); - return TypedResults.Ok(ToResponse(summary)); + return TypedResults.Ok(ToResponse(summary, await evidence.GetAsync(summary.BackupId, cancellationToken))); } - private static BackupSummaryResponse ToResponse(BackupSummary summary) => + private static BackupSummaryResponse ToResponse(BackupSummary summary, BackupEvidence? evidence) => new( summary.BackupId, summary.CreatedAt, summary.TotalSizeBytes, summary.TableCount, - summary.TotalRowCount, summary.AttachmentCount, summary.SchemaVersion, summary.ProductVersion); + summary.TotalRowCount, summary.AttachmentCount, summary.SchemaVersion, summary.ProductVersion, + evidence?.Protected ?? false, (evidence?.ProtectionVersion ?? 0).ToString(CultureInfo.InvariantCulture), + evidence?.IntegrityVerifiedAt, evidence?.RestoreVerifiedAt, evidence?.LastRestoreSucceeded); + + private static async Task> StatusAsync( + ListBackupsUseCase backups, IBackupEvidenceStore evidence, IPlatformSettingsReader settings, + IConfiguration configuration, TimeProvider clock, CancellationToken cancellationToken) + { + var policy = (await settings.GetEffectiveAsync(cancellationToken)).Values.BackupPolicyExpectation; + var items = await backups.ExecuteAsync(cancellationToken); + var receipts = new List(); + BackupEvidence? latestReceipt = null; + foreach (var backup in items) + if (await evidence.GetAsync(backup.BackupId, cancellationToken) is { } receipt) + { + receipts.Add(receipt); + if (items.Count > 0 && backup.BackupId == items[0].BackupId) latestReceipt = receipt; + } + DateTimeOffset? last = items.Count > 0 ? items[0].CreatedAt : null; + var integrity = receipts.Select(r => r.IntegrityVerifiedAt).Max(); + var restore = receipts.Select(r => r.RestoreVerifiedAt).Max(); + var now = clock.GetUtcNow(); + var state = last is null || latestReceipt?.IntegrityVerifiedAt is null ? "Unknown" + : now - last > TimeSpan.FromHours(policy.ExpectedIntervalHours) ? "Stale" : "Current"; + var restoreState = restore is null ? "Unknown" + : now - restore > TimeSpan.FromDays(policy.RestoreVerificationMaxAgeDays) ? "Stale" : "Verified"; + return TypedResults.Ok(new BackupStatusResponse(state, "External", policy.ScheduleDescription, null, + last?.AddHours(policy.ExpectedIntervalHours), last, integrity, restore, restoreState, + items.Sum(b => b.TotalSizeBytes), receipts.Count(r => r.Protected), policy.ExpectedIntervalHours, + configuration.GetValue("KnowledgeHub:Backup:RetainedCount") ?? 14, + policy.DailyRetentionCount, policy.WeeklyRetentionCount, policy.MonthlyRetentionCount, + policy.RestoreVerificationMaxAgeDays)); + } + + private static async Task ProtectAsync( + string backupId, BackupProtectionRequest request, IBackupEvidenceStore evidence, + IAuditEventRecorder audit, HttpContext context, TimeProvider clock, CancellationToken cancellationToken) + { + var header = context.Request.Headers.IfMatch.ToString(); + if (header.Length < 3 || header[0] != '"' || header[^1] != '"' + || !int.TryParse(header[1..^1], NumberStyles.None, CultureInfo.InvariantCulture, out var version)) + return Results.Problem(statusCode: 428, title: "If-Match is verplicht."); + var actor = Guid.Parse(context.User.FindFirstValue(ClaimTypes.NameIdentifier)!); + BackupProtectionResult result; + try { result = await evidence.SetProtectionAsync(backupId, request.Protected, version, actor, cancellationToken); } + catch (IOException) { return Results.Problem(statusCode: 409, title: "Back-upopslag is in gebruik; probeer opnieuw."); } + if (result == BackupProtectionResult.NotFound) return Results.NotFound(); + if (result == BackupProtectionResult.Conflict) return Results.Problem(statusCode: 412, title: "De bescherming is intussen gewijzigd."); + await audit.RecordAsync(new AuditEventRecord(AuditActorKind.User, actor, "backup", + request.Protected ? "BackupProtected" : "BackupUnprotected", "Backup", backupId, + request.Protected ? "Back-up beschermd tegen automatische retentie." : "Back-upbescherming verwijderd.", + context.Items[CorrelationIdMiddleware.ItemKey] as string, clock.GetUtcNow()), cancellationToken); + var receipt = await evidence.GetAsync(backupId, cancellationToken); + context.Response.Headers.ETag = $"\"{receipt!.ProtectionVersion}\""; + return Results.Ok(receipt); + } } diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DataSourcesEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DataSourcesEndpoints.cs new file mode 100644 index 0000000..8eddbb3 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DataSourcesEndpoints.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Security.Claims; +using ItWorx.KnowledgeHub.Api.Authorization; +using ItWorx.KnowledgeHub.Api.Security; +using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Application.Identity; +using ItWorx.KnowledgeHub.Application.Sources; +using ItWorx.KnowledgeHub.Contracts.Sources; +using ItWorx.KnowledgeHub.Domain.Sources; +using Microsoft.AspNetCore.Mvc; + +namespace ItWorx.KnowledgeHub.Api.Endpoints; + +public static class DataSourcesEndpoints +{ + public static IEndpointRouteBuilder MapDataSourcesEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/admin/data-sources").WithTags("DataSources").RequireAuthorization(AuthorizationPolicies.RequireAdministrator); + group.MapGet("", async (DataSourceUseCases useCases, IWorkspaceRepository workspaces, TimeProvider clock, [FromQuery] Guid? cursor, [FromQuery] int? limit, CancellationToken cancellationToken) => + { + var workspace = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspace is null) return Results.NotFound(); + var pageSize = Math.Clamp(limit ?? 50, 1, 100); + var rows = await useCases.ListAsync(workspace.Value, cursor, pageSize + 1, cancellationToken); + var items = rows.Take(pageSize).Select(source => ToResponse(source, clock.GetUtcNow())).ToArray(); + return Results.Ok(new DataSourceListResponse(items, rows.Count > pageSize ? items[^1].Id.ToString() : null)); + }); + group.MapPost("", async (RegisterDataSourceRequest request, DataSourceUseCases useCases, IWorkspaceRepository workspaces, HttpContext context, TimeProvider clock, CancellationToken cancellationToken) => + { + var workspace = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspace is null) return Results.NotFound(); + var result = await useCases.RegisterAsync(workspace.Value, request.SourceKey, request.DisplayName, request.Kind, request.ExpectedIntervalHours, Actor(context), Correlation(context), cancellationToken); + return Result(result, context, clock); + }).AddEndpointFilter(); + group.MapPut("/{id:guid}", async (Guid id, ConfigureDataSourceRequest request, DataSourceUseCases useCases, IWorkspaceRepository workspaces, HttpContext context, TimeProvider clock, CancellationToken cancellationToken) => + { + if (!Expected(context, out var expected)) return Results.Problem(statusCode: 428, title: "If-Match is verplicht."); + var workspace = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspace is null) return Results.NotFound(); + return Result(await useCases.ConfigureAsync(workspace.Value, id, expected, request.DisplayName, request.Enabled, request.ExpectedIntervalHours, Actor(context), Correlation(context), cancellationToken), context, clock); + }).AddEndpointFilter(); + group.MapPost("/{id:guid}/observations", async (Guid id, ReportDataSourceRequest request, DataSourceUseCases useCases, IWorkspaceRepository workspaces, HttpContext context, TimeProvider clock, CancellationToken cancellationToken) => + { + if (!Expected(context, out var expected)) return Results.Problem(statusCode: 428, title: "If-Match is verplicht."); + var workspace = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspace is null) return Results.NotFound(); + return Result(await useCases.ReportAsync(workspace.Value, id, expected, request.Success, request.RecordCount, request.ErrorCode, request.CorrelationId, Actor(context), Correlation(context), cancellationToken), context, clock); + }).AddEndpointFilter(); + return app; + } + + private static bool Expected(HttpContext context, out string value) + { + var header = context.Request.Headers.IfMatch.ToString(); + value = header.Length > 2 && header.StartsWith('"') && header.EndsWith('"') ? header[1..^1] : ""; + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var version) && version > 0; + } + private static Guid Actor(HttpContext context) => Guid.Parse(context.User.FindFirstValue(ClaimTypes.NameIdentifier)!); + private static string? Correlation(HttpContext context) => context.Items[CorrelationIdMiddleware.ItemKey] as string; + private static IResult Result(OperationResultValue result, HttpContext context, TimeProvider clock) + { + if (result.Status == OperationStatus.NotFound) return Results.NotFound(); + if (result.Status == OperationStatus.ConcurrencyConflict) return Results.Problem(statusCode: 412, title: "De bron is gewijzigd; laad de actuele versie."); + if (result.Status != OperationStatus.Success) return Results.Problem(statusCode: 400, title: "Ongeldige bronconfiguratie."); + var response = ToResponse(result.Value!, clock.GetUtcNow()); + context.Response.Headers.ETag = $"\"{response.ETag}\""; + return Results.Ok(response); + } + private static DataSourceResponse ToResponse(DataSource source, DateTimeOffset now) => new(source.Id, source.SourceKey, source.DisplayName, source.Kind.ToString(), source.Enabled, + source.GetState(now), source.LastAttemptAt is null ? "Unknown" : source.LastErrorCode == "authentication" ? "NeedsAuthorization" : "Reported", + "External", source.ExpectedIntervalHours, source.LastAttemptAt, source.LastSuccessAt, source.LastFailureAt, source.DataObservedAt, source.RecordCount, + source.LastErrorCode, source.LastCorrelationId, source.Version.ToString(CultureInfo.InvariantCulture), ["configure", "record-observation"]); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs index 23ea029..eed6c7d 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs @@ -7,11 +7,11 @@ using ItWorx.KnowledgeHub.Application.Common; using ItWorx.KnowledgeHub.Application.Identity; using ItWorx.KnowledgeHub.Application.Ingestion; using ItWorx.KnowledgeHub.Application.Ingestion.UseCases; +using ItWorx.KnowledgeHub.Application.Platform; using ItWorx.KnowledgeHub.Contracts.Ingestion; using ItWorx.KnowledgeHub.Domain.Identity; using ItWorx.KnowledgeHub.Domain.Ingestion; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; namespace ItWorx.KnowledgeHub.Api.Endpoints; @@ -48,10 +48,11 @@ public static class IngestionProposalsEndpoints } private static async Task CreateAsync([FromBody] CreateIngestionProposalRequest request, IWorkspaceRepository workspaces, - CreateIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, IOptions options, + CreateIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"].ToString())) return Results.Problem(statusCode: 400, detail: "Idempotency-Key-header is vereist."); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); @@ -73,7 +74,8 @@ public static class IngestionProposalsEndpoints } } var result = await useCase.ExecuteAsync(new(workspace.Value, Actor(principal), request.Manifest, - request.CorrelationId ?? Correlation(context), context.Request.Headers["Idempotency-Key"].ToString(), publication), ct); + request.CorrelationId ?? Correlation(context), context.Request.Headers["Idempotency-Key"].ToString(), publication, + policy.FailedRetryLimit), ct); metrics.RecordProposal(result.Status == OperationStatus.Success ? "success" : "failure", result.Status == OperationStatus.Success ? "none" : "validation"); if (result.Status != OperationStatus.Success) return Results.Problem(statusCode: 422, detail: string.Join(' ', result.Errors)); @@ -84,11 +86,12 @@ public static class IngestionProposalsEndpoints private static async Task ListAsync(IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships, IIngestionProposalRepository proposals, - IOptions options, HttpContext context, [FromQuery] string? status, [FromQuery] Guid? cursor, + IPlatformSettingsReader platformSettings, HttpContext context, [FromQuery] string? status, [FromQuery] Guid? cursor, [FromQuery] int? limit, [FromQuery] int? page, [FromQuery] int? pageSize, [FromQuery] Guid? projectId, [FromQuery] Guid? submitterId, [FromQuery] int? minAgeDays, [FromQuery] string? risk, [FromQuery] string? documentType, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return Results.Ok(new IngestionProposalListResponse([], null, 1, 50, 0, Correlation(context))); if (!TryStatus(status, out var parsed)) return Results.Problem(statusCode: 400, detail: "Ongeldige statusfilter."); @@ -116,9 +119,10 @@ public static class IngestionProposalsEndpoints private static async Task GetAsync(Guid proposalId, IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships, IIngestionProposalRepository proposals, - IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) + IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); var proposal = await proposals.FindReadOnlyAsync(workspace.Value, proposalId, ct); @@ -132,10 +136,11 @@ public static class IngestionProposalsEndpoints private static async Task PatchAsync(Guid proposalId, [FromBody] PatchIngestionProposalRequest request, IWorkspaceRepository workspaces, PatchIngestionProposalUseCase useCase, ApplyIngestionProposalAnalysisUseCase analysisUseCase, - IWorkspaceMembershipRepository memberships, IIngestionProposalRepository proposals, IOptions options, + IWorkspaceMembershipRepository memberships, IIngestionProposalRepository proposals, IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); var proposal = await proposals.FindReadOnlyAsync(workspace.Value, proposalId, ct); if (proposal is null || (IsServiceAccount(principal) && proposal.IntegrationActorId != Actor(principal))) return NotFound(); @@ -160,19 +165,20 @@ public static class IngestionProposalsEndpoints } private static Task RequestChangesAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces, - DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => - DecideAsync(IngestionDecision.RequestChanges, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct); + DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => + DecideAsync(IngestionDecision.RequestChanges, proposalId, request, workspaces, useCase, proposals, platformSettings, principal, context, ct); private static Task ApproveAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces, - DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => - DecideAsync(IngestionDecision.Approve, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct); + DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => + DecideAsync(IngestionDecision.Approve, proposalId, request, workspaces, useCase, proposals, platformSettings, principal, context, ct); private static Task RejectAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces, - DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => - DecideAsync(IngestionDecision.Reject, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct); + DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => + DecideAsync(IngestionDecision.Reject, proposalId, request, workspaces, useCase, proposals, platformSettings, principal, context, ct); private static async Task DecideAsync(IngestionDecision decision, Guid proposalId, IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces, - DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) + DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null) return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist."); var started = System.Diagnostics.Stopwatch.GetTimestamp(); @@ -183,9 +189,10 @@ public static class IngestionProposalsEndpoints } private static async Task RetryAsync(Guid proposalId, IWorkspaceRepository workspaces, RetryIngestionProposalUseCase useCase, - IIngestionProposalRepository proposals, IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) + IIngestionProposalRepository proposals, IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null) return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist."); var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context)), ct); @@ -194,9 +201,10 @@ public static class IngestionProposalsEndpoints private static async Task PublishAsync(Guid proposalId, IWorkspaceRepository workspaces, PublishIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, - IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) + IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); if (IsServiceAccount(principal)) return Results.Problem(statusCode: 403, detail: "Serviceaccounts mogen geen voorstellen publiceren."); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null) @@ -230,16 +238,17 @@ public static class IngestionProposalsEndpoints private static async Task UploadSourceAsync(Guid proposalId, IFormFile file, IWorkspaceRepository workspaces, StageIngestionProposalSourceUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, - IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) + IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null) return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist."); - if (file.Length > options.Value.MaximumSourceBytes) return Results.Problem(statusCode: 413, detail: "Bron is groter dan de toegestane limiet."); + if (file.Length > policy.MaximumSourceBytes) return Results.Problem(statusCode: 413, detail: "Bron is groter dan de toegestane limiet."); await using var source = file.OpenReadStream(); var started = System.Diagnostics.Stopwatch.GetTimestamp(); var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), file.FileName, file.ContentType, - source, Correlation(context), options.Value.MaximumSourceCount, ETag(context), ActorKind(principal), AuditSource(principal)), ct); + source, Correlation(context), policy.MaximumSourceCount, ETag(context), ActorKind(principal), AuditSource(principal)), ct); metrics.RecordStageDuration("source_staging", result.Status == OperationStatus.Success ? "success" : "failure", System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds); if (result.Status != OperationStatus.Success) return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct); @@ -251,9 +260,10 @@ public static class IngestionProposalsEndpoints private static async Task SubmitManualAsync(Guid proposalId, IWorkspaceRepository workspaces, SubmitManualIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, - IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) + IPlatformSettingsReader platformSettings, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) { - if (!options.Value.Enabled) return Disabled(); + var policy = (await platformSettings.GetEffectiveAsync(ct)).Values.IngestionProposals; + if (!policy.Enabled) return Disabled(); if (IsServiceAccount(principal)) return Results.Forbid(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound(); diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/JobsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/JobsEndpoints.cs new file mode 100644 index 0000000..352edee --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/JobsEndpoints.cs @@ -0,0 +1,277 @@ +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using ItWorx.KnowledgeHub.Api.Authorization; +using ItWorx.KnowledgeHub.Api.Security; +using ItWorx.KnowledgeHub.Application.Identity; +using ItWorx.KnowledgeHub.Application.Jobs; +using ItWorx.KnowledgeHub.Application.Jobs.UseCases; +using ItWorx.KnowledgeHub.Contracts.Errors; +using ItWorx.KnowledgeHub.Contracts.Jobs; +using ItWorx.KnowledgeHub.Domain.Identity; +using ItWorx.KnowledgeHub.Domain.Jobs; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace ItWorx.KnowledgeHub.Api.Endpoints; + +/// Cookie-admin-only control surface for durable jobs. Mutations require CSRF; +/// event streams expose only the latest persisted safe job snapshot. +public static class JobsEndpoints +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } + }; + + public static IEndpointRouteBuilder MapJobsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/jobs") + .WithTags("Jobs") + .RequireAuthorization(AuthorizationPolicies.RequireAdministrator); + + group.MapPost("/course-tagging", QueueCourseTaggingAsync) + .RequireRateLimiting(RateLimitPolicies.JobCreation) + .AddEndpointFilter(); + group.MapPost("/backups", QueueBackupAsync) + .RequireRateLimiting(RateLimitPolicies.JobCreation) + .AddEndpointFilter(); + group.MapGet("", ListAsync); + group.MapGet("/{jobId:guid}", GetAsync); + group.MapPost("/{jobId:guid}/cancel", CancelAsync) + .AddEndpointFilter(); + group.MapGet("/{jobId:guid}/events", EventsAsync); + return app; + } + + private static async Task QueueCourseTaggingAsync( + [FromBody] QueueCourseTaggingJobRequest request, + QueueBackgroundJobUseCase useCase, + IWorkspaceRepository workspaces, + HttpContext context, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"])) + return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Idempotency-Key is verplicht."); + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + return Results.Problem(statusCode: 404, type: ProblemTypes.NotFound); + + EnqueueJobResult result; + try + { + result = await useCase.QueueCourseTaggingAsync( + new QueueCourseTaggingJobCommand( + workspaceId.Value, CurrentUserId(context.User), request.RootPageId, request.Tag, + context.Request.Headers["Idempotency-Key"].ToString(), CorrelationId(context)), + cancellationToken); + } + catch (ArgumentException) + { + return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "De cursustagtaak is ongeldig."); + } + return ToEnqueueResult(result); + } + + private static async Task QueueBackupAsync( + QueueBackupJobRequest _, + QueueBackgroundJobUseCase useCase, + IWorkspaceRepository workspaces, + HttpContext context, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"])) + return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Idempotency-Key is verplicht."); + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + return Results.Problem(statusCode: 404, type: ProblemTypes.NotFound); + EnqueueJobResult result; + try + { + result = await useCase.QueueBackupAsync( + new QueueJobCommand(workspaceId.Value, CurrentUserId(context.User), + context.Request.Headers["Idempotency-Key"].ToString(), CorrelationId(context)), cancellationToken); + } + catch (ArgumentException) + { + return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Een geldige Idempotency-Key is verplicht."); + } + return ToEnqueueResult(result); + } + + private static async Task, ProblemHttpResult>> ListAsync( + BackgroundJobQueries queries, + IWorkspaceRepository workspaces, + [FromQuery] int? limit, + CancellationToken cancellationToken) + { + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + return TypedResults.Problem(statusCode: 404, type: ProblemTypes.NotFound); + var items = await queries.ListAsync(workspaceId.Value, limit ?? 50, cancellationToken); + return TypedResults.Ok(new BackgroundJobListResponse(items.Select(ToResponse).ToList())); + } + + private static async Task, NotFound>> GetAsync( + Guid jobId, + BackgroundJobQueries queries, + IWorkspaceRepository workspaces, + CancellationToken cancellationToken) + { + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + return TypedResults.NotFound(); + var job = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken); + return job is null ? TypedResults.NotFound() : TypedResults.Ok(ToResponse(job)); + } + + private static async Task CancelAsync( + Guid jobId, + CancelBackgroundJobUseCase useCase, + IWorkspaceRepository workspaces, + HttpContext context, + CancellationToken cancellationToken) + { + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + return Results.NotFound(); + var (status, job) = await useCase.ExecuteAsync( + workspaceId.Value, jobId, CurrentUserId(context.User), CorrelationId(context), cancellationToken); + if (status == CancelBackgroundJobStatus.NotFound || job is null) + return Results.NotFound(); + return Results.Ok(ToResponse(job)); + } + + private static async Task EventsAsync( + Guid jobId, + BackgroundJobQueries queries, + IWorkspaceRepository workspaces, + IUserAccountManager users, + IWorkspaceMembershipRepository memberships, + BackgroundJobsOptions options, + TimeProvider timeProvider, + HttpContext context, + CancellationToken cancellationToken) + { + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + return Results.NotFound(); + var initial = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken); + if (initial is null) + return Results.NotFound(); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/event-stream; charset=utf-8"; + context.Response.Headers["Cache-Control"] = "no-cache, no-transform"; + context.Response.Headers.Append("X-Accel-Buffering", "no"); + var actorId = CurrentUserId(context.User); + var lastVersion = -1L; + var lastHeartbeat = timeProvider.GetUtcNow(); + + while (!cancellationToken.IsCancellationRequested) + { + var actor = await users.FindByIdAsync(actorId, cancellationToken); + var role = actor is { Enabled: true } + ? await memberships.GetRoleAsync(workspaceId.Value, actorId, cancellationToken) + : null; + if (role != WorkspaceRole.Administrator) + break; + + var job = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken); + if (job is null) + break; + if (job.IsTerminal) + { + // Terminal state must close every stream, even if a concurrent + // worker update left the terminal snapshot at the last event ID. + await WriteEventAsync(context, "terminal", job, cancellationToken); + break; + } + if (job.Version != lastVersion) + { + var eventName = lastVersion < 0 ? "job" : "progress"; + await WriteEventAsync(context, eventName, job, cancellationToken); + lastVersion = job.Version; + lastHeartbeat = timeProvider.GetUtcNow(); + } + else if (timeProvider.GetUtcNow() - lastHeartbeat >= TimeSpan.FromSeconds(options.SseHeartbeatSeconds)) + { + await context.Response.WriteAsync(": heartbeat\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + lastHeartbeat = timeProvider.GetUtcNow(); + } + + await Task.Delay(TimeSpan.FromMilliseconds(options.SsePollMilliseconds), timeProvider, cancellationToken); + } + return Results.Empty; + } + + private static async Task WriteEventAsync(HttpContext context, string eventName, BackgroundJob job, CancellationToken cancellationToken) + { + var data = JsonSerializer.Serialize(ToResponse(job), JsonOptions); + await context.Response.WriteAsync($"id: {job.Version}\nevent: {eventName}\ndata: {data}\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + } + + private static IResult ToEnqueueResult(EnqueueJobResult result) => result.Status switch + { + EnqueueJobStatus.Created or EnqueueJobStatus.Existing when result.Job is not null => + Results.Accepted($"/api/v1/jobs/{result.Job.Id:D}", ToResponse(result.Job)), + EnqueueJobStatus.IdempotencyConflict => Results.Problem( + statusCode: 409, type: ProblemTypes.IdempotencyKeyConflict, + title: "Idempotency-Key is al gebruikt voor een andere aanvraag."), + EnqueueJobStatus.RootNotFound => Results.Problem(statusCode: 404, type: ProblemTypes.PageNotFound, title: "Hoofdpagina niet gevonden."), + EnqueueJobStatus.TooManyPages => Results.Problem( + statusCode: 413, type: ProblemTypes.Validation, + title: "De cursuspaginaboom overschrijdt de ingestelde taaklimiet."), + EnqueueJobStatus.QueueFull => Results.Problem( + statusCode: 429, title: "De taaklimiet is bereikt. Wacht tot lopende taken klaar zijn of het dagbudget is hersteld."), + _ => Results.Problem(statusCode: 500, title: "De taak kon niet worden ingepland.") + }; + + private static BackgroundJobResponse ToResponse(BackgroundJob job) + { + var errors = Array.Empty(); + try + { + errors = (JsonSerializer.Deserialize(job.ErrorsJson) ?? []) + .Select(error => new BackgroundJobErrorResponse(error.Code, error.Message, error.ItemId)) + .ToArray(); + } + catch (JsonException) + { + // A damaged error summary must not turn the read API into a 500 or expose raw storage. + } + JsonElement? result = null; + if (!string.IsNullOrWhiteSpace(job.ResultJson)) + { + try { result = JsonDocument.Parse(job.ResultJson).RootElement.Clone(); } + catch (JsonException) { result = null; } + } + + return new BackgroundJobResponse( + job.Id, job.WorkspaceId, job.ActorId, + job.Type switch { BackgroundJobType.CourseTagging => "course-tagging", BackgroundJobType.Backup => "backup", _ => "unknown" }, + ToStatus(job.Status), job.CreatedAt, job.StartedAt, job.CompletedAt, + job.CancelRequestedAt, job.CancelRequestedAt is not null, + job.Progress, job.Total, job.Processed, job.Succeeded, job.Skipped, job.Failed, + errors, result, job.CorrelationId, job.Version); + } + + private static string ToStatus(BackgroundJobStatus status) => status switch + { + BackgroundJobStatus.Queued => "queued", + BackgroundJobStatus.Running => "running", + BackgroundJobStatus.Succeeded => "succeeded", + BackgroundJobStatus.PartiallySucceeded => "partially_succeeded", + BackgroundJobStatus.Failed => "failed", + BackgroundJobStatus.Cancelled => "cancelled", + _ => "failed" + }; + + private static Guid CurrentUserId(ClaimsPrincipal principal) => + Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!); + + private static string? CorrelationId(HttpContext context) => + context.Items[CorrelationIdMiddleware.ItemKey] as string; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PlatformEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PlatformEndpoints.cs new file mode 100644 index 0000000..164f8ea --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PlatformEndpoints.cs @@ -0,0 +1,276 @@ +using System.Globalization; +using System.Security.Claims; +using ItWorx.KnowledgeHub.Api.Authorization; +using ItWorx.KnowledgeHub.Api.Knowledge; +using ItWorx.KnowledgeHub.Api.Security; +using ItWorx.KnowledgeHub.Application.Identity; +using ItWorx.KnowledgeHub.Application.Platform; +using ItWorx.KnowledgeHub.Application.Platform.UseCases; +using ItWorx.KnowledgeHub.Contracts.Errors; +using ItWorx.KnowledgeHub.Contracts.Platform; +using ItWorx.KnowledgeHub.Domain.Identity; +using ItWorx.KnowledgeHub.Domain.Platform; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace ItWorx.KnowledgeHub.Api.Endpoints; + +public static class PlatformEndpoints +{ + public static IEndpointRouteBuilder MapPlatformEndpoints(this IEndpointRouteBuilder app) + { + var me = app.MapGroup("/api/v1/me") + .WithTags("Preferences") + .RequireAuthorization(AuthorizationPolicies.RequireReader); + + me.MapGet("/preferences", GetUserPreferencesAsync); + me.MapPut("/preferences", UpdateUserPreferencesAsync) + .AddEndpointFilter(); + + var settings = app.MapGroup("/api/v1/admin/platform-settings") + .WithTags("Admin") + .RequireAuthorization(AuthorizationPolicies.RequireAdministrator); + + settings.MapGet("", GetPlatformSettingsAsync); + settings.MapPut("", UpdatePlatformSettingsAsync) + .AddEndpointFilter(); + + return app; + } + + private static async Task, ProblemHttpResult>> GetUserPreferencesAsync( + IWorkspaceRepository workspaces, + GetUserPreferencesUseCase useCase, + ClaimsPrincipal principal, + HttpContext context, + CancellationToken cancellationToken) + { + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + { + return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound); + } + + var snapshot = await useCase.ExecuteAsync(workspaceId.Value, CurrentUserId(principal), cancellationToken); + var response = ToUserPreferencesResponse(snapshot); + context.Response.Headers.ETag = ETagHelper.ToHeaderValue(response.ETag); + return TypedResults.Ok(response); + } + + private static async Task UpdateUserPreferencesAsync( + [FromBody] UserPreferencesRequest? request, + IWorkspaceRepository workspaces, + UpdateUserPreferencesUseCase useCase, + ClaimsPrincipal principal, + HttpContext context, + CancellationToken cancellationToken) + { + if (!TryGetExpectedVersion(context, out var expectedVersion, out var preconditionError)) + { + return preconditionError!; + } + + if (request is null) + { + return ValidationProblem("Voorkeuren ontbreken."); + } + + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + { + return NotFound(); + } + + var values = new UserPreferencesValues( + request.Theme, + request.Density, + request.PrimaryNavigationCollapsed, + request.ContextPanelOpen, + request.NavigationOrder, + request.DashboardWidgetOrder, + request.HiddenDashboardWidgets, + request.DefaultLandingPage, + request.CatalogView, + request.ContextPanelWidth, + request.NavigationWidth); + + var result = await useCase.ExecuteAsync( + new UpdateUserPreferencesCommand(workspaceId.Value, CurrentUserId(principal), expectedVersion, values), + cancellationToken); + + if (result.Status == UpdateUserPreferencesStatus.PreconditionFailed) + { + return ConcurrencyProblem("De voorkeuren zijn sinds de laatste lezing gewijzigd."); + } + if (result.Status == UpdateUserPreferencesStatus.ValidationFailed) + { + return ValidationProblem(string.Join(" ", result.Errors)); + } + + var response = ToUserPreferencesResponse(result.Snapshot!); + context.Response.Headers.ETag = ETagHelper.ToHeaderValue(response.ETag); + return TypedResults.Ok(response); + } + + private static async Task> GetPlatformSettingsAsync( + GetPlatformSettingsUseCase useCase, + HttpContext context, + CancellationToken cancellationToken) + { + var response = ToPlatformSettingsResponse(await useCase.ExecuteAsync(cancellationToken)); + context.Response.Headers.ETag = ETagHelper.ToHeaderValue(response.ETag); + return TypedResults.Ok(response); + } + + private static async Task UpdatePlatformSettingsAsync( + [FromBody] PlatformSettingsRequest? request, + UpdatePlatformSettingsUseCase useCase, + ClaimsPrincipal principal, + HttpContext context, + CancellationToken cancellationToken) + { + if (!TryGetExpectedVersion(context, out var expectedVersion, out var preconditionError)) + { + return preconditionError!; + } + + if (request is null || request.IngestionProposals is null + || request.BackupPolicyExpectation is null || request.InventoryFreshnessPolicy is null) + { + return ValidationProblem("Platforminstellingen ontbreken of zijn onvolledig."); + } + + var values = new PlatformSettingsValues( + request.DisplayName, + request.PrimaryLocale, + request.TimeZone, + new IngestionProposalPolicyValues( + request.IngestionProposals.Enabled, + request.IngestionProposals.MaximumSourceCount, + request.IngestionProposals.MaximumSourceBytes, + request.IngestionProposals.FailedRetryLimit), + new BackupPolicyExpectationValues( + request.BackupPolicyExpectation.ExpectedIntervalHours, + request.BackupPolicyExpectation.ScheduleDescription, + request.BackupPolicyExpectation.DailyRetentionCount, + request.BackupPolicyExpectation.WeeklyRetentionCount, + request.BackupPolicyExpectation.MonthlyRetentionCount, + request.BackupPolicyExpectation.ProtectedBackupCount, + request.BackupPolicyExpectation.RestoreVerificationMaxAgeDays), + new InventoryFreshnessPolicyValues(request.InventoryFreshnessPolicy.WarningAfterHours)); + + var result = await useCase.ExecuteAsync( + new UpdatePlatformSettingsCommand( + expectedVersion, + CurrentUserId(principal), + context.Items[CorrelationIdMiddleware.ItemKey] as string, + values), + cancellationToken); + + if (result.Status == UpdatePlatformSettingsStatus.PreconditionFailed) + { + return ConcurrencyProblem("De platforminstellingen zijn sinds de laatste lezing gewijzigd."); + } + if (result.Status == UpdatePlatformSettingsStatus.ValidationFailed) + { + return ValidationProblem(string.Join(" ", result.Errors)); + } + + var response = ToPlatformSettingsResponse(result.Snapshot!); + context.Response.Headers.ETag = ETagHelper.ToHeaderValue(response.ETag); + return TypedResults.Ok(response); + } + + private static UserPreferencesResponse ToUserPreferencesResponse(UserPreferencesSnapshot snapshot) + { + var values = snapshot.Values; + return new UserPreferencesResponse( + 1, + snapshot.Version.ToString(CultureInfo.InvariantCulture), + snapshot.IsDefault, + values.Theme, + values.Density, + values.PrimaryNavigationCollapsed, + values.ContextPanelOpen, + values.NavigationOrder, + values.DashboardWidgetOrder, + values.HiddenDashboardWidgets, + values.DefaultLandingPage, + values.CatalogView, + values.ContextPanelWidth, + values.NavigationWidth, + snapshot.UpdatedAt); + } + + private static PlatformSettingsResponse ToPlatformSettingsResponse(PlatformSettingsSnapshot snapshot) + { + var values = snapshot.Values; + return new PlatformSettingsResponse( + 1, + snapshot.Version.ToString(CultureInfo.InvariantCulture), + snapshot.IsDefault, + values.DisplayName, + values.PrimaryLocale, + values.TimeZone, + new IngestionProposalPolicyContract( + values.IngestionProposals.Enabled, + values.IngestionProposals.MaximumSourceCount, + values.IngestionProposals.MaximumSourceBytes, + values.IngestionProposals.FailedRetryLimit), + new BackupPolicyExpectationResponse( + "operator-managed", + values.BackupPolicyExpectation.ExpectedIntervalHours, + values.BackupPolicyExpectation.ScheduleDescription, + values.BackupPolicyExpectation.DailyRetentionCount, + values.BackupPolicyExpectation.WeeklyRetentionCount, + values.BackupPolicyExpectation.MonthlyRetentionCount, + values.BackupPolicyExpectation.ProtectedBackupCount, + values.BackupPolicyExpectation.RestoreVerificationMaxAgeDays), + new InventoryFreshnessPolicyContract(values.InventoryFreshnessPolicy.WarningAfterHours), + snapshot.UpdatedAt); + } + + private static bool TryGetExpectedVersion(HttpContext context, out int version, out IResult? error) + { + var raw = context.Request.Headers.IfMatch.ToString(); + if (string.IsNullOrWhiteSpace(raw)) + { + version = 0; + error = Results.Problem(statusCode: StatusCodes.Status428PreconditionRequired, + title: "If-Match-header vereist.", detail: "Lees eerst de actuele ETag en stuur die mee."); + return false; + } + + version = 0; + var token = ETagHelper.FromHeaderValue(raw); + if (token is null || !int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out version) || version < 0) + { + error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, + type: ProblemTypes.Validation, title: "Ongeldige If-Match-header."); + return false; + } + + error = null; + return true; + } + + private static Guid CurrentUserId(ClaimsPrincipal principal) + { + var raw = principal.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(raw, out var id) ? id : throw new InvalidOperationException("The authenticated user ID is missing."); + } + + private static IResult NotFound() => Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound); + + private static IResult ValidationProblem(string detail) => Results.Problem( + statusCode: StatusCodes.Status422UnprocessableEntity, + type: ProblemTypes.Validation, + title: "Ongeldige instellingen.", + detail: detail); + + private static IResult ConcurrencyProblem(string detail) => Results.Problem( + statusCode: StatusCodes.Status412PreconditionFailed, + type: ProblemTypes.ConcurrencyConflict, + title: "Versieconflict.", + detail: detail); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs index d9db901..65381d4 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs @@ -1,4 +1,5 @@ using ItWorx.KnowledgeHub.Api.Authorization; +using ItWorx.KnowledgeHub.Api.Security; using ItWorx.KnowledgeHub.Application.Identity; using ItWorx.KnowledgeHub.Application.Knowledge.UseCases; using ItWorx.KnowledgeHub.Contracts.Errors; @@ -16,6 +17,7 @@ public static class SearchEndpoints { app.MapGet("/api/v1/search", SearchAsync) .WithTags("Search") + .RequireRateLimiting(RateLimitPolicies.Search) .RequireContentAccess(WorkspaceRole.Reader, Scopes.KnowledgeSearch); return app; diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/UnifiedSearchEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/UnifiedSearchEndpoints.cs new file mode 100644 index 0000000..2e7e23a --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/UnifiedSearchEndpoints.cs @@ -0,0 +1,92 @@ +using ItWorx.KnowledgeHub.Api.Authorization; +using ItWorx.KnowledgeHub.Api.Security; +using ItWorx.KnowledgeHub.Application.Identity; +using ItWorx.KnowledgeHub.Application.UnifiedSearch; +using ItWorx.KnowledgeHub.Contracts.Errors; +using ItWorx.KnowledgeHub.Contracts.UnifiedSearch; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace ItWorx.KnowledgeHub.Api.Endpoints; + +public static class UnifiedSearchEndpoints +{ + public static IEndpointRouteBuilder MapUnifiedSearchEndpoints(this IEndpointRouteBuilder app) + { + // Unified search includes projects and infrastructure services. Keep it on + // the human workspace-reader policy so a bearer token with only + // knowledge:search cannot gain either resource's scope through aggregation. + app.MapGet("/api/v1/unified-search", SearchAsync) + .WithTags("Search") + .RequireRateLimiting(RateLimitPolicies.Search) + .RequireAuthorization(AuthorizationPolicies.RequireReader); + + return app; + } + + private static async Task, ProblemHttpResult>> SearchAsync( + [FromQuery] string? q, + [FromQuery] string? tag, + [FromQuery] string? kind, + [FromQuery] string? cursor, + [FromQuery] int? limit, + IWorkspaceRepository workspaces, + UnifiedSearchUseCase useCase, + CancellationToken cancellationToken) + { + if (!TryParseKind(kind, out var searchKind)) + { + return TypedResults.Problem( + statusCode: StatusCodes.Status422UnprocessableEntity, + type: ProblemTypes.Validation, + detail: "Kind must be one of: knowledge, projects, services, runbooks, decisions."); + } + + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + { + return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound); + } + + var result = await useCase.ExecuteAsync(workspaceId.Value, q, tag, searchKind, cursor, limit ?? 25, cancellationToken); + IReadOnlyList items = result.Items + .Select(hit => new UnifiedSearchResultItem( + ToPublicKind(hit.Kind), hit.Id, hit.Title, hit.Summary, hit.Snippet, hit.UpdatedAt, hit.Href)) + .ToArray(); + var counts = new UnifiedSearchCounts( + result.Counts.Knowledge, result.Counts.Projects, result.Counts.Services, + result.Counts.Runbooks, result.Counts.Decisions); + + return TypedResults.Ok(new UnifiedSearchResponse(items, counts, result.NextCursor)); + } + + private static bool TryParseKind(string? value, out UnifiedSearchKind? kind) + { + kind = null; + if (value is null) + { + return true; + } + + kind = value.Trim().ToLowerInvariant() switch + { + "knowledge" => UnifiedSearchKind.Knowledge, + "projects" => UnifiedSearchKind.Projects, + "services" => UnifiedSearchKind.Services, + "runbooks" => UnifiedSearchKind.Runbooks, + "decisions" => UnifiedSearchKind.Decisions, + _ => null + }; + return kind is not null; + } + + private static string ToPublicKind(string kind) => kind switch + { + "knowledge" => "knowledge", + "project" => "projects", + "service" => "services", + "runbook" => "runbooks", + "decision" => "decisions", + _ => throw new InvalidOperationException($"Unexpected unified search kind '{kind}'.") + }; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj b/src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj index 35869ec..b06ba0f 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj +++ b/src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj @@ -4,6 +4,11 @@ ItWorx.KnowledgeHub.Api false + + + + + diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs b/src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs index 27023fa..84a849d 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs +++ b/src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs @@ -21,13 +21,24 @@ public static class RestoreModeRunner public static async Task RunAsync(string[] args, IServiceProvider services, TextWriter output, CancellationToken cancellationToken) { var backupId = GetArgValue(args, "--backup-id"); - var targetConnection = GetArgValue(args, "--target-connection"); + var targetConnectionFile = GetArgValue(args, "--target-connection-file"); var targetAttachments = GetArgValue(args, "--target-attachments"); - if (backupId is null || targetConnection is null || targetAttachments is null) + if (HasArg(args, "--target-connection") || backupId is null || targetConnectionFile is null || targetAttachments is null) { await output.WriteLineAsync( - "Gebruik: --restore --backup-id= --target-connection= --target-attachments="); + "Gebruik: --restore --backup-id= --target-connection-file= --target-attachments="); + return 2; + } + + string targetConnection; + try + { + targetConnection = await ReadTargetConnectionAsync(targetConnectionFile, cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + await output.WriteLineAsync("Herstel niet gestart: de doelconnectie kon niet veilig worden gelezen."); return 2; } @@ -40,7 +51,9 @@ public static class RestoreModeRunner } catch (Exception exception) when (exception is not OperationCanceledException) { - await output.WriteLineAsync($"Herstel mislukt: {exception.Message}"); + // Provider messages may include the target connection string. Do not + // print exception text in this maintenance command's output. + await output.WriteLineAsync("Herstel mislukt; gevoelige foutdetails zijn niet weergegeven."); return 1; } @@ -63,7 +76,47 @@ public static class RestoreModeRunner private static string? GetArgValue(string[] args, string name) { var prefix = name + "="; - var match = args.FirstOrDefault(argument => argument.StartsWith(prefix, StringComparison.Ordinal)); - return match?[prefix.Length..]; + var matches = args.Where(argument => argument.StartsWith(prefix, StringComparison.Ordinal)).ToArray(); + return matches.Length == 1 && matches[0].Length > prefix.Length + ? matches[0][prefix.Length..] + : null; + } + + private static bool HasArg(string[] args, string name) => + args.Any(argument => argument.Equals(name, StringComparison.Ordinal) + || argument.StartsWith(name + "=", StringComparison.Ordinal)); + + private static async Task ReadTargetConnectionAsync(string path, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(path)) throw new IOException("Missing connection file."); + var fullPath = Path.GetFullPath(path); + var attributes = File.GetAttributes(fullPath); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + throw new IOException("Connection file must be a regular file."); + + if (!OperatingSystem.IsWindows()) + { + var mode = File.GetUnixFileMode(fullPath); + const UnixFileMode groupOrOtherAccess = UnixFileMode.GroupRead | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + if ((mode & groupOrOtherAccess) != 0 || (mode & UnixFileMode.UserRead) == 0) + throw new IOException("Connection file permissions are too broad."); + } + + await using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length is <= 0 or > 16_384) throw new IOException("Connection file is empty or too large."); + var bytes = new byte[checked((int)stream.Length)]; + await stream.ReadExactlyAsync(bytes, cancellationToken); + var extra = new byte[1]; + if (await stream.ReadAsync(extra, cancellationToken) != 0) + throw new IOException("Connection file changed while being read."); + + var connectionString = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetString(bytes) + .TrimStart('\uFEFF') + .TrimEnd('\r', '\n'); + if (string.IsNullOrWhiteSpace(connectionString) || connectionString.Contains('\r') || connectionString.Contains('\n')) + throw new IOException("Connection file contents are invalid."); + return connectionString; } } diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Program.cs b/src/backend/ItWorx.KnowledgeHub.Api/Program.cs index 30ff961..aa67579 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/Program.cs +++ b/src/backend/ItWorx.KnowledgeHub.Api/Program.cs @@ -13,23 +13,31 @@ using ItWorx.KnowledgeHub.Application.Identity.UseCases; using ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; using ItWorx.KnowledgeHub.Application.Ingestion; using ItWorx.KnowledgeHub.Application.Ingestion.UseCases; +using ItWorx.KnowledgeHub.Application.Jobs; +using ItWorx.KnowledgeHub.Application.Jobs.UseCases; using ItWorx.KnowledgeHub.Application.Knowledge.UseCases; using ItWorx.KnowledgeHub.Application.Migration.UseCases; using ItWorx.KnowledgeHub.Application.Operations.UseCases; +using ItWorx.KnowledgeHub.Application.Platform; +using ItWorx.KnowledgeHub.Application.Platform.UseCases; using ItWorx.KnowledgeHub.Application.Projects.UseCases; using ItWorx.KnowledgeHub.Application.Runbooks.UseCases; +using ItWorx.KnowledgeHub.Application.UnifiedSearch; using ItWorx.KnowledgeHub.Contracts.SystemInfo; using ItWorx.KnowledgeHub.Domain.Automation; using ItWorx.KnowledgeHub.Domain.Identity; using ItWorx.KnowledgeHub.Infrastructure; using ItWorx.KnowledgeHub.Infrastructure.Operations; using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using ItWorx.KnowledgeHub.Infrastructure.Platform; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; var timeProvider = TimeProvider.System; var startedAt = timeProvider.GetUtcNow(); @@ -58,6 +66,38 @@ if (builder.Environment.IsProduction()) } builder.Services.AddSingleton(timeProvider); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(provider => provider.GetRequiredService()); +builder.Services.AddScoped(provider => provider.GetRequiredService()); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(provider => +{ + var options = new BackgroundJobsOptions(); + provider.GetRequiredService().GetSection("KnowledgeHub:Jobs").Bind(options); + options.Validate(); + return options; +}); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("itworx-knowledge-hub")) + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddMeter("ItWorx.KnowledgeHub.Ingestion", "ItWorx.KnowledgeHub.Platform", "System.Runtime"); + // OTLP is enabled only by an explicit operator endpoint. No default cloud destination. + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + metrics.AddOtlpExporter(); + } + }); builder.Services.AddSingleton(_ => new Meter("ItWorx.KnowledgeHub.Ingestion")); builder.Services.AddSingleton(); builder.Services.AddProblemDetails(options => @@ -208,6 +248,10 @@ builder.Services.AddRateLimiter(options => CreateRemoteIpPartition(context, permitLimit: 10)); options.AddPolicy(RateLimitPolicies.SourceUpload, context => CreateRemoteIpPartition(context, permitLimit: 5)); + options.AddPolicy(RateLimitPolicies.Search, context => + CreateRemoteIpPartition(context, permitLimit: 60)); + options.AddPolicy(RateLimitPolicies.JobCreation, context => + CreateRemoteIpPartition(context, permitLimit: 10)); }); // --- Use cases (composition root) ------------------------------------------------------- @@ -228,6 +272,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -311,6 +357,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -495,6 +542,7 @@ app.MapGet( async ( IHostEnvironment environment, IConfiguration configuration, + IPlatformSettingsReader platformSettings, ReleaseManifestReader releaseManifestReader, KnowledgeHubDbContext db, CancellationToken cancellationToken) => @@ -502,8 +550,9 @@ app.MapGet( var assembly = Assembly.GetExecutingAssembly(); var version = assembly.GetName().Version?.ToString() ?? "0.0.0"; var commit = configuration["Build:Commit"] ?? "development"; - var locale = configuration["KnowledgeHub:PrimaryLocale"] ?? "nl-BE"; - var timeZone = configuration["KnowledgeHub:TimeZone"] ?? "Europe/Brussels"; + var settings = (await platformSettings.GetEffectiveAsync(cancellationToken)).Values; + var locale = settings.PrimaryLocale; + var timeZone = settings.TimeZone; var uptimeSeconds = Math.Max( 0, @@ -549,10 +598,10 @@ app.MapGet( return Results.Ok( new SystemInfoResponse( - "ItWorx Knowledge Hub", - version, + settings.DisplayName, + manifest?.SemanticVersion ?? version, commit, - "m7", + migrationRange?.Split("..").Last() ?? "Unknown", environment.EnvironmentName, locale, timeZone, @@ -568,6 +617,7 @@ app.MapPagesEndpoints(); app.MapDraftsEndpoints(); app.MapTemplatesEndpoints(); app.MapSearchEndpoints(); +app.MapUnifiedSearchEndpoints(); app.MapRagPublicationEndpoints(); app.MapAttachmentsEndpoints(); app.MapIngestionProposalsEndpoints(); @@ -578,6 +628,10 @@ app.MapDecisionsEndpoints(); app.MapRunbooksEndpoints(); app.MapServiceAccountsEndpoints(); app.MapBackupsEndpoints(); +app.MapAdminAuditEndpoints(); +app.MapDataSourcesEndpoints(); +app.MapPlatformEndpoints(); +app.MapJobsEndpoints(); app.MapInfrastructureEndpoints(); app.MapActivityEndpoints(); app.MapWorkspaceEndpoints(); diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs b/src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs index a181bfd..935c434 100644 --- a/src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs +++ b/src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs @@ -8,4 +8,6 @@ public static class RateLimitPolicies public const string Login = "login"; public const string Recovery = "recovery"; public const string SourceUpload = "source-upload"; + public const string Search = "search"; + public const string JobCreation = "job-creation"; } diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Security/StrictAntiforgeryEndpointFilter.cs b/src/backend/ItWorx.KnowledgeHub.Api/Security/StrictAntiforgeryEndpointFilter.cs new file mode 100644 index 0000000..05e8906 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Security/StrictAntiforgeryEndpointFilter.cs @@ -0,0 +1,29 @@ +using ItWorx.KnowledgeHub.Contracts.Errors; +using Microsoft.AspNetCore.Antiforgery; + +namespace ItWorx.KnowledgeHub.Api.Security; + +/// CSRF validation that cannot be bypassed by a caller-supplied Authorization +/// header. Used by routes that are specifically cookie-authenticated. +public sealed class StrictAntiforgeryEndpointFilter(IAntiforgery antiforgery) : IEndpointFilter +{ + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + try + { + await antiforgery.ValidateRequestAsync(context.HttpContext); + } + catch (AntiforgeryValidationException) + { + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + type: ProblemTypes.CsrfValidationFailed, + title: "CSRF-validatie mislukt.", + detail: "De aanvraag ontbreekt een geldig CSRF-token."); + } + + return await next(context); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Audit/IActivityEventReader.cs b/src/backend/ItWorx.KnowledgeHub.Application/Audit/IActivityEventReader.cs new file mode 100644 index 0000000..7295768 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Audit/IActivityEventReader.cs @@ -0,0 +1,8 @@ +namespace ItWorx.KnowledgeHub.Application.Audit; + +public sealed record OperationalActivityPage(IReadOnlyList Items, long? NextCursor); + +public interface IActivityEventReader +{ + Task ListAsync(int limit, long? before, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs b/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs index ac2df89..1bb644e 100644 --- a/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs +++ b/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs @@ -2,9 +2,10 @@ namespace ItWorx.KnowledgeHub.Application.Audit; public sealed record AuditEventView( long Id, string ActorKind, Guid? ActorId, string Source, string Action, - string? TargetType, string? TargetId, string Summary, DateTimeOffset OccurredAt); + string? TargetType, string? TargetId, string Summary, DateTimeOffset OccurredAt, string? CorrelationId = null); public interface IAuditEventReader { Task> ListRecentAsync(int limit, CancellationToken cancellationToken); + Task ListPageAsync(int limit, long? before, CancellationToken cancellationToken); } diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/InactiveInventorySourceException.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/InactiveInventorySourceException.cs new file mode 100644 index 0000000..5ca8925 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/InactiveInventorySourceException.cs @@ -0,0 +1,7 @@ +namespace ItWorx.KnowledgeHub.Application.Infrastructure; + +/// The registered inventory source was disabled or has an incompatible kind +/// when the import transaction acquired its source-row lock. +public sealed class InactiveInventorySourceException : Exception +{ +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/StaleInventorySnapshotException.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/StaleInventorySnapshotException.cs new file mode 100644 index 0000000..c36a132 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/StaleInventorySnapshotException.cs @@ -0,0 +1,6 @@ +namespace ItWorx.KnowledgeHub.Application.Infrastructure; + +public sealed class StaleInventorySnapshotException : Exception +{ + public StaleInventorySnapshotException() : base("De snapshot is ouder dan de laatst verwerkte inventaris.") { } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs index 03401f3..6a58df7 100644 --- a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs @@ -1,5 +1,9 @@ using ItWorx.KnowledgeHub.Application.Audit; using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Application.Operations; +using ItWorx.KnowledgeHub.Application.Platform; +using ItWorx.KnowledgeHub.Application.Sources; +using ItWorx.KnowledgeHub.Domain.Sources; namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; @@ -14,12 +18,19 @@ public sealed record SynchronizeInfrastructureInventoryCommand( public sealed class SynchronizeInfrastructureInventoryUseCase( IInfrastructureRepository infrastructure, IAuditEventRecorder auditEvents, - TimeProvider timeProvider) + TimeProvider timeProvider, + PlatformMetrics? metrics = null, + IDataSourceRepository? sources = null, + IPlatformSettingsReader? settings = null) { public async Task> ExecuteAsync( SynchronizeInfrastructureInventoryCommand command, CancellationToken cancellationToken) { + using var measurement = metrics?.Start("inventory"); + var now = timeProvider.GetUtcNow(); + if (command.Snapshot.CapturedAt > now || command.CorrelationId?.Length > 100 || command.CorrelationId?.Any(char.IsControl) == true) + return OperationResultValue.Failure(OperationStatus.ValidationFailed, ["Ongeldige observatietijd of correlatie."]); if (string.IsNullOrWhiteSpace(command.Snapshot.SourceKey) || string.IsNullOrWhiteSpace(command.Snapshot.SourceHash)) { @@ -34,8 +45,61 @@ public sealed class SynchronizeInfrastructureInventoryUseCase( OperationStatus.ValidationFailed, ["De inventaris bevat dubbele bron-ID's."]); } - var result = await infrastructure.ApplySnapshotAsync( - command.WorkspaceId, command.Snapshot, timeProvider.GetUtcNow(), cancellationToken); + var registeredSource = sources is null ? null : await sources.FindByKeyAsync(command.WorkspaceId, "inventory:default", cancellationToken); + if (registeredSource is { Enabled: false } or { Kind: not DataSourceKind.Inventory }) + return OperationResultValue.Failure(OperationStatus.InvalidTransition, ["Deze inventarisbron is uitgeschakeld."]); + InventorySyncResult result; + try + { + result = await infrastructure.ApplySnapshotAsync(command.WorkspaceId, command.Snapshot, now, cancellationToken); + } + catch (StaleInventorySnapshotException) + { + return OperationResultValue.Failure(OperationStatus.InvalidTransition, ["De snapshot is ouder dan de actuele inventaris."]); + } + catch (InactiveInventorySourceException) + { + return OperationResultValue.Failure(OperationStatus.InvalidTransition, ["Deze inventarisbron is uitgeschakeld of ongeldig."]); + } + + if (sources is not null) + { + // The snapshot lock has been released. A concurrent identical import may + // have registered the source while this request waited for that lock. + var source = registeredSource ?? await sources.FindByKeyAsync(command.WorkspaceId, "inventory:default", cancellationToken); + if (source is null) + { + var cadence = settings is null ? 24 : (await settings.GetEffectiveAsync(cancellationToken)).Values.InventoryFreshnessPolicy.WarningAfterHours; + source = DataSource.Create(command.WorkspaceId, "inventory:default", "Infrastructuurinventaris", DataSourceKind.Inventory, cadence, now); + sources.Add(source); + } + if (source is { Enabled: false } or { Kind: not DataSourceKind.Inventory }) + throw new InvalidOperationException("Inventaris verwerkt; de bronregistratie is inmiddels gewijzigd."); + if (!result.AlreadyImported || source.DataObservedAt is null) + { + source.Observe(true, command.Snapshot.Services.Count, null, command.CorrelationId, now, command.Snapshot.CapturedAt); + if (!await sources.SaveAsync(null, cancellationToken)) + { + // A competing import can still win the registration/update race + // after the read above. Accept it only if the persisted enabled + // source already carries at least this capture's evidence. + var persisted = await sources.FindByKeyAsync(command.WorkspaceId, "inventory:default", cancellationToken); + if (persisted is not { Enabled: true, Kind: DataSourceKind.Inventory }) + throw new InvalidOperationException("Inventaris verwerkt; bronregistratie gewijzigd. Dezelfde snapshot kan veilig opnieuw worden aangeboden."); + if (persisted.DataObservedAt is null || persisted.DataObservedAt < command.Snapshot.CapturedAt) + { + persisted.Observe(true, command.Snapshot.Services.Count, null, command.CorrelationId, + timeProvider.GetUtcNow(), command.Snapshot.CapturedAt); + if (!await sources.SaveAsync(null, cancellationToken)) + { + persisted = await sources.FindByKeyAsync(command.WorkspaceId, "inventory:default", cancellationToken); + if (persisted is not { Enabled: true, Kind: DataSourceKind.Inventory } || persisted.DataObservedAt is null || persisted.DataObservedAt < command.Snapshot.CapturedAt) + throw new InvalidOperationException("Inventaris verwerkt; bronregistratie gewijzigd. Dezelfde snapshot kan veilig opnieuw worden aangeboden."); + } + } + } + } + } await auditEvents.RecordAsync( new AuditEventRecord( @@ -47,6 +111,7 @@ public sealed class SynchronizeInfrastructureInventoryUseCase( command.CorrelationId, timeProvider.GetUtcNow()), cancellationToken); + measurement?.Complete(command.Snapshot.Services.Count); return OperationResultValue.Success(result); } diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CreateIngestionProposalUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CreateIngestionProposalUseCase.cs index ffb0405..c3bb921 100644 --- a/src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CreateIngestionProposalUseCase.cs +++ b/src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CreateIngestionProposalUseCase.cs @@ -4,13 +4,15 @@ using ItWorx.KnowledgeHub.Domain.Ingestion; namespace ItWorx.KnowledgeHub.Application.Ingestion.UseCases; public sealed record CreateIngestionProposalCommand(Guid WorkspaceId, Guid ActorId, string Manifest, string CorrelationId, - string? IdempotencyKey, IngestionPublication? Publication = null); + string? IdempotencyKey, IngestionPublication? Publication = null, + int MaxPublishRetries = IngestionProposal.DefaultMaxPublishRetries); public sealed class CreateIngestionProposalUseCase(IIngestionProposalRepository proposals, TimeProvider timeProvider) { public async Task> ExecuteAsync(CreateIngestionProposalCommand command, CancellationToken cancellationToken) { - if (command.WorkspaceId == Guid.Empty || command.ActorId == Guid.Empty || string.IsNullOrWhiteSpace(command.Manifest) || command.Manifest.Length > 100_000) + if (command.WorkspaceId == Guid.Empty || command.ActorId == Guid.Empty || string.IsNullOrWhiteSpace(command.Manifest) + || command.Manifest.Length > 100_000 || command.MaxPublishRetries is < 1 or > 3) return OperationResultValue.Failure(OperationStatus.ValidationFailed, ["Ongeldig voorstel."]); if (!string.IsNullOrWhiteSpace(command.IdempotencyKey)) @@ -22,7 +24,7 @@ public sealed class CreateIngestionProposalUseCase(IIngestionProposalRepository var proposal = IngestionProposal.Create(command.WorkspaceId, command.ActorId, string.IsNullOrWhiteSpace(command.CorrelationId) ? "ingestion" : command.CorrelationId, - command.Manifest, IngestionProposal.DefaultMaxPublishRetries); + command.Manifest, command.MaxPublishRetries); if (!string.IsNullOrWhiteSpace(command.IdempotencyKey)) proposal.AssignIdempotencyKey(command.IdempotencyKey); if (command.Publication is not null) { diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Jobs/BackgroundJobRepositoryModels.cs b/src/backend/ItWorx.KnowledgeHub.Application/Jobs/BackgroundJobRepositoryModels.cs new file mode 100644 index 0000000..7eff3cf --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Jobs/BackgroundJobRepositoryModels.cs @@ -0,0 +1,25 @@ +using ItWorx.KnowledgeHub.Domain.Jobs; + +namespace ItWorx.KnowledgeHub.Application.Jobs; + +public enum EnqueueJobStatus +{ + Created, + Existing, + IdempotencyConflict, + RootNotFound, + TooManyPages, + QueueFull +} + +public sealed record EnqueueJobResult(EnqueueJobStatus Status, BackgroundJob? Job); + +public sealed record BackgroundJobQueueLimits(int PendingPerWorkspace, int PendingPerActor, int CreatedPerDay); + +public sealed record BackgroundJobPageWorkItem( + long Ordinal, + Guid PageId, + bool InitiallyTagged, + BackgroundJobItemState State); + +public sealed record BackgroundJobQueueStatistics(long Queued, long Running, long Failed, DateTimeOffset? OldestQueuedAt); diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Jobs/IBackgroundJobRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Jobs/IBackgroundJobRepository.cs new file mode 100644 index 0000000..b5f46a8 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Jobs/IBackgroundJobRepository.cs @@ -0,0 +1,59 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Jobs; + +namespace ItWorx.KnowledgeHub.Application.Jobs; + +/// Persistence and lease port for durable operator jobs. Implementations claim +/// with PostgreSQL SKIP LOCKED and keep the subtree snapshot in rows, never application +/// memory. +public interface IBackgroundJobRepository +{ + Task EnqueueAsync( + BackgroundJob job, + Guid? rootPageId, + string? tag, + int maxCoursePages, + BackgroundJobQueueLimits limits, + AuditEventRecord auditEvent, + CancellationToken cancellationToken); + + Task FindAsync(Guid workspaceId, Guid jobId, CancellationToken cancellationToken); + Task> ListAsync(Guid workspaceId, int limit, CancellationToken cancellationToken); + Task FindByIdempotencyAsync(Guid workspaceId, Guid actorId, string keyHash, CancellationToken cancellationToken); + + Task RequestCancellationAsync( + Guid workspaceId, + Guid jobId, + DateTimeOffset now, + AuditEventRecord auditEvent, + CancellationToken cancellationToken); + + Task TryClaimNextAsync(Guid leaseToken, DateTimeOffset now, DateTimeOffset leaseUntil, CancellationToken cancellationToken); + Task RenewLeaseAsync(Guid jobId, Guid leaseToken, DateTimeOffset leaseUntil, CancellationToken cancellationToken); + Task FindLeasedAsync(Guid jobId, Guid leaseToken, CancellationToken cancellationToken); + Task FindNextPageItemAsync(Guid jobId, Guid leaseToken, CancellationToken cancellationToken); + Task MarkPageItemProcessingAsync(Guid jobId, Guid leaseToken, long ordinal, DateTimeOffset now, CancellationToken cancellationToken); + Task RecordPageItemOutcomeAsync( + Guid jobId, + Guid leaseToken, + long ordinal, + BackgroundJobItemState outcome, + string? errorCode, + string? errorMessage, + Guid? pageId, + DateTimeOffset now, + CancellationToken cancellationToken); + Task RecordJobOutcomeAsync( + Guid jobId, + Guid leaseToken, + BackgroundJobItemState outcome, + string? errorCode, + string? errorMessage, + CancellationToken cancellationToken); + Task CompleteLeasedJobAsync(Guid jobId, Guid leaseToken, string? resultJson, DateTimeOffset now, CancellationToken cancellationToken); + Task CancelLeasedJobAsync(Guid jobId, Guid leaseToken, DateTimeOffset now, CancellationToken cancellationToken); + Task RetryLeasedJobAsync(Guid jobId, Guid leaseToken, DateTimeOffset now, DateTimeOffset retryAt, string code, string safeMessage, CancellationToken cancellationToken); + Task FailLeasedJobAsync(Guid jobId, Guid leaseToken, DateTimeOffset now, string code, string safeMessage, CancellationToken cancellationToken); + Task IsCancellationRequestedAsync(Guid jobId, Guid leaseToken, CancellationToken cancellationToken); + Task GetQueueStatisticsAsync(CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Jobs/UseCases/CancelBackgroundJobUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Jobs/UseCases/CancelBackgroundJobUseCase.cs new file mode 100644 index 0000000..a919256 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Jobs/UseCases/CancelBackgroundJobUseCase.cs @@ -0,0 +1,66 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Jobs; + +namespace ItWorx.KnowledgeHub.Application.Jobs.UseCases; + +public enum CancelBackgroundJobStatus +{ + NotFound, + Requested, + AlreadyTerminal +} + +public sealed class CancelBackgroundJobUseCase( + IBackgroundJobRepository jobs, + TimeProvider timeProvider) +{ + public async Task<(CancelBackgroundJobStatus Status, BackgroundJob? Job)> ExecuteAsync( + Guid workspaceId, + Guid jobId, + Guid actorId, + string? correlationId, + CancellationToken cancellationToken) + { + var job = await jobs.FindAsync(workspaceId, jobId, cancellationToken); + if (job is null) + return (CancelBackgroundJobStatus.NotFound, null); + if (job.IsTerminal) + return (CancelBackgroundJobStatus.AlreadyTerminal, job); + + var requestedAt = timeProvider.GetUtcNow(); + var requested = await jobs.RequestCancellationAsync( + workspaceId, + jobId, + requestedAt, + new AuditEventRecord( + AuditActorKind.User, actorId, Source: "web", Action: "job.cancel_requested", + TargetType: "background_job", TargetId: jobId.ToString("D"), + Summary: "Annuleringsverzoek voor achtergrondtaak geregistreerd.", + correlationId, requestedAt), + cancellationToken); + if (!requested) + { + job = await jobs.FindAsync(workspaceId, jobId, cancellationToken); + if (job is null) + return (CancelBackgroundJobStatus.NotFound, null); + if (job.IsTerminal) + return (CancelBackgroundJobStatus.AlreadyTerminal, job); + return job.CancelRequestedAt is not null + ? (CancelBackgroundJobStatus.Requested, job) + : (CancelBackgroundJobStatus.NotFound, null); + } + job = await jobs.FindAsync(workspaceId, jobId, cancellationToken); + if (job is null) + return (CancelBackgroundJobStatus.NotFound, null); + return (CancelBackgroundJobStatus.Requested, job); + } +} + +public sealed class BackgroundJobQueries(IBackgroundJobRepository jobs) +{ + public Task GetAsync(Guid workspaceId, Guid jobId, CancellationToken cancellationToken) => + jobs.FindAsync(workspaceId, jobId, cancellationToken); + + public Task> ListAsync(Guid workspaceId, int limit, CancellationToken cancellationToken) => + jobs.ListAsync(workspaceId, Math.Clamp(limit, 1, 100), cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Jobs/UseCases/QueueBackgroundJobUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Jobs/UseCases/QueueBackgroundJobUseCase.cs new file mode 100644 index 0000000..45875ad --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Jobs/UseCases/QueueBackgroundJobUseCase.cs @@ -0,0 +1,140 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Jobs; + +namespace ItWorx.KnowledgeHub.Application.Jobs.UseCases; + +public sealed record QueueJobCommand( + Guid WorkspaceId, + Guid ActorId, + string IdempotencyKey, + string? CorrelationId); + +public sealed record QueueCourseTaggingJobCommand( + Guid WorkspaceId, + Guid ActorId, + Guid RootPageId, + string Tag, + string IdempotencyKey, + string? CorrelationId); + +public sealed class QueueBackgroundJobUseCase( + IBackgroundJobRepository jobs, + TimeProvider timeProvider, + BackgroundJobsOptions options) +{ + private static readonly JsonSerializerOptions PayloadOptions = new(JsonSerializerDefaults.Web); + + public async Task QueueCourseTaggingAsync( + QueueCourseTaggingJobCommand command, + CancellationToken cancellationToken) + { + var tag = NormalizeTag(command.Tag); + if (command.RootPageId == Guid.Empty || tag is null || !TryValidateKey(command.IdempotencyKey)) + throw new ArgumentException("Ongeldige cursustagtaak."); + + var payload = JsonSerializer.Serialize(new { rootPageId = command.RootPageId, tag }, PayloadOptions); + var job = Create( + command.WorkspaceId, command.ActorId, BackgroundJobType.CourseTagging, payload, + command.IdempotencyKey, command.CorrelationId, total: 0); + return await EnqueueAsync(job, command.RootPageId, tag, cancellationToken); + } + + public async Task QueueBackupAsync(QueueJobCommand command, CancellationToken cancellationToken) + { + if (!TryValidateKey(command.IdempotencyKey)) + throw new ArgumentException("Een geldige Idempotency-Key is verplicht."); + + var payload = "{}"; + var job = Create( + command.WorkspaceId, command.ActorId, BackgroundJobType.Backup, payload, + command.IdempotencyKey, command.CorrelationId, total: 1); + return await EnqueueAsync(job, null, null, cancellationToken); + } + + private Task EnqueueAsync( + BackgroundJob job, + Guid? rootPageId, + string? tag, + CancellationToken cancellationToken) => + jobs.EnqueueAsync( + job, rootPageId, tag, options.MaxCoursePages, + new BackgroundJobQueueLimits(options.MaxPendingPerWorkspace, options.MaxPendingPerActor, options.MaxCreatedPerDay), + new AuditEventRecord( + AuditActorKind.User, + job.ActorId, + Source: "web", + Action: "job.queued", + TargetType: "background_job", + TargetId: job.Id.ToString("D"), + Summary: $"Achtergrondtaak '{job.Type}' toegevoegd aan de wachtrij.", + job.CorrelationId, + job.CreatedAt), + cancellationToken); + + private BackgroundJob Create( + Guid workspaceId, + Guid actorId, + BackgroundJobType type, + string payload, + string idempotencyKey, + string? correlationId, + int total) + { + var now = timeProvider.GetUtcNow(); + var keyHash = Hash(idempotencyKey.Trim()); + var payloadHash = Hash($"{type}:{payload}"); + return BackgroundJob.Create( + Guid.NewGuid(), workspaceId, actorId, type, payload, keyHash, payloadHash, + correlationId, now, total); + } + + private static string? NormalizeTag(string? raw) + { + var tag = raw?.Trim(); + if (string.IsNullOrEmpty(tag) || tag.Length > 64 || tag.Any(char.IsControl)) + return null; + return tag.ToLowerInvariant(); + } + + private static bool TryValidateKey(string? key) => + !string.IsNullOrWhiteSpace(key) + && key.Length <= 128 + && key.Trim().Length > 0 + && !key.Any(char.IsControl); + + private static string Hash(string value) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value))); +} + +public sealed class BackgroundJobsOptions +{ + public const string SectionName = "KnowledgeHub:Jobs"; + public bool Enabled { get; set; } = true; + public int MaxCoursePages { get; set; } = 100_000; + public int MaxPendingPerWorkspace { get; set; } = 10; + public int MaxPendingPerActor { get; set; } = 3; + public int MaxCreatedPerDay { get; set; } = 100; + public int MaxConcurrency { get; set; } = 2; + public int LeaseSeconds { get; set; } = 120; + public int LeaseRenewSeconds { get; set; } = 30; + public int PollMilliseconds { get; set; } = 1000; + public int SsePollMilliseconds { get; set; } = 1000; + public int SseHeartbeatSeconds { get; set; } = 15; + + public void Validate() + { + MaxCoursePages = Math.Clamp(MaxCoursePages, 1, 100_000); + MaxPendingPerWorkspace = Math.Clamp(MaxPendingPerWorkspace, 1, 100); + MaxPendingPerActor = Math.Clamp(MaxPendingPerActor, 1, MaxPendingPerWorkspace); + MaxCreatedPerDay = Math.Clamp(MaxCreatedPerDay, 1, 1000); + MaxConcurrency = Math.Clamp(MaxConcurrency, 1, 8); + LeaseSeconds = Math.Clamp(LeaseSeconds, 30, 600); + LeaseRenewSeconds = Math.Clamp(LeaseRenewSeconds, 10, Math.Max(10, LeaseSeconds / 2)); + PollMilliseconds = Math.Clamp(PollMilliseconds, 100, 30_000); + SsePollMilliseconds = Math.Clamp(SsePollMilliseconds, 250, 10_000); + SseHeartbeatSeconds = Math.Clamp(SseHeartbeatSeconds, 5, 60); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SearchPagesUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SearchPagesUseCase.cs index 70df213..6b2d204 100644 --- a/src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SearchPagesUseCase.cs +++ b/src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SearchPagesUseCase.cs @@ -1,3 +1,5 @@ +using ItWorx.KnowledgeHub.Application.Operations; + namespace ItWorx.KnowledgeHub.Application.Knowledge.UseCases; public sealed record SearchPagesQuery( @@ -18,8 +20,13 @@ public sealed record SearchPagesQuery( /// reachable by an unauthenticated caller" (the endpoint itself requires Reader), /// not per-page grants that do not exist in this product yet. /// -public sealed class SearchPagesUseCase(ISearchRepository search) +public sealed class SearchPagesUseCase(ISearchRepository search, PlatformMetrics? metrics = null) { - public Task ExecuteAsync(SearchPagesQuery query, CancellationToken cancellationToken) => - search.SearchAsync(query.WorkspaceId, query.Query, query.Tag, query.DocumentType, query.Cursor, query.Limit, cancellationToken); + public async Task ExecuteAsync(SearchPagesQuery query, CancellationToken cancellationToken) + { + using var measurement = metrics?.Start("search"); + var result = await search.SearchAsync(query.WorkspaceId, query.Query, query.Tag, query.DocumentType, query.Cursor, query.Limit, cancellationToken); + measurement?.Complete(result.Items.Count); + return result; + } } diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Operations/IBackupEvidenceStore.cs b/src/backend/ItWorx.KnowledgeHub.Application/Operations/IBackupEvidenceStore.cs new file mode 100644 index 0000000..2e1cc46 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Operations/IBackupEvidenceStore.cs @@ -0,0 +1,20 @@ +namespace ItWorx.KnowledgeHub.Application.Operations; + +public sealed record BackupEvidence( + bool Protected, + int ProtectionVersion, + DateTimeOffset? IntegrityVerifiedAt, + DateTimeOffset? RestoreVerifiedAt, + bool? LastRestoreSucceeded); + +public enum BackupProtectionResult { Updated, NotFound, Conflict } + +/// Recovery evidence lives beside the backup, independently of the source database. +public interface IBackupEvidenceStore +{ + Task GetAsync( + string backupId, CancellationToken cancellationToken, string? expectedManifestSha256 = null); + Task RecordRestoreAsync(string backupId, RestoreReport report, CancellationToken cancellationToken); + Task SetProtectionAsync( + string backupId, bool protect, int expectedVersion, Guid actorId, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Operations/IOperationalTelemetryReader.cs b/src/backend/ItWorx.KnowledgeHub.Application/Operations/IOperationalTelemetryReader.cs new file mode 100644 index 0000000..d36ebc4 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Operations/IOperationalTelemetryReader.cs @@ -0,0 +1,11 @@ +namespace ItWorx.KnowledgeHub.Application.Operations; + +public sealed record OperationalTelemetrySnapshot( + long QueuedJobs, long RunningJobs, long FailedJobs, + DateTimeOffset? OldestQueuedJobAt, DateTimeOffset? OldestPendingIngestionAt, + DateTimeOffset? LatestInventoryCaptureAt, DateTimeOffset? LatestVerifiedBackupAt); + +public interface IOperationalTelemetryReader +{ + Task ReadAsync(CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Operations/IRestoreService.cs b/src/backend/ItWorx.KnowledgeHub.Application/Operations/IRestoreService.cs index 242f4a3..1f81b54 100644 --- a/src/backend/ItWorx.KnowledgeHub.Application/Operations/IRestoreService.cs +++ b/src/backend/ItWorx.KnowledgeHub.Application/Operations/IRestoreService.cs @@ -16,7 +16,8 @@ public sealed record RestoreReport( IReadOnlyList Tables, int AttachmentsRestored, int AttachmentsExpected, - IReadOnlyList Warnings) + IReadOnlyList Warnings, + string? ManifestSha256 = null) { public bool AttachmentsMatch => AttachmentsRestored == AttachmentsExpected; } diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Operations/PlatformMetrics.cs b/src/backend/ItWorx.KnowledgeHub.Application/Operations/PlatformMetrics.cs new file mode 100644 index 0000000..ed1a00d --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Operations/PlatformMetrics.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace ItWorx.KnowledgeHub.Application.Operations; + +/// Closed dimensions only: no actor, SQL, source URL, document, path or correlation label. +public sealed class PlatformMetrics : IDisposable +{ + private readonly Meter meter = new("ItWorx.KnowledgeHub.Platform"); + private readonly Counter operations; + private readonly Histogram duration; + private readonly Histogram records; + private QueueState? queueState; + private OperationalAges? operationalAges; + + public PlatformMetrics() + { + operations = meter.CreateCounter("platform_operations_total"); + duration = meter.CreateHistogram("platform_operation_duration_seconds", "s"); + records = meter.CreateHistogram("platform_operation_records"); + meter.CreateObservableGauge("platform_jobs", ObserveJobs); + meter.CreateObservableGauge("platform_queue_age_seconds", ObserveQueueAge, "s"); + meter.CreateObservableGauge("platform_evidence_age_seconds", ObserveEvidenceAges, "s"); + } + + public void SetQueueState(long queued, long running, long failed, double? oldestPendingAgeSeconds) => + Volatile.Write(ref queueState, new QueueState(Math.Max(0, queued), Math.Max(0, running), Math.Max(0, failed), ValidAge(oldestPendingAgeSeconds))); + + public void SetOperationalAges(double? ingestionAge, double? inventoryAge, double? backupAge) => + Volatile.Write(ref operationalAges, new OperationalAges(ValidAge(ingestionAge), ValidAge(inventoryAge), ValidAge(backupAge))); + + public void ClearSnapshots() + { + Volatile.Write(ref queueState, null); + Volatile.Write(ref operationalAges, null); + } + + private static double? ValidAge(double? age) => age is { } value && double.IsFinite(value) ? Math.Max(0, value) : null; + + private IEnumerable> ObserveJobs() + { + if (Volatile.Read(ref queueState) is not { } state) yield break; + yield return new(state.Queued, new KeyValuePair("state", "queued")); + yield return new(state.Running, new KeyValuePair("state", "running")); + yield return new(state.Failed, new KeyValuePair("state", "failed")); + } + + private IEnumerable> ObserveQueueAge() + { + if (Volatile.Read(ref queueState)?.OldestPendingAge is { } age) + yield return new(age, new KeyValuePair("queue", "jobs")); + } + + private IEnumerable> ObserveEvidenceAges() + { + if (Volatile.Read(ref operationalAges) is not { } ages) yield break; + if (ages.Ingestion is { } ingestion) yield return new(ingestion, new KeyValuePair("evidence", "ingestion_queue")); + if (ages.Inventory is { } inventory) yield return new(inventory, new KeyValuePair("evidence", "inventory")); + if (ages.Backup is { } backup) yield return new(backup, new KeyValuePair("evidence", "backup")); + } + + private sealed record QueueState(long Queued, long Running, long Failed, double? OldestPendingAge); + private sealed record OperationalAges(double? Ingestion, double? Inventory, double? Backup); + + public void Record(string operation, string outcome, double seconds, long recordCount = 0) + { + var tags = new TagList + { + { "operation", operation is "database" or "search" or "inventory" or "backup" or "job" ? operation : "other" }, + { "outcome", outcome is "success" or "failure" or "cancelled" ? outcome : "other" } + }; + operations.Add(1, tags); + duration.Record(double.IsFinite(seconds) ? Math.Max(0, seconds) : 0, tags); + records.Record(Math.Max(0, recordCount), tags); + } + + public Measurement Start(string operation) => new(this, operation); + public void Dispose() => meter.Dispose(); + + public sealed class Measurement(PlatformMetrics owner, string operation) : IDisposable + { + private readonly long started = Stopwatch.GetTimestamp(); + private string outcome = "failure"; + private long records; + public void Complete(long recordCount = 0) { outcome = "success"; records = recordCount; } + public void Cancel() => outcome = "cancelled"; + public void Dispose() => owner.Record(operation, outcome, Stopwatch.GetElapsedTime(started).TotalSeconds, records); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/CreateBackupUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/CreateBackupUseCase.cs index 8883001..085d076 100644 --- a/src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/CreateBackupUseCase.cs +++ b/src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/CreateBackupUseCase.cs @@ -8,10 +8,11 @@ public sealed record CreateBackupCommand(Guid ActorId, string? CorrelationId); /// an audit event on both success and failure — a failed backup is itself an /// operationally significant event (docs/19-observability.md "repeated backup /// failure" alert). -public sealed class CreateBackupUseCase(IBackupService backups, IAuditEventRecorder auditEvents, TimeProvider timeProvider) +public sealed class CreateBackupUseCase(IBackupService backups, IAuditEventRecorder auditEvents, TimeProvider timeProvider, PlatformMetrics? metrics = null) { public async Task ExecuteAsync(CreateBackupCommand command, CancellationToken cancellationToken) { + using var measurement = metrics?.Start("backup"); var now = timeProvider.GetUtcNow(); try { @@ -30,6 +31,7 @@ public sealed class CreateBackupUseCase(IBackupService backups, IAuditEventRecor now), cancellationToken); + measurement?.Complete(summary.AttachmentCount); return summary; } catch (Exception exception) when (exception is not OperationCanceledException) diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Platform/PlatformSettingsPort.cs b/src/backend/ItWorx.KnowledgeHub.Application/Platform/PlatformSettingsPort.cs new file mode 100644 index 0000000..59c8307 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Platform/PlatformSettingsPort.cs @@ -0,0 +1,24 @@ +using ItWorx.KnowledgeHub.Domain.Platform; + +namespace ItWorx.KnowledgeHub.Application.Platform; + +public sealed record PlatformSettingsSnapshot( + PlatformSettingsValues Values, + int Version, + bool IsDefault, + DateTimeOffset? UpdatedAt); + +public interface IPlatformSettingsReader +{ + Task GetEffectiveAsync(CancellationToken cancellationToken); +} + +public interface IPlatformSettingsRepository : IPlatformSettingsReader +{ + Task FindPersistedAsync(CancellationToken cancellationToken); + + void Add(PlatformSettings settings); + + /// Returns false for an optimistic-concurrency or singleton-insert race. + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Platform/UseCases/PlatformSettingsUseCases.cs b/src/backend/ItWorx.KnowledgeHub.Application/Platform/UseCases/PlatformSettingsUseCases.cs new file mode 100644 index 0000000..87580d9 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Platform/UseCases/PlatformSettingsUseCases.cs @@ -0,0 +1,162 @@ +using System.Text.RegularExpressions; +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Platform; + +namespace ItWorx.KnowledgeHub.Application.Platform.UseCases; + +public sealed class GetPlatformSettingsUseCase(IPlatformSettingsReader settings) +{ + public Task ExecuteAsync(CancellationToken cancellationToken) => + settings.GetEffectiveAsync(cancellationToken); +} + +public sealed record UpdatePlatformSettingsCommand( + int ExpectedVersion, + Guid ActorId, + string? CorrelationId, + PlatformSettingsValues Values); + +public enum UpdatePlatformSettingsStatus { Success, PreconditionFailed, ValidationFailed } + +public sealed record UpdatePlatformSettingsResult( + UpdatePlatformSettingsStatus Status, + PlatformSettingsSnapshot? Snapshot, + IReadOnlyList Errors) +{ + public static UpdatePlatformSettingsResult Failure(UpdatePlatformSettingsStatus status, params string[] errors) => + new(status, null, errors); +} + +/// Validates and persists versioned operator policy. Backup values are +/// expectations only because their scheduler and retention executor are external. +public sealed class UpdatePlatformSettingsUseCase( + IPlatformSettingsRepository settings, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + private const long MaximumIntakeBytes = 104_857_600; + private const int MaximumIntakeSources = 20; + + public async Task ExecuteAsync( + UpdatePlatformSettingsCommand command, + CancellationToken cancellationToken) + { + var errors = Validate(command.Values); + if (errors.Count > 0) + { + return new(UpdatePlatformSettingsStatus.ValidationFailed, null, errors); + } + + var current = await settings.FindPersistedAsync(cancellationToken); + if (current is null) + { + if (command.ExpectedVersion != 0) + { + return UpdatePlatformSettingsResult.Failure(UpdatePlatformSettingsStatus.PreconditionFailed); + } + + current = PlatformSettings.Create(command.Values, timeProvider.GetUtcNow()); + settings.Add(current); + } + else + { + if (current.Version != command.ExpectedVersion) + { + return UpdatePlatformSettingsResult.Failure(UpdatePlatformSettingsStatus.PreconditionFailed); + } + + current.Replace(command.Values, timeProvider.GetUtcNow()); + } + + auditEvents.Add(new AuditEventRecord( + AuditActorKind.User, + command.ActorId, + "web", + "platform.settings.updated", + "PlatformSettings", + PlatformSettings.SingletonId.ToString(), + "Operator platform settings updated.", + command.CorrelationId, + timeProvider.GetUtcNow())); + + if (!await settings.SaveChangesAsync(cancellationToken)) + { + return UpdatePlatformSettingsResult.Failure(UpdatePlatformSettingsStatus.PreconditionFailed); + } + + return new( + UpdatePlatformSettingsStatus.Success, + new PlatformSettingsSnapshot(current.ToValues(), current.Version, IsDefault: false, current.UpdatedAt), + []); + } + + private static List Validate(PlatformSettingsValues? values) + { + var errors = new List(); + if (values is null) + { + errors.Add("Platforminstellingen ontbreken."); + return errors; + } + + var displayName = values.DisplayName?.Trim(); + if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > 100 || displayName.Any(char.IsControl)) + { + errors.Add("De sitenaam moet 1 tot 100 tekens bevatten."); + } + if (values.PrimaryLocale is not "nl-BE") errors.Add("Alleen nl-BE is momenteel beschikbaar."); + if (!IsKnownTimeZone(values.TimeZone)) errors.Add("De tijdzone moet een bekende IANA-tijdzone zijn."); + + if (values.IngestionProposals is null + || values.IngestionProposals.MaximumSourceCount is < 1 or > MaximumIntakeSources + || values.IngestionProposals.MaximumSourceBytes is < 1 or > MaximumIntakeBytes + || values.IngestionProposals.FailedRetryLimit is < 1 or > 3) + { + errors.Add("De intake-instellingen overschrijden de vaste veiligheidslimieten."); + } + + var backup = values.BackupPolicyExpectation; + if (backup is null + || backup.ExpectedIntervalHours is < 1 or > 8760 + || string.IsNullOrWhiteSpace(backup.ScheduleDescription) + || backup.ScheduleDescription.Length > 200 + || backup.ScheduleDescription.Any(char.IsControl) + || backup.DailyRetentionCount is < 1 or > 365 + || backup.WeeklyRetentionCount is < 1 or > 104 + || backup.MonthlyRetentionCount is < 1 or > 120 + || backup.ProtectedBackupCount is < 1 or > 100 + || backup.RestoreVerificationMaxAgeDays is < 1 or > 365) + { + errors.Add("De externe back-upverwachting bevat waarden buiten de toegestane grenzen."); + } + + if (values.InventoryFreshnessPolicy is null || values.InventoryFreshnessPolicy.WarningAfterHours is < 1 or > 8760) + { + errors.Add("De inventariswaarschuwing moet tussen 1 en 8760 uur liggen."); + } + + return errors; + } + + private static bool IsKnownTimeZone(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 100 || !Regex.IsMatch(value, "^[A-Za-z0-9_+./-]+$", RegexOptions.CultureInvariant)) + { + return false; + } + + try + { + _ = TimeZoneInfo.FindSystemTimeZoneById(value); + return true; + } + catch (TimeZoneNotFoundException) + { + return false; + } + catch (InvalidTimeZoneException) + { + return false; + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Platform/UseCases/UserPreferencesUseCases.cs b/src/backend/ItWorx.KnowledgeHub.Application/Platform/UseCases/UserPreferencesUseCases.cs new file mode 100644 index 0000000..1f1a5a4 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Platform/UseCases/UserPreferencesUseCases.cs @@ -0,0 +1,147 @@ +using ItWorx.KnowledgeHub.Domain.Platform; + +namespace ItWorx.KnowledgeHub.Application.Platform.UseCases; + +public static class UserPreferencesDefaults +{ + public static readonly UserPreferencesValues Values = new( + "system", + "comfortable", + PrimaryNavigationCollapsed: false, + ContextPanelOpen: false, + NavigationOrder: ["/", "/infrastructure", "/infrastructure/services", "/projects", "/runbooks", "/knowledge", "/activity", "/search"], + DashboardWidgetOrder: ["attention", "server", "projects", "activity", "runbooks", "favorites", "quick-actions"], + HiddenDashboardWidgets: [], + DefaultLandingPage: "/", + CatalogView: "list", + ContextPanelWidth: 304, + NavigationWidth: 224); +} + +public sealed class GetUserPreferencesUseCase(IUserPreferencesRepository preferences) +{ + public async Task ExecuteAsync(Guid workspaceId, Guid userId, CancellationToken cancellationToken) + { + var current = await preferences.FindAsync(workspaceId, userId, cancellationToken); + return current is null + ? new UserPreferencesSnapshot(UserPreferencesDefaults.Values, 0, IsDefault: true, UpdatedAt: null) + : new UserPreferencesSnapshot(current.ToValues(), current.Version, IsDefault: false, current.UpdatedAt); + } +} + +public sealed record UpdateUserPreferencesCommand( + Guid WorkspaceId, + Guid UserId, + int ExpectedVersion, + UserPreferencesValues Values); + +public enum UpdateUserPreferencesStatus { Success, PreconditionFailed, ValidationFailed } + +public sealed record UpdateUserPreferencesResult( + UpdateUserPreferencesStatus Status, + UserPreferencesSnapshot? Snapshot, + IReadOnlyList Errors) +{ + public static UpdateUserPreferencesResult Failure(UpdateUserPreferencesStatus status, params string[] errors) => + new(status, null, errors); +} + +/// Validates complete user preference replacements and commits by ETag. +public sealed class UpdateUserPreferencesUseCase( + IUserPreferencesRepository preferences, + TimeProvider timeProvider) +{ + private static readonly HashSet NavigationRoutes = new(StringComparer.Ordinal) + { + "/", "/infrastructure", "/infrastructure/services", "/infrastructure/storage", "/projects", + "/runbooks", "/knowledge", "/activity", "/search", "/settings" + }; + + private static readonly HashSet LandingRoutes = new(StringComparer.Ordinal) + { + "/", "/infrastructure", "/infrastructure/services", "/projects", "/knowledge" + }; + + private static readonly HashSet DashboardWidgets = new(StringComparer.Ordinal) + { + "attention", "server", "projects", "activity", "runbooks", "favorites", "quick-actions" + }; + + public async Task ExecuteAsync( + UpdateUserPreferencesCommand command, + CancellationToken cancellationToken) + { + var errors = Validate(command.Values); + if (errors.Count > 0) + { + return new(UpdateUserPreferencesStatus.ValidationFailed, null, errors); + } + + var current = await preferences.FindAsync(command.WorkspaceId, command.UserId, cancellationToken); + if (current is null) + { + if (command.ExpectedVersion != 0) + { + return UpdateUserPreferencesResult.Failure(UpdateUserPreferencesStatus.PreconditionFailed); + } + + current = UserPreferences.Create(command.WorkspaceId, command.UserId, command.Values, timeProvider.GetUtcNow()); + preferences.Add(current); + } + else + { + if (current.Version != command.ExpectedVersion) + { + return UpdateUserPreferencesResult.Failure(UpdateUserPreferencesStatus.PreconditionFailed); + } + + current.Replace(command.Values, timeProvider.GetUtcNow()); + } + + if (!await preferences.SaveChangesAsync(cancellationToken)) + { + return UpdateUserPreferencesResult.Failure(UpdateUserPreferencesStatus.PreconditionFailed); + } + + return new( + UpdateUserPreferencesStatus.Success, + new UserPreferencesSnapshot(current.ToValues(), current.Version, IsDefault: false, current.UpdatedAt), + []); + } + + private static List Validate(UserPreferencesValues? values) + { + var errors = new List(); + if (values is null) + { + errors.Add("Voorkeuren ontbreken."); + return errors; + } + + if (values.Theme is not ("system" or "light" or "dark")) errors.Add("Ongeldig thema."); + if (values.Density is not ("comfortable" or "compact")) errors.Add("Ongeldige datadichtheid."); + if (values.CatalogView is not ("list" or "grid")) errors.Add("Ongeldige catalogusweergave."); + if (!LandingRoutes.Contains(values.DefaultLandingPage ?? string.Empty)) errors.Add("Ongeldige standaardlandingspagina."); + ValidateList(values.NavigationOrder, NavigationRoutes, "navigatie", errors); + ValidateList(values.DashboardWidgetOrder, DashboardWidgets, "dashboardwidgets", errors); + ValidateList(values.HiddenDashboardWidgets, DashboardWidgets, "verborgen dashboardwidgets", errors); + if (values.HiddenDashboardWidgets is not null + && values.DashboardWidgetOrder is not null + && values.HiddenDashboardWidgets.Except(values.DashboardWidgetOrder, StringComparer.Ordinal).Any()) + { + errors.Add("Verborgen widgets moeten in de widgetvolgorde voorkomen."); + } + if (values.ContextPanelWidth is < 256 or > 440) errors.Add("De contextpaneelbreedte moet tussen 256 en 440 pixels liggen."); + if (values.NavigationWidth is < 192 or > 320) errors.Add("De navigatiebreedte moet tussen 192 en 320 pixels liggen."); + return errors; + } + + private static void ValidateList(IReadOnlyList? values, HashSet allowed, string label, List errors) + { + if (values is null || values.Count > allowed.Count || values.Any(value => !allowed.Contains(value)) + || values.Distinct(StringComparer.Ordinal).Count() != values.Count) + { + errors.Add($"De lijst met {label} bevat onbekende of dubbele waarden."); + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Platform/UserPreferencesPort.cs b/src/backend/ItWorx.KnowledgeHub.Application/Platform/UserPreferencesPort.cs new file mode 100644 index 0000000..0c146f1 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Platform/UserPreferencesPort.cs @@ -0,0 +1,15 @@ +using ItWorx.KnowledgeHub.Domain.Platform; + +namespace ItWorx.KnowledgeHub.Application.Platform; + +public sealed record UserPreferencesSnapshot(UserPreferencesValues Values, int Version, bool IsDefault, DateTimeOffset? UpdatedAt); + +public interface IUserPreferencesRepository +{ + Task FindAsync(Guid workspaceId, Guid userId, CancellationToken cancellationToken); + + void Add(UserPreferences preferences); + + /// Returns false for an optimistic-concurrency or first-insert race. + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Sources/DataSourceUseCases.cs b/src/backend/ItWorx.KnowledgeHub.Application/Sources/DataSourceUseCases.cs new file mode 100644 index 0000000..b3dc20a --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Sources/DataSourceUseCases.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Domain.Sources; + +namespace ItWorx.KnowledgeHub.Application.Sources; + +public sealed class DataSourceUseCases(IDataSourceRepository sources, TimeProvider clock) +{ + public Task> ListAsync(Guid workspaceId, Guid? afterId, int limit, CancellationToken cancellationToken) => + sources.ListAsync(workspaceId, afterId, limit, cancellationToken); + + public async Task> RegisterAsync(Guid workspaceId, string key, string name, string kind, int intervalHours, Guid actorId, string? correlationId, CancellationToken cancellationToken) + { + if (!Enum.TryParse(kind, out var parsedKind) || !Enum.IsDefined(parsedKind)) + return OperationResultValue.Failure(OperationStatus.ValidationFailed); + // This key is owned by the inventory importer. A generic registration + // could otherwise label its observations as a different source kind. + if (string.Equals(key, "inventory:default", StringComparison.OrdinalIgnoreCase)) + return OperationResultValue.Failure(OperationStatus.ValidationFailed); + DataSource source; + try { source = DataSource.Create(workspaceId, key, name, parsedKind, intervalHours, clock.GetUtcNow()); } + catch (ArgumentException) { return OperationResultValue.Failure(OperationStatus.ValidationFailed); } + sources.Add(source); + return await sources.SaveAsync(Audit(source, actorId, "source.registered", correlationId), cancellationToken) + ? OperationResultValue.Success(source) : OperationResultValue.Failure(OperationStatus.ConcurrencyConflict); + } + + public async Task> ConfigureAsync(Guid workspaceId, Guid id, string expectedVersion, string name, bool enabled, int intervalHours, Guid actorId, string? correlationId, CancellationToken cancellationToken) + { + var source = await sources.FindAsync(workspaceId, id, cancellationToken); + if (source is null) return OperationResultValue.Failure(OperationStatus.NotFound); + if (expectedVersion != source.Version.ToString(CultureInfo.InvariantCulture)) return OperationResultValue.Failure(OperationStatus.ConcurrencyConflict); + try { source.Configure(name, enabled, intervalHours); } + catch (ArgumentException) { return OperationResultValue.Failure(OperationStatus.ValidationFailed); } + return await sources.SaveAsync(Audit(source, actorId, "source.configured", correlationId), cancellationToken) + ? OperationResultValue.Success(source) : OperationResultValue.Failure(OperationStatus.ConcurrencyConflict); + } + + public async Task> ReportAsync(Guid workspaceId, Guid id, string expectedVersion, bool success, long? records, string? errorCode, string? runCorrelationId, Guid actorId, string? correlationId, CancellationToken cancellationToken) + { + var source = await sources.FindAsync(workspaceId, id, cancellationToken); + if (source is null) return OperationResultValue.Failure(OperationStatus.NotFound); + if (expectedVersion != source.Version.ToString(CultureInfo.InvariantCulture)) return OperationResultValue.Failure(OperationStatus.ConcurrencyConflict); + try { source.Observe(success, records, errorCode, runCorrelationId, clock.GetUtcNow()); } + catch (ArgumentException) { return OperationResultValue.Failure(OperationStatus.ValidationFailed); } + return await sources.SaveAsync(Audit(source, actorId, "source.observation-recorded", correlationId), cancellationToken) + ? OperationResultValue.Success(source) : OperationResultValue.Failure(OperationStatus.ConcurrencyConflict); + } + + private AuditEventRecord Audit(DataSource source, Guid actorId, string action, string? correlationId) => + new(AuditActorKind.User, actorId, "web", action, "data-source", source.Id.ToString(), "Bronregistratie gewijzigd.", correlationId, clock.GetUtcNow()); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Sources/IDataSourceRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Sources/IDataSourceRepository.cs new file mode 100644 index 0000000..49e0ba7 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Sources/IDataSourceRepository.cs @@ -0,0 +1,13 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Sources; + +namespace ItWorx.KnowledgeHub.Application.Sources; + +public interface IDataSourceRepository +{ + Task> ListAsync(Guid workspaceId, Guid? afterId, int limit, CancellationToken cancellationToken); + Task FindAsync(Guid workspaceId, Guid id, CancellationToken cancellationToken); + Task FindByKeyAsync(Guid workspaceId, string sourceKey, CancellationToken cancellationToken); + void Add(DataSource source); + Task SaveAsync(AuditEventRecord? auditEvent, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/UnifiedSearch/IUnifiedSearchRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/UnifiedSearch/IUnifiedSearchRepository.cs new file mode 100644 index 0000000..0fc2453 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/UnifiedSearch/IUnifiedSearchRepository.cs @@ -0,0 +1,46 @@ +namespace ItWorx.KnowledgeHub.Application.UnifiedSearch; + +public enum UnifiedSearchKind +{ + Knowledge, + Projects, + Services, + Runbooks, + Decisions +} + +public sealed record UnifiedSearchHit( + string Kind, + Guid Id, + string Title, + string Summary, + string Snippet, + DateTimeOffset UpdatedAt, + string Href, + double Rank); + +public sealed record UnifiedSearchCountsResult( + int Knowledge, + int Projects, + int Services, + int Runbooks, + int Decisions); + +public sealed record UnifiedSearchPage( + IReadOnlyList Items, + UnifiedSearchCountsResult Counts, + string? NextCursor); + +/// Workspace-scoped search port. Implementations must apply visibility and trash +/// predicates before producing counts, snippets, or paginated results. +public interface IUnifiedSearchRepository +{ + Task SearchAsync( + Guid workspaceId, + string query, + string? tag, + UnifiedSearchKind? kind, + string? cursor, + int limit, + CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/UnifiedSearch/UnifiedSearchUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/UnifiedSearch/UnifiedSearchUseCase.cs new file mode 100644 index 0000000..a719463 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/UnifiedSearch/UnifiedSearchUseCase.cs @@ -0,0 +1,32 @@ +namespace ItWorx.KnowledgeHub.Application.UnifiedSearch; + +public sealed class UnifiedSearchUseCase(IUnifiedSearchRepository repository) +{ + public Task ExecuteAsync( + Guid workspaceId, + string? query, + string? tag, + UnifiedSearchKind? kind, + string? cursor, + int limit, + CancellationToken cancellationToken) + { + var normalizedQuery = (query ?? string.Empty).Trim(); + if (normalizedQuery.Length > 500) + { + normalizedQuery = normalizedQuery[..500]; + } + + var normalizedTag = string.IsNullOrWhiteSpace(tag) ? null : tag.Trim().ToLowerInvariant(); + + // An empty query is useful for tag-only browsing, but never means an + // unfiltered workspace scan. + if (normalizedQuery.Length == 0 && normalizedTag is null) + { + return Task.FromResult(new UnifiedSearchPage( + [], new UnifiedSearchCountsResult(0, 0, 0, 0, 0), null)); + } + + return repository.SearchAsync(workspaceId, normalizedQuery, normalizedTag, kind, cursor, limit, cancellationToken); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Contracts/Activity/ActivityContracts.cs b/src/backend/ItWorx.KnowledgeHub.Contracts/Activity/ActivityContracts.cs index a88c79f..0b61f6e 100644 --- a/src/backend/ItWorx.KnowledgeHub.Contracts/Activity/ActivityContracts.cs +++ b/src/backend/ItWorx.KnowledgeHub.Contracts/Activity/ActivityContracts.cs @@ -4,4 +4,10 @@ public sealed record ActivityItemResponse( long Id, string ActorKind, Guid? ActorId, string Source, string Action, string? TargetType, string? TargetId, string Summary, DateTimeOffset OccurredAt); -public sealed record ActivityListResponse(IReadOnlyList Items); +public sealed record ActivityListResponse(IReadOnlyList Items, long? NextCursor = null); + +public sealed record AdminAuditItemResponse( + long Id, string ActorKind, Guid? ActorId, string Source, string Action, + string? TargetType, string? TargetId, string Summary, DateTimeOffset OccurredAt, string? CorrelationId); + +public sealed record AdminAuditListResponse(IReadOnlyList Items, long? NextCursor); diff --git a/src/backend/ItWorx.KnowledgeHub.Contracts/Jobs/BackgroundJobContracts.cs b/src/backend/ItWorx.KnowledgeHub.Contracts/Jobs/BackgroundJobContracts.cs new file mode 100644 index 0000000..cae14d0 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Contracts/Jobs/BackgroundJobContracts.cs @@ -0,0 +1,33 @@ +using System.Text.Json; + +namespace ItWorx.KnowledgeHub.Contracts.Jobs; + +public sealed record QueueCourseTaggingJobRequest(Guid RootPageId, string Tag); + +public sealed record QueueBackupJobRequest; + +public sealed record BackgroundJobErrorResponse(string Code, string Message, Guid? ItemId); + +public sealed record BackgroundJobResponse( + Guid Id, + Guid WorkspaceId, + Guid ActorId, + string Type, + string Status, + DateTimeOffset CreatedAt, + DateTimeOffset? StartedAt, + DateTimeOffset? CompletedAt, + DateTimeOffset? CancelRequestedAt, + bool CancelRequested, + int Progress, + int Total, + int Processed, + int Succeeded, + int Skipped, + int Failed, + IReadOnlyList Errors, + JsonElement? Result, + string? CorrelationId, + long Version); + +public sealed record BackgroundJobListResponse(IReadOnlyList Items); diff --git a/src/backend/ItWorx.KnowledgeHub.Contracts/Operations/BackupContracts.cs b/src/backend/ItWorx.KnowledgeHub.Contracts/Operations/BackupContracts.cs index 7c48c51..27a98df 100644 --- a/src/backend/ItWorx.KnowledgeHub.Contracts/Operations/BackupContracts.cs +++ b/src/backend/ItWorx.KnowledgeHub.Contracts/Operations/BackupContracts.cs @@ -11,6 +11,32 @@ public sealed record BackupSummaryResponse( long TotalRowCount, int AttachmentCount, string SchemaVersion, - string ProductVersion); + string ProductVersion, + bool Protected = false, + string ProtectionETag = "0", + DateTimeOffset? IntegrityVerifiedAt = null, + DateTimeOffset? RestoreVerifiedAt = null, + bool? LastRestoreSucceeded = null); public sealed record BackupListResponse(IReadOnlyList Items); + +public sealed record BackupProtectionRequest(bool Protected); + +public sealed record BackupStatusResponse( + string State, + string ScheduleAuthority, + string ScheduleDescription, + DateTimeOffset? NextScheduledRun, + DateTimeOffset? NextExpectedBy, + DateTimeOffset? LastSuccessfulBackupAt, + DateTimeOffset? LastIntegrityVerifiedAt, + DateTimeOffset? LastVerifiedRestoreAt, + string RestoreConfidence, + long StoredBackupBytes, + int ProtectedCount, + int ExpectedIntervalHours, + int ApplicationRetentionCount, + int DailyRetentionExpectation, + int WeeklyRetentionExpectation, + int MonthlyRetentionExpectation, + int RestoreVerificationMaxAgeDays); diff --git a/src/backend/ItWorx.KnowledgeHub.Contracts/Platform/PlatformSettingsContracts.cs b/src/backend/ItWorx.KnowledgeHub.Contracts/Platform/PlatformSettingsContracts.cs new file mode 100644 index 0000000..145a24a --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Contracts/Platform/PlatformSettingsContracts.cs @@ -0,0 +1,48 @@ +namespace ItWorx.KnowledgeHub.Contracts.Platform; + +public sealed record IngestionProposalPolicyContract( + bool Enabled, + int MaximumSourceCount, + long MaximumSourceBytes, + int FailedRetryLimit); + +public sealed record BackupPolicyExpectationRequest( + int ExpectedIntervalHours, + string ScheduleDescription, + int DailyRetentionCount, + int WeeklyRetentionCount, + int MonthlyRetentionCount, + int ProtectedBackupCount, + int RestoreVerificationMaxAgeDays); + +public sealed record BackupPolicyExpectationResponse( + string Enforcement, + int ExpectedIntervalHours, + string ScheduleDescription, + int DailyRetentionCount, + int WeeklyRetentionCount, + int MonthlyRetentionCount, + int ProtectedBackupCount, + int RestoreVerificationMaxAgeDays); + +public sealed record InventoryFreshnessPolicyContract(int WarningAfterHours); + +public sealed record PlatformSettingsRequest( + string DisplayName, + string PrimaryLocale, + string TimeZone, + IngestionProposalPolicyContract IngestionProposals, + BackupPolicyExpectationRequest BackupPolicyExpectation, + InventoryFreshnessPolicyContract InventoryFreshnessPolicy); + +public sealed record PlatformSettingsResponse( + int SchemaVersion, + string ETag, + bool IsDefault, + string DisplayName, + string PrimaryLocale, + string TimeZone, + IngestionProposalPolicyContract IngestionProposals, + BackupPolicyExpectationResponse BackupPolicyExpectation, + InventoryFreshnessPolicyContract InventoryFreshnessPolicy, + DateTimeOffset? UpdatedAt); diff --git a/src/backend/ItWorx.KnowledgeHub.Contracts/Platform/UserPreferencesContracts.cs b/src/backend/ItWorx.KnowledgeHub.Contracts/Platform/UserPreferencesContracts.cs new file mode 100644 index 0000000..c53b346 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Contracts/Platform/UserPreferencesContracts.cs @@ -0,0 +1,31 @@ +namespace ItWorx.KnowledgeHub.Contracts.Platform; + +public sealed record UserPreferencesRequest( + string Theme, + string Density, + bool PrimaryNavigationCollapsed, + bool ContextPanelOpen, + IReadOnlyList NavigationOrder, + IReadOnlyList DashboardWidgetOrder, + IReadOnlyList HiddenDashboardWidgets, + string DefaultLandingPage, + string CatalogView, + int ContextPanelWidth, + int NavigationWidth); + +public sealed record UserPreferencesResponse( + int SchemaVersion, + string ETag, + bool IsDefault, + string Theme, + string Density, + bool PrimaryNavigationCollapsed, + bool ContextPanelOpen, + IReadOnlyList NavigationOrder, + IReadOnlyList DashboardWidgetOrder, + IReadOnlyList HiddenDashboardWidgets, + string DefaultLandingPage, + string CatalogView, + int ContextPanelWidth, + int NavigationWidth, + DateTimeOffset? UpdatedAt); diff --git a/src/backend/ItWorx.KnowledgeHub.Contracts/Sources/DataSourceContracts.cs b/src/backend/ItWorx.KnowledgeHub.Contracts/Sources/DataSourceContracts.cs new file mode 100644 index 0000000..f2e99b4 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Contracts/Sources/DataSourceContracts.cs @@ -0,0 +1,10 @@ +namespace ItWorx.KnowledgeHub.Contracts.Sources; + +public sealed record DataSourceResponse(Guid Id, string SourceKey, string DisplayName, string Kind, bool Enabled, + string State, string ConfigurationState, string SyncAuthority, int ExpectedIntervalHours, DateTimeOffset? LastAttemptAt, + DateTimeOffset? LastSuccessAt, DateTimeOffset? LastFailureAt, DateTimeOffset? DataObservedAt, long? RecordCount, string? LastErrorCode, + string? LastCorrelationId, string ETag, IReadOnlyList Actions); +public sealed record DataSourceListResponse(IReadOnlyList Items, string? NextCursor); +public sealed record RegisterDataSourceRequest(string SourceKey, string DisplayName, string Kind, int ExpectedIntervalHours); +public sealed record ConfigureDataSourceRequest(string DisplayName, bool Enabled, int ExpectedIntervalHours); +public sealed record ReportDataSourceRequest(bool Success, long? RecordCount, string? ErrorCode, string? CorrelationId); diff --git a/src/backend/ItWorx.KnowledgeHub.Contracts/UnifiedSearch/UnifiedSearchContracts.cs b/src/backend/ItWorx.KnowledgeHub.Contracts/UnifiedSearch/UnifiedSearchContracts.cs new file mode 100644 index 0000000..edc0cd4 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Contracts/UnifiedSearch/UnifiedSearchContracts.cs @@ -0,0 +1,27 @@ +namespace ItWorx.KnowledgeHub.Contracts.UnifiedSearch; + +/// One permission-filtered, cursor-paginated page across the workspace search corpus. +public sealed record UnifiedSearchResponse( + IReadOnlyList Items, + UnifiedSearchCounts Counts, + string? NextCursor); + +/// Counts over the complete matching visible population, before pagination. +public sealed record UnifiedSearchCounts( + int Knowledge, + int Projects, + int Services, + int Runbooks, + int Decisions) +{ + public int Total => Knowledge + Projects + Services + Runbooks + Decisions; +} + +public sealed record UnifiedSearchResultItem( + string Kind, + Guid Id, + string Title, + string Summary, + string Snippet, + DateTimeOffset UpdatedAt, + string Href); diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEntities.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEntities.cs index 6a0edef..9fbe245 100644 --- a/src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEntities.cs +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEntities.cs @@ -150,9 +150,10 @@ public sealed class InfrastructureService if (Presence != InventoryPresence.Archived) { Presence = InventoryPresence.Missing; + Health = HealthState.Unknown; + RuntimeStatus = "unknown"; } - - LastSeenAt = observedAt; + // Keep the time it was last actually present; absence is captured by the snapshot. } public void Archive() => Presence = InventoryPresence.Archived; diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJob.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJob.cs new file mode 100644 index 0000000..d6dccd4 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJob.cs @@ -0,0 +1,234 @@ +using System.Text.Json; + +namespace ItWorx.KnowledgeHub.Domain.Jobs; + +/// A durable, workspace-scoped unit of operator work. Payload and result JSON +/// contain only bounded, non-secret product data; persisted failures are sanitized +/// summaries rather than exception messages. +public sealed class BackgroundJob +{ + public const int MaxStoredErrors = 20; + public const int MaxErrorMessageLength = 280; + + private BackgroundJob() { } + + public Guid Id { get; private set; } + public Guid WorkspaceId { get; private set; } + public Guid ActorId { get; private set; } + public BackgroundJobType Type { get; private set; } + public BackgroundJobStatus Status { get; private set; } + public string PayloadJson { get; private set; } = "{}"; + public string? ResultJson { get; private set; } + public string ErrorsJson { get; private set; } = "[]"; + public string IdempotencyKeyHash { get; private set; } = string.Empty; + public string PayloadHash { get; private set; } = string.Empty; + public string? CorrelationId { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? StartedAt { get; private set; } + public DateTimeOffset? CompletedAt { get; private set; } + public DateTimeOffset NextAttemptAt { get; private set; } + public DateTimeOffset? CancelRequestedAt { get; private set; } + public DateTimeOffset? LeaseUntil { get; private set; } + public Guid? LeaseToken { get; private set; } + public int AttemptCount { get; private set; } + public int Total { get; private set; } + public int Processed { get; private set; } + public int Succeeded { get; private set; } + public int Skipped { get; private set; } + public int Failed { get; private set; } + public long Version { get; private set; } + + public static BackgroundJob Create( + Guid id, + Guid workspaceId, + Guid actorId, + BackgroundJobType type, + string payloadJson, + string idempotencyKeyHash, + string payloadHash, + string? correlationId, + DateTimeOffset now, + int total) + { + if (id == Guid.Empty || workspaceId == Guid.Empty || actorId == Guid.Empty) + throw new ArgumentException("Job identity fields must be non-empty."); + ArgumentOutOfRangeException.ThrowIfNegative(total); + if (payloadJson.Length > 2048 || idempotencyKeyHash.Length != 64 || payloadHash.Length != 64) + throw new ArgumentException("Job payload and idempotency values must be bounded hashes."); + + return new BackgroundJob + { + Id = id, + WorkspaceId = workspaceId, + ActorId = actorId, + Type = type, + Status = BackgroundJobStatus.Queued, + PayloadJson = payloadJson, + IdempotencyKeyHash = idempotencyKeyHash, + PayloadHash = payloadHash, + CorrelationId = Limit(correlationId, 128), + CreatedAt = now, + NextAttemptAt = now, + Total = total, + Version = 1 + }; + } + + public bool IsTerminal => Status is BackgroundJobStatus.Succeeded + or BackgroundJobStatus.PartiallySucceeded or BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled; + + public int Progress => Total == 0 ? (IsTerminal ? 100 : 0) : Math.Min(100, Processed * 100 / Total); + + public void Start(Guid leaseToken, DateTimeOffset now, DateTimeOffset leaseUntil) + { + if (IsTerminal) + return; + Status = BackgroundJobStatus.Running; + StartedAt ??= now; + LeaseToken = leaseToken; + LeaseUntil = leaseUntil; + AttemptCount++; + Version++; + } + + public void RenewLease(Guid leaseToken, DateTimeOffset leaseUntil) + { + if (Status == BackgroundJobStatus.Running && LeaseToken == leaseToken) + LeaseUntil = leaseUntil; + } + + public void RequestCancellation(DateTimeOffset now) + { + if (IsTerminal) + return; + CancelRequestedAt ??= now; + if (Status == BackgroundJobStatus.Queued) + { + Status = BackgroundJobStatus.Cancelled; + CompletedAt = now; + } + Version++; + } + + public void RecordOutcome(BackgroundJobItemState state, string? code = null, string? message = null, Guid? itemId = null) + { + if (state is not (BackgroundJobItemState.Succeeded or BackgroundJobItemState.Skipped or BackgroundJobItemState.Failed)) + throw new ArgumentOutOfRangeException(nameof(state)); + if (Processed >= Total) + throw new InvalidOperationException("A job cannot process more work items than its persisted total."); + + Processed++; + switch (state) + { + case BackgroundJobItemState.Succeeded: Succeeded++; break; + case BackgroundJobItemState.Skipped: Skipped++; break; + case BackgroundJobItemState.Failed: + Failed++; + if (code is not null && message is not null) + AddError(code, message, itemId); + break; + } + Version++; + } + + public void Touch() => Version++; + + public void Complete(DateTimeOffset now, string? resultJson = null) + { + if (IsTerminal) + return; + ResultJson = resultJson is { Length: <= 4096 } ? resultJson : null; + Status = Failed == 0 ? BackgroundJobStatus.Succeeded + : Succeeded == 0 && Skipped == 0 ? BackgroundJobStatus.Failed + : BackgroundJobStatus.PartiallySucceeded; + CompletedAt = now; + LeaseToken = null; + LeaseUntil = null; + Version++; + } + + public void Cancel(DateTimeOffset now) + { + if (IsTerminal) + return; + Status = BackgroundJobStatus.Cancelled; + CompletedAt = now; + LeaseToken = null; + LeaseUntil = null; + Version++; + } + + public void ScheduleRetry(DateTimeOffset now, DateTimeOffset retryAt, string code, string message) + { + if (IsTerminal) + return; + AddError(code, message, null); + LeaseToken = null; + LeaseUntil = null; + if (AttemptCount <= 3 && CancelRequestedAt is null) + { + Status = BackgroundJobStatus.Queued; + NextAttemptAt = retryAt; + } + else + { + if (Processed < Total) + Processed++; + Failed++; + Status = Succeeded == 0 && Skipped == 0 ? BackgroundJobStatus.Failed : BackgroundJobStatus.PartiallySucceeded; + CompletedAt = now; + } + Version++; + } + + public void Fail(DateTimeOffset now, string code, string safeMessage) + { + if (IsTerminal) + return; + AddError(code, safeMessage, null); + Failed++; + Status = Succeeded == 0 && Skipped == 0 ? BackgroundJobStatus.Failed : BackgroundJobStatus.PartiallySucceeded; + CompletedAt = now; + LeaseToken = null; + LeaseUntil = null; + Version++; + } + + public void SetResult(string resultJson) + { + ResultJson = resultJson.Length <= 4096 ? resultJson : null; + Version++; + } + + public void SetTotal(int total) + { + if (Status != BackgroundJobStatus.Queued || Processed != 0 || total < 0) + throw new InvalidOperationException("The total can only be assigned before a queued job starts."); + Total = total; + Version++; + } + + private void AddError(string code, string message, Guid? itemId) + { + try + { + var errors = JsonSerializer.Deserialize>(ErrorsJson) ?? []; + if (errors.Count >= MaxStoredErrors) + return; + errors.Add(new BackgroundJobError( + Limit(code, 64) ?? "job_error", + Limit(message, MaxErrorMessageLength) ?? "De taak is mislukt.", + itemId)); + ErrorsJson = JsonSerializer.Serialize(errors); + } + catch (JsonException) + { + ErrorsJson = "[]"; + } + } + + private static string? Limit(string? value, int length) => + string.IsNullOrEmpty(value) ? value : value.Length <= length ? value : value[..length]; +} + +public sealed record BackgroundJobError(string Code, string Message, Guid? ItemId); diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobItem.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobItem.cs new file mode 100644 index 0000000..0427942 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobItem.cs @@ -0,0 +1,34 @@ +namespace ItWorx.KnowledgeHub.Domain.Jobs; + +/// One row in the database-side snapshot of a course-tagging job. The stable +/// ordinal lets workers resume after a process restart without loading a subtree into +/// application memory. +public sealed class BackgroundJobItem +{ + private BackgroundJobItem() { } + + public long Ordinal { get; private set; } + public Guid JobId { get; private set; } + public Guid PageId { get; private set; } + public bool InitiallyTagged { get; private set; } + public BackgroundJobItemState State { get; private set; } + public DateTimeOffset? StartedAt { get; private set; } + public DateTimeOffset? CompletedAt { get; private set; } + + public void MarkProcessing(DateTimeOffset now) + { + if (State is BackgroundJobItemState.Pending or BackgroundJobItemState.Processing) + { + State = BackgroundJobItemState.Processing; + StartedAt ??= now; + } + } + + public void Complete(BackgroundJobItemState state, DateTimeOffset now) + { + if (state is not (BackgroundJobItemState.Succeeded or BackgroundJobItemState.Skipped or BackgroundJobItemState.Failed)) + throw new ArgumentOutOfRangeException(nameof(state)); + State = state; + CompletedAt = now; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobItemState.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobItemState.cs new file mode 100644 index 0000000..755b9d2 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobItemState.cs @@ -0,0 +1,10 @@ +namespace ItWorx.KnowledgeHub.Domain.Jobs; + +public enum BackgroundJobItemState +{ + Pending, + Processing, + Succeeded, + Skipped, + Failed +} diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobStatus.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobStatus.cs new file mode 100644 index 0000000..78cafc1 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobStatus.cs @@ -0,0 +1,11 @@ +namespace ItWorx.KnowledgeHub.Domain.Jobs; + +public enum BackgroundJobStatus +{ + Queued, + Running, + Succeeded, + PartiallySucceeded, + Failed, + Cancelled +} diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobType.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobType.cs new file mode 100644 index 0000000..914d37c --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Jobs/BackgroundJobType.cs @@ -0,0 +1,7 @@ +namespace ItWorx.KnowledgeHub.Domain.Jobs; + +public enum BackgroundJobType +{ + CourseTagging, + Backup +} diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Platform/PlatformSettings.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Platform/PlatformSettings.cs new file mode 100644 index 0000000..d77fb34 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Platform/PlatformSettings.cs @@ -0,0 +1,126 @@ +namespace ItWorx.KnowledgeHub.Domain.Platform; + +public sealed record IngestionProposalPolicyValues( + bool Enabled, + int MaximumSourceCount, + long MaximumSourceBytes, + int FailedRetryLimit); + +/// +/// Expectations about operator-managed backup jobs. The application records and reports +/// these values; it does not schedule or prune external backup jobs. +/// +public sealed record BackupPolicyExpectationValues( + int ExpectedIntervalHours, + string ScheduleDescription, + int DailyRetentionCount, + int WeeklyRetentionCount, + int MonthlyRetentionCount, + int ProtectedBackupCount, + int RestoreVerificationMaxAgeDays); + +public sealed record InventoryFreshnessPolicyValues(int WarningAfterHours); + +public sealed record PlatformSettingsValues( + string DisplayName, + string PrimaryLocale, + string TimeZone, + IngestionProposalPolicyValues IngestionProposals, + BackupPolicyExpectationValues BackupPolicyExpectation, + InventoryFreshnessPolicyValues InventoryFreshnessPolicy); + +/// Versioned, installation-wide operator policy. +public sealed class PlatformSettings +{ + public static readonly Guid SingletonId = new("00000000-0000-0000-0000-000000000002"); + + private PlatformSettings() { } + + private PlatformSettings(PlatformSettingsValues values, DateTimeOffset now) + { + Id = SingletonId; + Apply(values); + Version = 1; + UpdatedAt = now; + } + + public Guid Id { get; private set; } + + public string DisplayName { get; private set; } = "ItWorx Knowledge Hub"; + + public string PrimaryLocale { get; private set; } = "nl-BE"; + + public string TimeZone { get; private set; } = "Europe/Brussels"; + + public bool IngestionEnabled { get; private set; } + + public int MaximumSourceCount { get; private set; } = 20; + + public long MaximumSourceBytes { get; private set; } = 104_857_600; + + public int FailedRetryLimit { get; private set; } = 3; + + public int BackupExpectedIntervalHours { get; private set; } = 24; + + public string BackupScheduleDescription { get; private set; } = "Extern beheerd"; + + public int BackupDailyRetentionCount { get; private set; } = 7; + + public int BackupWeeklyRetentionCount { get; private set; } = 4; + + public int BackupMonthlyRetentionCount { get; private set; } = 6; + + public int ProtectedBackupCount { get; private set; } = 1; + + public int RestoreVerificationMaxAgeDays { get; private set; } = 90; + + public int InventoryWarningAfterHours { get; private set; } = 24; + + /// Monotonic strong ETag for this row. + public int Version { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public static PlatformSettings Create(PlatformSettingsValues values, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(values); + return new PlatformSettings(values, now); + } + + public PlatformSettingsValues ToValues() => new( + DisplayName, + PrimaryLocale, + TimeZone, + new IngestionProposalPolicyValues(IngestionEnabled, MaximumSourceCount, MaximumSourceBytes, FailedRetryLimit), + new BackupPolicyExpectationValues( + BackupExpectedIntervalHours, BackupScheduleDescription, BackupDailyRetentionCount, BackupWeeklyRetentionCount, + BackupMonthlyRetentionCount, ProtectedBackupCount, RestoreVerificationMaxAgeDays), + new InventoryFreshnessPolicyValues(InventoryWarningAfterHours)); + + public void Replace(PlatformSettingsValues values, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(values); + Apply(values); + Version = checked(Version + 1); + UpdatedAt = now; + } + + private void Apply(PlatformSettingsValues values) + { + DisplayName = values.DisplayName; + PrimaryLocale = values.PrimaryLocale; + TimeZone = values.TimeZone; + IngestionEnabled = values.IngestionProposals.Enabled; + MaximumSourceCount = values.IngestionProposals.MaximumSourceCount; + MaximumSourceBytes = values.IngestionProposals.MaximumSourceBytes; + FailedRetryLimit = values.IngestionProposals.FailedRetryLimit; + BackupExpectedIntervalHours = values.BackupPolicyExpectation.ExpectedIntervalHours; + BackupScheduleDescription = values.BackupPolicyExpectation.ScheduleDescription; + BackupDailyRetentionCount = values.BackupPolicyExpectation.DailyRetentionCount; + BackupWeeklyRetentionCount = values.BackupPolicyExpectation.WeeklyRetentionCount; + BackupMonthlyRetentionCount = values.BackupPolicyExpectation.MonthlyRetentionCount; + ProtectedBackupCount = values.BackupPolicyExpectation.ProtectedBackupCount; + RestoreVerificationMaxAgeDays = values.BackupPolicyExpectation.RestoreVerificationMaxAgeDays; + InventoryWarningAfterHours = values.InventoryFreshnessPolicy.WarningAfterHours; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Platform/UserPreferences.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Platform/UserPreferences.cs new file mode 100644 index 0000000..33d073c --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Platform/UserPreferences.cs @@ -0,0 +1,107 @@ +namespace ItWorx.KnowledgeHub.Domain.Platform; + +public sealed record UserPreferencesValues( + string Theme, + string Density, + bool PrimaryNavigationCollapsed, + bool ContextPanelOpen, + IReadOnlyList NavigationOrder, + IReadOnlyList DashboardWidgetOrder, + IReadOnlyList HiddenDashboardWidgets, + string DefaultLandingPage, + string CatalogView, + int ContextPanelWidth, + int NavigationWidth); + +/// Workspace-scoped presentation preferences for one interactive user. +public sealed class UserPreferences +{ + private UserPreferences() { } + + private UserPreferences(Guid workspaceId, Guid userId, UserPreferencesValues values, DateTimeOffset now) + { + WorkspaceId = workspaceId; + UserId = userId; + Apply(values); + Version = 1; + UpdatedAt = now; + } + + public Guid WorkspaceId { get; private set; } + + public Guid UserId { get; private set; } + + public string Theme { get; private set; } = "system"; + + public string Density { get; private set; } = "comfortable"; + + public bool PrimaryNavigationCollapsed { get; private set; } + + public bool ContextPanelOpen { get; private set; } + + public string[] NavigationOrder { get; private set; } = []; + + public string[] DashboardWidgetOrder { get; private set; } = []; + + public string[] HiddenDashboardWidgets { get; private set; } = []; + + public string DefaultLandingPage { get; private set; } = "/"; + + public string CatalogView { get; private set; } = "list"; + + public int ContextPanelWidth { get; private set; } = 304; + + public int NavigationWidth { get; private set; } = 224; + + /// Monotonic strong ETag for this preference row. + public int Version { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public static UserPreferences Create(Guid workspaceId, Guid userId, UserPreferencesValues values, DateTimeOffset now) + { + if (workspaceId == Guid.Empty || userId == Guid.Empty) + { + throw new ArgumentException("Workspace and user IDs are required."); + } + + ArgumentNullException.ThrowIfNull(values); + return new UserPreferences(workspaceId, userId, values, now); + } + + public UserPreferencesValues ToValues() => new( + Theme, + Density, + PrimaryNavigationCollapsed, + ContextPanelOpen, + NavigationOrder.ToArray(), + DashboardWidgetOrder.ToArray(), + HiddenDashboardWidgets.ToArray(), + DefaultLandingPage, + CatalogView, + ContextPanelWidth, + NavigationWidth); + + public void Replace(UserPreferencesValues values, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(values); + Apply(values); + Version = checked(Version + 1); + UpdatedAt = now; + } + + private void Apply(UserPreferencesValues values) + { + Theme = values.Theme; + Density = values.Density; + PrimaryNavigationCollapsed = values.PrimaryNavigationCollapsed; + ContextPanelOpen = values.ContextPanelOpen; + NavigationOrder = values.NavigationOrder.ToArray(); + DashboardWidgetOrder = values.DashboardWidgetOrder.ToArray(); + HiddenDashboardWidgets = values.HiddenDashboardWidgets.ToArray(); + DefaultLandingPage = values.DefaultLandingPage; + CatalogView = values.CatalogView; + ContextPanelWidth = values.ContextPanelWidth; + NavigationWidth = values.NavigationWidth; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Domain/Sources/DataSource.cs b/src/backend/ItWorx.KnowledgeHub.Domain/Sources/DataSource.cs new file mode 100644 index 0000000..2375374 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Domain/Sources/DataSource.cs @@ -0,0 +1,87 @@ +using System.Text.RegularExpressions; + +namespace ItWorx.KnowledgeHub.Domain.Sources; + +public enum DataSourceKind { Inventory, Gitea, KnowledgeClient, ManualIntake, LeafWiki, CuratedPackage } + +/// Operator registration and observed evidence; credentials never enter this aggregate. +public sealed class DataSource +{ + private DataSource() { } + public Guid Id { get; private set; } + public Guid WorkspaceId { get; private set; } + public string SourceKey { get; private set; } = ""; + public string DisplayName { get; private set; } = ""; + public DataSourceKind Kind { get; private set; } + public bool Enabled { get; private set; } = true; + public int ExpectedIntervalHours { get; private set; } + public int Version { get; private set; } = 1; + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? LastAttemptAt { get; private set; } + public DateTimeOffset? LastSuccessAt { get; private set; } + public DateTimeOffset? LastFailureAt { get; private set; } + public DateTimeOffset? DataObservedAt { get; private set; } + public long? RecordCount { get; private set; } + public string? LastErrorCode { get; private set; } + public string? LastCorrelationId { get; private set; } + + public static DataSource Create(Guid workspaceId, string sourceKey, string displayName, DataSourceKind kind, int expectedIntervalHours, DateTimeOffset now) + { + if (workspaceId == Guid.Empty || !Enum.IsDefined(kind) || !Regex.IsMatch(sourceKey, "^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$", RegexOptions.CultureInvariant)) + { + throw new ArgumentException("Invalid source identity."); + } + var source = new DataSource { Id = Guid.NewGuid(), WorkspaceId = workspaceId, SourceKey = sourceKey, Kind = kind, CreatedAt = now, Version = 0 }; + source.Configure(displayName, true, expectedIntervalHours); + return source; + } + + public void Configure(string displayName, bool enabled, int expectedIntervalHours) + { + if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > 120 || displayName.Any(char.IsControl) || expectedIntervalHours is < 1 or > 8760) + { + throw new ArgumentException("Invalid source policy."); + } + DisplayName = displayName.Trim(); + Enabled = enabled; + ExpectedIntervalHours = expectedIntervalHours; + Version++; + } + + public void Observe(bool success, long? recordCount, string? errorCode, string? correlationId, DateTimeOffset observedAt, DateTimeOffset? dataObservedAt = null) + { + if (recordCount < 0 || (!success && errorCode is not ("authentication" or "connectivity" or "validation" or "unexpected")) + || (LastAttemptAt is { } previous && observedAt < previous) + || dataObservedAt > observedAt || correlationId?.Length > 100 || correlationId?.Any(char.IsControl) == true) + { + throw new ArgumentException("Invalid source observation."); + } + LastAttemptAt = observedAt; + LastCorrelationId = correlationId; + if (success) + { + LastSuccessAt = observedAt; + var captured = dataObservedAt ?? observedAt; + if (DataObservedAt is null || captured >= DataObservedAt) + { + DataObservedAt = captured; + RecordCount = recordCount; + } + LastErrorCode = null; + } + else + { + LastFailureAt = observedAt; + LastErrorCode = errorCode; + } + Version++; + } + + public string GetState(DateTimeOffset now) + { + if (!Enabled) return "Disabled"; + if (LastErrorCode is not null) return "Failed"; + if (DataObservedAt is null || DataObservedAt > now) return "Unknown"; + return now - DataObservedAt.Value > TimeSpan.FromHours(ExpectedIntervalHours) ? "Stale" : "Current"; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventReader.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventReader.cs index 09df879..a1be0ca 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventReader.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventReader.cs @@ -4,8 +4,33 @@ using Microsoft.EntityFrameworkCore; namespace ItWorx.KnowledgeHub.Infrastructure.Audit; -public sealed class AuditEventReader(KnowledgeHubDbContext db) : IAuditEventReader +public sealed class AuditEventReader(KnowledgeHubDbContext db) : IAuditEventReader, IActivityEventReader { + private static readonly string[] OperationalTargets = + ["page", "project", "decision", "runbook", "milestone", "project-update", "inventory-snapshot", "infrastructure-service"]; + + public async Task ListPageAsync(int limit, long? before, CancellationToken cancellationToken) + { + var size = Math.Clamp(limit, 1, 200); + var query = db.AuditEvents.AsNoTracking(); + if (before is not null) query = query.Where(item => item.Id < before); + var items = await query.OrderByDescending(item => item.Id).Take(size + 1) + .Select(item => new AuditEventView(item.Id, item.ActorKind, item.ActorId, item.Source, item.Action, + item.TargetType, item.TargetId, item.Summary, item.OccurredAt, item.CorrelationId)).ToListAsync(cancellationToken); + return new OperationalActivityPage(items.Take(size).ToArray(), items.Count > size ? items[size - 1].Id : null); + } + + public async Task ListAsync(int limit, long? before, CancellationToken cancellationToken) + { + var size = Math.Clamp(limit, 1, 200); + var query = db.AuditEvents.AsNoTracking().Where(item => OperationalTargets.Contains(item.TargetType)); + if (before is not null) query = query.Where(item => item.Id < before); + var items = await query.OrderByDescending(item => item.Id).Take(size + 1) + .Select(item => new AuditEventView(item.Id, item.ActorKind, item.ActorId, item.Source, item.Action, + item.TargetType, item.TargetId, item.Summary, item.OccurredAt)).ToListAsync(cancellationToken); + return new OperationalActivityPage(items.Take(size).ToArray(), items.Count > size ? items[size - 1].Id : null); + } + public async Task> ListRecentAsync(int limit, CancellationToken cancellationToken) => await db.AuditEvents .AsNoTracking() diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureKnowledge/InfrastructureRepository.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureKnowledge/InfrastructureRepository.cs index eef500c..4571c23 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureKnowledge/InfrastructureRepository.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureKnowledge/InfrastructureRepository.cs @@ -3,6 +3,7 @@ using System.Text; using ItWorx.KnowledgeHub.Application.Common; using ItWorx.KnowledgeHub.Application.Infrastructure; using ItWorx.KnowledgeHub.Domain.Infrastructure; +using ItWorx.KnowledgeHub.Domain.Sources; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Npgsql; @@ -399,6 +400,19 @@ public sealed class InfrastructureRepository(KnowledgeHubDbContext db) : IInfras Guid workspaceId, InventorySnapshotInput input, DateTimeOffset importedAt, CancellationToken cancellationToken) { var normalizedHash = input.SourceHash.Trim().ToLowerInvariant(); + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + // Serialize imports for one workspace before checking idempotency or age. + // Concurrent snapshots must not resurrect older container facts. + var lockKey = "knowledge-hub:inventory:" + workspaceId.ToString("N"); + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtext({lockKey}))", cancellationToken); + // Lock the current registry row in the same transaction as the inventory + // mutation. An operator disable that commits first is observed here; one + // arriving later waits until this snapshot commits. + var sourceRows = await db.Set() + .FromSqlInterpolated($"SELECT * FROM data_sources WHERE workspace_id = {workspaceId} AND source_key = 'inventory:default' FOR SHARE") + .AsNoTracking().ToListAsync(cancellationToken); + if (sourceRows.Any(source => !source.Enabled || source.Kind != DataSourceKind.Inventory)) + throw new InactiveInventorySourceException(); var existingSnapshot = await db.InventorySnapshots.AsNoTracking() .FirstOrDefaultAsync( snapshot => snapshot.WorkspaceId == workspaceId && snapshot.SourceHash == normalizedHash, @@ -412,7 +426,10 @@ public sealed class InfrastructureRepository(KnowledgeHubDbContext db) : IInfras await db.ManualOverrides.CountAsync(manualOverride => manualOverride.WorkspaceId == workspaceId, cancellationToken)); } - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + var latestCapture = await db.InventorySnapshots.AsNoTracking() + .Where(item => item.WorkspaceId == workspaceId) + .MaxAsync(item => (DateTimeOffset?)item.CapturedAt, cancellationToken); + if (latestCapture > input.CapturedAt) throw new StaleInventorySnapshotException(); var snapshot = InventorySnapshot.Create( workspaceId, input.SourceKey, normalizedHash, input.CapturedAt, importedAt, input.Hosts.Count, input.Services.Count); db.InventorySnapshots.Add(snapshot); diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureServiceCollectionExtensions.cs index b2d3eac..3105c64 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -6,12 +6,14 @@ using ItWorx.KnowledgeHub.Application.Decisions; using ItWorx.KnowledgeHub.Application.Identity; using ItWorx.KnowledgeHub.Application.Infrastructure; using ItWorx.KnowledgeHub.Application.Ingestion; +using ItWorx.KnowledgeHub.Application.Jobs; using ItWorx.KnowledgeHub.Application.Knowledge; using ItWorx.KnowledgeHub.Application.Knowledge.UseCases; using ItWorx.KnowledgeHub.Application.Migration; using ItWorx.KnowledgeHub.Application.Operations; using ItWorx.KnowledgeHub.Application.Projects; using ItWorx.KnowledgeHub.Application.Runbooks; +using ItWorx.KnowledgeHub.Application.UnifiedSearch; using ItWorx.KnowledgeHub.Infrastructure.Attachments; using ItWorx.KnowledgeHub.Infrastructure.Audit; using ItWorx.KnowledgeHub.Infrastructure.Automation; @@ -19,12 +21,14 @@ using ItWorx.KnowledgeHub.Infrastructure.Decisions; using ItWorx.KnowledgeHub.Infrastructure.Identity; using ItWorx.KnowledgeHub.Infrastructure.InfrastructureKnowledge; using ItWorx.KnowledgeHub.Infrastructure.Ingestion; +using ItWorx.KnowledgeHub.Infrastructure.Jobs; using ItWorx.KnowledgeHub.Infrastructure.Knowledge; using ItWorx.KnowledgeHub.Infrastructure.LeafWiki; using ItWorx.KnowledgeHub.Infrastructure.Operations; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.Infrastructure.Projects; using ItWorx.KnowledgeHub.Infrastructure.Runbooks; +using ItWorx.KnowledgeHub.Infrastructure.UnifiedSearch; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -53,10 +57,15 @@ public static class InfrastructureServiceCollectionExtensions services.AddSingleton(provider => new DatabaseConnectionOptions(DatabaseConnectionStringFactory.Build(provider.GetRequiredService()))); + services.TryAddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services.AddDbContext((provider, options) => options .UseNpgsql(provider.GetRequiredService().ConnectionString, npgsql => npgsql.MigrationsAssembly( typeof(KnowledgeHubDbContext).Assembly.GetName().Name)) - .UseSnakeCaseNamingConvention()); + .UseSnakeCaseNamingConvention() + .AddInterceptors(provider.GetRequiredService())); services.AddHttpContextAccessor(); @@ -103,6 +112,7 @@ public static class InfrastructureServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -137,6 +147,7 @@ public static class InfrastructureServiceCollectionExtensions }); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); @@ -152,7 +163,10 @@ public static class InfrastructureServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddSingleton( + // The catalog resolves scoped adapters (including the SQLite reader). + // Keep it scoped so request-time preflight never resolves them from the + // root provider or holds a reader beyond its request scope. + services.AddScoped( provider => new LeafWikiAdapterCatalog( [ // A database-backed adapter is preferred when both could apply: it can @@ -188,7 +202,15 @@ public static class InfrastructureServiceCollectionExtensions services.AddScoped(); services.AddSingleton(); - services.AddSingleton(_ => new BackupPaths(ResolveBackupsDirectory(configuration), ResolveAttachmentsDirectory(configuration))); + services.AddSingleton(provider => + { + var resolvedConfiguration = provider.GetRequiredService(); + return new BackupPaths( + ResolveBackupsDirectory(resolvedConfiguration), + ResolveAttachmentsDirectory(resolvedConfiguration), + ResolveKeysDirectory(resolvedConfiguration)); + }); + services.AddSingleton(); services.AddSingleton(provider => new PostgresBackupService( provider.GetRequiredService(), provider.GetRequiredService(), @@ -213,6 +235,10 @@ public static class InfrastructureServiceCollectionExtensions configuration["KnowledgeHub:Paths:Backups"] ?? Path.Combine(AppContext.BaseDirectory, "backups-data"); + private static string ResolveKeysDirectory(IConfiguration configuration) => + configuration["KnowledgeHub:Paths:Keys"] + ?? Path.Combine(AppContext.BaseDirectory, "keys-data"); + private static string ResolveReleaseManifestPath(IConfiguration configuration) => configuration["KnowledgeHub:Paths:ReleaseManifest"] ?? Path.Combine(AppContext.BaseDirectory, "release-manifest.json"); diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Jobs/BackgroundJobRepository.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Jobs/BackgroundJobRepository.cs new file mode 100644 index 0000000..4e97068 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Jobs/BackgroundJobRepository.cs @@ -0,0 +1,417 @@ +using System.Data; +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Jobs; +using ItWorx.KnowledgeHub.Domain.Jobs; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace ItWorx.KnowledgeHub.Infrastructure.Jobs; + +public sealed class BackgroundJobRepository( + KnowledgeHubDbContext db, + IAuditEventRecorder auditEvents) : IBackgroundJobRepository +{ + public async Task EnqueueAsync( + BackgroundJob job, + Guid? rootPageId, + string? tag, + int maxCoursePages, + BackgroundJobQueueLimits limits, + AuditEventRecord auditEvent, + CancellationToken cancellationToken) + { + await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken); + // Serialize quota checks with insertion across processes; a burst of + // distinct idempotency keys must not race past the durable queue budget. + var lockKey = "knowledge-hub:job-enqueue:" + job.WorkspaceId.ToString("N"); + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtext({lockKey}))", cancellationToken); + var prior = await FindByIdempotencyCoreAsync(job.WorkspaceId, job.ActorId, job.IdempotencyKeyHash, cancellationToken); + if (prior is not null) + { + await transaction.RollbackAsync(cancellationToken); + return new EnqueueJobResult( + prior.PayloadHash == job.PayloadHash ? EnqueueJobStatus.Existing : EnqueueJobStatus.IdempotencyConflict, + prior.PayloadHash == job.PayloadHash ? prior : null); + } + + var pending = db.BackgroundJobs.Where(item => item.WorkspaceId == job.WorkspaceId + && (item.Status == BackgroundJobStatus.Queued || item.Status == BackgroundJobStatus.Running)); + var since = job.CreatedAt.AddDays(-1); + if (await pending.CountAsync(cancellationToken) >= limits.PendingPerWorkspace + || await pending.CountAsync(item => item.ActorId == job.ActorId, cancellationToken) >= limits.PendingPerActor + || await db.BackgroundJobs.CountAsync(item => item.WorkspaceId == job.WorkspaceId && item.CreatedAt >= since, cancellationToken) >= limits.CreatedPerDay) + { + await transaction.RollbackAsync(cancellationToken); + return new EnqueueJobResult(EnqueueJobStatus.QueueFull, null); + } + + db.BackgroundJobs.Add(job); + try + { + await db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (IsIdempotencyViolation(exception)) + { + await transaction.RollbackAsync(cancellationToken); + await transaction.DisposeAsync(); + db.ChangeTracker.Clear(); + prior = await FindByIdempotencyCoreAsync(job.WorkspaceId, job.ActorId, job.IdempotencyKeyHash, cancellationToken); + return prior is not null && prior.PayloadHash == job.PayloadHash + ? new EnqueueJobResult(EnqueueJobStatus.Existing, prior) + : new EnqueueJobResult(EnqueueJobStatus.IdempotencyConflict, null); + } + + if (job.Type == BackgroundJobType.CourseTagging) + { + if (rootPageId is null || string.IsNullOrWhiteSpace(tag)) + throw new InvalidOperationException("Course-tagging payload is incomplete."); + + await db.Database.ExecuteSqlInterpolatedAsync($""" + WITH RECURSIVE subtree(page_id) AS ( + SELECT id FROM pages + WHERE id = {rootPageId.Value} AND workspace_id = {job.WorkspaceId} AND trashed_at IS NULL + UNION + SELECT child.id FROM pages child + INNER JOIN subtree parent ON child.parent_page_id = parent.page_id + WHERE child.workspace_id = {job.WorkspaceId} AND child.trashed_at IS NULL + ) + INSERT INTO background_job_items (job_id, page_id, initially_tagged, state) + SELECT {job.Id}, page.id, + EXISTS (SELECT 1 FROM unnest(page.tags) AS existing_tags(existing_tag) WHERE lower(existing_tags.existing_tag) = {tag}), + 'Pending' + FROM subtree + INNER JOIN pages page ON page.id = subtree.page_id + LIMIT {maxCoursePages + 1} + """, cancellationToken); + + var itemCount = await db.BackgroundJobItems.CountAsync(item => item.JobId == job.Id, cancellationToken); + if (itemCount == 0) + { + await transaction.RollbackAsync(cancellationToken); + db.ChangeTracker.Clear(); + return new EnqueueJobResult(EnqueueJobStatus.RootNotFound, null); + } + if (itemCount > maxCoursePages) + { + await transaction.RollbackAsync(cancellationToken); + db.ChangeTracker.Clear(); + return new EnqueueJobResult(EnqueueJobStatus.TooManyPages, null); + } + + job.SetTotal(itemCount); + await db.SaveChangesAsync(cancellationToken); + } + + auditEvents.Add(auditEvent); + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new EnqueueJobResult(EnqueueJobStatus.Created, job); + } + + public Task FindAsync(Guid workspaceId, Guid jobId, CancellationToken cancellationToken) => + db.BackgroundJobs.AsNoTracking() + .FirstOrDefaultAsync(job => job.WorkspaceId == workspaceId && job.Id == jobId, cancellationToken); + + public async Task> ListAsync(Guid workspaceId, int limit, CancellationToken cancellationToken) => + await db.BackgroundJobs.AsNoTracking() + .Where(job => job.WorkspaceId == workspaceId) + .OrderByDescending(job => job.CreatedAt) + .ThenByDescending(job => job.Id) + .Take(Math.Clamp(limit, 1, 100)) + .ToListAsync(cancellationToken); + + public Task FindByIdempotencyAsync( + Guid workspaceId, Guid actorId, string keyHash, CancellationToken cancellationToken) => + FindByIdempotencyCoreAsync(workspaceId, actorId, keyHash, cancellationToken); + + public async Task RequestCancellationAsync( + Guid workspaceId, + Guid jobId, + DateTimeOffset now, + AuditEventRecord auditEvent, + CancellationToken cancellationToken) + { + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + // Preserve cancellation intent even when a worker claims or renews the row + // concurrently. A repeated request is a no-op, so it cannot emit duplicate + // audit records or advance the snapshot version again. + var updated = await db.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE background_jobs + SET cancel_requested_at = COALESCE(cancel_requested_at, {now}), + completed_at = CASE WHEN status = 'Queued' THEN {now} ELSE completed_at END, + status = CASE WHEN status = 'Queued' THEN 'Cancelled' ELSE status END, + version = version + 1 + WHERE workspace_id = {workspaceId} AND id = {jobId} + AND status IN ('Queued', 'Running') AND cancel_requested_at IS NULL + """, cancellationToken); + if (updated > 0) + { + await db.BackgroundJobItems.Where(item => item.JobId == jobId + && db.BackgroundJobs.Any(job => job.Id == jobId && job.WorkspaceId == workspaceId && job.Status == BackgroundJobStatus.Cancelled)) + .ExecuteDeleteAsync(cancellationToken); + auditEvents.Add(auditEvent); + await db.SaveChangesAsync(cancellationToken); + } + await transaction.CommitAsync(cancellationToken); + return updated > 0; + } + + public async Task TryClaimNextAsync( + Guid leaseToken, + DateTimeOffset now, + DateTimeOffset leaseUntil, + CancellationToken cancellationToken) + { + await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken); + + var expired = await db.BackgroundJobs + .Where(job => job.Status == BackgroundJobStatus.Running + && job.LeaseUntil <= now + && job.AttemptCount >= 4) + .OrderBy(job => job.LeaseUntil) + .Take(4) + .ToListAsync(cancellationToken); + foreach (var job in expired) + job.ScheduleRetry(now, now, "worker_recovery_exhausted", "De taak kon na procesonderbrekingen niet worden hervat."); + + var cancelledQueued = await db.BackgroundJobs + .Where(job => job.Status == BackgroundJobStatus.Queued && job.CancelRequestedAt != null) + .OrderBy(job => job.CreatedAt) + .Take(20) + .ToListAsync(cancellationToken); + foreach (var job in cancelledQueued) + job.Cancel(now); + + if (expired.Count > 0 || cancelledQueued.Count > 0) + { + await db.SaveChangesAsync(cancellationToken); + var terminalIds = expired.Concat(cancelledQueued).Where(job => job.IsTerminal).Select(job => job.Id).ToArray(); + await db.BackgroundJobItems.Where(item => terminalIds.Contains(item.JobId)).ExecuteDeleteAsync(cancellationToken); + } + + var candidate = await db.BackgroundJobs + .FromSqlInterpolated($""" + SELECT * FROM background_jobs + WHERE (status = 'Queued' AND next_attempt_at <= {now}) + OR (status = 'Running' AND lease_until <= {now} AND attempt_count < 4) + ORDER BY created_at, id + LIMIT 1 + FOR UPDATE SKIP LOCKED + """) + .FirstOrDefaultAsync(cancellationToken); + + if (candidate is null) + { + await transaction.CommitAsync(cancellationToken); + return null; + } + + candidate.Start(leaseToken, now, leaseUntil); + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return candidate.Id; + } + + public async Task RenewLeaseAsync(Guid jobId, Guid leaseToken, DateTimeOffset leaseUntil, CancellationToken cancellationToken) + { + return await db.BackgroundJobs.Where(item => item.Id == jobId && item.Status == BackgroundJobStatus.Running && item.LeaseToken == leaseToken) + .ExecuteUpdateAsync(update => update.SetProperty(item => item.LeaseUntil, leaseUntil), cancellationToken) == 1; + } + + public Task FindLeasedAsync(Guid jobId, Guid leaseToken, CancellationToken cancellationToken) + { + // Each mutation must use fresh cancellation and lease state rather than an + // identity-resolved instance left in this long-lived worker scope. + foreach (var entry in db.ChangeTracker.Entries().Where(entry => entry.Entity.Id == jobId).ToArray()) + entry.State = EntityState.Detached; + return db.BackgroundJobs.FirstOrDefaultAsync( + job => job.Id == jobId && job.Status == BackgroundJobStatus.Running && job.LeaseToken == leaseToken, + cancellationToken); + } + + public async Task FindNextPageItemAsync(Guid jobId, Guid leaseToken, CancellationToken cancellationToken) + { + if (!await HasLeaseAsync(jobId, leaseToken, cancellationToken)) + return null; + return await db.BackgroundJobItems.AsNoTracking() + .Where(item => item.JobId == jobId + && (item.State == BackgroundJobItemState.Pending || item.State == BackgroundJobItemState.Processing)) + .OrderBy(item => item.Ordinal) + .Select(item => new BackgroundJobPageWorkItem(item.Ordinal, item.PageId, item.InitiallyTagged, item.State)) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task MarkPageItemProcessingAsync( + Guid jobId, Guid leaseToken, long ordinal, DateTimeOffset now, CancellationToken cancellationToken) + { + var job = await FindLeasedAsync(jobId, leaseToken, cancellationToken) + ?? throw new InvalidOperationException("The job lease is no longer valid."); + var item = await db.BackgroundJobItems.FirstAsync( + row => row.JobId == jobId && row.Ordinal == ordinal, cancellationToken); + item.MarkProcessing(now); + job.Touch(); + await SaveWorkerChangesAsync(cancellationToken); + } + + public async Task RecordPageItemOutcomeAsync( + Guid jobId, + Guid leaseToken, + long ordinal, + BackgroundJobItemState outcome, + string? errorCode, + string? errorMessage, + Guid? pageId, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var job = await FindLeasedAsync(jobId, leaseToken, cancellationToken) + ?? throw new InvalidOperationException("The job lease is no longer valid."); + var item = await db.BackgroundJobItems.FirstAsync( + row => row.JobId == jobId && row.Ordinal == ordinal, cancellationToken); + if (item.State is BackgroundJobItemState.Succeeded or BackgroundJobItemState.Skipped or BackgroundJobItemState.Failed) + return; + item.Complete(outcome, now); + job.RecordOutcome(outcome, errorCode, errorMessage, pageId); + await SaveWorkerChangesAsync(cancellationToken); + } + + public async Task RecordJobOutcomeAsync( + Guid jobId, + Guid leaseToken, + BackgroundJobItemState outcome, + string? errorCode, + string? errorMessage, + CancellationToken cancellationToken) + { + var job = await FindLeasedAsync(jobId, leaseToken, cancellationToken) + ?? throw new InvalidOperationException("The job lease is no longer valid."); + job.RecordOutcome(outcome, errorCode, errorMessage); + await SaveWorkerChangesAsync(cancellationToken); + } + + public async Task CompleteLeasedJobAsync( + Guid jobId, Guid leaseToken, string? resultJson, DateTimeOffset now, CancellationToken cancellationToken) + { + var job = await FindLeasedAsync(jobId, leaseToken, cancellationToken) + ?? throw new InvalidOperationException("The job lease is no longer valid."); + job.Complete(now, resultJson); + await SaveStateAsync(job, cancellationToken); + } + + public async Task CancelLeasedJobAsync(Guid jobId, Guid leaseToken, DateTimeOffset now, CancellationToken cancellationToken) + { + var job = await FindLeasedAsync(jobId, leaseToken, cancellationToken) + ?? throw new InvalidOperationException("The job lease is no longer valid."); + job.Cancel(now); + await SaveStateAsync(job, cancellationToken); + } + + public async Task RetryLeasedJobAsync( + Guid jobId, Guid leaseToken, DateTimeOffset now, DateTimeOffset retryAt, string code, string safeMessage, + CancellationToken cancellationToken) + { + var job = await FindLeasedAsync(jobId, leaseToken, cancellationToken) + ?? throw new InvalidOperationException("The job lease is no longer valid."); + job.ScheduleRetry(now, retryAt, code, safeMessage); + await SaveStateAsync(job, cancellationToken); + } + + public async Task FailLeasedJobAsync( + Guid jobId, Guid leaseToken, DateTimeOffset now, string code, string safeMessage, + CancellationToken cancellationToken) + { + var job = await FindLeasedAsync(jobId, leaseToken, cancellationToken) + ?? throw new InvalidOperationException("The job lease is no longer valid."); + job.Fail(now, code, safeMessage); + await SaveStateAsync(job, cancellationToken); + } + + private async Task SaveStateAsync(BackgroundJob job, CancellationToken cancellationToken) + { + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + await SaveWorkerChangesAsync(cancellationToken); + // Terminal jobs retain their counters, result, bounded errors and audit + // history. Their potentially large recovery snapshot is no longer needed. + if (job.IsTerminal) + await db.BackgroundJobItems.Where(item => item.JobId == job.Id).ExecuteDeleteAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + + private async Task SaveWorkerChangesAsync(CancellationToken cancellationToken) + { + try + { + await db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException exception) + { + var jobEntry = exception.Entries.FirstOrDefault(entry => entry.Entity is BackgroundJob); + if (jobEntry?.Entity is not BackgroundJob job) + throw; + + // Cancellation intentionally updates a running row with SQL so it cannot + // be lost behind a tracked worker snapshot. If that update wins the race, + // retry this worker save against the newer version while preserving the + // cancellation columns (which this tracked instance never modified). + var persisted = await db.BackgroundJobs.AsNoTracking() + .Where(item => item.Id == job.Id) + .Select(item => new { item.Version, item.Status, item.LeaseToken }) + .SingleOrDefaultAsync(cancellationToken); + var version = jobEntry.Property(nameof(BackgroundJob.Version)); + if (persisted is null + || persisted.Status != BackgroundJobStatus.Running + || persisted.LeaseToken != job.LeaseToken + || persisted.Version <= (long)version.OriginalValue!) + { + throw; + } + + version.OriginalValue = persisted.Version; + version.CurrentValue = checked(persisted.Version + 1); + await db.SaveChangesAsync(cancellationToken); + } + } + + public async Task IsCancellationRequestedAsync(Guid jobId, Guid leaseToken, CancellationToken cancellationToken) => + await db.BackgroundJobs.AsNoTracking().AnyAsync( + job => job.Id == jobId && job.Status == BackgroundJobStatus.Running + && job.LeaseToken == leaseToken && job.CancelRequestedAt != null, + cancellationToken); + + public async Task GetQueueStatisticsAsync(CancellationToken cancellationToken) + { + var queued = await db.BackgroundJobs.LongCountAsync(job => job.Status == BackgroundJobStatus.Queued, cancellationToken); + var running = await db.BackgroundJobs.LongCountAsync(job => job.Status == BackgroundJobStatus.Running, cancellationToken); + var failed = await db.BackgroundJobs.LongCountAsync(job => job.Status == BackgroundJobStatus.Failed, cancellationToken); + var oldest = queued == 0 + ? null + : await db.BackgroundJobs.Where(job => job.Status == BackgroundJobStatus.Queued) + .MinAsync(job => (DateTimeOffset?)job.CreatedAt, cancellationToken); + return new BackgroundJobQueueStatistics(queued, running, failed, oldest); + } + + private Task HasLeaseAsync(Guid jobId, Guid leaseToken, CancellationToken cancellationToken) => + db.BackgroundJobs.AsNoTracking().AnyAsync( + job => job.Id == jobId && job.Status == BackgroundJobStatus.Running && job.LeaseToken == leaseToken, + cancellationToken); + + private Task FindByIdempotencyCoreAsync( + Guid workspaceId, Guid actorId, string keyHash, CancellationToken cancellationToken) => + db.BackgroundJobs.AsNoTracking().FirstOrDefaultAsync( + job => job.WorkspaceId == workspaceId && job.ActorId == actorId + && job.IdempotencyKeyHash == keyHash, + cancellationToken); + + private static bool IsIdempotencyViolation(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException!) + { + if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "ux_background_jobs_idempotency" }) + return true; + if (current.InnerException is null) + break; + } + return false; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupPaths.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupPaths.cs index a2a433c..92f0cec 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupPaths.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupPaths.cs @@ -3,4 +3,10 @@ namespace ItWorx.KnowledgeHub.Infrastructure.Operations; /// Resolved filesystem roots the backup/restore services read and write — /// kept as one small DI-registered value rather than each service re-reading /// configuration independently. -public sealed record BackupPaths(string BackupsRoot, string AttachmentsRoot); +public sealed record BackupPaths(string BackupsRoot, string AttachmentsRoot, string KeysRoot) +{ + /// Authentication key for backup integrity receipts. This file must + /// remain outside so a backup-directory write access + /// cannot be used to forge its own authenticity evidence. + public string IntegrityKeyPath => Path.Combine(KeysRoot, "backup-integrity.key"); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupStorageSafety.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupStorageSafety.cs new file mode 100644 index 0000000..b619fa3 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupStorageSafety.cs @@ -0,0 +1,100 @@ +namespace ItWorx.KnowledgeHub.Infrastructure.Operations; + +internal static class BackupStorageSafety +{ + private static readonly UnixFileMode OwnerOnlyDirectory = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + private static readonly UnixFileMode OwnerOnlyFile = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + internal static bool SafeId(string? id) => id is { Length: > 0 and <= 100 } + && id.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_'); + + internal static void EnsurePrivateDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + var pathRoot = Path.GetPathRoot(fullPath)!; + if (fullPath.TrimEnd(Path.DirectorySeparatorChar) == pathRoot.TrimEnd(Path.DirectorySeparatorChar) + || !IsSafePath(path, path)) + throw new InvalidOperationException("Onveilige back-upopslag."); + + if (OperatingSystem.IsWindows()) + { + Directory.CreateDirectory(path); + return; + } + + try + { + Directory.CreateDirectory(path, OwnerOnlyDirectory); + File.SetUnixFileMode(path, OwnerOnlyDirectory); + if (File.GetUnixFileMode(path) != OwnerOnlyDirectory) + throw new IOException("Unix directory permissions did not match the requested private mode."); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException) + { + throw new IOException("De back-upmap kan niet tot Unix-modus 0700 worden beperkt.", exception); + } + } + + internal static FileStream CreatePrivateFile( + string path, + FileMode mode = FileMode.CreateNew, + FileAccess access = FileAccess.Write, + FileOptions options = FileOptions.None) + { + var streamOptions = new FileStreamOptions + { + Mode = mode, + Access = access, + Share = FileShare.None, + Options = options + }; + if (!OperatingSystem.IsWindows()) + streamOptions.UnixCreateMode = OwnerOnlyFile; + + FileStream? stream = null; + try + { + stream = new FileStream(path, streamOptions); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, OwnerOnlyFile); + if (File.GetUnixFileMode(path) != OwnerOnlyFile) + throw new IOException("Unix file permissions did not match the requested private mode."); + } + return stream; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException) + { + stream?.Dispose(); + throw new IOException("Het back-upbestand kan niet tot Unix-modus 0600 worden beperkt.", exception); + } + } + + // Lexical containment alone does not stop a symlink or Windows junction. + internal static bool IsSafePath(string root, string candidate) + { + var fullRoot = Path.GetFullPath(root); + var fullCandidate = Path.GetFullPath(candidate); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!fullCandidate.Equals(fullRoot, comparison) + && !fullCandidate.StartsWith(fullRoot.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, comparison)) + return false; + for (var current = fullCandidate; current is not null; current = Path.GetDirectoryName(current)) + { + if ((File.Exists(current) || Directory.Exists(current)) + && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false; + } + return true; + } + + internal static FileStream AcquireLease(string root) + { + EnsurePrivateDirectory(root); + var path = Path.Combine(root, ".backup.lock"); + if (!IsSafePath(root, path)) throw new InvalidOperationException("Onveilige back-upopslag."); + // Do not unlink the lock: another process could otherwise acquire the old + // inode and a third process a new inode. Closing releases the OS lease. + return CreatePrivateFile(path, FileMode.OpenOrCreate, FileAccess.ReadWrite); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/DatabaseMetricsInterceptor.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/DatabaseMetricsInterceptor.cs new file mode 100644 index 0000000..2b66855 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/DatabaseMetricsInterceptor.cs @@ -0,0 +1,25 @@ +using System.Data.Common; +using ItWorx.KnowledgeHub.Application.Operations; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace ItWorx.KnowledgeHub.Infrastructure.Operations; + +public sealed class DatabaseMetricsInterceptor(PlatformMetrics metrics) : DbCommandInterceptor +{ + public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result) + { Record(eventData); return result; } + public override ValueTask ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken = default) + { Record(eventData); return ValueTask.FromResult(result); } + public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result) + { Record(eventData); return result; } + public override ValueTask NonQueryExecutedAsync(DbCommand command, CommandExecutedEventData eventData, int result, CancellationToken cancellationToken = default) + { Record(eventData); return ValueTask.FromResult(result); } + public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result) + { Record(eventData); return result; } + public override ValueTask ScalarExecutedAsync(DbCommand command, CommandExecutedEventData eventData, object? result, CancellationToken cancellationToken = default) + { Record(eventData); return ValueTask.FromResult(result); } + public override void CommandFailed(DbCommand command, CommandErrorEventData eventData) => metrics.Record("database", "failure", eventData.Duration.TotalSeconds); + public override Task CommandFailedAsync(DbCommand command, CommandErrorEventData eventData, CancellationToken cancellationToken = default) + { CommandFailed(command, eventData); return Task.CompletedTask; } + private void Record(CommandExecutedEventData data) => metrics.Record("database", "success", data.Duration.TotalSeconds); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/FileBackupEvidenceStore.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/FileBackupEvidenceStore.cs new file mode 100644 index 0000000..77b7215 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/FileBackupEvidenceStore.cs @@ -0,0 +1,285 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ItWorx.KnowledgeHub.Application.Operations; + +namespace ItWorx.KnowledgeHub.Infrastructure.Operations; + +/// Stores backup evidence beside each backup while authenticating every +/// verification receipt with a persistent HMAC key outside the backup tree. +public sealed class FileBackupEvidenceStore(BackupPaths paths, TimeProvider clock) : IBackupEvidenceStore +{ + internal const string ProtectionFile = ".protection.json"; + internal const string IntegrityFile = ".integrity.json"; + private const string RestoreFile = ".restore.json"; + private const int SignatureVersion = 1; + private const int IntegrityKeyLength = 32; + private const int SignatureHexLength = 64; + + public async Task GetAsync( + string backupId, CancellationToken cancellationToken, string? expectedManifestSha256 = null) + { + var directory = DirectoryFor(backupId); + if (directory is null) return null; + var hash = await ManifestHashAsync(directory, cancellationToken); + var protection = await ReadAsync(directory, ProtectionFile, cancellationToken); + var integrity = await ReadAsync(directory, IntegrityFile, cancellationToken); + var restore = await ReadAsync(directory, RestoreFile, cancellationToken); + + // A malformed protection marker is fail-closed for retention. Verification + // markers are fail-closed for trust: without the external key and valid MAC, + // editing both the manifest and its adjacent receipt cannot authenticate it. + var protectedBackup = protection?.Protected ?? File.Exists(Path.Combine(directory, ProtectionFile)); + var validIntegrity = integrity is not null + && integrity.BackupId == backupId + && integrity.ManifestSha256 == hash + && (expectedManifestSha256 is null + || string.Equals(integrity.ManifestSha256, expectedManifestSha256, StringComparison.Ordinal)) + && ValidTime(integrity.CompletedAt) + && integrity.Success + && await HasValidSignatureAsync(integrity, "integrity", cancellationToken); + var validRestore = restore is not null + && restore.BackupId == backupId + && restore.ManifestSha256 == hash + && ValidTime(restore.CompletedAt) + && await HasValidSignatureAsync(restore, "restore", cancellationToken); + + return new BackupEvidence(protectedBackup, protection?.Version ?? 0, + validIntegrity ? integrity!.CompletedAt : null, + validRestore && restore!.Success ? restore.CompletedAt : null, + validRestore ? restore!.Success : null); + } + + public async Task RecordRestoreAsync(string backupId, RestoreReport report, CancellationToken cancellationToken) + { + var directory = DirectoryFor(backupId) ?? throw new InvalidOperationException("Back-up niet gevonden."); + if (report.BackupId != backupId) throw new ArgumentException("Herstelbewijs hoort bij een andere back-up."); + var currentHash = await ManifestHashAsync(directory, cancellationToken); + if (report.ManifestSha256 != currentHash) + throw new IOException("Back-upmanifest is gewijzigd; herstelbewijs is niet opgeslagen."); + if ((await GetAsync(backupId, cancellationToken))?.IntegrityVerifiedAt is null) + throw new IOException("Back-upintegriteit is niet bevestigd; herstelbewijs is niet opgeslagen."); + + var key = await ReadIntegrityKeyAsync(createIfMissing: false, cancellationToken) + ?? throw new IOException("De sleutel voor back-upintegriteitsbewijs ontbreekt of is ongeldig."); + var success = report.Success && report.Tables.All(t => t.Matches) && report.AttachmentsMatch; + var record = CreateSignedRecord(key, "restore", backupId, currentHash, clock.GetUtcNow(), success); + await WriteAsync(directory, RestoreFile, record, cancellationToken); + } + + public async Task SetProtectionAsync( + string backupId, bool protect, int expectedVersion, Guid actorId, CancellationToken cancellationToken) + { + if (!BackupStorageSafety.SafeId(backupId)) return BackupProtectionResult.NotFound; + await using var lease = BackupStorageSafety.AcquireLease(paths.BackupsRoot); + var directory = DirectoryFor(backupId); + if (directory is null) return BackupProtectionResult.NotFound; + var current = await ReadAsync(directory, ProtectionFile, cancellationToken); + if ((current?.Version ?? 0) != expectedVersion) return BackupProtectionResult.Conflict; + // A broken marker cannot be cleared through a fabricated version zero. + if (current is null && File.Exists(Path.Combine(directory, ProtectionFile))) return BackupProtectionResult.Conflict; + await WriteAsync(directory, ProtectionFile, new ProtectionRecord( + protect, checked(expectedVersion + 1), actorId, clock.GetUtcNow()), cancellationToken); + return BackupProtectionResult.Updated; + } + + internal static async Task WriteIntegrityAsync( + string directory, string backupId, BackupPaths paths, DateTimeOffset verifiedAt, CancellationToken cancellationToken) + { + if (!BackupStorageSafety.IsSafePath(paths.BackupsRoot, directory) + || !BackupStorageSafety.IsSafePath(directory, Path.Combine(directory, "manifest.json"))) + throw new InvalidOperationException("Onveilige back-upopslag."); + if (!BackupStorageSafety.SafeId(backupId)) throw new InvalidOperationException("Ongeldig back-up-ID."); + + var store = new FileBackupEvidenceStore(paths, TimeProvider.System); + var key = await store.ReadIntegrityKeyAsync(createIfMissing: true, cancellationToken) + ?? throw new InvalidOperationException("De sleutel voor back-upintegriteitsbewijs ontbreekt of is ongeldig."); + var manifestHash = await ManifestHashAsync(directory, cancellationToken); + var record = CreateSignedRecord(key, "integrity", backupId, manifestHash, verifiedAt, success: true); + await store.WriteAsync(directory, IntegrityFile, record, cancellationToken); + } + + private bool ValidTime(DateTimeOffset time) => time > DateTimeOffset.UnixEpoch && time <= clock.GetUtcNow(); + + private string? DirectoryFor(string id) + { + if (!BackupStorageSafety.SafeId(id)) return null; + var directory = Path.Combine(paths.BackupsRoot, id); + return BackupStorageSafety.IsSafePath(paths.BackupsRoot, directory) + && BackupStorageSafety.IsSafePath(directory, Path.Combine(directory, "manifest.json")) + && File.Exists(Path.Combine(directory, "manifest.json")) ? directory : null; + } + + private async Task HasValidSignatureAsync( + SignedVerificationRecord record, string purpose, CancellationToken cancellationToken) + { + if (record.Version != SignatureVersion + || record.HmacSha256 is not { Length: SignatureHexLength } + || !record.HmacSha256.All(Uri.IsHexDigit)) + return false; + + var key = await ReadIntegrityKeyAsync(createIfMissing: false, cancellationToken); + if (key is null) return false; + var expected = CreateSignature(key, purpose, record.BackupId, record.ManifestSha256, + record.CompletedAt, record.Success); + return CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(expected), + Encoding.ASCII.GetBytes(record.HmacSha256.ToLowerInvariant())); + } + + private async Task ReadIntegrityKeyAsync(bool createIfMissing, CancellationToken cancellationToken) + { + var keyPath = paths.IntegrityKeyPath; + if (!BackupStorageSafety.IsSafePath(paths.KeysRoot, keyPath) + || BackupStorageSafety.IsSafePath(paths.BackupsRoot, keyPath)) + throw new InvalidOperationException("De sleutel voor back-upintegriteitsbewijs moet veilig buiten de back-upmap staan."); + + if (!File.Exists(keyPath)) + { + if (!createIfMissing) return null; + await CreateIntegrityKeyAsync(keyPath, cancellationToken); + } + + try + { + if (!BackupStorageSafety.IsSafePath(paths.KeysRoot, keyPath) + || BackupStorageSafety.IsSafePath(paths.BackupsRoot, keyPath)) + throw new InvalidOperationException("De sleutel voor back-upintegriteitsbewijs staat niet veilig buiten de back-upmap."); + + if (!OperatingSystem.IsWindows()) + { + var ownerOnlyMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + try + { + if (File.GetUnixFileMode(keyPath) != ownerOnlyMode) + File.SetUnixFileMode(keyPath, ownerOnlyMode); + if (File.GetUnixFileMode(keyPath) != ownerOnlyMode) + return null; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException) + { + if (createIfMissing) + throw new InvalidOperationException("De sleutel voor back-upintegriteitsbewijs kan niet tot Unix-modus 0600 worden beperkt.", exception); + return null; + } + } + + var key = await File.ReadAllBytesAsync(keyPath, cancellationToken); + return key.Length == IntegrityKeyLength ? key : null; + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + catch (IOException) when (!createIfMissing) + { + return null; + } + catch (UnauthorizedAccessException) when (!createIfMissing) + { + return null; + } + } + + private static async Task CreateIntegrityKeyAsync(string keyPath, CancellationToken cancellationToken) + { + var keyDirectory = Path.GetDirectoryName(keyPath)!; + Directory.CreateDirectory(keyDirectory); + var tempPath = Path.Combine(keyDirectory, ".backup-integrity-key-" + Guid.NewGuid().ToString("N")); + var key = RandomNumberGenerator.GetBytes(IntegrityKeyLength); + try + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough + }; + if (!OperatingSystem.IsWindows()) + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + await using (var stream = new FileStream(tempPath, options)) + { + await stream.WriteAsync(key, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + try + { + File.Move(tempPath, keyPath); + } + catch (IOException) when (File.Exists(keyPath)) + { + // Another application process created the same persistent key first. + } + } + finally + { + CryptographicOperations.ZeroMemory(key); + if (File.Exists(tempPath)) File.Delete(tempPath); + } + } + + private static SignedVerificationRecord CreateSignedRecord( + byte[] key, string purpose, string backupId, string manifestHash, DateTimeOffset completedAt, bool success) => + new(SignatureVersion, backupId, manifestHash, completedAt, success, + CreateSignature(key, purpose, backupId, manifestHash, completedAt, success)); + + private static string CreateSignature( + byte[] key, string purpose, string backupId, string manifestHash, DateTimeOffset completedAt, bool success) + { + var payload = string.Join('\n', + "itworx-knowledge-hub-backup-evidence-v1", + purpose, + backupId, + manifestHash, + completedAt.UtcTicks.ToString(CultureInfo.InvariantCulture), + success ? "1" : "0"); + return Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload))); + } + + private static async Task ManifestHashAsync(string directory, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(Path.Combine(directory, "manifest.json")); + return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, cancellationToken)); + } + + private static async Task ReadAsync(string directory, string name, CancellationToken cancellationToken) + { + var path = Path.Combine(directory, name); + if (!BackupStorageSafety.IsSafePath(directory, path) || !File.Exists(path)) return default; + try + { + if (new FileInfo(path).Length > 16_384) return default; + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); + } + catch (JsonException) { return default; } + } + + private async Task WriteAsync(string directory, string name, T record, CancellationToken cancellationToken) + { + BackupStorageSafety.EnsurePrivateDirectory(paths.BackupsRoot); + BackupStorageSafety.EnsurePrivateDirectory(directory); + var path = Path.Combine(directory, name); + if (!BackupStorageSafety.IsSafePath(directory, path)) throw new InvalidOperationException("Onveilige back-upopslag."); + var temp = Path.Combine(directory, ".evidence-" + Guid.NewGuid().ToString("N")); + try + { + await using (var stream = BackupStorageSafety.CreatePrivateFile(temp, options: FileOptions.Asynchronous)) + await JsonSerializer.SerializeAsync(stream, record, cancellationToken: cancellationToken); + File.Move(temp, path, true); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + + private sealed record ProtectionRecord(bool Protected, int Version, Guid ActorId, DateTimeOffset UpdatedAt); + private sealed record SignedVerificationRecord( + int Version, string BackupId, string ManifestSha256, DateTimeOffset CompletedAt, bool Success, string? HmacSha256); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/OperationalTelemetryReader.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/OperationalTelemetryReader.cs new file mode 100644 index 0000000..3eeeeff --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/OperationalTelemetryReader.cs @@ -0,0 +1,31 @@ +using ItWorx.KnowledgeHub.Application.Jobs; +using ItWorx.KnowledgeHub.Application.Operations; +using ItWorx.KnowledgeHub.Domain.Ingestion; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace ItWorx.KnowledgeHub.Infrastructure.Operations; + +public sealed class OperationalTelemetryReader( + KnowledgeHubDbContext db, IBackgroundJobRepository jobs, + IBackupService backups, IBackupEvidenceStore evidence) : IOperationalTelemetryReader +{ + public async Task ReadAsync(CancellationToken cancellationToken) + { + var queue = await jobs.GetQueueStatisticsAsync(cancellationToken); + var pending = db.IngestionProposals.AsNoTracking().Where(p => + p.Status != IngestionProposalStatus.Published && p.Status != IngestionProposalStatus.Rejected); + var oldest = await db.IngestionProposalRevisions.AsNoTracking() + .Where(r => pending.Any(p => p.Id == EF.Property(r, "IngestionProposalId"))) + .Select(r => (DateTimeOffset?)r.OccurredAt).MinAsync(cancellationToken); + var capture = await db.InventorySnapshots.AsNoTracking() + .Select(s => (DateTimeOffset?)s.CapturedAt).MaxAsync(cancellationToken); + var completed = await backups.ListBackupsAsync(cancellationToken); + DateTimeOffset? verifiedBackup = null; + if (completed.Count > 0 && + await evidence.GetAsync(completed[0].BackupId, cancellationToken) is { IntegrityVerifiedAt: not null }) + verifiedBackup = completed[0].CreatedAt; + return new(queue.Queued, queue.Running, queue.Failed, queue.OldestQueuedAt, + oldest, capture, verifiedBackup); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresBackupService.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresBackupService.cs index 0612088..7f1ddb5 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresBackupService.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresBackupService.cs @@ -26,24 +26,24 @@ public sealed class PostgresBackupService( int retainedBackupCount = 14) : IBackupService { private const string TempPrefix = ".tmp-"; - private const string LockFileName = ".backup.lock"; private static readonly JsonSerializerOptions ManifestSerializerOptions = new() { WriteIndented = true }; public async Task CreateBackupAsync(CancellationToken cancellationToken) { - Directory.CreateDirectory(paths.BackupsRoot); + BackupStorageSafety.EnsurePrivateDirectory(paths.BackupsRoot); await using var lease = await AcquireLeaseAsync(cancellationToken); var backupId = $"{timeProvider.GetUtcNow():yyyyMMddTHHmmssZ}-{Guid.NewGuid().ToString("N")[..8]}"; var tempDir = Path.Combine(paths.BackupsRoot, TempPrefix + backupId); var dbDir = Path.Combine(tempDir, "db"); var attachmentsDir = Path.Combine(tempDir, "attachments"); - Directory.CreateDirectory(dbDir); - Directory.CreateDirectory(attachmentsDir); + BackupStorageSafety.EnsurePrivateDirectory(tempDir); + BackupStorageSafety.EnsurePrivateDirectory(dbDir); + BackupStorageSafety.EnsurePrivateDirectory(attachmentsDir); try { - var tableEntries = await BackupTablesAsync(dbDir, cancellationToken); + var (schemaVersion, tableEntries) = await BackupTablesAsync(dbDir, cancellationToken); var attachmentEntries = await BackupAttachmentsAsync(attachmentsDir, cancellationToken); var manifest = new BackupManifest( @@ -52,7 +52,7 @@ public sealed class PostgresBackupService( "ItWorx Knowledge Hub", installationDefaults.ProductVersion, Environment.GetEnvironmentVariable("Build__Commit") ?? "development", - installationDefaults.SchemaVersion, + schemaVersion, tableEntries, attachmentEntries, tableEntries.Sum(t => t.SizeBytes) + attachmentEntries.Sum(a => a.SizeBytes)); @@ -61,10 +61,13 @@ public sealed class PostgresBackupService( await VerifyFilesAsync(attachmentsDir, attachmentEntries.Select(a => (a.RelativePath, a.Sha256)), cancellationToken); var manifestPath = Path.Combine(tempDir, "manifest.json"); - await File.WriteAllTextAsync( - manifestPath, - JsonSerializer.Serialize(manifest, ManifestSerializerOptions), - cancellationToken); + await using (var manifestStream = BackupStorageSafety.CreatePrivateFile( + manifestPath, options: FileOptions.Asynchronous)) + { + await JsonSerializer.SerializeAsync( + manifestStream, manifest, ManifestSerializerOptions, cancellationToken); + } + await FileBackupEvidenceStore.WriteIntegrityAsync(tempDir, backupId, paths, timeProvider.GetUtcNow(), cancellationToken); var finalDir = Path.Combine(paths.BackupsRoot, backupId); // Same-volume directory rename: the backup only becomes visible to @@ -123,35 +126,52 @@ public sealed class PostgresBackupService( // --- table export ------------------------------------------------------------------ - private async Task> BackupTablesAsync(string dbDir, CancellationToken cancellationToken) + private async Task<(string SchemaVersion, IReadOnlyList Tables)> BackupTablesAsync( + string dbDir, CancellationToken cancellationToken) { await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString); await connection.OpenAsync(cancellationToken); + // COPY and row counts for every table share one PostgreSQL snapshot. + await using var transaction = await connection.BeginTransactionAsync(System.Data.IsolationLevel.RepeatableRead, cancellationToken); var tables = await TableDiscovery.DiscoverBaseTablesAsync(connection, cancellationToken); + const string schemaVersionSql = """ + SELECT migration_id + FROM "public"."__EFMigrationsHistory" + ORDER BY migration_id DESC + LIMIT 1 + """; + await using var schemaVersionCommand = new NpgsqlCommand(schemaVersionSql, connection, transaction); + var schemaVersion = (string?)await schemaVersionCommand.ExecuteScalarAsync(cancellationToken) + ?? throw new InvalidOperationException("Er is geen toegepaste databaseschemamigratie gevonden; de back-up is geweigerd."); var entries = new List(tables.Count); foreach (var table in tables) { + // Keep the SQL boundary defensive even though discovery validates catalog + // identifiers before returning them. + PostgresIdentifierSafety.RequireSafe(table); var fileName = $"{table}.bin"; var filePath = Path.Combine(dbDir, fileName); await using (var rawCopy = await connection.BeginRawBinaryCopyAsync( $"COPY \"public\".\"{table}\" TO STDOUT (FORMAT BINARY)", cancellationToken)) - await using (var destination = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + await using (var destination = BackupStorageSafety.CreatePrivateFile( + filePath, options: FileOptions.Asynchronous)) { await rawCopy.CopyToAsync(destination, cancellationToken); } var (sizeBytes, sha256) = await ComputeFileHashAsync(filePath, cancellationToken); - await using var countCommand = new NpgsqlCommand($"SELECT count(*) FROM \"public\".\"{table}\"", connection); + await using var countCommand = new NpgsqlCommand($"SELECT count(*) FROM \"public\".\"{table}\"", connection, transaction); var rowCount = (long)(await countCommand.ExecuteScalarAsync(cancellationToken) ?? 0L); entries.Add(new BackupTableManifestEntry(table, fileName, rowCount, sizeBytes, sha256)); } - return entries; + await transaction.CommitAsync(cancellationToken); + return (schemaVersion, entries); } // --- attachment export --------------------------------------------------------------- @@ -177,9 +197,17 @@ public sealed class PostgresBackupService( continue; } + if (!BackupStorageSafety.IsSafePath(paths.AttachmentsRoot, sourcePath)) + throw new InvalidOperationException("Onveilige bronopslag voor bijlagen."); + var destinationPath = Path.Combine(attachmentsDir, relative); - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - File.Copy(sourcePath, destinationPath); + BackupStorageSafety.EnsurePrivateDirectory(Path.GetDirectoryName(destinationPath)!); + await using (var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + await using (var destination = BackupStorageSafety.CreatePrivateFile( + destinationPath, options: FileOptions.Asynchronous)) + { + await source.CopyToAsync(destination, cancellationToken); + } var (sizeBytes, sha256) = await ComputeFileHashAsync(destinationPath, cancellationToken); entries.Add(new BackupAttachmentManifestEntry(sha256, relative, sizeBytes)); @@ -225,7 +253,7 @@ public sealed class PostgresBackupService( // --- retention ------------------------------------------------------------------------ - private Task ApplyRetentionAsync(CancellationToken cancellationToken) + private async Task ApplyRetentionAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -240,10 +268,10 @@ public sealed class PostgresBackupService( foreach (var stale in completed.Skip(Math.Max(1, retainedBackupCount))) { + var evidence = await new FileBackupEvidenceStore(paths, timeProvider).GetAsync(stale.Manifest!.BackupId, cancellationToken); + if (evidence?.Protected != false) continue; TryDeleteDirectory(stale.Dir); } - - return Task.CompletedTask; } // --- housekeeping --------------------------------------------------------------------- @@ -257,6 +285,7 @@ public sealed class PostgresBackupService( foreach (var dir in Directory.EnumerateDirectories(paths.BackupsRoot)) { + if (!BackupStorageSafety.IsSafePath(paths.BackupsRoot, dir)) continue; var name = Path.GetFileName(dir); if (name.StartsWith(TempPrefix, StringComparison.Ordinal) || name.StartsWith('.')) { @@ -273,14 +302,36 @@ public sealed class PostgresBackupService( private static BackupManifest? ReadManifest(string backupDirectory) { var manifestPath = Path.Combine(backupDirectory, "manifest.json"); - if (!File.Exists(manifestPath)) + if (!BackupStorageSafety.IsSafePath(backupDirectory, manifestPath) || !File.Exists(manifestPath)) { return null; } try { - return JsonSerializer.Deserialize(File.ReadAllText(manifestPath)); + if (new FileInfo(manifestPath).Length > 16_777_216) return null; + var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath)); + if (manifest is null || manifest.BackupId != Path.GetFileName(backupDirectory) + || manifest.Tables is null || manifest.Attachments is null) + return null; + + try + { + PostgresIdentifierSafety.ValidateUniqueTableNames(manifest.Tables.Select(table => table?.TableName)); + } + catch (InvalidOperationException) + { + return null; + } + + if (manifest.Tables.Any(t => t is null || string.IsNullOrWhiteSpace(t.FileName) + || Path.GetFileName(t.FileName) != t.FileName + || !BackupStorageSafety.IsSafePath(backupDirectory, Path.Combine(backupDirectory, "db", t.FileName))) + || manifest.Attachments.Any(a => a is null || string.IsNullOrWhiteSpace(a.RelativePath) + || Path.IsPathRooted(a.RelativePath) + || !BackupStorageSafety.IsSafePath(Path.Combine(backupDirectory, "attachments"), + Path.Combine(backupDirectory, "attachments", a.RelativePath)))) return null; + return manifest; } catch (JsonException) { @@ -293,11 +344,11 @@ public sealed class PostgresBackupService( && backupId.Length <= 100 && backupId.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_'); - private static void TryDeleteDirectory(string path) + private void TryDeleteDirectory(string path) { try { - if (Directory.Exists(path)) + if (BackupStorageSafety.IsSafePath(paths.BackupsRoot, path) && Directory.Exists(path)) { Directory.Delete(path, recursive: true); } @@ -310,13 +361,12 @@ public sealed class PostgresBackupService( } } - private async Task AcquireLeaseAsync(CancellationToken cancellationToken) + private Task AcquireLeaseAsync(CancellationToken cancellationToken) { - var lockPath = Path.Combine(paths.BackupsRoot, LockFileName); + cancellationToken.ThrowIfCancellationRequested(); try { - var stream = new FileStream(lockPath, FileMode.Create, FileAccess.Write, FileShare.None); - return new LeaseHandle(stream); + return Task.FromResult(BackupStorageSafety.AcquireLease(paths.BackupsRoot)); } catch (IOException exception) { @@ -325,20 +375,4 @@ public sealed class PostgresBackupService( } } - private sealed class LeaseHandle(FileStream stream) : IAsyncDisposable - { - public async ValueTask DisposeAsync() - { - await stream.DisposeAsync(); - try - { - File.Delete(stream.Name); - } - catch (IOException) - { - // Non-fatal: the next backup attempt will overwrite this lock file via - // FileMode.Create regardless. - } - } - } } diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresRestoreService.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresRestoreService.cs index 1cd51ac..4dbe6a3 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresRestoreService.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresRestoreService.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Text.Json; using ItWorx.KnowledgeHub.Application.Operations; using Npgsql; @@ -13,28 +14,25 @@ namespace ItWorx.KnowledgeHub.Infrastructure.Operations; /// afterwards so the next ordinary insert does not collide with a restored row /// (ADR-016 decision 2). /// -public sealed class PostgresRestoreService(BackupPaths paths, IBackupService backups) : IRestoreService +public sealed class PostgresRestoreService(BackupPaths paths, IBackupEvidenceStore evidence) : IRestoreService { + private const long MaximumManifestBytes = 16 * 1024 * 1024; + private const string SnapshotDirectoryPrefix = ".ikh-restore-"; + public async Task RestoreAsync(string backupId, RestoreTarget target, CancellationToken cancellationToken) { - var manifest = await backups.GetManifestAsync(backupId, cancellationToken) - ?? throw new InvalidOperationException($"Back-up '{backupId}' bestaat niet of het manifest is onleesbaar."); - - var backupDir = Path.Combine(paths.BackupsRoot, backupId); - var dbDir = Path.Combine(backupDir, "db"); - var attachmentsDir = Path.Combine(backupDir, "attachments"); - - // Verify every source file's hash before touching the target at all — - // a corrupted backup must never partially restore. - foreach (var table in manifest.Tables) - { - await VerifyHashAsync(Path.Combine(dbDir, table.FileName), table.Sha256, cancellationToken); - } - - foreach (var attachment in manifest.Attachments) - { - await VerifyHashAsync(Path.Combine(attachmentsDir, attachment.RelativePath), attachment.Sha256, cancellationToken); - } + if (!BackupStorageSafety.SafeId(backupId)) throw new InvalidOperationException("Ongeldig back-up-ID."); + var targetAttachmentsDirectory = ValidateEmptyTargetAttachmentsDirectory(target.AttachmentsDirectory); + await using var lease = BackupStorageSafety.AcquireLease(paths.BackupsRoot); + var backupDirectory = Path.Combine(paths.BackupsRoot, backupId); + var manifestPath = Path.Combine(backupDirectory, "manifest.json"); + if (!BackupStorageSafety.IsSafePath(paths.BackupsRoot, backupDirectory) + || !BackupStorageSafety.IsSafePath(backupDirectory, manifestPath)) + throw new InvalidOperationException("Onveilige back-upopslag."); + await ValidateManifestIdentifiersAsync(manifestPath, cancellationToken); + await using var snapshot = await CreateVerifiedSnapshotAsync( + backupId, backupDirectory, manifestPath, targetAttachmentsDirectory, cancellationToken); + var manifest = snapshot.Manifest; var warnings = new List(); var tableReports = new List(); @@ -42,6 +40,7 @@ public sealed class PostgresRestoreService(BackupPaths paths, IBackupService bac await using (var connection = new NpgsqlConnection(target.ConnectionString)) { await connection.OpenAsync(cancellationToken); + await EnsureTargetSchemaMatchesAsync(connection, manifest.SchemaVersion, cancellationToken); await EnsureTargetIsCleanAsync(connection, cancellationToken); await using var transaction = await connection.BeginTransactionAsync(cancellationToken); @@ -57,7 +56,8 @@ public sealed class PostgresRestoreService(BackupPaths paths, IBackupService bac await using (var rawCopy = await connection.BeginRawBinaryCopyAsync( $"COPY \"public\".\"{table.TableName}\" FROM STDIN (FORMAT BINARY)", cancellationToken)) await using (var source = new FileStream( - Path.Combine(dbDir, table.FileName), FileMode.Open, FileAccess.Read, FileShare.Read)) + SafeRelativePath(snapshot.DatabaseDirectory, table.FileName, flatOnly: true), + FileMode.Open, FileAccess.Read, FileShare.Read)) { await source.CopyToAsync(rawCopy, cancellationToken); } @@ -79,7 +79,8 @@ public sealed class PostgresRestoreService(BackupPaths paths, IBackupService bac await transaction.CommitAsync(cancellationToken); } - var attachmentsRestored = await RestoreAttachmentsAsync(attachmentsDir, manifest, target.AttachmentsDirectory, cancellationToken); + var attachmentsRestored = await RestoreAttachmentsAsync( + snapshot.AttachmentsDirectory, manifest, target.AttachmentsDirectory, cancellationToken); foreach (var mismatch in tableReports.Where(report => !report.Matches)) { @@ -88,7 +89,277 @@ public sealed class PostgresRestoreService(BackupPaths paths, IBackupService bac var success = tableReports.All(report => report.Matches) && attachmentsRestored == manifest.Attachments.Count; - return new RestoreReport(backupId, success, tableReports, attachmentsRestored, manifest.Attachments.Count, warnings); + var report = new RestoreReport( + backupId, success, tableReports, attachmentsRestored, manifest.Attachments.Count, warnings, snapshot.ManifestSha256); + try + { + await evidence.RecordRestoreAsync(backupId, report, cancellationToken); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // The restore can finish even if receipt persistence fails after the + // storage lease was acquired. The UI must then remain unverified. + warnings.Add("Herstel voltooid, maar het herstelbewijs kon niet worden opgeslagen."); + } + return report; + } + + private async Task CreateVerifiedSnapshotAsync( + string backupId, string backupDirectory, string manifestPath, + string targetAttachmentsDirectory, CancellationToken cancellationToken) + { + // Stage on the operator-provisioned target attachments volume. This keeps + // large restores off the container overlay and makes capacity predictable. + var stagingParent = targetAttachmentsDirectory; + var snapshotRoot = Path.Combine(stagingParent, SnapshotDirectoryPrefix + Guid.NewGuid().ToString("N")); + if (Directory.Exists(snapshotRoot) || File.Exists(snapshotRoot)) + throw new IOException("Er kon geen privé-herstelmap worden gemaakt."); + + CreatePrivateDirectory(snapshotRoot); + try + { + var sourceManifestBytes = await ReadBoundedFileAsync(manifestPath, cancellationToken); + var manifestHash = HashBytes(sourceManifestBytes); + var stagedManifestPath = Path.Combine(snapshotRoot, "manifest.json"); + await WritePrivateFileAsync(stagedManifestPath, sourceManifestBytes, cancellationToken); + + var stagedManifestBytes = await ReadBoundedFileAsync(stagedManifestPath, cancellationToken); + if (!string.Equals(HashBytes(stagedManifestBytes), manifestHash, StringComparison.Ordinal)) + throw new InvalidOperationException("Het herstelmanifest kon niet veilig worden vastgezet."); + + BackupManifest manifest; + try + { + manifest = JsonSerializer.Deserialize(stagedManifestBytes) + ?? throw new InvalidOperationException("Het herstelmanifest is ongeldig."); + } + catch (JsonException exception) + { + throw new InvalidOperationException("Het herstelmanifest is ongeldig.", exception); + } + + // Bind the evidence lookup to the exact bytes that were staged and parsed. + // A source-volume writer cannot swap in a different valid manifest between + // the hash check and evidence lookup and make this snapshot trusted. + var sourceEvidence = await evidence.GetAsync(backupId, cancellationToken, manifestHash); + if (sourceEvidence?.IntegrityVerifiedAt is null + || manifest.BackupId != backupId + || manifest.Tables is null + || manifest.Attachments is null + || manifest.Tables.Count == 0) + throw new InvalidOperationException("Back-upintegriteit is niet bevestigd; herstel is geweigerd."); + + // A valid HMAC proves these exact bytes came from a backup, but catalog + // identifiers still must be safe before they are used as SQL identifiers. + // Reject malformed and duplicate entries before connecting to the target. + PostgresIdentifierSafety.ValidateUniqueTableNames(manifest.Tables.Select(table => table?.TableName)); + + var sourceDatabaseDirectory = Path.Combine(backupDirectory, "db"); + var sourceAttachmentsDirectory = Path.Combine(backupDirectory, "attachments"); + if (!BackupStorageSafety.IsSafePath(backupDirectory, sourceDatabaseDirectory) + || !BackupStorageSafety.IsSafePath(backupDirectory, sourceAttachmentsDirectory)) + throw new InvalidOperationException("Onveilige back-upopslag."); + + var stagedDatabaseDirectory = Path.Combine(snapshotRoot, "db"); + var stagedAttachmentsDirectory = Path.Combine(snapshotRoot, "attachments"); + CreatePrivateDirectory(stagedDatabaseDirectory); + CreatePrivateDirectory(stagedAttachmentsDirectory); + + foreach (var table in manifest.Tables) + { + if (table is null || table.SizeBytes < 0) + throw new InvalidOperationException("Het herstelmanifest bevat ongeldige tabelmetadata."); + var sourcePath = SafeRelativePath(sourceDatabaseDirectory, table.FileName, flatOnly: true); + var stagedPath = SafeRelativePath(stagedDatabaseDirectory, table.FileName, flatOnly: true); + await CopyAndVerifyAsync(sourcePath, stagedPath, table.SizeBytes, table.Sha256, cancellationToken); + } + + foreach (var attachment in manifest.Attachments) + { + if (attachment is null || attachment.SizeBytes < 0) + throw new InvalidOperationException("Het herstelmanifest bevat ongeldige bijlagemetadata."); + if (IsReservedStagingPath(attachment.RelativePath)) + throw new InvalidOperationException("Het herstelmanifest gebruikt een gereserveerd bijlagepad."); + var sourcePath = SafeRelativePath(sourceAttachmentsDirectory, attachment.RelativePath); + var stagedPath = SafeRelativePath(stagedAttachmentsDirectory, attachment.RelativePath); + var stagedParent = Path.GetDirectoryName(stagedPath)!; + CreatePrivateDirectory(stagedParent); + await CopyAndVerifyAsync(sourcePath, stagedPath, attachment.SizeBytes, attachment.Sha256, cancellationToken); + } + + return new VerifiedBackupSnapshot(snapshotRoot, stagedDatabaseDirectory, + stagedAttachmentsDirectory, manifest, manifestHash, stagingParent); + } + catch + { + DeleteSnapshotDirectory(snapshotRoot, stagingParent); + throw; + } + } + + private static async Task ValidateManifestIdentifiersAsync(string manifestPath, CancellationToken cancellationToken) + { + // Reject malformed catalog names before creating even a temporary staging + // directory under the target attachments root. The exact staged bytes are + // validated again after their HMAC is checked to close source-volume races. + var manifestBytes = await ReadBoundedFileAsync(manifestPath, cancellationToken); + BackupManifest manifest; + try + { + manifest = JsonSerializer.Deserialize(manifestBytes) + ?? throw new InvalidOperationException("Het herstelmanifest is ongeldig."); + } + catch (JsonException exception) + { + throw new InvalidOperationException("Het herstelmanifest is ongeldig.", exception); + } + + if (manifest.Tables is null || manifest.Tables.Count == 0) + throw new InvalidOperationException("Het herstelmanifest bevat geen tabellen."); + PostgresIdentifierSafety.ValidateUniqueTableNames(manifest.Tables.Select(table => table?.TableName)); + } + + private static async Task ReadBoundedFileAsync(string path, CancellationToken cancellationToken) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length is <= 0 or > MaximumManifestBytes) + throw new InvalidOperationException("Het herstelmanifest ontbreekt of is te groot."); + + var bytes = new byte[checked((int)stream.Length)]; + await stream.ReadExactlyAsync(bytes, cancellationToken); + var extra = new byte[1]; + if (await stream.ReadAsync(extra, cancellationToken) != 0) + throw new InvalidOperationException("Het herstelmanifest is gewijzigd tijdens het lezen."); + return bytes; + } + + private static async Task CopyAndVerifyAsync( + string sourcePath, string destinationPath, long expectedSize, string expectedSha256, + CancellationToken cancellationToken) + { + if (expectedSha256 is not { Length: 64 } || !expectedSha256.All(Uri.IsHexDigit)) + throw new InvalidOperationException("Het herstelmanifest bevat een ongeldige bestandshash."); + + await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using var destination = CreatePrivateFile(destinationPath); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0) + { + total = checked(total + read); + if (total > expectedSize) + throw new InvalidOperationException($"Back-upverificatie mislukt voor bestand '{Path.GetFileName(sourcePath)}'."); + hash.AppendData(buffer, 0, read); + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + + await destination.FlushAsync(cancellationToken); + RestrictPrivateFile(destinationPath); + if (total != expectedSize + || !string.Equals(Convert.ToHexStringLower(hash.GetHashAndReset()), expectedSha256, StringComparison.Ordinal)) + throw new InvalidOperationException($"Back-upverificatie mislukt voor bestand '{Path.GetFileName(sourcePath)}'."); + } + + private static async Task WritePrivateFileAsync(string path, byte[] contents, CancellationToken cancellationToken) + { + await using (var destination = CreatePrivateFile(path)) + { + await destination.WriteAsync(contents, cancellationToken); + await destination.FlushAsync(cancellationToken); + } + RestrictPrivateFile(path); + } + + private static FileStream CreatePrivateFile(string path) + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous | FileOptions.SequentialScan + }; + if (!OperatingSystem.IsWindows()) + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + return new FileStream(path, options); + } + + private static void CreatePrivateDirectory(string path) + { + const UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + if (OperatingSystem.IsWindows()) + { + Directory.CreateDirectory(path); + } + else + { + Directory.CreateDirectory(path, mode); + File.SetUnixFileMode(path, mode); + if (File.GetUnixFileMode(path) != mode) + throw new IOException("De privé-herstelmap kan niet tot Unix-modus 0700 worden beperkt."); + } + + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new IOException("De privé-herstelmap mag geen symbolische koppeling zijn."); + } + + private static void RestrictPrivateFile(string path) + { + if (!OperatingSystem.IsWindows()) + { + const UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(path, mode); + if (File.GetUnixFileMode(path) != mode) + throw new IOException("Een herstelbestand kan niet tot Unix-modus 0600 worden beperkt."); + } + } + + private static string SafeRelativePath(string root, string relativePath, bool flatOnly = false) + { + if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath)) + throw new InvalidOperationException("Het herstelmanifest bevat een onveilig bestandspad."); + var segments = relativePath.Replace('\\', '/').Split('/'); + if (segments.Any(segment => segment.Length == 0 || segment is "." or "..") + || (flatOnly && segments.Length != 1)) + throw new InvalidOperationException("Het herstelmanifest bevat een onveilig bestandspad."); + var candidate = Path.Combine([root, .. segments]); + if (!BackupStorageSafety.IsSafePath(root, candidate)) + throw new InvalidOperationException("Het herstelmanifest bevat een onveilig bestandspad."); + return candidate; + } + + private static string ValidateEmptyTargetAttachmentsDirectory(string targetAttachmentsDirectory) + { + if (string.IsNullOrWhiteSpace(targetAttachmentsDirectory)) + throw new InvalidOperationException("Een doelmap voor bijlagen is vereist."); + var fullPath = Path.GetFullPath(targetAttachmentsDirectory); + if (!Directory.Exists(fullPath) || !BackupStorageSafety.IsSafePath(fullPath, fullPath)) + throw new InvalidOperationException("De doelmap voor bijlagen moet een bestaande, veilige map zijn."); + if (Directory.EnumerateFileSystemEntries(fullPath).Any()) + throw new InvalidOperationException("De doelmap voor bijlagen is niet leeg — herstel vereist een schone doelomgeving."); + return fullPath; + } + + private static bool IsReservedStagingPath(string relativePath) + { + var firstSegment = relativePath.Replace('\\', '/').Split('/', 2)[0]; + return firstSegment.StartsWith(SnapshotDirectoryPrefix, StringComparison.OrdinalIgnoreCase); + } + + private static string HashBytes(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); + + private static void DeleteSnapshotDirectory(string snapshotRoot, string stagingParent) + { + var fullSnapshotRoot = Path.GetFullPath(snapshotRoot); + var fullStagingParent = Path.GetFullPath(stagingParent); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!Path.GetFileName(fullSnapshotRoot).StartsWith(SnapshotDirectoryPrefix, StringComparison.Ordinal) + || !string.Equals(Path.GetDirectoryName(fullSnapshotRoot), fullStagingParent, comparison) + || !BackupStorageSafety.IsSafePath(fullStagingParent, fullSnapshotRoot)) + throw new IOException("De privé-herstelmap is gewijzigd; veilige opschoning is geweigerd."); + if (Directory.Exists(fullSnapshotRoot)) Directory.Delete(fullSnapshotRoot, recursive: true); } private static async Task EnsureTargetIsCleanAsync(NpgsqlConnection connection, CancellationToken cancellationToken) @@ -106,15 +377,30 @@ public sealed class PostgresRestoreService(BackupPaths paths, IBackupService bac } } + private static async Task EnsureTargetSchemaMatchesAsync( + NpgsqlConnection connection, string expectedSchema, CancellationToken cancellationToken) + { + const string sql = """ + SELECT migration_id FROM "public"."__EFMigrationsHistory" + ORDER BY migration_id DESC LIMIT 1 + """; + await using var command = new NpgsqlCommand(sql, connection); + var actualSchema = (string?)await command.ExecuteScalarAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(expectedSchema) + || !string.Equals(expectedSchema, actualSchema, StringComparison.Ordinal)) + throw new InvalidOperationException("Het doelschema komt niet overeen met de back-up; herstel eerst met de bijbehorende applicatieversie."); + } + private static async Task ResetIdentitySequencesAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, CancellationToken cancellationToken) { var identityColumns = await TableDiscovery.DiscoverIdentityColumnsAsync(connection, cancellationToken); foreach (var (table, column) in identityColumns) { - // Table/column names come only from information_schema (never user input), - // so identifier interpolation here is safe; pg_get_serial_sequence itself - // takes plain string literals, not bindable identifier parameters. + // Discovery validates catalog names, and repeat the check at the SQL + // boundary so every sequence reset remains safe if its caller changes. + PostgresIdentifierSafety.RequireSafe(table); + PostgresIdentifierSafety.RequireSafe(column); var sql = $""" SELECT setval( pg_get_serial_sequence('public.{table}', '{column}'), @@ -133,15 +419,16 @@ public sealed class PostgresRestoreService(BackupPaths paths, IBackupService bac var restored = 0; foreach (var attachment in manifest.Attachments) { - var sourcePath = Path.Combine(backupAttachmentsDir, attachment.RelativePath); - var destinationPath = Path.Combine(targetAttachmentsDirectory, attachment.RelativePath); + var sourcePath = SafeRelativePath(backupAttachmentsDir, attachment.RelativePath); + var destinationPath = SafeRelativePath(targetAttachmentsDirectory, attachment.RelativePath); + if (!BackupStorageSafety.IsSafePath(targetAttachmentsDirectory, destinationPath)) + throw new InvalidOperationException("Onveilige doelopslag voor bijlagen."); Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - await using (var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read)) - await using (var destination = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await source.CopyToAsync(destination, cancellationToken); - } + // The verified snapshot is inside this target volume, so moving the + // staged blob avoids a second full-size copy at peak restore time. + File.Move(sourcePath, destinationPath); + await VerifyHashAsync(destinationPath, attachment.Sha256, cancellationToken); restored++; } @@ -155,20 +442,36 @@ public sealed class PostgresRestoreService(BackupPaths paths, IBackupService bac throw new InvalidOperationException($"Back-upbestand ontbreekt: '{Path.GetFileName(path)}'."); } - await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - using var sha256 = SHA256.Create(); - var buffer = new byte[81920]; - int read; - while ((read = await stream.ReadAsync(buffer, cancellationToken)) > 0) - { - sha256.TransformBlock(buffer, 0, read, null, 0); - } - - sha256.TransformFinalBlock([], 0, 0); - var actual = Convert.ToHexString(sha256.Hash!).ToLowerInvariant(); + var actual = await HashAsync(path, cancellationToken); if (!string.Equals(actual, expectedSha256, StringComparison.Ordinal)) { throw new InvalidOperationException($"Back-upverificatie mislukt voor bestand '{Path.GetFileName(path)}': hash komt niet overeen."); } } + + private static async Task HashAsync(string path, CancellationToken cancellationToken) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, cancellationToken)); + } + + private sealed class VerifiedBackupSnapshot( + string rootDirectory, + string databaseDirectory, + string attachmentsDirectory, + BackupManifest manifest, + string manifestSha256, + string stagingParent) : IAsyncDisposable + { + public string DatabaseDirectory { get; } = databaseDirectory; + public string AttachmentsDirectory { get; } = attachmentsDirectory; + public BackupManifest Manifest { get; } = manifest; + public string ManifestSha256 { get; } = manifestSha256; + + public ValueTask DisposeAsync() + { + DeleteSnapshotDirectory(rootDirectory, stagingParent); + return ValueTask.CompletedTask; + } + } } diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/TableDiscovery.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/TableDiscovery.cs index c99e90d..4e3a199 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/TableDiscovery.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/TableDiscovery.cs @@ -29,9 +29,13 @@ internal static class TableDiscovery await using var reader = await command.ExecuteReaderAsync(cancellationToken); var tables = new List(); + var seen = new HashSet(StringComparer.Ordinal); while (await reader.ReadAsync(cancellationToken)) { - tables.Add(reader.GetString(0)); + var table = PostgresIdentifierSafety.RequireSafe(reader.GetString(0)); + if (!seen.Add(table)) + throw new InvalidOperationException("De database bevat dubbele tabelnamen."); + tables.Add(table); } return tables; @@ -55,11 +59,47 @@ internal static class TableDiscovery await using var reader = await command.ExecuteReaderAsync(cancellationToken); var columns = new List<(string, string)>(); + var seen = new HashSet<(string Table, string Column)>(); while (await reader.ReadAsync(cancellationToken)) { - columns.Add((reader.GetString(0), reader.GetString(1))); + var table = PostgresIdentifierSafety.RequireSafe(reader.GetString(0)); + var column = PostgresIdentifierSafety.RequireSafe(reader.GetString(1)); + if (!seen.Add((table, column))) + throw new InvalidOperationException("De database bevat dubbele identiteitskolommen."); + columns.Add((table, column)); } return columns; } } + +/// +/// The application schema uses PostgreSQL's ordinary ASCII snake-case identifiers. +/// Restrict names read from the catalog and backup manifests before they are inserted +/// into SQL, rather than trusting a quoted value or a signed manifest to be a valid +/// SQL identifier. +/// +internal static class PostgresIdentifierSafety +{ + internal static string RequireSafe(string? identifier) + { + if (identifier is not { Length: > 0 and <= 63 } + || !identifier.All(character => char.IsAsciiLetterOrDigit(character) || character == '_')) + { + throw new InvalidOperationException("De back-up bevat een onveilige PostgreSQL-identificatie."); + } + + return identifier; + } + + internal static void ValidateUniqueTableNames(IEnumerable tableNames) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var tableName in tableNames) + { + var safeName = RequireSafe(tableName); + if (!seen.Add(safeName)) + throw new InvalidOperationException("Het back-upmanifest bevat dubbele tabelnamen."); + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/BackgroundJobConfiguration.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/BackgroundJobConfiguration.cs new file mode 100644 index 0000000..4998f8d --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/BackgroundJobConfiguration.cs @@ -0,0 +1,55 @@ +using ItWorx.KnowledgeHub.Domain.Identity; +using ItWorx.KnowledgeHub.Domain.Jobs; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Configurations; + +public sealed class BackgroundJobConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("background_jobs", table => table.HasCheckConstraint( + "ck_background_jobs_counts", + "total >= 0 AND processed >= 0 AND succeeded >= 0 AND skipped >= 0 AND failed >= 0 AND processed <= total")); + builder.HasKey(job => job.Id); + builder.Property(job => job.Type).HasConversion().HasMaxLength(32).IsRequired(); + builder.Property(job => job.Status).HasConversion().HasMaxLength(32).IsRequired().IsConcurrencyToken(); + builder.Property(job => job.LeaseToken).IsConcurrencyToken(); + builder.Property(job => job.PayloadJson).HasColumnType("jsonb").IsRequired(); + builder.Property(job => job.ResultJson).HasColumnType("jsonb"); + builder.Property(job => job.ErrorsJson).HasColumnType("jsonb").IsRequired(); + builder.Property(job => job.IdempotencyKeyHash).HasMaxLength(64).IsRequired(); + builder.Property(job => job.PayloadHash).HasMaxLength(64).IsRequired(); + builder.Property(job => job.CorrelationId).HasMaxLength(128); + builder.Property(job => job.AttemptCount).HasDefaultValue(0); + builder.Property(job => job.Version).HasDefaultValue(1L).IsConcurrencyToken(); + builder.HasOne().WithMany().HasForeignKey(job => job.WorkspaceId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(job => job.ActorId).OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(job => new { job.WorkspaceId, job.ActorId, job.IdempotencyKeyHash }).IsUnique() + .HasDatabaseName("ux_background_jobs_idempotency"); + builder.HasIndex(job => new { job.WorkspaceId, job.CreatedAt }).IsDescending(false, true) + .HasDatabaseName("ix_background_jobs_workspace_created"); + builder.HasIndex(job => new { job.Status, job.NextAttemptAt, job.CreatedAt }) + .HasDatabaseName("ix_background_jobs_queue"); + builder.HasIndex(job => new { job.Status, job.LeaseUntil }) + .HasDatabaseName("ix_background_jobs_lease"); + } +} + +public sealed class BackgroundJobItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("background_job_items"); + builder.HasKey(item => item.Ordinal); + builder.Property(item => item.Ordinal).UseIdentityByDefaultColumn(); + builder.Property(item => item.State).HasConversion().HasMaxLength(24).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(item => item.JobId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(item => item.PageId).OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(item => new { item.JobId, item.PageId }).IsUnique() + .HasDatabaseName("ux_background_job_items_page"); + builder.HasIndex(item => new { item.JobId, item.State, item.Ordinal }) + .HasDatabaseName("ix_background_job_items_queue"); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DataSourceConfiguration.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DataSourceConfiguration.cs new file mode 100644 index 0000000..1387dd3 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DataSourceConfiguration.cs @@ -0,0 +1,24 @@ +using ItWorx.KnowledgeHub.Domain.Identity; +using ItWorx.KnowledgeHub.Domain.Sources; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Configurations; + +public sealed class DataSourceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("data_sources"); + builder.HasKey(source => source.Id); + builder.Property(source => source.Id).ValueGeneratedNever(); + builder.Property(source => source.SourceKey).HasMaxLength(200).IsRequired(); + builder.Property(source => source.DisplayName).HasMaxLength(120).IsRequired(); + builder.Property(source => source.Kind).HasConversion().HasMaxLength(30); + builder.Property(source => source.LastErrorCode).HasMaxLength(30); + builder.Property(source => source.LastCorrelationId).HasMaxLength(100); + builder.Property(source => source.Version).IsConcurrencyToken(); + builder.HasIndex(source => new { source.WorkspaceId, source.SourceKey }).IsUnique(); + builder.HasOne().WithMany().HasForeignKey(source => source.WorkspaceId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PlatformSettingsConfiguration.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PlatformSettingsConfiguration.cs new file mode 100644 index 0000000..57c5430 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PlatformSettingsConfiguration.cs @@ -0,0 +1,33 @@ +using ItWorx.KnowledgeHub.Domain.Platform; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Configurations; + +public sealed class PlatformSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("platform_settings", table => table.HasCheckConstraint( + "ck_platform_settings_singleton", + "\"id\" = '00000000-0000-0000-0000-000000000002'")); + builder.HasKey(settings => settings.Id); + + builder.Property(settings => settings.Id).ValueGeneratedNever(); + builder.Property(settings => settings.DisplayName).HasMaxLength(100).IsRequired(); + builder.Property(settings => settings.PrimaryLocale).HasMaxLength(20).IsRequired(); + builder.Property(settings => settings.TimeZone).HasMaxLength(100).IsRequired(); + builder.Property(settings => settings.BackupScheduleDescription).HasMaxLength(200).IsRequired(); + builder.Property(settings => settings.Version).IsConcurrencyToken().IsRequired(); + builder.Property(settings => settings.UpdatedAt).IsRequired(); + + builder.ToTable(table => + { + table.HasCheckConstraint("ck_platform_settings_source_count", "maximum_source_count BETWEEN 1 AND 20"); + table.HasCheckConstraint("ck_platform_settings_source_bytes", "maximum_source_bytes BETWEEN 1 AND 104857600"); + table.HasCheckConstraint("ck_platform_settings_retry_limit", "failed_retry_limit BETWEEN 1 AND 3"); + table.HasCheckConstraint("ck_platform_settings_backup_expectation", "backup_expected_interval_hours BETWEEN 1 AND 8760 AND backup_daily_retention_count BETWEEN 1 AND 365 AND backup_weekly_retention_count BETWEEN 1 AND 104 AND backup_monthly_retention_count BETWEEN 1 AND 120 AND protected_backup_count BETWEEN 1 AND 100 AND restore_verification_max_age_days BETWEEN 1 AND 365"); + table.HasCheckConstraint("ck_platform_settings_inventory_freshness", "inventory_warning_after_hours BETWEEN 1 AND 8760"); + }); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/UserPreferencesConfiguration.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/UserPreferencesConfiguration.cs new file mode 100644 index 0000000..6985e93 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/UserPreferencesConfiguration.cs @@ -0,0 +1,31 @@ +using ItWorx.KnowledgeHub.Domain.Identity; +using ItWorx.KnowledgeHub.Domain.Platform; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Configurations; + +public sealed class UserPreferencesConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_preferences"); + builder.HasKey(preferences => new { preferences.WorkspaceId, preferences.UserId }); + + builder.Property(preferences => preferences.Theme).HasMaxLength(16).IsRequired(); + builder.Property(preferences => preferences.Density).HasMaxLength(16).IsRequired(); + builder.Property(preferences => preferences.NavigationOrder).HasColumnType("text[]").IsRequired(); + builder.Property(preferences => preferences.DashboardWidgetOrder).HasColumnType("text[]").IsRequired(); + builder.Property(preferences => preferences.HiddenDashboardWidgets).HasColumnType("text[]").IsRequired(); + builder.Property(preferences => preferences.DefaultLandingPage).HasMaxLength(100).IsRequired(); + builder.Property(preferences => preferences.CatalogView).HasMaxLength(16).IsRequired(); + builder.Property(preferences => preferences.Version).IsConcurrencyToken().IsRequired(); + builder.Property(preferences => preferences.UpdatedAt).IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(preferences => preferences.WorkspaceId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(preferences => preferences.UserId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(preferences => preferences.UserId); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContext.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContext.cs index 69a50f1..75adf43 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContext.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContext.cs @@ -4,8 +4,10 @@ using ItWorx.KnowledgeHub.Domain.Decisions; using ItWorx.KnowledgeHub.Domain.Identity; using ItWorx.KnowledgeHub.Domain.Infrastructure; using ItWorx.KnowledgeHub.Domain.Ingestion; +using ItWorx.KnowledgeHub.Domain.Jobs; using ItWorx.KnowledgeHub.Domain.Knowledge; using ItWorx.KnowledgeHub.Domain.Migration; +using ItWorx.KnowledgeHub.Domain.Platform; using ItWorx.KnowledgeHub.Domain.Projects; using ItWorx.KnowledgeHub.Domain.Runbooks; using Microsoft.AspNetCore.Identity; @@ -29,6 +31,14 @@ public sealed class KnowledgeHubDbContext(DbContextOptions WorkspaceMemberships => Set(); + public DbSet UserPreferences => Set(); + + public DbSet PlatformSettings => Set(); + + public DbSet BackgroundJobs => Set(); + + public DbSet BackgroundJobItems => Set(); + public DbSet RecoveryTokens => Set(); public DbSet AuditEvents => Set(); diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContextFactory.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContextFactory.cs index 1764f3e..5eed21d 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContextFactory.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContextFactory.cs @@ -12,7 +12,7 @@ public sealed class KnowledgeHubDbContextFactory : IDesignTimeDbContextFactory() .UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly( diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002001948_PlatformFoundations.Designer.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002001948_PlatformFoundations.Designer.cs new file mode 100644 index 0000000..268b1d6 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002001948_PlatformFoundations.Designer.cs @@ -0,0 +1,3465 @@ +// +using System; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(KnowledgeHubDbContext))] + [Migration("20261002001948_PlatformFoundations")] + partial class PlatformFoundations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentBlob", b => + { + b.Property("Sha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("ByteLength") + .HasColumnType("bigint") + .HasColumnName("byte_length"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeliveryPolicy") + .HasColumnType("smallint") + .HasColumnName("delivery_policy"); + + b.Property("DetectedMediaType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("detected_media_type"); + + b.Property("StorageState") + .HasColumnType("smallint") + .HasColumnName("storage_state"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Sha256") + .HasName("pk_attachment_blobs"); + + b.ToTable("attachment_blobs", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AltText") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("alt_text"); + + b.Property("BlobSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("blob_sha256"); + + b.Property("Caption") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("caption"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("OriginalFilename") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("original_filename"); + + b.Property("TrashedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("trashed_at"); + + b.Property("UploadedBy") + .HasColumnType("uuid") + .HasColumnName("uploaded_by"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_attachment_references"); + + b.HasIndex("BlobSha256") + .HasDatabaseName("ix_attachment_references_blob_sha256"); + + b.HasIndex("WorkspaceId", "BlobSha256") + .HasDatabaseName("ix_attachment_references_workspace_id_blob_sha256"); + + b.ToTable("attachment_references", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttachmentReferenceId") + .HasColumnType("uuid") + .HasColumnName("attachment_reference_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IngestionProposalId") + .HasColumnType("uuid") + .HasColumnName("ingestion_proposal_id"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("StagingSessionId") + .HasColumnType("uuid") + .HasColumnName("staging_session_id"); + + b.HasKey("Id") + .HasName("pk_attachment_usages"); + + b.HasIndex("AttachmentReferenceId") + .HasDatabaseName("ix_attachment_usages_attachment_reference_id"); + + b.HasIndex("IngestionProposalId") + .HasDatabaseName("ix_attachment_usages_ingestion_proposal_id"); + + b.HasIndex("PageId") + .HasDatabaseName("ix_attachment_usages_page_id"); + + b.HasIndex("ProjectId") + .HasDatabaseName("ix_attachment_usages_project_id"); + + b.HasIndex("StagingSessionId") + .HasDatabaseName("ix_attachment_usages_staging_session_id"); + + b.ToTable("attachment_usages", null, t => + { + t.HasCheckConstraint("ck_attachment_usages_exactly_one_owner", "((page_id IS NOT NULL)::integer + (staging_session_id IS NOT NULL)::integer + (ingestion_proposal_id IS NOT NULL)::integer + (project_id IS NOT NULL)::integer) = 1"); + }); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Automation.IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CallerId") + .HasColumnType("uuid") + .HasColumnName("caller_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("request_fingerprint"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("response_body_json"); + + b.Property("ResponseStatusCode") + .HasColumnType("integer") + .HasColumnName("response_status_code"); + + b.HasKey("Id") + .HasName("pk_idempotency_records"); + + b.HasIndex("CallerId", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_caller_id_key"); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Automation.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("available_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("AvailableAt", "Id") + .HasDatabaseName("ix_outbox_messages_available_at_id") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Automation.ServiceAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("disabled_at"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_service_accounts"); + + b.HasIndex("WorkspaceId", "Name") + .HasDatabaseName("ix_service_accounts_workspace_id_name"); + + b.ToTable("service_accounts", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Automation.ServiceAccountToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("PublicId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("public_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.PrimitiveCollection("ScopesValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("secret_hash"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid") + .HasColumnName("service_account_id"); + + b.HasKey("Id") + .HasName("pk_service_account_tokens"); + + b.HasIndex("PublicId") + .IsUnique() + .HasDatabaseName("ix_service_account_tokens_public_id"); + + b.HasIndex("ServiceAccountId") + .HasDatabaseName("ix_service_account_tokens_service_account_id"); + + b.ToTable("service_account_tokens", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Decisions.DecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DecisionDate") + .HasColumnType("date") + .HasColumnName("decision_date"); + + b.Property("DecisionNumber") + .HasColumnType("integer") + .HasColumnName("decision_number"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("summary"); + + b.Property("SupersededByDecisionId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_decision_id"); + + b.Property("SupersedesDecisionId") + .HasColumnType("uuid") + .HasColumnName("supersedes_decision_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_decision_records"); + + b.HasIndex("PageId") + .IsUnique() + .HasDatabaseName("ix_decision_records_page_id"); + + b.HasIndex("ProjectId") + .HasDatabaseName("ix_decision_records_project_id"); + + b.HasIndex("ProjectId", "DecisionNumber") + .IsUnique() + .HasDatabaseName("ix_decision_records_accepted_number_per_project") + .HasFilter("status = 1 AND project_id IS NOT NULL"); + + b.HasIndex("WorkspaceId", "DecisionNumber") + .IsUnique() + .HasDatabaseName("ix_decision_records_accepted_number_workspace_level") + .HasFilter("status = 1 AND project_id IS NULL"); + + b.HasIndex("WorkspaceId", "Status") + .HasDatabaseName("ix_decision_records_workspace_id_status"); + + b.ToTable("decision_records", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Identity.Installation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("PrimaryLocale") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("primary_locale"); + + b.Property("ProductVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("product_version"); + + b.Property("SchemaVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("schema_version"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("time_zone"); + + b.HasKey("Id") + .HasName("pk_installations"); + + b.ToTable("installations", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Identity.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SettingsVersion") + .HasColumnType("integer") + .HasColumnName("settings_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(220) + .HasColumnType("character varying(220)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_workspaces"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_workspaces_slug"); + + b.ToTable("workspaces", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Identity.WorkspaceMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Role") + .HasColumnType("smallint") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_workspace_memberships"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique() + .HasDatabaseName("ix_workspace_memberships_workspace_id_user_id"); + + b.ToTable("workspace_memberships", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.EditorialProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BackupStatus") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("backup_status"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("display_name"); + + b.Property("IsArchived") + .HasColumnType("boolean") + .HasColumnName("is_archived"); + + b.Property("IsFavorite") + .HasColumnType("boolean") + .HasColumnName("is_favorite"); + + b.Property("IsHidden") + .HasColumnType("boolean") + .HasColumnName("is_hidden"); + + b.Property("KnownRisks") + .IsRequired() + .HasColumnType("text") + .HasColumnName("known_risks"); + + b.Property("LiveUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("live_url"); + + b.Property("MaintenanceNotes") + .IsRequired() + .HasColumnType("text") + .HasColumnName("maintenance_notes"); + + b.Property("MonitoringUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("monitoring_url"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("purpose"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("repository_url"); + + b.Property("RollbackProcedure") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rollback_procedure"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("summary"); + + b.PrimitiveCollection("TagsValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetKind") + .HasColumnType("smallint") + .HasColumnName("target_kind"); + + b.Property("UpdateProcedure") + .IsRequired() + .HasColumnType("text") + .HasColumnName("update_procedure"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_infrastructure_editorial_profiles"); + + b.HasIndex("WorkspaceId", "IsFavorite") + .HasDatabaseName("ix_infrastructure_editorial_profiles_workspace_id_is_favorite"); + + b.HasIndex("WorkspaceId", "TargetKind", "TargetId") + .IsUnique() + .HasDatabaseName("ix_infrastructure_editorial_profiles_workspace_id_target_kind_"); + + b.ToTable("infrastructure_editorial_profiles", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureHost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Architecture") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("architecture"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_at"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("hostname"); + + b.Property("KernelVersion") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("kernel_version"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("LastSnapshotId") + .HasColumnType("uuid") + .HasColumnName("last_snapshot_id"); + + b.Property("LogicalCpuCount") + .HasColumnType("integer") + .HasColumnName("logical_cpu_count"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("operating_system"); + + b.Property("Presence") + .HasColumnType("smallint") + .HasColumnName("presence"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("source_key"); + + b.Property("TotalMemoryBytes") + .HasColumnType("bigint") + .HasColumnName("total_memory_bytes"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_infrastructure_hosts"); + + b.HasIndex("WorkspaceId", "Presence") + .HasDatabaseName("ix_infrastructure_hosts_workspace_id_presence"); + + b.HasIndex("WorkspaceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_hosts_workspace_id_source_key"); + + b.ToTable("infrastructure_hosts", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Confidence") + .HasColumnType("smallint") + .HasColumnName("confidence"); + + b.Property("DetectedName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("detected_name"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_at"); + + b.Property("Health") + .HasColumnType("smallint") + .HasColumnName("health"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("ImageDigest") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_digest"); + + b.Property("ImageReference") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("image_reference"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("LastSnapshotId") + .HasColumnType("uuid") + .HasColumnName("last_snapshot_id"); + + b.Property("NoNewPrivileges") + .HasColumnType("boolean") + .HasColumnName("no_new_privileges"); + + b.Property("Presence") + .HasColumnType("smallint") + .HasColumnName("presence"); + + b.Property("Privileged") + .HasColumnType("boolean") + .HasColumnName("privileged"); + + b.Property("ReadOnlyRootFilesystem") + .HasColumnType("boolean") + .HasColumnName("read_only_root_filesystem"); + + b.Property("RuntimeKind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtime_kind"); + + b.Property("RuntimeStatus") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtime_status"); + + b.Property("RuntimeUser") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("runtime_user"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_key"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_infrastructure_services"); + + b.HasIndex("HostId") + .HasDatabaseName("ix_infrastructure_services_host_id"); + + b.HasIndex("WorkspaceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_services_workspace_id_source_key"); + + b.HasIndex("WorkspaceId", "DetectedName", "Id") + .HasDatabaseName("ix_infrastructure_services_workspace_id_detected_name_id"); + + b.HasIndex("WorkspaceId", "Presence", "Health") + .HasDatabaseName("ix_infrastructure_services_workspace_id_presence_health"); + + b.ToTable("infrastructure_services", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InventoryChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeKind") + .HasColumnType("smallint") + .HasColumnName("change_kind"); + + b.PrimitiveCollection("ChangedFieldsValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("changed_fields"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("SnapshotId") + .HasColumnType("uuid") + .HasColumnName("snapshot_id"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_key"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetKind") + .HasColumnType("smallint") + .HasColumnName("target_kind"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_inventory_changes"); + + b.HasIndex("TargetKind", "TargetId") + .HasDatabaseName("ix_inventory_changes_target_kind_target_id"); + + b.HasIndex("WorkspaceId", "SnapshotId") + .HasDatabaseName("ix_inventory_changes_workspace_id_snapshot_id"); + + b.ToTable("inventory_changes", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InventorySnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CapturedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("captured_at"); + + b.Property("HostCount") + .HasColumnType("integer") + .HasColumnName("host_count"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("imported_at"); + + b.Property("ServiceCount") + .HasColumnType("integer") + .HasColumnName("service_count"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("source_key"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_inventory_snapshots"); + + b.HasIndex("WorkspaceId", "CapturedAt") + .HasDatabaseName("ix_inventory_snapshots_workspace_id_captured_at"); + + b.HasIndex("WorkspaceId", "SourceHash") + .IsUnique() + .HasDatabaseName("ix_inventory_snapshots_workspace_id_source_hash"); + + b.ToTable("inventory_snapshots", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.ManualOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("field_name"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reason"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetKind") + .HasColumnType("smallint") + .HasColumnName("target_kind"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value_json"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_infrastructure_manual_overrides"); + + b.HasIndex("WorkspaceId", "TargetKind", "TargetId", "FieldName") + .IsUnique() + .HasDatabaseName("ix_infrastructure_manual_overrides_workspace_id_target_kind_ta"); + + b.ToTable("infrastructure_manual_overrides", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Confidence") + .HasColumnType("smallint") + .HasColumnName("confidence"); + + b.Property("LastSnapshotId") + .HasColumnType("uuid") + .HasColumnName("last_snapshot_id"); + + b.Property("ObservedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("observed_at"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_key"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id"); + + b.ToTable((string)null); + + b.UseTpcMappingStrategy(); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Analysis") + .HasColumnType("text") + .HasColumnName("analysis"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("ApprovedRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("approved_revision_number"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("FailedLifecycleStatus") + .HasColumnType("smallint") + .HasColumnName("failed_lifecycle_status"); + + b.Property("FailedProcessingStage") + .HasColumnType("smallint") + .HasColumnName("failed_processing_stage"); + + b.Property("FailureErrorType") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("failure_error_type"); + + b.Property("FailureMessage") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("failure_message"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("IsRetryEligible") + .HasColumnType("boolean") + .HasColumnName("is_retry_eligible"); + + b.Property("Manifest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("manifest"); + + b.Property("MaxPublishRetries") + .HasColumnType("integer") + .HasColumnName("max_publish_retries"); + + b.Property("PublicationIdempotencyKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("publication_idempotency_key"); + + b.Property("PublishedPageId") + .HasColumnType("uuid") + .HasColumnName("published_page_id"); + + b.Property("PublishedPageRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("published_page_revision_number"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("ReviewerComment") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reviewer_comment"); + + b.Property("RevisionNumber") + .HasColumnType("bigint") + .HasColumnName("revision_number"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("SubmittedByUserId") + .HasColumnType("uuid") + .HasColumnName("submitted_by_user_id"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_ingestion_proposals"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_ingestion_proposals_workspace_id_idempotency_key"); + + b.ToTable("ingestion_proposals", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposalRevision", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("Analysis") + .HasColumnType("text") + .HasColumnName("analysis"); + + b.Property("FailedLifecycleStatus") + .HasColumnType("smallint") + .HasColumnName("failed_lifecycle_status"); + + b.Property("FailedProcessingStage") + .HasColumnType("smallint") + .HasColumnName("failed_processing_stage"); + + b.Property("FailureErrorType") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("failure_error_type"); + + b.Property("FailureMessage") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("failure_message"); + + b.Property("IngestionProposalId") + .HasColumnType("uuid") + .HasColumnName("ingestion_proposal_id"); + + b.Property("IsRetryEligible") + .HasColumnType("boolean") + .HasColumnName("is_retry_eligible"); + + b.Property("Manifest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("manifest"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("operation"); + + b.Property("ReviewerComment") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reviewer_comment"); + + b.Property("RevisionNumber") + .HasColumnType("bigint") + .HasColumnName("revision_number"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_ingestion_proposal_revisions"); + + b.HasIndex("IngestionProposalId", "RevisionNumber") + .IsUnique() + .HasDatabaseName("ix_ingestion_proposal_revisions_ingestion_proposal_id_revision"); + + b.ToTable("ingestion_proposal_revisions", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposalSource", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttachmentReferenceId") + .HasColumnType("uuid") + .HasColumnName("attachment_reference_id"); + + b.Property("ByteSize") + .HasColumnType("bigint") + .HasColumnName("byte_size"); + + b.Property("DigestSha256") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("digest_sha256"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("file_name"); + + b.Property("IngestionProposalId") + .HasColumnType("uuid") + .HasColumnName("ingestion_proposal_id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(127) + .IsUnicode(false) + .HasColumnType("character varying(127)") + .HasColumnName("mime_type"); + + b.Property("ScanResult") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("scan_result"); + + b.HasKey("Id") + .HasName("pk_ingestion_proposal_sources"); + + b.HasIndex("IngestionProposalId", "DigestSha256") + .HasDatabaseName("ix_ingestion_proposal_sources_ingestion_proposal_id_digest_sha"); + + b.ToTable("ingestion_proposal_sources", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.Draft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("base_revision_number"); + + b.Property("ChangeSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("change_summary"); + + b.Property("DocumentStatus") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_status"); + + b.Property("DocumentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_type"); + + b.Property("DraftKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("draft_key"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("markdown"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.PrimitiveCollection("TagsValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_drafts"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_drafts_expires_at"); + + b.HasIndex("WorkspaceId", "UserId", "DraftKey") + .IsUnique() + .HasDatabaseName("ix_drafts_workspace_id_user_id_draft_key"); + + b.ToTable("drafts", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("current_revision_number"); + + b.Property("DocumentStatus") + .HasColumnType("smallint") + .HasColumnName("document_status"); + + b.Property("DocumentType") + .HasColumnType("smallint") + .HasColumnName("document_type"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("markdown"); + + b.Property("ParentPageId") + .HasColumnType("uuid") + .HasColumnName("parent_page_id"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PurgeAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("purge_after"); + + b.Property("SlugValue") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("slug"); + + b.Property("SortKeyValue") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)") + .HasColumnName("sort_key"); + + b.PrimitiveCollection("TagsValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("TitleValue") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("TrashedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("trashed_at"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("TagsValue") + .HasDatabaseName("ix_pages_tags"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TagsValue"), "gin"); + + b.HasIndex("WorkspaceId", "SlugValue") + .HasDatabaseName("ix_pages_workspace_id_slug"); + + b.HasIndex("WorkspaceId", "TitleValue") + .HasDatabaseName("ix_pages_workspace_id_title"); + + b.HasIndex("WorkspaceId", "ParentPageId", "SortKeyValue", "Id") + .HasDatabaseName("ix_pages_workspace_id_parent_page_id_sort_key_id"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.PageCreationSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("CommittedPageId") + .HasColumnType("uuid") + .HasColumnName("committed_page_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ParentPageId") + .HasColumnType("uuid") + .HasColumnName("parent_page_id"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_page_creation_sessions"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_page_creation_sessions_expires_at"); + + b.HasIndex("WorkspaceId", "Status") + .HasDatabaseName("ix_page_creation_sessions_workspace_id_status"); + + b.ToTable("page_creation_sessions", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.PageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DisplayLabel") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("display_label"); + + b.Property("OccurrenceIndex") + .HasColumnType("integer") + .HasColumnName("occurrence_index"); + + b.Property("ResolutionState") + .HasColumnType("smallint") + .HasColumnName("resolution_state"); + + b.Property("SourcePageId") + .HasColumnType("uuid") + .HasColumnName("source_page_id"); + + b.Property("SourceRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("source_revision_number"); + + b.Property("TargetPageId") + .HasColumnType("uuid") + .HasColumnName("target_page_id"); + + b.Property("TargetText") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("target_text"); + + b.HasKey("Id") + .HasName("pk_page_links"); + + b.HasIndex("TargetPageId") + .HasDatabaseName("ix_page_links_target_page_id"); + + b.HasIndex("SourcePageId", "OccurrenceIndex") + .IsUnique() + .HasDatabaseName("ix_page_links_source_page_id_occurrence_index"); + + b.ToTable("page_links", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.PageRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthoredBy") + .HasColumnType("uuid") + .HasColumnName("authored_by"); + + b.Property("ChangeSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("change_summary"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DocumentStatus") + .HasColumnType("smallint") + .HasColumnName("document_status"); + + b.Property("DocumentType") + .HasColumnType("smallint") + .HasColumnName("document_type"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("markdown"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ParentPageId") + .HasColumnType("uuid") + .HasColumnName("parent_page_id"); + + b.Property("RevisionNumber") + .HasColumnType("bigint") + .HasColumnName("revision_number"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("slug"); + + b.Property("SortKey") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)") + .HasColumnName("sort_key"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_page_revisions"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_page_revisions_created_at"); + + b.HasIndex("PageId", "RevisionNumber") + .IsUnique() + .HasDatabaseName("ix_page_revisions_page_id_revision_number"); + + b.ToTable("page_revisions", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Migration.ImportItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Diagnostic") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("diagnostic"); + + b.Property("ImportJobId") + .HasColumnType("uuid") + .HasColumnName("import_job_id"); + + b.Property("SourceIdentity") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("source_identity"); + + b.Property("SourceKind") + .HasColumnType("smallint") + .HasColumnName("source_kind"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source_sha256"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("TargetPageId") + .HasColumnType("uuid") + .HasColumnName("target_page_id"); + + b.HasKey("Id") + .HasName("pk_import_items"); + + b.HasIndex("ImportJobId", "SourceIdentity") + .IsUnique() + .HasDatabaseName("ix_import_items_import_job_id_source_identity"); + + b.ToTable("import_items", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Migration.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("AdapterName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_name"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DiscoveredCount") + .HasColumnType("integer") + .HasColumnName("discovered_count"); + + b.Property("FailedCount") + .HasColumnType("integer") + .HasColumnName("failed_count"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("ImportedCount") + .HasColumnType("integer") + .HasColumnName("imported_count"); + + b.Property("PlanHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("plan_hash"); + + b.Property("PlannedCount") + .HasColumnType("integer") + .HasColumnName("planned_count"); + + b.Property("SkippedCount") + .HasColumnType("integer") + .HasColumnName("skipped_count"); + + b.Property("SourceFingerprintSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source_fingerprint_sha256"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_import_jobs"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_import_jobs_workspace_id_idempotency_key"); + + b.ToTable("import_jobs", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Migration.ImportProvenance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImportItemId") + .HasColumnType("uuid") + .HasColumnName("import_item_id"); + + b.Property("ImportJobId") + .HasColumnType("uuid") + .HasColumnName("import_job_id"); + + b.Property("TargetEntityId") + .HasColumnType("uuid") + .HasColumnName("target_entity_id"); + + b.Property("TargetEntityType") + .HasColumnType("smallint") + .HasColumnName("target_entity_type"); + + b.HasKey("Id") + .HasName("pk_import_provenance"); + + b.HasIndex("ImportJobId") + .HasDatabaseName("ix_import_provenance_import_job_id"); + + b.HasIndex("TargetEntityType", "TargetEntityId") + .HasDatabaseName("ix_import_provenance_target_entity_type_target_entity_id"); + + b.ToTable("import_provenance", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.PlatformSettings", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BackupDailyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_daily_retention_count"); + + b.Property("BackupExpectedIntervalHours") + .HasColumnType("integer") + .HasColumnName("backup_expected_interval_hours"); + + b.Property("BackupMonthlyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_monthly_retention_count"); + + b.Property("BackupScheduleDescription") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("backup_schedule_description"); + + b.Property("BackupWeeklyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_weekly_retention_count"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("display_name"); + + b.Property("FailedRetryLimit") + .HasColumnType("integer") + .HasColumnName("failed_retry_limit"); + + b.Property("IngestionEnabled") + .HasColumnType("boolean") + .HasColumnName("ingestion_enabled"); + + b.Property("InventoryWarningAfterHours") + .HasColumnType("integer") + .HasColumnName("inventory_warning_after_hours"); + + b.Property("MaximumSourceBytes") + .HasColumnType("bigint") + .HasColumnName("maximum_source_bytes"); + + b.Property("MaximumSourceCount") + .HasColumnType("integer") + .HasColumnName("maximum_source_count"); + + b.Property("PrimaryLocale") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("primary_locale"); + + b.Property("ProtectedBackupCount") + .HasColumnType("integer") + .HasColumnName("protected_backup_count"); + + b.Property("RestoreVerificationMaxAgeDays") + .HasColumnType("integer") + .HasColumnName("restore_verification_max_age_days"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("time_zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_platform_settings"); + + b.ToTable("platform_settings", null, t => + { + t.HasCheckConstraint("ck_platform_settings_backup_expectation", "backup_expected_interval_hours BETWEEN 1 AND 8760 AND backup_daily_retention_count BETWEEN 1 AND 365 AND backup_weekly_retention_count BETWEEN 1 AND 104 AND backup_monthly_retention_count BETWEEN 1 AND 120 AND protected_backup_count BETWEEN 1 AND 100 AND restore_verification_max_age_days BETWEEN 1 AND 365"); + + t.HasCheckConstraint("ck_platform_settings_inventory_freshness", "inventory_warning_after_hours BETWEEN 1 AND 8760"); + + t.HasCheckConstraint("ck_platform_settings_retry_limit", "failed_retry_limit BETWEEN 1 AND 3"); + + t.HasCheckConstraint("ck_platform_settings_singleton", "\"id\" = '00000000-0000-0000-0000-000000000002'"); + + t.HasCheckConstraint("ck_platform_settings_source_bytes", "maximum_source_bytes BETWEEN 1 AND 104857600"); + + t.HasCheckConstraint("ck_platform_settings_source_count", "maximum_source_count BETWEEN 1 AND 20"); + }); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.UserPreferences", b => + { + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CatalogView") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("catalog_view"); + + b.Property("ContextPanelOpen") + .HasColumnType("boolean") + .HasColumnName("context_panel_open"); + + b.Property("ContextPanelWidth") + .HasColumnType("integer") + .HasColumnName("context_panel_width"); + + b.PrimitiveCollection("DashboardWidgetOrder") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("dashboard_widget_order"); + + b.Property("DefaultLandingPage") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("default_landing_page"); + + b.Property("Density") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("density"); + + b.PrimitiveCollection("HiddenDashboardWidgets") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("hidden_dashboard_widgets"); + + b.PrimitiveCollection("NavigationOrder") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("navigation_order"); + + b.Property("NavigationWidth") + .HasColumnType("integer") + .HasColumnName("navigation_width"); + + b.Property("PrimaryNavigationCollapsed") + .HasColumnType("boolean") + .HasColumnName("primary_navigation_collapsed"); + + b.Property("Theme") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("theme"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("WorkspaceId", "UserId") + .HasName("pk_user_preferences"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_preferences_user_id"); + + b.ToTable("user_preferences", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletionDate") + .HasColumnType("date") + .HasColumnName("completion_date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CurrentStateMarkdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("current_state_markdown"); + + b.Property("DocumentationUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("documentation_url"); + + b.Property("IconAttachmentReferenceId") + .HasColumnType("uuid") + .HasColumnName("icon_attachment_reference_id"); + + b.Property("IssueTrackerUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("issue_tracker_url"); + + b.Property("LiveUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("live_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Priority") + .HasColumnType("smallint") + .HasColumnName("priority"); + + b.Property("RepositoryUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("repository_url"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("summary"); + + b.Property("TargetDate") + .HasColumnType("date") + .HasColumnName("target_date"); + + b.PrimitiveCollection("TechnologiesValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("technologies"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_projects"); + + b.HasIndex("IconAttachmentReferenceId") + .IsUnique() + .HasDatabaseName("ix_projects_icon_attachment_reference_id"); + + b.HasIndex("WorkspaceId", "Slug") + .IsUnique() + .HasDatabaseName("ix_projects_workspace_id_slug"); + + b.HasIndex("WorkspaceId", "Status") + .HasDatabaseName("ix_projects_workspace_id_status"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.ProjectDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LinkedBy") + .HasColumnType("uuid") + .HasColumnName("linked_by"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Role") + .HasColumnType("smallint") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_project_documents"); + + b.HasIndex("PageId") + .HasDatabaseName("ix_project_documents_page_id"); + + b.HasIndex("ProjectId", "PageId", "Role") + .IsUnique() + .HasDatabaseName("ix_project_documents_project_id_page_id_role"); + + b.ToTable("project_documents", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.ProjectMilestone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletionDate") + .HasColumnType("date") + .HasColumnName("completion_date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("SortKeyValue") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)") + .HasColumnName("sort_key"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("SuccessCriteria") + .IsRequired() + .HasColumnType("text") + .HasColumnName("success_criteria"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text") + .HasColumnName("summary"); + + b.Property("TargetDate") + .HasColumnType("date") + .HasColumnName("target_date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_project_milestones"); + + b.HasIndex("ProjectId", "SortKeyValue") + .HasDatabaseName("ix_project_milestones_project_id_sort_key"); + + b.ToTable("project_milestones", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.ProjectUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("AttachmentReferenceId") + .HasColumnType("uuid") + .HasColumnName("attachment_reference_id"); + + b.Property("Category") + .HasColumnType("smallint") + .HasColumnName("category"); + + b.Property("CommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("commit_sha"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DecisionRecordId") + .HasColumnType("uuid") + .HasColumnName("decision_record_id"); + + b.Property("IssueIdentifier") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("issue_identifier"); + + b.Property("MilestoneId") + .HasColumnType("uuid") + .HasColumnName("milestone_id"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("ReleaseTag") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("release_tag"); + + b.Property("SummaryMarkdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("summary_markdown"); + + b.Property("SupersedesUpdateId") + .HasColumnType("uuid") + .HasColumnName("supersedes_update_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("Url") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("url"); + + b.Property("VerificationStatus") + .HasColumnType("smallint") + .HasColumnName("verification_status"); + + b.HasKey("Id") + .HasName("pk_project_updates"); + + b.HasIndex("ProjectId", "OccurredAt", "Id") + .HasDatabaseName("ix_project_updates_project_id_occurred_at_id"); + + b.ToTable("project_updates", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Runbooks.RunbookProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EstimatedDurationMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_duration_minutes"); + + b.Property("LastValidatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_validated_at"); + + b.Property("Owner") + .HasColumnType("uuid") + .HasColumnName("owner"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("RequiresMaintenanceWindow") + .HasColumnType("boolean") + .HasColumnName("requires_maintenance_window"); + + b.Property("ReviewDueAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("review_due_at"); + + b.Property("RiskLevel") + .HasColumnType("smallint") + .HasColumnName("risk_level"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("ValidatedAtRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("validated_at_revision_number"); + + b.Property("ValidatedBy") + .HasColumnType("uuid") + .HasColumnName("validated_by"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_runbook_profiles"); + + b.HasIndex("PageId") + .IsUnique() + .HasDatabaseName("ix_runbook_profiles_page_id"); + + b.HasIndex("RiskLevel", "ReviewDueAt") + .HasDatabaseName("ix_runbook_profiles_risk_level_review_due_at"); + + b.ToTable("runbook_profiles", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Sources.DataSource", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataObservedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_observed_at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("display_name"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExpectedIntervalHours") + .HasColumnType("integer") + .HasColumnName("expected_interval_hours"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("kind"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_at"); + + b.Property("LastCorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_correlation_id"); + + b.Property("LastErrorCode") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("last_error_code"); + + b.Property("LastFailureAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_failure_at"); + + b.Property("LastSuccessAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_success_at"); + + b.Property("RecordCount") + .HasColumnType("bigint") + .HasColumnName("record_count"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("source_key"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_data_sources"); + + b.HasIndex("WorkspaceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_data_sources_workspace_id_source_key"); + + b.ToTable("data_sources", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Infrastructure.Persistence.AuditEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("actor_kind"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("target_type"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("OccurredAt") + .HasDatabaseName("ix_audit_events_occurred_at"); + + b.HasIndex("ActorId", "OccurredAt") + .HasDatabaseName("ix_audit_events_actor_id_occurred_at"); + + b.ToTable("audit_events", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Infrastructure.Persistence.RecoveryTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IssuedByUserId") + .HasColumnType("uuid") + .HasColumnName("issued_by_user_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerifierHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("verifier_hash"); + + b.HasKey("Id") + .HasName("pk_recovery_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_recovery_tokens_user_id"); + + b.HasIndex("VerifierHash") + .IsUnique() + .HasDatabaseName("ix_recovery_tokens_verifier_hash"); + + b.ToTable("recovery_tokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.BackupCoverage", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("LastSuccessfulAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_successful_at"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("method"); + + b.Property("RestoreTested") + .HasColumnType("boolean") + .HasColumnName("restore_tested"); + + b.Property("Schedule") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("schedule"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_backup_coverages_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_backup_coverages_service_id_source_key"); + + b.ToTable("backup_coverages", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.DeploymentReference", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("Commit") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("commit"); + + b.Property("DeployedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deployed_at"); + + b.Property("ImageDigest") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_digest"); + + b.Property("ImageReference") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("image_reference"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("repository_url"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_deployment_references_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_deployment_references_service_id_source_key"); + + b.ToTable("deployment_references", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.HealthObservation", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("CheckKind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("check_kind"); + + b.Property("ResponseTimeMilliseconds") + .HasColumnType("integer") + .HasColumnName("response_time_milliseconds"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_health_observations_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_health_observations_service_id_source_key"); + + b.ToTable("health_observations", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureDatabase", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("Engine") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("engine"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("HostPortPublished") + .HasColumnType("boolean") + .HasColumnName("host_port_published"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("Port") + .HasColumnType("integer") + .HasColumnName("port"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("version"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_infrastructure_databases_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_databases_service_id_source_key"); + + b.ToTable("infrastructure_databases", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureEndpoint", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("PublishedPort") + .HasColumnType("integer") + .HasColumnName("published_port"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scheme"); + + b.Property("TargetPort") + .HasColumnType("integer") + .HasColumnName("target_port"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("url"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_infrastructure_endpoints_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_endpoints_service_id_source_key"); + + b.ToTable("infrastructure_endpoints", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureNetwork", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("Driver") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("driver"); + + b.Property("Internal") + .HasColumnType("boolean") + .HasColumnName("internal"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("scope"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_infrastructure_networks_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_networks_service_id_source_key"); + + b.ToTable("infrastructure_networks", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureVolume", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("MountPath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("mount_path"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("ReadOnly") + .HasColumnType("boolean") + .HasColumnName("read_only"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StorageClass") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("storage_class"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_infrastructure_volumes_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_volumes_service_id_source_key"); + + b.ToTable("infrastructure_volumes", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceComponent", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("ImageReference") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("image_reference"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("kind"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RuntimeId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("runtime_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("status"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_service_components_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_service_components_service_id_source_key"); + + b.ToTable("service_components", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceDependency", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("DependsOnServiceId") + .HasColumnType("uuid") + .HasColumnName("depends_on_service_id"); + + b.Property("DependsOnSourceKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("depends_on_source_key"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("kind"); + + b.HasIndex("DependsOnServiceId") + .HasDatabaseName("ix_service_dependencies_depends_on_service_id"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_service_dependencies_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_service_dependencies_service_id_source_key"); + + b.ToTable("service_dependencies", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentBlob", null) + .WithMany() + .HasForeignKey("BlobSha256") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_attachment_references_attachment_blobs_blob_sha256"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentUsage", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", null) + .WithMany() + .HasForeignKey("AttachmentReferenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_attachment_usages_attachment_references_attachment_referenc"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", null) + .WithMany() + .HasForeignKey("IngestionProposalId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_attachment_usages_ingestion_proposals_ingestion_proposal_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Projects.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_attachment_usages_projects_project_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Knowledge.PageCreationSession", null) + .WithMany() + .HasForeignKey("StagingSessionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_attachment_usages_page_creation_sessions_staging_session_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", b => + { + b.OwnsOne("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionPublication", "Publication", b1 => + { + b1.Property("IngestionProposalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("DocumentStatus") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("publication_document_status"); + + b1.Property("DocumentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("publication_document_type"); + + b1.Property("ExpectedTargetETag") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("publication_expected_target_etag"); + + b1.Property("Markdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("publication_markdown"); + + b1.Property("MutationKind") + .HasColumnType("smallint") + .HasColumnName("publication_mutation_kind"); + + b1.Property("ParentPageId") + .HasColumnType("uuid") + .HasColumnName("publication_parent_page_id"); + + b1.Property("TargetPageId") + .HasColumnType("uuid") + .HasColumnName("publication_target_page_id"); + + b1.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("publication_title"); + + b1.HasKey("IngestionProposalId"); + + b1.ToTable("ingestion_proposals"); + + b1.WithOwner() + .HasForeignKey("IngestionProposalId") + .HasConstraintName("fk_ingestion_proposals_ingestion_proposals_id"); + }); + + b.Navigation("Publication"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposalRevision", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", null) + .WithMany("Revisions") + .HasForeignKey("IngestionProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingestion_proposal_revisions_ingestion_proposals_ingestion_"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposalSource", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", null) + .WithMany("Sources") + .HasForeignKey("IngestionProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingestion_proposal_sources_ingestion_proposals_ingestion_pr"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.UserPreferences", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_preferences_users_user_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Identity.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_preferences_workspaces_workspace_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.Project", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", null) + .WithMany() + .HasForeignKey("IconAttachmentReferenceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_projects_attachment_references_icon_attachment_reference_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Sources.DataSource", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Identity.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_data_sources_workspaces_workspace_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", b => + { + b.Navigation("Revisions"); + + b.Navigation("Sources"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002001948_PlatformFoundations.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002001948_PlatformFoundations.cs new file mode 100644 index 0000000..77cfd36 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002001948_PlatformFoundations.cs @@ -0,0 +1,142 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations +{ + /// + public partial class PlatformFoundations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "data_sources", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + workspace_id = table.Column(type: "uuid", nullable: false), + source_key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + display_name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + kind = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + enabled = table.Column(type: "boolean", nullable: false), + expected_interval_hours = table.Column(type: "integer", nullable: false), + version = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + last_attempt_at = table.Column(type: "timestamp with time zone", nullable: true), + last_success_at = table.Column(type: "timestamp with time zone", nullable: true), + last_failure_at = table.Column(type: "timestamp with time zone", nullable: true), + data_observed_at = table.Column(type: "timestamp with time zone", nullable: true), + record_count = table.Column(type: "bigint", nullable: true), + last_error_code = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), + last_correlation_id = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_data_sources", x => x.id); + table.ForeignKey( + name: "fk_data_sources_workspaces_workspace_id", + column: x => x.workspace_id, + principalTable: "workspaces", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "platform_settings", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + display_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + primary_locale = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + time_zone = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ingestion_enabled = table.Column(type: "boolean", nullable: false), + maximum_source_count = table.Column(type: "integer", nullable: false), + maximum_source_bytes = table.Column(type: "bigint", nullable: false), + failed_retry_limit = table.Column(type: "integer", nullable: false), + backup_expected_interval_hours = table.Column(type: "integer", nullable: false), + backup_schedule_description = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + backup_daily_retention_count = table.Column(type: "integer", nullable: false), + backup_weekly_retention_count = table.Column(type: "integer", nullable: false), + backup_monthly_retention_count = table.Column(type: "integer", nullable: false), + protected_backup_count = table.Column(type: "integer", nullable: false), + restore_verification_max_age_days = table.Column(type: "integer", nullable: false), + inventory_warning_after_hours = table.Column(type: "integer", nullable: false), + version = table.Column(type: "integer", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_platform_settings", x => x.id); + table.CheckConstraint("ck_platform_settings_backup_expectation", "backup_expected_interval_hours BETWEEN 1 AND 8760 AND backup_daily_retention_count BETWEEN 1 AND 365 AND backup_weekly_retention_count BETWEEN 1 AND 104 AND backup_monthly_retention_count BETWEEN 1 AND 120 AND protected_backup_count BETWEEN 1 AND 100 AND restore_verification_max_age_days BETWEEN 1 AND 365"); + table.CheckConstraint("ck_platform_settings_inventory_freshness", "inventory_warning_after_hours BETWEEN 1 AND 8760"); + table.CheckConstraint("ck_platform_settings_retry_limit", "failed_retry_limit BETWEEN 1 AND 3"); + table.CheckConstraint("ck_platform_settings_singleton", "\"id\" = '00000000-0000-0000-0000-000000000002'"); + table.CheckConstraint("ck_platform_settings_source_bytes", "maximum_source_bytes BETWEEN 1 AND 104857600"); + table.CheckConstraint("ck_platform_settings_source_count", "maximum_source_count BETWEEN 1 AND 20"); + }); + + migrationBuilder.CreateTable( + name: "user_preferences", + columns: table => new + { + workspace_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + theme = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + density = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + primary_navigation_collapsed = table.Column(type: "boolean", nullable: false), + context_panel_open = table.Column(type: "boolean", nullable: false), + navigation_order = table.Column(type: "text[]", nullable: false), + dashboard_widget_order = table.Column(type: "text[]", nullable: false), + hidden_dashboard_widgets = table.Column(type: "text[]", nullable: false), + default_landing_page = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + catalog_view = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + context_panel_width = table.Column(type: "integer", nullable: false), + navigation_width = table.Column(type: "integer", nullable: false), + version = table.Column(type: "integer", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_preferences", x => new { x.workspace_id, x.user_id }); + table.ForeignKey( + name: "fk_user_preferences_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_user_preferences_workspaces_workspace_id", + column: x => x.workspace_id, + principalTable: "workspaces", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "ix_data_sources_workspace_id_source_key", + table: "data_sources", + columns: new[] { "workspace_id", "source_key" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_user_preferences_user_id", + table: "user_preferences", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "data_sources"); + + migrationBuilder.DropTable( + name: "platform_settings"); + + migrationBuilder.DropTable( + name: "user_preferences"); + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002095318_AddBackgroundJobs.Designer.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002095318_AddBackgroundJobs.Designer.cs new file mode 100644 index 0000000..6b70c6f --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002095318_AddBackgroundJobs.Designer.cs @@ -0,0 +1,3697 @@ +// +using System; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(KnowledgeHubDbContext))] + [Migration("20261002095318_AddBackgroundJobs")] + partial class AddBackgroundJobs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentBlob", b => + { + b.Property("Sha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("ByteLength") + .HasColumnType("bigint") + .HasColumnName("byte_length"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeliveryPolicy") + .HasColumnType("smallint") + .HasColumnName("delivery_policy"); + + b.Property("DetectedMediaType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("detected_media_type"); + + b.Property("StorageState") + .HasColumnType("smallint") + .HasColumnName("storage_state"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Sha256") + .HasName("pk_attachment_blobs"); + + b.ToTable("attachment_blobs", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AltText") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("alt_text"); + + b.Property("BlobSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("blob_sha256"); + + b.Property("Caption") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("caption"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("OriginalFilename") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("original_filename"); + + b.Property("TrashedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("trashed_at"); + + b.Property("UploadedBy") + .HasColumnType("uuid") + .HasColumnName("uploaded_by"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_attachment_references"); + + b.HasIndex("BlobSha256") + .HasDatabaseName("ix_attachment_references_blob_sha256"); + + b.HasIndex("WorkspaceId", "BlobSha256") + .HasDatabaseName("ix_attachment_references_workspace_id_blob_sha256"); + + b.ToTable("attachment_references", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttachmentReferenceId") + .HasColumnType("uuid") + .HasColumnName("attachment_reference_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IngestionProposalId") + .HasColumnType("uuid") + .HasColumnName("ingestion_proposal_id"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("StagingSessionId") + .HasColumnType("uuid") + .HasColumnName("staging_session_id"); + + b.HasKey("Id") + .HasName("pk_attachment_usages"); + + b.HasIndex("AttachmentReferenceId") + .HasDatabaseName("ix_attachment_usages_attachment_reference_id"); + + b.HasIndex("IngestionProposalId") + .HasDatabaseName("ix_attachment_usages_ingestion_proposal_id"); + + b.HasIndex("PageId") + .HasDatabaseName("ix_attachment_usages_page_id"); + + b.HasIndex("ProjectId") + .HasDatabaseName("ix_attachment_usages_project_id"); + + b.HasIndex("StagingSessionId") + .HasDatabaseName("ix_attachment_usages_staging_session_id"); + + b.ToTable("attachment_usages", null, t => + { + t.HasCheckConstraint("ck_attachment_usages_exactly_one_owner", "((page_id IS NOT NULL)::integer + (staging_session_id IS NOT NULL)::integer + (ingestion_proposal_id IS NOT NULL)::integer + (project_id IS NOT NULL)::integer) = 1"); + }); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Automation.IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CallerId") + .HasColumnType("uuid") + .HasColumnName("caller_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("request_fingerprint"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("response_body_json"); + + b.Property("ResponseStatusCode") + .HasColumnType("integer") + .HasColumnName("response_status_code"); + + b.HasKey("Id") + .HasName("pk_idempotency_records"); + + b.HasIndex("CallerId", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_caller_id_key"); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Automation.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("available_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("AvailableAt", "Id") + .HasDatabaseName("ix_outbox_messages_available_at_id") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Automation.ServiceAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("disabled_at"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_service_accounts"); + + b.HasIndex("WorkspaceId", "Name") + .HasDatabaseName("ix_service_accounts_workspace_id_name"); + + b.ToTable("service_accounts", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Automation.ServiceAccountToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("PublicId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("public_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.PrimitiveCollection("ScopesValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("secret_hash"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid") + .HasColumnName("service_account_id"); + + b.HasKey("Id") + .HasName("pk_service_account_tokens"); + + b.HasIndex("PublicId") + .IsUnique() + .HasDatabaseName("ix_service_account_tokens_public_id"); + + b.HasIndex("ServiceAccountId") + .HasDatabaseName("ix_service_account_tokens_service_account_id"); + + b.ToTable("service_account_tokens", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Decisions.DecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DecisionDate") + .HasColumnType("date") + .HasColumnName("decision_date"); + + b.Property("DecisionNumber") + .HasColumnType("integer") + .HasColumnName("decision_number"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("summary"); + + b.Property("SupersededByDecisionId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_decision_id"); + + b.Property("SupersedesDecisionId") + .HasColumnType("uuid") + .HasColumnName("supersedes_decision_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_decision_records"); + + b.HasIndex("PageId") + .IsUnique() + .HasDatabaseName("ix_decision_records_page_id"); + + b.HasIndex("ProjectId") + .HasDatabaseName("ix_decision_records_project_id"); + + b.HasIndex("ProjectId", "DecisionNumber") + .IsUnique() + .HasDatabaseName("ix_decision_records_accepted_number_per_project") + .HasFilter("status = 1 AND project_id IS NOT NULL"); + + b.HasIndex("WorkspaceId", "DecisionNumber") + .IsUnique() + .HasDatabaseName("ix_decision_records_accepted_number_workspace_level") + .HasFilter("status = 1 AND project_id IS NULL"); + + b.HasIndex("WorkspaceId", "Status") + .HasDatabaseName("ix_decision_records_workspace_id_status"); + + b.ToTable("decision_records", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Identity.Installation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("PrimaryLocale") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("primary_locale"); + + b.Property("ProductVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("product_version"); + + b.Property("SchemaVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("schema_version"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("time_zone"); + + b.HasKey("Id") + .HasName("pk_installations"); + + b.ToTable("installations", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Identity.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SettingsVersion") + .HasColumnType("integer") + .HasColumnName("settings_version"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(220) + .HasColumnType("character varying(220)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_workspaces"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_workspaces_slug"); + + b.ToTable("workspaces", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Identity.WorkspaceMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Role") + .HasColumnType("smallint") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_workspace_memberships"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique() + .HasDatabaseName("ix_workspace_memberships_workspace_id_user_id"); + + b.ToTable("workspace_memberships", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.EditorialProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BackupStatus") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("backup_status"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("display_name"); + + b.Property("IsArchived") + .HasColumnType("boolean") + .HasColumnName("is_archived"); + + b.Property("IsFavorite") + .HasColumnType("boolean") + .HasColumnName("is_favorite"); + + b.Property("IsHidden") + .HasColumnType("boolean") + .HasColumnName("is_hidden"); + + b.Property("KnownRisks") + .IsRequired() + .HasColumnType("text") + .HasColumnName("known_risks"); + + b.Property("LiveUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("live_url"); + + b.Property("MaintenanceNotes") + .IsRequired() + .HasColumnType("text") + .HasColumnName("maintenance_notes"); + + b.Property("MonitoringUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("monitoring_url"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("purpose"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("repository_url"); + + b.Property("RollbackProcedure") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rollback_procedure"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("summary"); + + b.PrimitiveCollection("TagsValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetKind") + .HasColumnType("smallint") + .HasColumnName("target_kind"); + + b.Property("UpdateProcedure") + .IsRequired() + .HasColumnType("text") + .HasColumnName("update_procedure"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_infrastructure_editorial_profiles"); + + b.HasIndex("WorkspaceId", "IsFavorite") + .HasDatabaseName("ix_infrastructure_editorial_profiles_workspace_id_is_favorite"); + + b.HasIndex("WorkspaceId", "TargetKind", "TargetId") + .IsUnique() + .HasDatabaseName("ix_infrastructure_editorial_profiles_workspace_id_target_kind_"); + + b.ToTable("infrastructure_editorial_profiles", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureHost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Architecture") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("architecture"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_at"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("hostname"); + + b.Property("KernelVersion") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("kernel_version"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("LastSnapshotId") + .HasColumnType("uuid") + .HasColumnName("last_snapshot_id"); + + b.Property("LogicalCpuCount") + .HasColumnType("integer") + .HasColumnName("logical_cpu_count"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("operating_system"); + + b.Property("Presence") + .HasColumnType("smallint") + .HasColumnName("presence"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("source_key"); + + b.Property("TotalMemoryBytes") + .HasColumnType("bigint") + .HasColumnName("total_memory_bytes"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_infrastructure_hosts"); + + b.HasIndex("WorkspaceId", "Presence") + .HasDatabaseName("ix_infrastructure_hosts_workspace_id_presence"); + + b.HasIndex("WorkspaceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_hosts_workspace_id_source_key"); + + b.ToTable("infrastructure_hosts", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Confidence") + .HasColumnType("smallint") + .HasColumnName("confidence"); + + b.Property("DetectedName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("detected_name"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_at"); + + b.Property("Health") + .HasColumnType("smallint") + .HasColumnName("health"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("ImageDigest") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_digest"); + + b.Property("ImageReference") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("image_reference"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("LastSnapshotId") + .HasColumnType("uuid") + .HasColumnName("last_snapshot_id"); + + b.Property("NoNewPrivileges") + .HasColumnType("boolean") + .HasColumnName("no_new_privileges"); + + b.Property("Presence") + .HasColumnType("smallint") + .HasColumnName("presence"); + + b.Property("Privileged") + .HasColumnType("boolean") + .HasColumnName("privileged"); + + b.Property("ReadOnlyRootFilesystem") + .HasColumnType("boolean") + .HasColumnName("read_only_root_filesystem"); + + b.Property("RuntimeKind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtime_kind"); + + b.Property("RuntimeStatus") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtime_status"); + + b.Property("RuntimeUser") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("runtime_user"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_key"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_infrastructure_services"); + + b.HasIndex("HostId") + .HasDatabaseName("ix_infrastructure_services_host_id"); + + b.HasIndex("WorkspaceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_services_workspace_id_source_key"); + + b.HasIndex("WorkspaceId", "DetectedName", "Id") + .HasDatabaseName("ix_infrastructure_services_workspace_id_detected_name_id"); + + b.HasIndex("WorkspaceId", "Presence", "Health") + .HasDatabaseName("ix_infrastructure_services_workspace_id_presence_health"); + + b.ToTable("infrastructure_services", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InventoryChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeKind") + .HasColumnType("smallint") + .HasColumnName("change_kind"); + + b.PrimitiveCollection("ChangedFieldsValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("changed_fields"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("SnapshotId") + .HasColumnType("uuid") + .HasColumnName("snapshot_id"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_key"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetKind") + .HasColumnType("smallint") + .HasColumnName("target_kind"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_inventory_changes"); + + b.HasIndex("TargetKind", "TargetId") + .HasDatabaseName("ix_inventory_changes_target_kind_target_id"); + + b.HasIndex("WorkspaceId", "SnapshotId") + .HasDatabaseName("ix_inventory_changes_workspace_id_snapshot_id"); + + b.ToTable("inventory_changes", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InventorySnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CapturedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("captured_at"); + + b.Property("HostCount") + .HasColumnType("integer") + .HasColumnName("host_count"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("imported_at"); + + b.Property("ServiceCount") + .HasColumnType("integer") + .HasColumnName("service_count"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("source_key"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_inventory_snapshots"); + + b.HasIndex("WorkspaceId", "CapturedAt") + .HasDatabaseName("ix_inventory_snapshots_workspace_id_captured_at"); + + b.HasIndex("WorkspaceId", "SourceHash") + .IsUnique() + .HasDatabaseName("ix_inventory_snapshots_workspace_id_source_hash"); + + b.ToTable("inventory_snapshots", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.ManualOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("field_name"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reason"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetKind") + .HasColumnType("smallint") + .HasColumnName("target_kind"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value_json"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_infrastructure_manual_overrides"); + + b.HasIndex("WorkspaceId", "TargetKind", "TargetId", "FieldName") + .IsUnique() + .HasDatabaseName("ix_infrastructure_manual_overrides_workspace_id_target_kind_ta"); + + b.ToTable("infrastructure_manual_overrides", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Confidence") + .HasColumnType("smallint") + .HasColumnName("confidence"); + + b.Property("LastSnapshotId") + .HasColumnType("uuid") + .HasColumnName("last_snapshot_id"); + + b.Property("ObservedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("observed_at"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_key"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id"); + + b.ToTable((string)null); + + b.UseTpcMappingStrategy(); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Analysis") + .HasColumnType("text") + .HasColumnName("analysis"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("ApprovedRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("approved_revision_number"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("FailedLifecycleStatus") + .HasColumnType("smallint") + .HasColumnName("failed_lifecycle_status"); + + b.Property("FailedProcessingStage") + .HasColumnType("smallint") + .HasColumnName("failed_processing_stage"); + + b.Property("FailureErrorType") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("failure_error_type"); + + b.Property("FailureMessage") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("failure_message"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("IsRetryEligible") + .HasColumnType("boolean") + .HasColumnName("is_retry_eligible"); + + b.Property("Manifest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("manifest"); + + b.Property("MaxPublishRetries") + .HasColumnType("integer") + .HasColumnName("max_publish_retries"); + + b.Property("PublicationIdempotencyKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("publication_idempotency_key"); + + b.Property("PublishedPageId") + .HasColumnType("uuid") + .HasColumnName("published_page_id"); + + b.Property("PublishedPageRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("published_page_revision_number"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("ReviewerComment") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reviewer_comment"); + + b.Property("RevisionNumber") + .HasColumnType("bigint") + .HasColumnName("revision_number"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("SubmittedByUserId") + .HasColumnType("uuid") + .HasColumnName("submitted_by_user_id"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_ingestion_proposals"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_ingestion_proposals_workspace_id_idempotency_key"); + + b.ToTable("ingestion_proposals", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposalRevision", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("Analysis") + .HasColumnType("text") + .HasColumnName("analysis"); + + b.Property("FailedLifecycleStatus") + .HasColumnType("smallint") + .HasColumnName("failed_lifecycle_status"); + + b.Property("FailedProcessingStage") + .HasColumnType("smallint") + .HasColumnName("failed_processing_stage"); + + b.Property("FailureErrorType") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("failure_error_type"); + + b.Property("FailureMessage") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("failure_message"); + + b.Property("IngestionProposalId") + .HasColumnType("uuid") + .HasColumnName("ingestion_proposal_id"); + + b.Property("IsRetryEligible") + .HasColumnType("boolean") + .HasColumnName("is_retry_eligible"); + + b.Property("Manifest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("manifest"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("operation"); + + b.Property("ReviewerComment") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reviewer_comment"); + + b.Property("RevisionNumber") + .HasColumnType("bigint") + .HasColumnName("revision_number"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_ingestion_proposal_revisions"); + + b.HasIndex("IngestionProposalId", "RevisionNumber") + .IsUnique() + .HasDatabaseName("ix_ingestion_proposal_revisions_ingestion_proposal_id_revision"); + + b.ToTable("ingestion_proposal_revisions", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposalSource", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttachmentReferenceId") + .HasColumnType("uuid") + .HasColumnName("attachment_reference_id"); + + b.Property("ByteSize") + .HasColumnType("bigint") + .HasColumnName("byte_size"); + + b.Property("DigestSha256") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("digest_sha256"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("file_name"); + + b.Property("IngestionProposalId") + .HasColumnType("uuid") + .HasColumnName("ingestion_proposal_id"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(127) + .IsUnicode(false) + .HasColumnType("character varying(127)") + .HasColumnName("mime_type"); + + b.Property("ScanResult") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("scan_result"); + + b.HasKey("Id") + .HasName("pk_ingestion_proposal_sources"); + + b.HasIndex("IngestionProposalId", "DigestSha256") + .HasDatabaseName("ix_ingestion_proposal_sources_ingestion_proposal_id_digest_sha"); + + b.ToTable("ingestion_proposal_sources", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("AttemptCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt_count"); + + b.Property("CancelRequestedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("cancel_requested_at"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ErrorsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("errors_json"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("IdempotencyKeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("idempotency_key_hash"); + + b.Property("LeaseToken") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("lease_token"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("lease_until"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("payload_hash"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("Processed") + .HasColumnType("integer") + .HasColumnName("processed"); + + b.Property("ResultJson") + .HasColumnType("jsonb") + .HasColumnName("result_json"); + + b.Property("Skipped") + .HasColumnType("integer") + .HasColumnName("skipped"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("version"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_background_jobs"); + + b.HasIndex("ActorId") + .HasDatabaseName("ix_background_jobs_actor_id"); + + b.HasIndex("Status", "LeaseUntil") + .HasDatabaseName("ix_background_jobs_lease"); + + b.HasIndex("WorkspaceId", "CreatedAt") + .IsDescending(false, true) + .HasDatabaseName("ix_background_jobs_workspace_created"); + + b.HasIndex("Status", "NextAttemptAt", "CreatedAt") + .HasDatabaseName("ix_background_jobs_queue"); + + b.HasIndex("WorkspaceId", "ActorId", "IdempotencyKeyHash") + .IsUnique() + .HasDatabaseName("ux_background_jobs_idempotency"); + + b.ToTable("background_jobs", null, t => + { + t.HasCheckConstraint("ck_background_jobs_counts", "total >= 0 AND processed >= 0 AND succeeded >= 0 AND skipped >= 0 AND failed >= 0 AND processed <= total"); + }); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJobItem", b => + { + b.Property("Ordinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("ordinal"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Ordinal")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("InitiallyTagged") + .HasColumnType("boolean") + .HasColumnName("initially_tagged"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("State") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)") + .HasColumnName("state"); + + b.HasKey("Ordinal") + .HasName("pk_background_job_items"); + + b.HasIndex("PageId") + .HasDatabaseName("ix_background_job_items_page_id"); + + b.HasIndex("JobId", "PageId") + .IsUnique() + .HasDatabaseName("ux_background_job_items_page"); + + b.HasIndex("JobId", "State", "Ordinal") + .HasDatabaseName("ix_background_job_items_queue"); + + b.ToTable("background_job_items", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.Draft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("base_revision_number"); + + b.Property("ChangeSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("change_summary"); + + b.Property("DocumentStatus") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_status"); + + b.Property("DocumentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_type"); + + b.Property("DraftKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("draft_key"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("markdown"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.PrimitiveCollection("TagsValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_drafts"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_drafts_expires_at"); + + b.HasIndex("WorkspaceId", "UserId", "DraftKey") + .IsUnique() + .HasDatabaseName("ix_drafts_workspace_id_user_id_draft_key"); + + b.ToTable("drafts", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("current_revision_number"); + + b.Property("DocumentStatus") + .HasColumnType("smallint") + .HasColumnName("document_status"); + + b.Property("DocumentType") + .HasColumnType("smallint") + .HasColumnName("document_type"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("markdown"); + + b.Property("ParentPageId") + .HasColumnType("uuid") + .HasColumnName("parent_page_id"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PurgeAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("purge_after"); + + b.Property("SlugValue") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("slug"); + + b.Property("SortKeyValue") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)") + .HasColumnName("sort_key"); + + b.PrimitiveCollection("TagsValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("TitleValue") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("TrashedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("trashed_at"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_pages"); + + b.HasIndex("TagsValue") + .HasDatabaseName("ix_pages_tags"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TagsValue"), "gin"); + + b.HasIndex("WorkspaceId", "SlugValue") + .HasDatabaseName("ix_pages_workspace_id_slug"); + + b.HasIndex("WorkspaceId", "TitleValue") + .HasDatabaseName("ix_pages_workspace_id_title"); + + b.HasIndex("WorkspaceId", "ParentPageId", "SortKeyValue", "Id") + .HasDatabaseName("ix_pages_workspace_id_parent_page_id_sort_key_id"); + + b.ToTable("pages", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.PageCreationSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("CommittedPageId") + .HasColumnType("uuid") + .HasColumnName("committed_page_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ParentPageId") + .HasColumnType("uuid") + .HasColumnName("parent_page_id"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_page_creation_sessions"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_page_creation_sessions_expires_at"); + + b.HasIndex("WorkspaceId", "Status") + .HasDatabaseName("ix_page_creation_sessions_workspace_id_status"); + + b.ToTable("page_creation_sessions", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.PageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DisplayLabel") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("display_label"); + + b.Property("OccurrenceIndex") + .HasColumnType("integer") + .HasColumnName("occurrence_index"); + + b.Property("ResolutionState") + .HasColumnType("smallint") + .HasColumnName("resolution_state"); + + b.Property("SourcePageId") + .HasColumnType("uuid") + .HasColumnName("source_page_id"); + + b.Property("SourceRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("source_revision_number"); + + b.Property("TargetPageId") + .HasColumnType("uuid") + .HasColumnName("target_page_id"); + + b.Property("TargetText") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("target_text"); + + b.HasKey("Id") + .HasName("pk_page_links"); + + b.HasIndex("TargetPageId") + .HasDatabaseName("ix_page_links_target_page_id"); + + b.HasIndex("SourcePageId", "OccurrenceIndex") + .IsUnique() + .HasDatabaseName("ix_page_links_source_page_id_occurrence_index"); + + b.ToTable("page_links", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.PageRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthoredBy") + .HasColumnType("uuid") + .HasColumnName("authored_by"); + + b.Property("ChangeSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("change_summary"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DocumentStatus") + .HasColumnType("smallint") + .HasColumnName("document_status"); + + b.Property("DocumentType") + .HasColumnType("smallint") + .HasColumnName("document_type"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("markdown"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ParentPageId") + .HasColumnType("uuid") + .HasColumnName("parent_page_id"); + + b.Property("RevisionNumber") + .HasColumnType("bigint") + .HasColumnName("revision_number"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("slug"); + + b.Property("SortKey") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)") + .HasColumnName("sort_key"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_page_revisions"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_page_revisions_created_at"); + + b.HasIndex("PageId", "RevisionNumber") + .IsUnique() + .HasDatabaseName("ix_page_revisions_page_id_revision_number"); + + b.ToTable("page_revisions", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Migration.ImportItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Diagnostic") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("diagnostic"); + + b.Property("ImportJobId") + .HasColumnType("uuid") + .HasColumnName("import_job_id"); + + b.Property("SourceIdentity") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("source_identity"); + + b.Property("SourceKind") + .HasColumnType("smallint") + .HasColumnName("source_kind"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source_sha256"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("TargetPageId") + .HasColumnType("uuid") + .HasColumnName("target_page_id"); + + b.HasKey("Id") + .HasName("pk_import_items"); + + b.HasIndex("ImportJobId", "SourceIdentity") + .IsUnique() + .HasDatabaseName("ix_import_items_import_job_id_source_identity"); + + b.ToTable("import_items", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Migration.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("AdapterName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_name"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DiscoveredCount") + .HasColumnType("integer") + .HasColumnName("discovered_count"); + + b.Property("FailedCount") + .HasColumnType("integer") + .HasColumnName("failed_count"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("ImportedCount") + .HasColumnType("integer") + .HasColumnName("imported_count"); + + b.Property("PlanHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("plan_hash"); + + b.Property("PlannedCount") + .HasColumnType("integer") + .HasColumnName("planned_count"); + + b.Property("SkippedCount") + .HasColumnType("integer") + .HasColumnName("skipped_count"); + + b.Property("SourceFingerprintSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source_fingerprint_sha256"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_import_jobs"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_import_jobs_workspace_id_idempotency_key"); + + b.ToTable("import_jobs", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Migration.ImportProvenance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImportItemId") + .HasColumnType("uuid") + .HasColumnName("import_item_id"); + + b.Property("ImportJobId") + .HasColumnType("uuid") + .HasColumnName("import_job_id"); + + b.Property("TargetEntityId") + .HasColumnType("uuid") + .HasColumnName("target_entity_id"); + + b.Property("TargetEntityType") + .HasColumnType("smallint") + .HasColumnName("target_entity_type"); + + b.HasKey("Id") + .HasName("pk_import_provenance"); + + b.HasIndex("ImportJobId") + .HasDatabaseName("ix_import_provenance_import_job_id"); + + b.HasIndex("TargetEntityType", "TargetEntityId") + .HasDatabaseName("ix_import_provenance_target_entity_type_target_entity_id"); + + b.ToTable("import_provenance", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.PlatformSettings", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BackupDailyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_daily_retention_count"); + + b.Property("BackupExpectedIntervalHours") + .HasColumnType("integer") + .HasColumnName("backup_expected_interval_hours"); + + b.Property("BackupMonthlyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_monthly_retention_count"); + + b.Property("BackupScheduleDescription") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("backup_schedule_description"); + + b.Property("BackupWeeklyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_weekly_retention_count"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("display_name"); + + b.Property("FailedRetryLimit") + .HasColumnType("integer") + .HasColumnName("failed_retry_limit"); + + b.Property("IngestionEnabled") + .HasColumnType("boolean") + .HasColumnName("ingestion_enabled"); + + b.Property("InventoryWarningAfterHours") + .HasColumnType("integer") + .HasColumnName("inventory_warning_after_hours"); + + b.Property("MaximumSourceBytes") + .HasColumnType("bigint") + .HasColumnName("maximum_source_bytes"); + + b.Property("MaximumSourceCount") + .HasColumnType("integer") + .HasColumnName("maximum_source_count"); + + b.Property("PrimaryLocale") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("primary_locale"); + + b.Property("ProtectedBackupCount") + .HasColumnType("integer") + .HasColumnName("protected_backup_count"); + + b.Property("RestoreVerificationMaxAgeDays") + .HasColumnType("integer") + .HasColumnName("restore_verification_max_age_days"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("time_zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_platform_settings"); + + b.ToTable("platform_settings", null, t => + { + t.HasCheckConstraint("ck_platform_settings_backup_expectation", "backup_expected_interval_hours BETWEEN 1 AND 8760 AND backup_daily_retention_count BETWEEN 1 AND 365 AND backup_weekly_retention_count BETWEEN 1 AND 104 AND backup_monthly_retention_count BETWEEN 1 AND 120 AND protected_backup_count BETWEEN 1 AND 100 AND restore_verification_max_age_days BETWEEN 1 AND 365"); + + t.HasCheckConstraint("ck_platform_settings_inventory_freshness", "inventory_warning_after_hours BETWEEN 1 AND 8760"); + + t.HasCheckConstraint("ck_platform_settings_retry_limit", "failed_retry_limit BETWEEN 1 AND 3"); + + t.HasCheckConstraint("ck_platform_settings_singleton", "\"id\" = '00000000-0000-0000-0000-000000000002'"); + + t.HasCheckConstraint("ck_platform_settings_source_bytes", "maximum_source_bytes BETWEEN 1 AND 104857600"); + + t.HasCheckConstraint("ck_platform_settings_source_count", "maximum_source_count BETWEEN 1 AND 20"); + }); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.UserPreferences", b => + { + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CatalogView") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("catalog_view"); + + b.Property("ContextPanelOpen") + .HasColumnType("boolean") + .HasColumnName("context_panel_open"); + + b.Property("ContextPanelWidth") + .HasColumnType("integer") + .HasColumnName("context_panel_width"); + + b.PrimitiveCollection("DashboardWidgetOrder") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("dashboard_widget_order"); + + b.Property("DefaultLandingPage") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("default_landing_page"); + + b.Property("Density") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("density"); + + b.PrimitiveCollection("HiddenDashboardWidgets") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("hidden_dashboard_widgets"); + + b.PrimitiveCollection("NavigationOrder") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("navigation_order"); + + b.Property("NavigationWidth") + .HasColumnType("integer") + .HasColumnName("navigation_width"); + + b.Property("PrimaryNavigationCollapsed") + .HasColumnType("boolean") + .HasColumnName("primary_navigation_collapsed"); + + b.Property("Theme") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("theme"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("WorkspaceId", "UserId") + .HasName("pk_user_preferences"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_preferences_user_id"); + + b.ToTable("user_preferences", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletionDate") + .HasColumnType("date") + .HasColumnName("completion_date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CurrentStateMarkdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("current_state_markdown"); + + b.Property("DocumentationUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("documentation_url"); + + b.Property("IconAttachmentReferenceId") + .HasColumnType("uuid") + .HasColumnName("icon_attachment_reference_id"); + + b.Property("IssueTrackerUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("issue_tracker_url"); + + b.Property("LiveUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("live_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Priority") + .HasColumnType("smallint") + .HasColumnName("priority"); + + b.Property("RepositoryUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("repository_url"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("summary"); + + b.Property("TargetDate") + .HasColumnType("date") + .HasColumnName("target_date"); + + b.PrimitiveCollection("TechnologiesValue") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("technologies"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_projects"); + + b.HasIndex("IconAttachmentReferenceId") + .IsUnique() + .HasDatabaseName("ix_projects_icon_attachment_reference_id"); + + b.HasIndex("WorkspaceId", "Slug") + .IsUnique() + .HasDatabaseName("ix_projects_workspace_id_slug"); + + b.HasIndex("WorkspaceId", "Status") + .HasDatabaseName("ix_projects_workspace_id_status"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.ProjectDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LinkedBy") + .HasColumnType("uuid") + .HasColumnName("linked_by"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Role") + .HasColumnType("smallint") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_project_documents"); + + b.HasIndex("PageId") + .HasDatabaseName("ix_project_documents_page_id"); + + b.HasIndex("ProjectId", "PageId", "Role") + .IsUnique() + .HasDatabaseName("ix_project_documents_project_id_page_id_role"); + + b.ToTable("project_documents", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.ProjectMilestone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletionDate") + .HasColumnType("date") + .HasColumnName("completion_date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("SortKeyValue") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)") + .HasColumnName("sort_key"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("SuccessCriteria") + .IsRequired() + .HasColumnType("text") + .HasColumnName("success_criteria"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text") + .HasColumnName("summary"); + + b.Property("TargetDate") + .HasColumnType("date") + .HasColumnName("target_date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_project_milestones"); + + b.HasIndex("ProjectId", "SortKeyValue") + .HasDatabaseName("ix_project_milestones_project_id_sort_key"); + + b.ToTable("project_milestones", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.ProjectUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("AttachmentReferenceId") + .HasColumnType("uuid") + .HasColumnName("attachment_reference_id"); + + b.Property("Category") + .HasColumnType("smallint") + .HasColumnName("category"); + + b.Property("CommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("commit_sha"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DecisionRecordId") + .HasColumnType("uuid") + .HasColumnName("decision_record_id"); + + b.Property("IssueIdentifier") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("issue_identifier"); + + b.Property("MilestoneId") + .HasColumnType("uuid") + .HasColumnName("milestone_id"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("ReleaseTag") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("release_tag"); + + b.Property("SummaryMarkdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("summary_markdown"); + + b.Property("SupersedesUpdateId") + .HasColumnType("uuid") + .HasColumnName("supersedes_update_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("Url") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("url"); + + b.Property("VerificationStatus") + .HasColumnType("smallint") + .HasColumnName("verification_status"); + + b.HasKey("Id") + .HasName("pk_project_updates"); + + b.HasIndex("ProjectId", "OccurredAt", "Id") + .HasDatabaseName("ix_project_updates_project_id_occurred_at_id"); + + b.ToTable("project_updates", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Runbooks.RunbookProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EstimatedDurationMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_duration_minutes"); + + b.Property("LastValidatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_validated_at"); + + b.Property("Owner") + .HasColumnType("uuid") + .HasColumnName("owner"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("RequiresMaintenanceWindow") + .HasColumnType("boolean") + .HasColumnName("requires_maintenance_window"); + + b.Property("ReviewDueAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("review_due_at"); + + b.Property("RiskLevel") + .HasColumnType("smallint") + .HasColumnName("risk_level"); + + b.Property("Status") + .HasColumnType("smallint") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("ValidatedAtRevisionNumber") + .HasColumnType("bigint") + .HasColumnName("validated_at_revision_number"); + + b.Property("ValidatedBy") + .HasColumnType("uuid") + .HasColumnName("validated_by"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_runbook_profiles"); + + b.HasIndex("PageId") + .IsUnique() + .HasDatabaseName("ix_runbook_profiles_page_id"); + + b.HasIndex("RiskLevel", "ReviewDueAt") + .HasDatabaseName("ix_runbook_profiles_risk_level_review_due_at"); + + b.ToTable("runbook_profiles", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Sources.DataSource", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataObservedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_observed_at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("display_name"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExpectedIntervalHours") + .HasColumnType("integer") + .HasColumnName("expected_interval_hours"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("kind"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_at"); + + b.Property("LastCorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_correlation_id"); + + b.Property("LastErrorCode") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("last_error_code"); + + b.Property("LastFailureAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_failure_at"); + + b.Property("LastSuccessAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_success_at"); + + b.Property("RecordCount") + .HasColumnType("bigint") + .HasColumnName("record_count"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("source_key"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_data_sources"); + + b.HasIndex("WorkspaceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_data_sources_workspace_id_source_key"); + + b.ToTable("data_sources", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Infrastructure.Persistence.AuditEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("actor_kind"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("target_type"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_events_correlation_id"); + + b.HasIndex("OccurredAt") + .HasDatabaseName("ix_audit_events_occurred_at"); + + b.HasIndex("ActorId", "OccurredAt") + .HasDatabaseName("ix_audit_events_actor_id_occurred_at"); + + b.ToTable("audit_events", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Infrastructure.Persistence.RecoveryTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IssuedByUserId") + .HasColumnType("uuid") + .HasColumnName("issued_by_user_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerifierHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("verifier_hash"); + + b.HasKey("Id") + .HasName("pk_recovery_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_recovery_tokens_user_id"); + + b.HasIndex("VerifierHash") + .IsUnique() + .HasDatabaseName("ix_recovery_tokens_verifier_hash"); + + b.ToTable("recovery_tokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.BackupCoverage", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("LastSuccessfulAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_successful_at"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("method"); + + b.Property("RestoreTested") + .HasColumnType("boolean") + .HasColumnName("restore_tested"); + + b.Property("Schedule") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("schedule"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_backup_coverages_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_backup_coverages_service_id_source_key"); + + b.ToTable("backup_coverages", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.DeploymentReference", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("Commit") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("commit"); + + b.Property("DeployedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deployed_at"); + + b.Property("ImageDigest") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_digest"); + + b.Property("ImageReference") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("image_reference"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("repository_url"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_deployment_references_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_deployment_references_service_id_source_key"); + + b.ToTable("deployment_references", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.HealthObservation", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("CheckKind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("check_kind"); + + b.Property("ResponseTimeMilliseconds") + .HasColumnType("integer") + .HasColumnName("response_time_milliseconds"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_health_observations_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_health_observations_service_id_source_key"); + + b.ToTable("health_observations", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureDatabase", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("Engine") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("engine"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("HostPortPublished") + .HasColumnType("boolean") + .HasColumnName("host_port_published"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("Port") + .HasColumnType("integer") + .HasColumnName("port"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("version"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_infrastructure_databases_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_databases_service_id_source_key"); + + b.ToTable("infrastructure_databases", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureEndpoint", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("PublishedPort") + .HasColumnType("integer") + .HasColumnName("published_port"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scheme"); + + b.Property("TargetPort") + .HasColumnType("integer") + .HasColumnName("target_port"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("url"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_infrastructure_endpoints_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_endpoints_service_id_source_key"); + + b.ToTable("infrastructure_endpoints", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureNetwork", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("Driver") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("driver"); + + b.Property("Internal") + .HasColumnType("boolean") + .HasColumnName("internal"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("scope"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_infrastructure_networks_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_networks_service_id_source_key"); + + b.ToTable("infrastructure_networks", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.InfrastructureVolume", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("MountPath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("mount_path"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("ReadOnly") + .HasColumnType("boolean") + .HasColumnName("read_only"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StorageClass") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("storage_class"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_infrastructure_volumes_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_infrastructure_volumes_service_id_source_key"); + + b.ToTable("infrastructure_volumes", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceComponent", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("ImageReference") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("image_reference"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("kind"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RuntimeId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("runtime_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("status"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_service_components_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_service_components_service_id_source_key"); + + b.ToTable("service_components", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceDependency", b => + { + b.HasBaseType("ItWorx.KnowledgeHub.Domain.Infrastructure.ServiceFactEntity"); + + b.Property("DependsOnServiceId") + .HasColumnType("uuid") + .HasColumnName("depends_on_service_id"); + + b.Property("DependsOnSourceKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("depends_on_source_key"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("kind"); + + b.HasIndex("DependsOnServiceId") + .HasDatabaseName("ix_service_dependencies_depends_on_service_id"); + + b.HasIndex("LastSnapshotId") + .HasDatabaseName("ix_service_dependencies_last_snapshot_id"); + + b.HasIndex("ServiceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_service_dependencies_service_id_source_key"); + + b.ToTable("service_dependencies", (string)null); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentBlob", null) + .WithMany() + .HasForeignKey("BlobSha256") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_attachment_references_attachment_blobs_blob_sha256"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentUsage", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", null) + .WithMany() + .HasForeignKey("AttachmentReferenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_attachment_usages_attachment_references_attachment_referenc"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", null) + .WithMany() + .HasForeignKey("IngestionProposalId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_attachment_usages_ingestion_proposals_ingestion_proposal_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Projects.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_attachment_usages_projects_project_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Knowledge.PageCreationSession", null) + .WithMany() + .HasForeignKey("StagingSessionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_attachment_usages_page_creation_sessions_staging_session_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", b => + { + b.OwnsOne("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionPublication", "Publication", b1 => + { + b1.Property("IngestionProposalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("DocumentStatus") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("publication_document_status"); + + b1.Property("DocumentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("publication_document_type"); + + b1.Property("ExpectedTargetETag") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("publication_expected_target_etag"); + + b1.Property("Markdown") + .IsRequired() + .HasColumnType("text") + .HasColumnName("publication_markdown"); + + b1.Property("MutationKind") + .HasColumnType("smallint") + .HasColumnName("publication_mutation_kind"); + + b1.Property("ParentPageId") + .HasColumnType("uuid") + .HasColumnName("publication_parent_page_id"); + + b1.Property("TargetPageId") + .HasColumnType("uuid") + .HasColumnName("publication_target_page_id"); + + b1.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("publication_title"); + + b1.HasKey("IngestionProposalId"); + + b1.ToTable("ingestion_proposals"); + + b1.WithOwner() + .HasForeignKey("IngestionProposalId") + .HasConstraintName("fk_ingestion_proposals_ingestion_proposals_id"); + }); + + b.Navigation("Publication"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposalRevision", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", null) + .WithMany("Revisions") + .HasForeignKey("IngestionProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingestion_proposal_revisions_ingestion_proposals_ingestion_"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposalSource", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", null) + .WithMany("Sources") + .HasForeignKey("IngestionProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingestion_proposal_sources_ingestion_proposals_ingestion_pr"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJob", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_background_jobs_users_actor_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Identity.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_background_jobs_workspaces_workspace_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJobItem", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJob", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_background_job_items_background_jobs_job_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Knowledge.Page", null) + .WithMany() + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_background_job_items_pages_page_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.UserPreferences", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_preferences_users_user_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Identity.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_preferences_workspaces_workspace_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.Project", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", null) + .WithMany() + .HasForeignKey("IconAttachmentReferenceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_projects_attachment_references_icon_attachment_reference_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Sources.DataSource", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Identity.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_data_sources_workspaces_workspace_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Ingestion.IngestionProposal", b => + { + b.Navigation("Revisions"); + + b.Navigation("Sources"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002095318_AddBackgroundJobs.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002095318_AddBackgroundJobs.cs new file mode 100644 index 0000000..ee06dab --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20261002095318_AddBackgroundJobs.cs @@ -0,0 +1,147 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddBackgroundJobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "background_jobs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + workspace_id = table.Column(type: "uuid", nullable: false), + actor_id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + payload_json = table.Column(type: "jsonb", nullable: false), + result_json = table.Column(type: "jsonb", nullable: true), + errors_json = table.Column(type: "jsonb", nullable: false), + idempotency_key_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + payload_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + correlation_id = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + started_at = table.Column(type: "timestamp with time zone", nullable: true), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + next_attempt_at = table.Column(type: "timestamp with time zone", nullable: false), + cancel_requested_at = table.Column(type: "timestamp with time zone", nullable: true), + lease_until = table.Column(type: "timestamp with time zone", nullable: true), + lease_token = table.Column(type: "uuid", nullable: true), + attempt_count = table.Column(type: "integer", nullable: false, defaultValue: 0), + total = table.Column(type: "integer", nullable: false), + processed = table.Column(type: "integer", nullable: false), + succeeded = table.Column(type: "integer", nullable: false), + skipped = table.Column(type: "integer", nullable: false), + failed = table.Column(type: "integer", nullable: false), + version = table.Column(type: "bigint", nullable: false, defaultValue: 1L) + }, + constraints: table => + { + table.PrimaryKey("pk_background_jobs", x => x.id); + table.CheckConstraint("ck_background_jobs_counts", "total >= 0 AND processed >= 0 AND succeeded >= 0 AND skipped >= 0 AND failed >= 0 AND processed <= total"); + table.ForeignKey( + name: "fk_background_jobs_users_actor_id", + column: x => x.actor_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_background_jobs_workspaces_workspace_id", + column: x => x.workspace_id, + principalTable: "workspaces", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "background_job_items", + columns: table => new + { + ordinal = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + job_id = table.Column(type: "uuid", nullable: false), + page_id = table.Column(type: "uuid", nullable: false), + initially_tagged = table.Column(type: "boolean", nullable: false), + state = table.Column(type: "character varying(24)", maxLength: 24, nullable: false), + started_at = table.Column(type: "timestamp with time zone", nullable: true), + completed_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_background_job_items", x => x.ordinal); + table.ForeignKey( + name: "fk_background_job_items_background_jobs_job_id", + column: x => x.job_id, + principalTable: "background_jobs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_background_job_items_pages_page_id", + column: x => x.page_id, + principalTable: "pages", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_background_job_items_page_id", + table: "background_job_items", + column: "page_id"); + + migrationBuilder.CreateIndex( + name: "ix_background_job_items_queue", + table: "background_job_items", + columns: new[] { "job_id", "state", "ordinal" }); + + migrationBuilder.CreateIndex( + name: "ux_background_job_items_page", + table: "background_job_items", + columns: new[] { "job_id", "page_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_background_jobs_actor_id", + table: "background_jobs", + column: "actor_id"); + + migrationBuilder.CreateIndex( + name: "ix_background_jobs_lease", + table: "background_jobs", + columns: new[] { "status", "lease_until" }); + + migrationBuilder.CreateIndex( + name: "ix_background_jobs_queue", + table: "background_jobs", + columns: new[] { "status", "next_attempt_at", "created_at" }); + + migrationBuilder.CreateIndex( + name: "ix_background_jobs_workspace_created", + table: "background_jobs", + columns: new[] { "workspace_id", "created_at" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "ux_background_jobs_idempotency", + table: "background_jobs", + columns: new[] { "workspace_id", "actor_id", "idempotency_key_hash" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "background_job_items"); + + migrationBuilder.DropTable( + name: "background_jobs"); + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/KnowledgeHubDbContextModelSnapshot.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/KnowledgeHubDbContextModelSnapshot.cs index 73af9d1..d4fef4e 100644 --- a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/KnowledgeHubDbContextModelSnapshot.cs +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/KnowledgeHubDbContextModelSnapshot.cs @@ -1394,6 +1394,204 @@ namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations b.ToTable("ingestion_proposal_sources", (string)null); }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("AttemptCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt_count"); + + b.Property("CancelRequestedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("cancel_requested_at"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ErrorsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("errors_json"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("IdempotencyKeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("idempotency_key_hash"); + + b.Property("LeaseToken") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("lease_token"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("lease_until"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("payload_hash"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("Processed") + .HasColumnType("integer") + .HasColumnName("processed"); + + b.Property("ResultJson") + .HasColumnType("jsonb") + .HasColumnName("result_json"); + + b.Property("Skipped") + .HasColumnType("integer") + .HasColumnName("skipped"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("version"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_background_jobs"); + + b.HasIndex("ActorId") + .HasDatabaseName("ix_background_jobs_actor_id"); + + b.HasIndex("Status", "LeaseUntil") + .HasDatabaseName("ix_background_jobs_lease"); + + b.HasIndex("WorkspaceId", "CreatedAt") + .IsDescending(false, true) + .HasDatabaseName("ix_background_jobs_workspace_created"); + + b.HasIndex("Status", "NextAttemptAt", "CreatedAt") + .HasDatabaseName("ix_background_jobs_queue"); + + b.HasIndex("WorkspaceId", "ActorId", "IdempotencyKeyHash") + .IsUnique() + .HasDatabaseName("ux_background_jobs_idempotency"); + + b.ToTable("background_jobs", null, t => + { + t.HasCheckConstraint("ck_background_jobs_counts", "total >= 0 AND processed >= 0 AND succeeded >= 0 AND skipped >= 0 AND failed >= 0 AND processed <= total"); + }); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJobItem", b => + { + b.Property("Ordinal") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("ordinal"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Ordinal")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("InitiallyTagged") + .HasColumnType("boolean") + .HasColumnName("initially_tagged"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("PageId") + .HasColumnType("uuid") + .HasColumnName("page_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("State") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)") + .HasColumnName("state"); + + b.HasKey("Ordinal") + .HasName("pk_background_job_items"); + + b.HasIndex("PageId") + .HasDatabaseName("ix_background_job_items_page_id"); + + b.HasIndex("JobId", "PageId") + .IsUnique() + .HasDatabaseName("ux_background_job_items_page"); + + b.HasIndex("JobId", "State", "Ordinal") + .HasDatabaseName("ix_background_job_items_queue"); + + b.ToTable("background_job_items", (string)null); + }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Knowledge.Draft", b => { b.Property("Id") @@ -1953,6 +2151,191 @@ namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations b.ToTable("import_provenance", (string)null); }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.PlatformSettings", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BackupDailyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_daily_retention_count"); + + b.Property("BackupExpectedIntervalHours") + .HasColumnType("integer") + .HasColumnName("backup_expected_interval_hours"); + + b.Property("BackupMonthlyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_monthly_retention_count"); + + b.Property("BackupScheduleDescription") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("backup_schedule_description"); + + b.Property("BackupWeeklyRetentionCount") + .HasColumnType("integer") + .HasColumnName("backup_weekly_retention_count"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("display_name"); + + b.Property("FailedRetryLimit") + .HasColumnType("integer") + .HasColumnName("failed_retry_limit"); + + b.Property("IngestionEnabled") + .HasColumnType("boolean") + .HasColumnName("ingestion_enabled"); + + b.Property("InventoryWarningAfterHours") + .HasColumnType("integer") + .HasColumnName("inventory_warning_after_hours"); + + b.Property("MaximumSourceBytes") + .HasColumnType("bigint") + .HasColumnName("maximum_source_bytes"); + + b.Property("MaximumSourceCount") + .HasColumnType("integer") + .HasColumnName("maximum_source_count"); + + b.Property("PrimaryLocale") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("primary_locale"); + + b.Property("ProtectedBackupCount") + .HasColumnType("integer") + .HasColumnName("protected_backup_count"); + + b.Property("RestoreVerificationMaxAgeDays") + .HasColumnType("integer") + .HasColumnName("restore_verification_max_age_days"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("time_zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_platform_settings"); + + b.ToTable("platform_settings", null, t => + { + t.HasCheckConstraint("ck_platform_settings_backup_expectation", "backup_expected_interval_hours BETWEEN 1 AND 8760 AND backup_daily_retention_count BETWEEN 1 AND 365 AND backup_weekly_retention_count BETWEEN 1 AND 104 AND backup_monthly_retention_count BETWEEN 1 AND 120 AND protected_backup_count BETWEEN 1 AND 100 AND restore_verification_max_age_days BETWEEN 1 AND 365"); + + t.HasCheckConstraint("ck_platform_settings_inventory_freshness", "inventory_warning_after_hours BETWEEN 1 AND 8760"); + + t.HasCheckConstraint("ck_platform_settings_retry_limit", "failed_retry_limit BETWEEN 1 AND 3"); + + t.HasCheckConstraint("ck_platform_settings_singleton", "\"id\" = '00000000-0000-0000-0000-000000000002'"); + + t.HasCheckConstraint("ck_platform_settings_source_bytes", "maximum_source_bytes BETWEEN 1 AND 104857600"); + + t.HasCheckConstraint("ck_platform_settings_source_count", "maximum_source_count BETWEEN 1 AND 20"); + }); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.UserPreferences", b => + { + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CatalogView") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("catalog_view"); + + b.Property("ContextPanelOpen") + .HasColumnType("boolean") + .HasColumnName("context_panel_open"); + + b.Property("ContextPanelWidth") + .HasColumnType("integer") + .HasColumnName("context_panel_width"); + + b.PrimitiveCollection("DashboardWidgetOrder") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("dashboard_widget_order"); + + b.Property("DefaultLandingPage") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("default_landing_page"); + + b.Property("Density") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("density"); + + b.PrimitiveCollection("HiddenDashboardWidgets") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("hidden_dashboard_widgets"); + + b.PrimitiveCollection("NavigationOrder") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("navigation_order"); + + b.Property("NavigationWidth") + .HasColumnType("integer") + .HasColumnName("navigation_width"); + + b.Property("PrimaryNavigationCollapsed") + .HasColumnType("boolean") + .HasColumnName("primary_navigation_collapsed"); + + b.Property("Theme") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("theme"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("WorkspaceId", "UserId") + .HasName("pk_user_preferences"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_preferences_user_id"); + + b.ToTable("user_preferences", (string)null); + }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.Project", b => { b.Property("Id") @@ -2348,6 +2731,91 @@ namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations b.ToTable("runbook_profiles", (string)null); }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Sources.DataSource", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataObservedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_observed_at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("display_name"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExpectedIntervalHours") + .HasColumnType("integer") + .HasColumnName("expected_interval_hours"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("kind"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_at"); + + b.Property("LastCorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_correlation_id"); + + b.Property("LastErrorCode") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("last_error_code"); + + b.Property("LastFailureAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_failure_at"); + + b.Property("LastSuccessAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_success_at"); + + b.Property("RecordCount") + .HasColumnType("bigint") + .HasColumnName("record_count"); + + b.Property("SourceKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("source_key"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkspaceId") + .HasColumnType("uuid") + .HasColumnName("workspace_id"); + + b.HasKey("Id") + .HasName("pk_data_sources"); + + b.HasIndex("WorkspaceId", "SourceKey") + .IsUnique() + .HasDatabaseName("ix_data_sources_workspace_id_source_key"); + + b.ToTable("data_sources", (string)null); + }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", b => { b.Property("Id") @@ -3114,6 +3582,57 @@ namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations .HasConstraintName("fk_ingestion_proposal_sources_ingestion_proposals_ingestion_pr"); }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJob", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_background_jobs_users_actor_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Identity.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_background_jobs_workspaces_workspace_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJobItem", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Jobs.BackgroundJob", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_background_job_items_background_jobs_job_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Knowledge.Page", null) + .WithMany() + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_background_job_items_pages_page_id"); + }); + + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Platform.UserPreferences", b => + { + b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_preferences_users_user_id"); + + b.HasOne("ItWorx.KnowledgeHub.Domain.Identity.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_preferences_workspaces_workspace_id"); + }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Projects.Project", b => { b.HasOne("ItWorx.KnowledgeHub.Domain.Attachments.AttachmentReference", null) @@ -3123,6 +3642,16 @@ namespace ItWorx.KnowledgeHub.Infrastructure.Persistence.Migrations .HasConstraintName("fk_projects_attachment_references_icon_attachment_reference_id"); }); + modelBuilder.Entity("ItWorx.KnowledgeHub.Domain.Sources.DataSource", b => + { + b.HasOne("ItWorx.KnowledgeHub.Domain.Identity.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_data_sources_workspaces_workspace_id"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { b.HasOne("ItWorx.KnowledgeHub.Infrastructure.Persistence.ApplicationUser", null) diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Platform/PlatformSettingsRepository.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Platform/PlatformSettingsRepository.cs new file mode 100644 index 0000000..d9945d6 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Platform/PlatformSettingsRepository.cs @@ -0,0 +1,74 @@ +using ItWorx.KnowledgeHub.Application.Ingestion; +using ItWorx.KnowledgeHub.Application.Platform; +using ItWorx.KnowledgeHub.Domain.Platform; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace ItWorx.KnowledgeHub.Infrastructure.Platform; + +public sealed class PlatformSettingsRepository( + KnowledgeHubDbContext db, + IConfiguration configuration, + IOptions ingestionOptions) : IPlatformSettingsRepository +{ + public Task FindPersistedAsync(CancellationToken cancellationToken) => + db.PlatformSettings.SingleOrDefaultAsync(settings => settings.Id == PlatformSettings.SingletonId, cancellationToken); + + public async Task GetEffectiveAsync(CancellationToken cancellationToken) + { + var persisted = await db.PlatformSettings.AsNoTracking() + .SingleOrDefaultAsync(settings => settings.Id == PlatformSettings.SingletonId, cancellationToken); + return persisted is null + ? new PlatformSettingsSnapshot(GetBootstrapValues(), 0, IsDefault: true, UpdatedAt: null) + : new PlatformSettingsSnapshot(persisted.ToValues(), persisted.Version, IsDefault: false, persisted.UpdatedAt); + } + + public PlatformSettingsValues GetBootstrapValues() + { + var ingestion = ingestionOptions.Value; + return new PlatformSettingsValues( + configuration["KnowledgeHub:DisplayName"] ?? "ItWorx Knowledge Hub", + configuration["KnowledgeHub:PrimaryLocale"] ?? "nl-BE", + configuration["KnowledgeHub:TimeZone"] ?? "Europe/Brussels", + new IngestionProposalPolicyValues( + ingestion.Enabled, + ingestion.MaximumSourceCount, + ingestion.MaximumSourceBytes, + ingestion.FailedRetryLimit), + new BackupPolicyExpectationValues( + configuration.GetValue("PlatformDefaults:BackupExpectedIntervalHours") ?? 24, + configuration["PlatformDefaults:BackupScheduleDescription"] ?? "Extern beheerd", + configuration.GetValue("PlatformDefaults:BackupDailyRetentionCount") ?? 7, + configuration.GetValue("PlatformDefaults:BackupWeeklyRetentionCount") ?? 4, + configuration.GetValue("PlatformDefaults:BackupMonthlyRetentionCount") ?? 6, + configuration.GetValue("PlatformDefaults:ProtectedBackupCount") ?? 1, + configuration.GetValue("PlatformDefaults:RestoreVerificationMaxAgeDays") ?? 90), + new InventoryFreshnessPolicyValues( + configuration.GetValue("PlatformDefaults:InventoryWarningAfterHours") ?? 24)); + } + + public void Add(PlatformSettings settings) => db.PlatformSettings.Add(settings); + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + try + { + await db.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateConcurrencyException) + { + db.ChangeTracker.Clear(); + return false; + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + // A version-zero save races the singleton row's first insertion. + db.ChangeTracker.Clear(); + return false; + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Platform/UserPreferencesRepository.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Platform/UserPreferencesRepository.cs new file mode 100644 index 0000000..99c546c --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Platform/UserPreferencesRepository.cs @@ -0,0 +1,38 @@ +using ItWorx.KnowledgeHub.Application.Platform; +using ItWorx.KnowledgeHub.Domain.Platform; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace ItWorx.KnowledgeHub.Infrastructure.Platform; + +public sealed class UserPreferencesRepository(KnowledgeHubDbContext db) : IUserPreferencesRepository +{ + public Task FindAsync(Guid workspaceId, Guid userId, CancellationToken cancellationToken) => + db.UserPreferences.SingleOrDefaultAsync( + preferences => preferences.WorkspaceId == workspaceId && preferences.UserId == userId, + cancellationToken); + + public void Add(UserPreferences preferences) => db.UserPreferences.Add(preferences); + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + try + { + await db.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateConcurrencyException) + { + db.ChangeTracker.Clear(); + return false; + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + // Two requests may both observe missing preferences and attempt the first + // version-1 insert. The primary-key winner remains authoritative. + db.ChangeTracker.Clear(); + return false; + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/Sources/DataSourceRepository.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Sources/DataSourceRepository.cs new file mode 100644 index 0000000..8f62e10 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/Sources/DataSourceRepository.cs @@ -0,0 +1,42 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Sources; +using ItWorx.KnowledgeHub.Domain.Sources; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace ItWorx.KnowledgeHub.Infrastructure.Sources; + +public sealed class DataSourceRepository(KnowledgeHubDbContext db) : IDataSourceRepository +{ + public async Task> ListAsync(Guid workspaceId, Guid? afterId, int limit, CancellationToken cancellationToken) => + await db.Set().AsNoTracking().Where(source => source.WorkspaceId == workspaceId && (afterId == null || source.Id.CompareTo(afterId.Value) > 0)) + .OrderBy(source => source.Id).Take(Math.Clamp(limit, 1, 101)).ToListAsync(cancellationToken); + public Task FindAsync(Guid workspaceId, Guid id, CancellationToken cancellationToken) => + db.Set().SingleOrDefaultAsync(source => source.WorkspaceId == workspaceId && source.Id == id, cancellationToken); + public Task FindByKeyAsync(Guid workspaceId, string sourceKey, CancellationToken cancellationToken) => + db.Set().SingleOrDefaultAsync(source => source.WorkspaceId == workspaceId && source.SourceKey == sourceKey, cancellationToken); + public void Add(DataSource source) => db.Set().Add(source); + public async Task SaveAsync(AuditEventRecord? auditEvent, CancellationToken cancellationToken) + { + if (auditEvent is not null) + { + db.AuditEvents.Add(new AuditEventEntity + { + ActorKind = auditEvent.ActorKind.ToString(), + ActorId = auditEvent.ActorId, + Source = auditEvent.Source, + Action = auditEvent.Action, + TargetType = auditEvent.TargetType, + TargetId = auditEvent.TargetId, + Summary = auditEvent.Summary, + CorrelationId = auditEvent.CorrelationId, + OccurredAt = auditEvent.OccurredAt + }); + } + try { await db.SaveChangesAsync(cancellationToken); return true; } + catch (DbUpdateConcurrencyException) { db.ChangeTracker.Clear(); return false; } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { db.ChangeTracker.Clear(); return false; } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Infrastructure/UnifiedSearch/UnifiedSearchRepository.cs b/src/backend/ItWorx.KnowledgeHub.Infrastructure/UnifiedSearch/UnifiedSearchRepository.cs new file mode 100644 index 0000000..c33a1d8 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Infrastructure/UnifiedSearch/UnifiedSearchRepository.cs @@ -0,0 +1,506 @@ +using System.Data; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ItWorx.KnowledgeHub.Application.UnifiedSearch; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using NpgsqlTypes; + +namespace ItWorx.KnowledgeHub.Infrastructure.UnifiedSearch; + +/// +/// Searches the current workspace across the five user-facing content kinds. The +/// parameterized PostgreSQL query applies workspace, trash and service-visibility +/// predicates before it derives grouped counts, ranked rows or snippets. +/// +public sealed class UnifiedSearchRepository(KnowledgeHubDbContext db) : IUnifiedSearchRepository +{ + private const int MaxQueryLength = 500; + private const int MaxPageSize = 100; + private static readonly TimeSpan CursorLifetime = TimeSpan.FromMinutes(30); + + private const string Sql = + """ + WITH search_query AS MATERIALIZED ( + SELECT plainto_tsquery('simple', @query) AS ts_query + ), + candidates AS MATERIALIZED ( + SELECT + 'knowledge'::text AS kind, + p.id, + p.title, + ''::text AS summary, + '/knowledge/' || p.id::text || '/' || p.slug AS href, + p.updated_at, + CASE + WHEN lower(p.title) = lower(@query) THEN 1.0::double precision + WHEN p.title ILIKE @prefix ESCAPE E'\\' THEN 0.95::double precision + WHEN p.title ILIKE @pattern ESCAPE E'\\' THEN 0.9::double precision + WHEN p.title % @query THEN 0.8 + similarity(p.title, @query) * 0.1 + WHEN p.search_vector @@ q.ts_query THEN 0.6 + LEAST(ts_rank_cd(p.search_vector, q.ts_query), 1.0) * 0.15 + ELSE 0.5::double precision + END AS rank + FROM pages AS p + CROSS JOIN search_query AS q + WHERE p.workspace_id = @workspaceId + AND p.trashed_at IS NULL + AND (@tagFilter IS NULL OR EXISTS ( + SELECT 1 FROM unnest(p.tags) AS candidate_tag(value) + WHERE lower(candidate_tag.value) = @tagFilter)) + AND NOT EXISTS (SELECT 1 FROM runbook_profiles AS rb WHERE rb.page_id = p.id) + AND NOT EXISTS (SELECT 1 FROM decision_records AS dr WHERE dr.page_id = p.id) + AND (@query = '' OR ( + p.search_vector @@ q.ts_query + OR p.title % @query + OR p.title ILIKE @pattern ESCAPE E'\\' + OR p.markdown ILIKE @pattern ESCAPE E'\\' + )) + + UNION ALL + + SELECT + 'project'::text, + pr.id, + pr.name, + pr.summary, + '/projects/' || pr.id::text, + pr.updated_at, + CASE + WHEN lower(pr.name) = lower(@query) THEN 1.0::double precision + WHEN pr.name ILIKE @prefix ESCAPE E'\\' THEN 0.95::double precision + WHEN pr.name ILIKE @pattern ESCAPE E'\\' THEN 0.9::double precision + WHEN pr.name % @query THEN 0.8 + similarity(pr.name, @query) * 0.1 + WHEN to_tsvector('simple', concat_ws(' ', pr.name, pr.summary, pr.current_state_markdown, + array_to_string(pr.technologies, ' '), + CASE pr.status WHEN 0 THEN 'concept' WHEN 1 THEN 'planned' WHEN 2 THEN 'active' + WHEN 3 THEN 'blocked' WHEN 4 THEN 'maintenance' WHEN 5 THEN 'paused' + WHEN 6 THEN 'completed' WHEN 7 THEN 'archived' ELSE '' END)) @@ q.ts_query + THEN 0.6 + LEAST(ts_rank_cd(to_tsvector('simple', concat_ws(' ', pr.name, pr.summary, + pr.current_state_markdown, array_to_string(pr.technologies, ' '))), q.ts_query), 1.0) * 0.15 + ELSE 0.5::double precision + END + FROM projects AS pr + CROSS JOIN search_query AS q + WHERE pr.workspace_id = @workspaceId + AND @tagFilter IS NULL + AND ( + pr.name ILIKE @pattern ESCAPE E'\\' + OR pr.summary ILIKE @pattern ESCAPE E'\\' + OR pr.current_state_markdown ILIKE @pattern ESCAPE E'\\' + OR array_to_string(pr.technologies, ' ') ILIKE @pattern ESCAPE E'\\' + OR (CASE pr.status WHEN 0 THEN 'Concept' WHEN 1 THEN 'Planned' WHEN 2 THEN 'Active' + WHEN 3 THEN 'Blocked' WHEN 4 THEN 'Maintenance' WHEN 5 THEN 'Paused' + WHEN 6 THEN 'Completed' WHEN 7 THEN 'Archived' ELSE '' END) ILIKE @pattern ESCAPE E'\\' + OR to_tsvector('simple', concat_ws(' ', pr.name, pr.summary, pr.current_state_markdown, + array_to_string(pr.technologies, ' '))) @@ q.ts_query + OR EXISTS ( + SELECT 1 FROM project_updates AS pu + WHERE pu.project_id = pr.id + AND (pu.title ILIKE @pattern ESCAPE E'\\' OR pu.summary_markdown ILIKE @pattern ESCAPE E'\\') + ) + OR EXISTS ( + SELECT 1 + FROM decision_records AS linked_decision + JOIN pages AS linked_decision_page ON linked_decision_page.id = linked_decision.page_id + WHERE linked_decision.workspace_id = @workspaceId + AND linked_decision.project_id = pr.id + AND linked_decision_page.workspace_id = @workspaceId + AND linked_decision_page.trashed_at IS NULL + AND linked_decision.summary ILIKE @pattern ESCAPE E'\\' + ) + OR EXISTS ( + SELECT 1 + FROM project_documents AS pd + JOIN pages AS linked_page ON linked_page.id = pd.page_id + WHERE pd.project_id = pr.id + AND linked_page.workspace_id = @workspaceId + AND linked_page.trashed_at IS NULL + AND (linked_page.title ILIKE @pattern ESCAPE E'\\' OR linked_page.search_vector @@ q.ts_query) + ) + ) + + UNION ALL + + SELECT + 'service'::text, + service.id, + COALESCE(NULLIF(profile.display_name, ''), service.detected_name), + COALESCE(NULLIF(profile.summary, ''), NULLIF(profile.purpose, ''), ''), + '/infrastructure/services/' || service.id::text, + GREATEST(service.last_seen_at, COALESCE(profile.updated_at, service.last_seen_at)), + CASE + WHEN lower(COALESCE(NULLIF(profile.display_name, ''), service.detected_name)) = lower(@query) THEN 1.0::double precision + WHEN COALESCE(NULLIF(profile.display_name, ''), service.detected_name) ILIKE @prefix ESCAPE E'\\' THEN 0.95::double precision + WHEN COALESCE(NULLIF(profile.display_name, ''), service.detected_name) ILIKE @pattern ESCAPE E'\\' THEN 0.9::double precision + WHEN to_tsvector('simple', concat_ws(' ', service.detected_name, service.runtime_kind, + service.image_reference, profile.display_name, profile.summary, profile.category, + profile.purpose, array_to_string(profile.tags, ' '))) @@ q.ts_query + THEN 0.6 + LEAST(ts_rank_cd(to_tsvector('simple', concat_ws(' ', service.detected_name, + service.runtime_kind, service.image_reference, profile.display_name, profile.summary, + profile.category, profile.purpose, array_to_string(profile.tags, ' '))), q.ts_query), 1.0) * 0.15 + ELSE 0.5::double precision + END + FROM infrastructure_services AS service + LEFT JOIN infrastructure_editorial_profiles AS profile + ON profile.workspace_id = service.workspace_id + AND profile.target_kind = 1 + AND profile.target_id = service.id + CROSS JOIN search_query AS q + WHERE service.workspace_id = @workspaceId + AND @tagFilter IS NULL + AND service.presence IN (1, 2) + AND (profile.id IS NULL OR (profile.is_hidden = FALSE AND profile.is_archived = FALSE)) + AND ( + service.detected_name ILIKE @pattern ESCAPE E'\\' + OR service.runtime_kind ILIKE @pattern ESCAPE E'\\' + OR service.runtime_status ILIKE @pattern ESCAPE E'\\' + OR service.image_reference ILIKE @pattern ESCAPE E'\\' + OR COALESCE(profile.display_name, '') ILIKE @pattern ESCAPE E'\\' + OR COALESCE(profile.summary, '') ILIKE @pattern ESCAPE E'\\' + OR COALESCE(profile.category, '') ILIKE @pattern ESCAPE E'\\' + OR COALESCE(profile.purpose, '') ILIKE @pattern ESCAPE E'\\' + OR array_to_string(profile.tags, ' ') ILIKE @pattern ESCAPE E'\\' + OR to_tsvector('simple', concat_ws(' ', service.detected_name, service.runtime_kind, + service.image_reference, profile.display_name, profile.summary, profile.category, + profile.purpose, array_to_string(profile.tags, ' '))) @@ q.ts_query + ) + + UNION ALL + + SELECT + 'runbook'::text, + runbook.id, + page.title, + CASE runbook.status WHEN 0 THEN 'Draft' WHEN 1 THEN 'Active' ELSE 'Deprecated' END, + '/runbooks/' || runbook.id::text, + runbook.updated_at, + CASE + WHEN lower(page.title) = lower(@query) THEN 1.0::double precision + WHEN page.title ILIKE @prefix ESCAPE E'\\' THEN 0.95::double precision + WHEN page.title ILIKE @pattern ESCAPE E'\\' THEN 0.9::double precision + WHEN page.title % @query THEN 0.8 + similarity(page.title, @query) * 0.1 + WHEN page.search_vector @@ q.ts_query THEN 0.6 + LEAST(ts_rank_cd(page.search_vector, q.ts_query), 1.0) * 0.15 + ELSE 0.5::double precision + END + FROM runbook_profiles AS runbook + JOIN pages AS page ON page.id = runbook.page_id + CROSS JOIN search_query AS q + WHERE page.workspace_id = @workspaceId + AND page.trashed_at IS NULL + AND page.document_type = 5 + AND (@tagFilter IS NULL OR EXISTS ( + SELECT 1 FROM unnest(page.tags) AS candidate_tag(value) + WHERE lower(candidate_tag.value) = @tagFilter)) + AND (@query = '' OR ( + page.search_vector @@ q.ts_query + OR page.title % @query + OR page.title ILIKE @pattern ESCAPE E'\\' + OR page.markdown ILIKE @pattern ESCAPE E'\\' + )) + + UNION ALL + + SELECT + 'decision'::text, + decision.id, + page.title, + decision.summary, + CASE WHEN project.id IS NULL + THEN '/knowledge/' || page.id::text || '/' || page.slug + ELSE '/projects/' || project.id::text || '/decisions/' || decision.id::text END, + decision.updated_at, + CASE + WHEN lower(page.title) = lower(@query) THEN 1.0::double precision + WHEN page.title ILIKE @prefix ESCAPE E'\\' THEN 0.95::double precision + WHEN page.title ILIKE @pattern ESCAPE E'\\' THEN 0.9::double precision + WHEN page.title % @query THEN 0.8 + similarity(page.title, @query) * 0.1 + WHEN page.search_vector @@ q.ts_query OR decision.summary ILIKE @pattern ESCAPE E'\\' + THEN 0.6 + LEAST(ts_rank_cd(page.search_vector, q.ts_query), 1.0) * 0.15 + ELSE 0.5::double precision + END + FROM decision_records AS decision + JOIN pages AS page ON page.id = decision.page_id + LEFT JOIN projects AS project ON project.id = decision.project_id AND project.workspace_id = @workspaceId + CROSS JOIN search_query AS q + WHERE decision.workspace_id = @workspaceId + AND page.workspace_id = @workspaceId + AND page.trashed_at IS NULL + AND page.document_type = 4 + AND (@tagFilter IS NULL OR EXISTS ( + SELECT 1 FROM unnest(page.tags) AS candidate_tag(value) + WHERE lower(candidate_tag.value) = @tagFilter)) + AND (@query = '' OR ( + page.search_vector @@ q.ts_query + OR decision.summary ILIKE @pattern ESCAPE E'\\' + OR page.title % @query + OR page.title ILIKE @pattern ESCAPE E'\\' + OR page.markdown ILIKE @pattern ESCAPE E'\\' + )) + ), + grouped_counts AS ( + SELECT + COUNT(*) FILTER (WHERE kind = 'knowledge')::integer AS knowledge_count, + COUNT(*) FILTER (WHERE kind = 'project')::integer AS project_count, + COUNT(*) FILTER (WHERE kind = 'service')::integer AS service_count, + COUNT(*) FILTER (WHERE kind = 'runbook')::integer AS runbook_count, + COUNT(*) FILTER (WHERE kind = 'decision')::integer AS decision_count + FROM candidates + ), + page AS MATERIALIZED ( + SELECT * + FROM candidates AS candidate + WHERE (@kindFilter IS NULL OR candidate.kind = @kindFilter) + AND (@hasCursor = FALSE + OR candidate.rank < @cursorRank + OR (candidate.rank = @cursorRank AND candidate.updated_at < @cursorUpdatedAt) + OR (candidate.rank = @cursorRank AND candidate.updated_at = @cursorUpdatedAt AND candidate.kind > @cursorKind) + OR (candidate.rank = @cursorRank AND candidate.updated_at = @cursorUpdatedAt AND candidate.kind = @cursorKind AND candidate.id > @cursorId)) + ORDER BY candidate.rank DESC, candidate.updated_at DESC, candidate.kind ASC, candidate.id ASC + LIMIT @take + ) + SELECT + page.kind, page.id, page.title, page.summary, page.href, page.updated_at, page.rank, + CASE page.kind + WHEN 'knowledge' THEN ( + SELECT COALESCE(NULLIF(ts_headline('simple', source.markdown, query.ts_query, + 'StartSel=**,StopSel=**,MaxWords=35,MinWords=15,MaxFragments=1'), ''), + LEFT(regexp_replace(source.markdown, E'[\\r\\n\\t]+', ' ', 'g'), 280)) + FROM pages AS source CROSS JOIN search_query AS query WHERE source.id = page.id + ) + WHEN 'project' THEN ( + SELECT COALESCE(NULLIF(ts_headline('simple', concat_ws(' ', source.name, source.summary, + source.current_state_markdown, array_to_string(source.technologies, ' ')), query.ts_query, + 'StartSel=**,StopSel=**,MaxWords=35,MinWords=15,MaxFragments=1'), ''), + LEFT(regexp_replace(source.summary, E'[\\r\\n\\t]+', ' ', 'g'), 280)) + FROM projects AS source CROSS JOIN search_query AS query WHERE source.id = page.id + ) + WHEN 'service' THEN ( + SELECT COALESCE(NULLIF(ts_headline('simple', concat_ws(' ', service.detected_name, + profile.display_name, profile.summary, profile.category, profile.purpose, + array_to_string(profile.tags, ' ')), query.ts_query, + 'StartSel=**,StopSel=**,MaxWords=35,MinWords=15,MaxFragments=1'), ''), + LEFT(regexp_replace(COALESCE(profile.summary, service.detected_name), E'[\\r\\n\\t]+', ' ', 'g'), 280)) + FROM infrastructure_services AS service + LEFT JOIN infrastructure_editorial_profiles AS profile + ON profile.workspace_id = service.workspace_id AND profile.target_kind = 1 AND profile.target_id = service.id + CROSS JOIN search_query AS query WHERE service.id = page.id + ) + WHEN 'runbook' THEN ( + SELECT COALESCE(NULLIF(ts_headline('simple', source.markdown, query.ts_query, + 'StartSel=**,StopSel=**,MaxWords=35,MinWords=15,MaxFragments=1'), ''), + LEFT(regexp_replace(source.markdown, E'[\\r\\n\\t]+', ' ', 'g'), 280)) + FROM runbook_profiles AS runbook + JOIN pages AS source ON source.id = runbook.page_id + CROSS JOIN search_query AS query WHERE runbook.id = page.id + ) + WHEN 'decision' THEN ( + SELECT COALESCE(NULLIF(ts_headline('simple', concat_ws(' ', decision.summary, source.markdown), query.ts_query, + 'StartSel=**,StopSel=**,MaxWords=35,MinWords=15,MaxFragments=1'), ''), + LEFT(regexp_replace(decision.summary, E'[\\r\\n\\t]+', ' ', 'g'), 280)) + FROM decision_records AS decision + JOIN pages AS source ON source.id = decision.page_id + CROSS JOIN search_query AS query WHERE decision.id = page.id + ) + ELSE '' + END AS snippet, + grouped_counts.knowledge_count, grouped_counts.project_count, + grouped_counts.service_count, grouped_counts.runbook_count, grouped_counts.decision_count + FROM grouped_counts + LEFT JOIN page ON TRUE + ORDER BY page.rank DESC NULLS LAST, page.updated_at DESC NULLS LAST, page.kind ASC NULLS LAST, page.id ASC NULLS LAST + """; + + public async Task SearchAsync( + Guid workspaceId, + string query, + string? tag, + UnifiedSearchKind? kind, + string? cursor, + int limit, + CancellationToken cancellationToken) + { + var normalizedQuery = (query ?? string.Empty).Trim(); + if (normalizedQuery.Length > MaxQueryLength) + { + normalizedQuery = normalizedQuery[..MaxQueryLength]; + } + + var normalizedTag = NormalizeTag(tag); + var boundedLimit = Math.Clamp(limit, 1, MaxPageSize); + var sqlKind = GetSqlKind(kind); + var hasCursor = TryDecodeCursor(cursor, normalizedQuery, normalizedTag, kind, out var decodedCursor); + var escapedQuery = EscapeLikePattern(normalizedQuery); + var pattern = $"%{escapedQuery}%"; + var prefix = $"{escapedQuery}%"; + + var connection = (NpgsqlConnection)db.Database.GetDbConnection(); + var ownsConnection = connection.State != ConnectionState.Open; + if (ownsConnection) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + await using (var thresholdCommand = connection.CreateCommand()) + { + thresholdCommand.Transaction = transaction; + thresholdCommand.CommandText = "SET LOCAL pg_trgm.similarity_threshold = '0.2';"; + await thresholdCommand.ExecuteNonQueryAsync(cancellationToken); + } + + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = Sql; + command.Parameters.Add(new NpgsqlParameter("workspaceId", NpgsqlDbType.Uuid) { Value = workspaceId }); + command.Parameters.Add(new NpgsqlParameter("query", NpgsqlDbType.Text) { Value = normalizedQuery }); + command.Parameters.Add(new NpgsqlParameter("tagFilter", NpgsqlDbType.Text) + { + Value = (object?)normalizedTag ?? DBNull.Value + }); + command.Parameters.Add(new NpgsqlParameter("kindFilter", NpgsqlDbType.Text) + { + Value = (object?)sqlKind ?? DBNull.Value + }); + command.Parameters.Add(new NpgsqlParameter("pattern", NpgsqlDbType.Text) { Value = pattern }); + command.Parameters.Add(new NpgsqlParameter("prefix", NpgsqlDbType.Text) { Value = prefix }); + command.Parameters.Add(new NpgsqlParameter("hasCursor", NpgsqlDbType.Boolean) { Value = hasCursor }); + command.Parameters.Add(new NpgsqlParameter("cursorRank", NpgsqlDbType.Double) { Value = decodedCursor?.Rank ?? 0d }); + command.Parameters.Add(new NpgsqlParameter("cursorUpdatedAt", NpgsqlDbType.TimestampTz) + { + Value = decodedCursor?.UpdatedAt ?? DateTimeOffset.MinValue + }); + command.Parameters.Add(new NpgsqlParameter("cursorKind", NpgsqlDbType.Text) { Value = decodedCursor?.Kind ?? string.Empty }); + command.Parameters.Add(new NpgsqlParameter("cursorId", NpgsqlDbType.Uuid) { Value = decodedCursor?.Id ?? Guid.Empty }); + command.Parameters.Add(new NpgsqlParameter("take", NpgsqlDbType.Integer) { Value = boundedLimit + 1 }); + + var items = new List(); + UnifiedSearchCountsResult counts = new(0, 0, 0, 0, 0); + await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + counts = new UnifiedSearchCountsResult( + reader.GetInt32(8), reader.GetInt32(9), reader.GetInt32(10), reader.GetInt32(11), reader.GetInt32(12)); + + if (reader.IsDBNull(0)) + { + continue; + } + + items.Add(new UnifiedSearchHit( + reader.GetString(0), reader.GetGuid(1), reader.GetString(2), reader.GetString(3), + reader.IsDBNull(7) ? string.Empty : reader.GetString(7), + reader.GetFieldValue(5), reader.GetString(4), reader.GetDouble(6))); + } + } + + var hasMore = items.Count > boundedLimit; + var page = hasMore ? items[..boundedLimit] : items; + var nextCursor = hasMore && page.Count > 0 ? EncodeCursor(page[^1], normalizedQuery, normalizedTag, kind) : null; + await transaction.CommitAsync(cancellationToken); + return new UnifiedSearchPage(page, counts, nextCursor); + } + finally + { + if (ownsConnection) + { + await connection.CloseAsync(); + } + } + } + + private static string EscapeLikePattern(string value) => value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("%", "\\%", StringComparison.Ordinal) + .Replace("_", "\\_", StringComparison.Ordinal); + + private static string? NormalizeTag(string? tag) => + string.IsNullOrWhiteSpace(tag) ? null : tag.Trim().ToLowerInvariant(); + + private static string? GetSqlKind(UnifiedSearchKind? kind) => kind switch + { + null => null, + UnifiedSearchKind.Knowledge => "knowledge", + UnifiedSearchKind.Projects => "project", + UnifiedSearchKind.Services => "service", + UnifiedSearchKind.Runbooks => "runbook", + UnifiedSearchKind.Decisions => "decision", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown unified search kind.") + }; + + private static string EncodeCursor(UnifiedSearchHit item, string query, string? tag, UnifiedSearchKind? kind) + { + var payload = new CursorPayload( + 1, + item.Rank, + item.UpdatedAt.UtcTicks, + item.Kind, + item.Id, + QueryFingerprint(query, tag, kind), + DateTimeOffset.UtcNow.Add(CursorLifetime).UtcTicks); + return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(payload)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static bool TryDecodeCursor( + string? value, string query, string? tag, UnifiedSearchKind? kind, out CursorPayload? payload) + { + payload = null; + if (string.IsNullOrWhiteSpace(value) || value.Length > 1024) + { + return false; + } + + try + { + var base64 = value.Replace('-', '+').Replace('_', '/'); + base64 += new string('=', (4 - base64.Length % 4) % 4); + var parsed = JsonSerializer.Deserialize(Convert.FromBase64String(base64)); + if (parsed is null + || parsed.Version != 1 + || parsed.Kind is not ("knowledge" or "project" or "service" or "runbook" or "decision") + || !double.IsFinite(parsed.Rank) + || parsed.Rank is < 0 or > 2 + || parsed.Id == Guid.Empty + || parsed.QueryHash is null + || parsed.QueryHash.Length != 64 + || parsed.ExpiresAtUtcTicks < DateTimeOffset.UtcNow.UtcTicks + || !CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(parsed.QueryHash), Encoding.ASCII.GetBytes(QueryFingerprint(query, tag, kind)))) + { + return false; + } + + _ = new DateTimeOffset(parsed.UpdatedAtUtcTicks, TimeSpan.Zero); + payload = parsed; + return true; + } + catch (Exception exception) when (exception is FormatException or JsonException or ArgumentOutOfRangeException) + { + return false; + } + } + + private static string QueryFingerprint(string query, string? tag, UnifiedSearchKind? kind) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + $"{query}\0{tag ?? string.Empty}\0{kind?.ToString() ?? string.Empty}"))); + + private sealed record CursorPayload( + int Version, + double Rank, + long UpdatedAtUtcTicks, + string Kind, + Guid Id, + string QueryHash, + long ExpiresAtUtcTicks) + { + public DateTimeOffset UpdatedAt => new(UpdatedAtUtcTicks, TimeSpan.Zero); + } +} diff --git a/src/frontend/package.json b/src/frontend/package.json index b04a1b1..f860d62 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -21,12 +21,13 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.7", - "@lezer/highlight": "^1.2.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", + "@lezer/highlight": "^1.2.3", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-tooltip": "^1.2.16", + "@tanstack/react-query": "^5.104.0", "@tanstack/react-table": "^8.21.3", "@uiw/react-codemirror": "^4.25.11", "lucide-react": "^1.28.0", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 4ccbc78..57277ff 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { AuthenticatedRoutes } from "./router/AuthenticatedRoutes"; import { LoginPage } from "./pages/LoginPage"; import { SetupPage } from "./pages/SetupPage"; import { LoadingScreen, SessionErrorScreen } from "./pages/StatusScreens"; +import { QueryProvider } from "./query/QueryProvider"; export function App() { return ( @@ -77,7 +78,9 @@ function SessionRouter() { key={state.session.userId} storageScope={state.session.userId!} > - + + + ); } diff --git a/src/frontend/src/api/adminQueries.ts b/src/frontend/src/api/adminQueries.ts new file mode 100644 index 0000000..13064cf --- /dev/null +++ b/src/frontend/src/api/adminQueries.ts @@ -0,0 +1,79 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "./client"; +import type { AdminUser, BackupListResponse, ServiceAccountListResponse } from "./adminTypes"; +import type { AdminAuditListResponse, AdminJob, AdminJobListResponse, BackupStatusResponse, DataSourceListResponse, PlatformSettingsResponse, StagingCleanupStatusResponse, SystemInfoResponse } from "./platformTypes"; + +export const adminQueryKeys = { + all: ["admin"] as const, + users: ["admin", "users"] as const, + access: ["admin", "access"] as const, + backups: ["admin", "backups"] as const, + backupStatus: ["admin", "backups", "status"] as const, + jobs: ["admin", "jobs"] as const, + job: (id: string) => ["admin", "jobs", id] as const, + audit: ["admin", "audit"] as const, + auditPage: (before: number | null) => ["admin", "audit", before] as const, + taxonomies: ["admin", "taxonomies"] as const, + sources: ["admin", "sources"] as const, + sourcePage: (cursor: string | null) => ["admin", "sources", cursor] as const, + platformSettings: ["admin", "platform-settings"] as const, + systemInfo: ["admin", "system-info"] as const, + stagingCleanup: ["admin", "staging-cleanup"] as const, +}; + +export function useAdminUsers() { + return useQuery({ queryKey: adminQueryKeys.users, queryFn: ({ signal }) => apiGet("/api/v1/admin/users", signal) }); +} + +export function useServiceAccounts() { + return useQuery({ queryKey: adminQueryKeys.access, queryFn: ({ signal }) => apiGet("/api/v1/service-accounts", signal) }); +} + +export function useAdminBackups() { + return useQuery({ queryKey: adminQueryKeys.backups, queryFn: ({ signal }) => apiGet("/api/v1/admin/backups", signal) }); +} + +export function useAdminBackupStatus() { + return useQuery({ queryKey: adminQueryKeys.backupStatus, queryFn: ({ signal }) => apiGet("/api/v1/admin/backups/status", signal) }); +} + +export function useAdminJobs() { + return useQuery({ queryKey: adminQueryKeys.jobs, queryFn: ({ signal }) => apiGet("/api/v1/jobs?limit=50", signal), refetchInterval: 10_000 }); +} + +export function useAdminJob(id: string | null) { + return useQuery({ + queryKey: id ? adminQueryKeys.job(id) : [...adminQueryKeys.jobs, "none"], + enabled: !!id, + queryFn: ({ signal }) => apiGet(`/api/v1/jobs/${encodeURIComponent(id!)}`, signal), + refetchInterval: (query) => query.state.data && isTerminalJob(query.state.data.status) ? false : 3_000 + }); +} + +export function useAdminAuditPage(before: number | null) { + const params = new URLSearchParams({ limit: "100" }); + if (before !== null) params.set("before", String(before)); + return useQuery({ queryKey: adminQueryKeys.auditPage(before), queryFn: ({ signal }) => apiGet(`/api/v1/admin/audit-log?${params}`, signal) }); +} + +export function isTerminalJob(status: string) { + return status === "succeeded" || status === "partially_succeeded" || status === "failed" || status === "cancelled"; +} + +export function useDataSources(cursor: string | null = null) { + const params = new URLSearchParams({ limit: "50" }); + if (cursor) params.set("cursor", cursor); + return useQuery({ queryKey: adminQueryKeys.sourcePage(cursor), queryFn: ({ signal }) => apiGet(`/api/v1/admin/data-sources?${params}`, signal) }); +} + +export function usePlatformSettings() { + return useQuery({ queryKey: adminQueryKeys.platformSettings, queryFn: ({ signal }) => apiGet("/api/v1/admin/platform-settings", signal) }); +} + +export function useSystemInfo() { + return useQuery({ queryKey: adminQueryKeys.systemInfo, queryFn: ({ signal }) => apiGet("/api/v1/system/info", signal) }); +} + +export function useStagingCleanupStatus() { + return useQuery({ queryKey: adminQueryKeys.stagingCleanup, queryFn: ({ signal }) => apiGet("/api/v1/admin/staging-cleanup/status", signal) }); +} diff --git a/src/frontend/src/api/adminTypes.ts b/src/frontend/src/api/adminTypes.ts index a7d1d4f..ef237de 100644 --- a/src/frontend/src/api/adminTypes.ts +++ b/src/frontend/src/api/adminTypes.ts @@ -17,6 +17,11 @@ export type BackupSummary = { attachmentCount: number; schemaVersion: string; productVersion: string; + protected: boolean; + protectionETag: string; + integrityVerifiedAt: string | null; + restoreVerifiedAt: string | null; + lastRestoreSucceeded: boolean | null; }; export type BackupListResponse = { items: BackupSummary[] }; diff --git a/src/frontend/src/api/domainQueries.ts b/src/frontend/src/api/domainQueries.ts new file mode 100644 index 0000000..0952d07 --- /dev/null +++ b/src/frontend/src/api/domainQueries.ts @@ -0,0 +1,93 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { apiGet } from "./client"; +import type { InfrastructureOverview, InfrastructureServiceDetail, InfrastructureServiceListResponse } from "./infrastructureTypes"; +import type { ProjectListResponse, ProjectDetail, ProjectCatalogTotals, RunbookListResponse } from "./projectTypes"; +import type { PaginatedIngestionProposals } from "./intakeTypes"; +import type { ActivityListResponse } from "./activityTypes"; +import type { PageDetail } from "./knowledgeTypes"; +import type { UnifiedSearchResponse, UnifiedSearchKind } from "./unifiedSearchTypes"; + +export const queryKeys = { + infrastructure: ["infrastructure"] as const, + projects: ["projects"] as const, + runbooks: ["runbooks"] as const, + activity: ["activity"] as const, + knowledge: ["knowledge"] as const, + ingestion: ["ingestion"] as const, + admin: ["admin"] as const, + unifiedSearch: ["unified-search"] as const, +}; + +export function useUnifiedSearch(query: string, kind: UnifiedSearchKind | "all", limit = 20, tag = "") { + return useInfiniteQuery({ + queryKey: [...queryKeys.unifiedSearch, query, kind, limit, tag], + enabled: query.trim().length > 0 || tag.trim().length > 0, + initialPageParam: null as string | null, + queryFn: ({ signal, pageParam }) => { + const params = new URLSearchParams({ q: query.trim(), limit: String(limit) }); + if (kind !== "all") params.set("kind", kind); + if (tag.trim()) params.set("tag", tag.trim()); + if (pageParam) params.set("cursor", pageParam); + return apiGet(`/api/v1/unified-search?${params}`, signal); + }, + getNextPageParam: page => page.nextCursor ?? undefined, + }); +} + +export function useInfrastructureOverview() { + return useQuery({ queryKey: [...queryKeys.infrastructure, "overview"], queryFn: ({ signal }) => apiGet("/api/v1/infrastructure/overview", signal) }); +} +export function useServices(search = "", enabled = true) { + return useQuery({ queryKey: [...queryKeys.infrastructure, "services", search], enabled, queryFn: ({ signal }) => apiGet(`/api/v1/infrastructure/services${search ? `?${search}` : ""}`, signal) }); +} +export function useProjectCatalog(search: string) { + return useInfiniteQuery({ + queryKey: [...queryKeys.projects, "catalog", search], + initialPageParam: null as string | null, + queryFn: ({ signal, pageParam }) => { + const params = new URLSearchParams(search); + if (pageParam) params.set("cursor", pageParam); + return apiGet(`/api/v1/projects?${params}`, signal); + }, + getNextPageParam: (page) => page.nextCursor ?? undefined, + }); +} +export function useServiceCatalog(search: string) { + return useInfiniteQuery({ + queryKey: [...queryKeys.infrastructure, "services", search], + initialPageParam: null as string | null, + queryFn: ({ signal, pageParam }) => { + const params = new URLSearchParams(search); + if (pageParam) params.set("cursor", pageParam); + return apiGet(`/api/v1/infrastructure/services?${params}`, signal); + }, + getNextPageParam: (page) => page.nextCursor ?? undefined, + }); +} +export function useProjectTotals() { + return useQuery({ queryKey: [...queryKeys.projects, "totals"], queryFn: ({ signal }) => apiGet("/api/v1/projects/totals", signal) }); +} +export function useWorkspaceMembers() { + return useQuery({ queryKey: ["workspace", "members"], queryFn: ({ signal }) => apiGet<{ items: { id: string; displayName: string }[] }>("/api/v1/workspace/members", signal) }); +} +export function useService(id: string | undefined) { + return useQuery({ queryKey: [...queryKeys.infrastructure, "service", id], enabled: !!id, queryFn: ({ signal }) => apiGet(`/api/v1/infrastructure/services/${id}`, signal) }); +} +export function useProjects(search = "") { + return useQuery({ queryKey: [...queryKeys.projects, "list", search], queryFn: ({ signal }) => apiGet(`/api/v1/projects${search ? `?${search}` : ""}`, signal) }); +} +export function useActivity(search = "limit=100") { + return useQuery({ queryKey: [...queryKeys.activity, search], queryFn: ({ signal }) => apiGet(`/api/v1/activity?${search}`, signal) }); +} +export function useProject(id: string | undefined) { + return useQuery({ queryKey: [...queryKeys.projects, "detail", id], enabled: !!id, queryFn: ({ signal }) => apiGet(`/api/v1/projects/${id}`, signal) }); +} +export function useRunbooks(search = "limit=100") { + return useQuery({ queryKey: [...queryKeys.runbooks, search], queryFn: ({ signal }) => apiGet(`/api/v1/runbooks?${search}`, signal) }); +} +export function useIngestionQueue(search = "pageSize=50") { + return useQuery({ queryKey: [...queryKeys.ingestion, "queue", search], queryFn: ({ signal }) => apiGet(`/api/v1/ingestion-proposals?${search}`, signal) }); +} +export function useKnowledgePage(id: string | undefined) { + return useQuery({ queryKey: [...queryKeys.knowledge, "page", id], enabled: !!id, queryFn: ({ signal }) => apiGet(`/api/v1/pages/${id}`, signal) }); +} diff --git a/src/frontend/src/api/platformTypes.ts b/src/frontend/src/api/platformTypes.ts new file mode 100644 index 0000000..a9b5ed7 --- /dev/null +++ b/src/frontend/src/api/platformTypes.ts @@ -0,0 +1,161 @@ +import type { ActivityItem } from "./activityTypes"; + +export type IngestionProposalPolicy = { + enabled: boolean; + maximumSourceCount: number; + maximumSourceBytes: number; + failedRetryLimit: number; +}; + +export type BackupPolicyExpectation = { + expectedIntervalHours: number; + scheduleDescription: string; + dailyRetentionCount: number; + weeklyRetentionCount: number; + monthlyRetentionCount: number; + protectedBackupCount: number; + restoreVerificationMaxAgeDays: number; +}; + +export type PlatformSettingsRequest = { + displayName: string; + primaryLocale: string; + timeZone: string; + ingestionProposals: IngestionProposalPolicy; + backupPolicyExpectation: BackupPolicyExpectation; + inventoryFreshnessPolicy: { warningAfterHours: number }; +}; + +export type PlatformSettingsResponse = PlatformSettingsRequest & { + schemaVersion: 1; + eTag: string; + isDefault: boolean; + backupPolicyExpectation: BackupPolicyExpectation & { enforcement: string }; + updatedAt: string | null; +}; + +export type DataSourceKind = "Inventory" | "Gitea" | "KnowledgeClient" | "ManualIntake" | "LeafWiki" | "CuratedPackage"; +export type DataSourceState = "Unknown" | "Healthy" | "Degraded" | "Failed"; +export type DataSourceErrorCode = "authentication" | "connectivity" | "validation" | "unexpected"; + +export type DataSourceSummary = { + id: string; + sourceKey: string; + displayName: string; + kind: DataSourceKind; + enabled: boolean; + state: DataSourceState; + configurationState: string; + syncAuthority: string; + expectedIntervalHours: number; + lastAttemptAt: string | null; + lastSuccessAt: string | null; + lastFailureAt: string | null; + dataObservedAt: string | null; + recordCount: number | null; + lastErrorCode: string | null; + lastCorrelationId: string | null; + eTag: string; + actions: string[]; +}; + +export type DataSourceListResponse = { items: DataSourceSummary[]; nextCursor: string | null }; +export type RegisterDataSourceRequest = { sourceKey: string; displayName: string; kind: DataSourceKind; expectedIntervalHours: number }; +export type ConfigureDataSourceRequest = { displayName: string; enabled: boolean; expectedIntervalHours: number }; +export type ReportDataSourceRequest = { success: boolean; recordCount: number | null; errorCode: DataSourceErrorCode | null; correlationId: string | null }; + +export type SystemInfoResponse = { + product: string; + version: string; + commit: string; + schemaVersion: string; + environment: string; + primaryLocale: string; + timeZone: string; + uptimeSeconds: number; + release: { + semanticVersion: string | null; + releaseTag: string | null; + sourceCommit: string | null; + imageRepository: string | null; + imageTag: string | null; + imageId: string | null; + imageDigest: string | null; + buildTimestamp: string | null; + deploymentTimestamp: string | null; + openApiVersion: string | null; + openApiHash: string | null; + frontendBuildHash: string | null; + databaseMigrationRange: string | null; + inventorySnapshotId: string | null; + backupId: string | null; + }; +}; + +export type StagingCleanupRunSummary = { + sessionsExpired: number; + sessionsClaimed: number; + sessionsLostRace: number; + attachmentReferencesRemoved: number; + blobsRemoved: number; + warnings: string[]; +}; + +export type StagingCleanupStatusResponse = { + activeSessionCount: number; + expiredPendingCleanupCount: number; + runInProgress: boolean; + lastRunStartedAt: string | null; + lastRunCompletedAt: string | null; + lastRunTrigger: string | null; + lastResult: StagingCleanupRunSummary | null; +}; + +export type RunStagingCleanupResponse = { dryRun: boolean; result: StagingCleanupRunSummary }; + +export type AdminJobType = "course-tagging" | "backup"; +export type AdminJobStatus = "queued" | "running" | "succeeded" | "partially_succeeded" | "failed" | "cancelled"; +export type AdminJobError = { code: string; message: string; itemId: string | null }; +export type AdminJob = { + id: string; + type: AdminJobType; + status: AdminJobStatus; + actorId: string; + createdAt: string; + startedAt: string | null; + completedAt: string | null; + progress: number; + total: number; + processed: number; + succeeded: number; + skipped: number; + failed: number; + errors: AdminJobError[]; + result: Record | null; + correlationId: string | null; + cancelRequested: boolean; +}; +export type AdminJobListResponse = { items: AdminJob[] }; + +export type AdminAuditItem = ActivityItem & { correlationId: string | null }; +export type AdminAuditListResponse = { items: AdminAuditItem[]; nextCursor: number | null }; + +export type BackupStatusResponse = { + state: "Unknown" | "Stale" | "Current"; + scheduleAuthority: string; + scheduleDescription: string; + nextScheduledRun: string | null; + nextExpectedBy: string | null; + lastSuccessfulBackupAt: string | null; + lastIntegrityVerifiedAt: string | null; + lastVerifiedRestoreAt: string | null; + restoreConfidence: "Unknown" | "Stale" | "Verified"; + storedBackupBytes: number; + protectedCount: number; + expectedIntervalHours: number; + applicationRetentionCount: number; + dailyRetentionExpectation: number; + weeklyRetentionExpectation: number; + monthlyRetentionExpectation: number; + restoreVerificationMaxAgeDays: number; +}; diff --git a/src/frontend/src/api/unifiedSearchTypes.ts b/src/frontend/src/api/unifiedSearchTypes.ts new file mode 100644 index 0000000..1f289e3 --- /dev/null +++ b/src/frontend/src/api/unifiedSearchTypes.ts @@ -0,0 +1,25 @@ +export type UnifiedSearchKind = "knowledge" | "projects" | "services" | "runbooks" | "decisions"; + +export type UnifiedSearchItem = { + kind: UnifiedSearchKind; + id: string; + title: string; + summary: string; + snippet: string; + updatedAt: string; + href: string; +}; + +export type UnifiedSearchResponse = { + items: UnifiedSearchItem[]; + counts: Record; + nextCursor: string | null; +}; + +export const searchKindLabels: Record = { + knowledge: "Kennis", projects: "Projecten", services: "Services", runbooks: "Runbooks", decisions: "Besluiten", +}; + +export function safeSearchHref(href: string) { + return href.startsWith("/") && !href.startsWith("//") && !href.includes("\\") ? href : "/search"; +} diff --git a/src/frontend/src/components/CachedDataNotice.tsx b/src/frontend/src/components/CachedDataNotice.tsx new file mode 100644 index 0000000..91fbc3a --- /dev/null +++ b/src/frontend/src/components/CachedDataNotice.tsx @@ -0,0 +1,7 @@ +export function CachedDataNotice({ failed, onRetry }: { failed: boolean; onRetry: () => void }) { + if (!failed) return null; + return
+ Vernieuwen is mislukt. De laatst geladen gegevens blijven zichtbaar. + +
; +} diff --git a/src/frontend/src/components/CommandPalette.test.tsx b/src/frontend/src/components/CommandPalette.test.tsx index 243b72d..70f0443 100644 --- a/src/frontend/src/components/CommandPalette.test.tsx +++ b/src/frontend/src/components/CommandPalette.test.tsx @@ -34,7 +34,7 @@ describe("CommandPalette", () => { it("searches real knowledge content, not just the fixed destination list", async () => { const fetchMock = vi.fn().mockImplementation((input: string) => { - if (typeof input === "string" && input.startsWith("/api/v1/search")) { + if (typeof input === "string" && input.startsWith("/api/v1/unified-search")) { return Promise.resolve({ ok: true, status: 200, @@ -44,12 +44,10 @@ describe("CommandPalette", () => { items: [ { id: "page-1", - parentPageId: null, + kind: "knowledge", + href: "/knowledge/page-1/postgresql-back-upprocedure", + summary: "", title: "PostgreSQL back-upprocedure", - slug: "postgresql-back-upprocedure", - documentType: "Runbook", - documentStatus: "Published", - tags: [], updatedAt: "2026-08-02T00:00:00Z", snippet: "", }, @@ -74,7 +72,7 @@ describe("CommandPalette", () => { expect(await screen.findByRole("option", { name: /PostgreSQL back-upprocedure/ })).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/search?q=PostgreSQL"), + expect.stringContaining("/api/v1/unified-search?q=PostgreSQL"), expect.anything(), ); }); @@ -140,7 +138,7 @@ describe("CommandPalette", () => { await user.click(screen.getByRole("button", { name: "Opdrachten en zoeken openen" })); await user.type(screen.getByLabelText("Zoeken en opdrachten"), "zzzz-storing"); - expect(await screen.findByRole("alert")).toHaveTextContent("Kennis zoeken is tijdelijk niet beschikbaar"); + expect(await screen.findByRole("alert")).toHaveTextContent("Zoeken is tijdelijk niet beschikbaar"); expect(screen.queryByText("Geen overeenkomsten.")).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Opnieuw proberen" })); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); diff --git a/src/frontend/src/components/CommandPalette.tsx b/src/frontend/src/components/CommandPalette.tsx index 64d7021..efceca6 100644 --- a/src/frontend/src/components/CommandPalette.tsx +++ b/src/frontend/src/components/CommandPalette.tsx @@ -6,7 +6,7 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { apiGet } from "../api/client"; -import type { SearchResponse, SearchResultItem } from "../api/knowledgeTypes"; +import { safeSearchHref, searchKindLabels, type UnifiedSearchItem, type UnifiedSearchResponse } from "../api/unifiedSearchTypes"; const destinations = [ { label: "Home", description: "Operationeel overzicht", path: "/", icon: Gauge }, @@ -16,7 +16,7 @@ const destinations = [ { label: "Projecten", description: "Projectworkspaces", path: "/projects", icon: FolderKanban }, { label: "Runbooks", description: "Veilige operationele procedures", path: "/runbooks", icon: TerminalSquare }, { label: "Kennisbank", description: "Vrije technische documentatie", path: "/knowledge", icon: BookOpenText }, - { label: "Zoeken", description: "Doorzoek alle kennis", path: "/search", icon: Search }, + { label: "Zoeken", description: "Doorzoek alle onderdelen", path: "/search", icon: Search }, { label: "Beheer", description: "Gebruikers en beveiliging", path: "/admin", icon: ShieldCheck }, { label: "Instellingen", description: "Thema, dichtheid en navigatie", path: "/settings", icon: Settings } ] as const; @@ -28,7 +28,7 @@ const minSearchLength = 1; export function CommandPalette({ isAdministrator }: { isAdministrator: boolean }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); - const [knowledgeResultSet, setKnowledgeResultSet] = useState<{ query: string; items: SearchResultItem[] }>({ + const [unifiedResultSet, setUnifiedResultSet] = useState<{ query: string; items: UnifiedSearchItem[] }>({ query: "", items: [], }); @@ -59,7 +59,7 @@ export function CommandPalette({ isAdministrator }: { isAdministrator: boolean } const trimmed = query.trim(); // No setState here for the below-threshold/closed case: nothing to cancel yet, // and rendering below only ever displays a result set when the query - // currently qualifies (see displayedKnowledgeResults), so a stale array from a + // currently qualifies (see displayedSearchResults), so a stale array from a // previous open simply never reaches the screen instead of needing an eager // effect-body reset (react-hooks/set-state-in-effect — see [[v2-m2-gotchas]]). if (!open || trimmed.length < minSearchLength) { @@ -69,9 +69,9 @@ export function CommandPalette({ isAdministrator }: { isAdministrator: boolean } const timeout = window.setTimeout(() => { setSearchingQuery(trimmed); setFailedQuery(null); - apiGet(`/api/v1/search?q=${encodeURIComponent(trimmed)}&limit=6`) + apiGet(`/api/v1/unified-search?q=${encodeURIComponent(trimmed)}&limit=8`) .then((response) => { - if (!cancelled) setKnowledgeResultSet({ query: trimmed, items: response.items }); + if (!cancelled) setUnifiedResultSet({ query: trimmed, items: response.items }); }) .catch(() => { if (!cancelled) setFailedQuery(trimmed); @@ -87,9 +87,9 @@ export function CommandPalette({ isAdministrator }: { isAdministrator: boolean } }, [open, query, searchAttempt]); const trimmedQuery = query.trim(); - const displayedKnowledgeResults = - trimmedQuery.length >= minSearchLength && knowledgeResultSet.query === trimmedQuery - ? knowledgeResultSet.items + const displayedSearchResults = + trimmedQuery.length >= minSearchLength && unifiedResultSet.query === trimmedQuery + ? unifiedResultSet.items : []; const searchFailed = failedQuery === trimmedQuery; // Treat the debounce window as pending too. Otherwise a new query briefly says @@ -98,7 +98,7 @@ export function CommandPalette({ isAdministrator }: { isAdministrator: boolean } const searching = trimmedQuery.length >= minSearchLength && !searchFailed - && (knowledgeResultSet.query !== trimmedQuery || searchingQuery === trimmedQuery); + && (unifiedResultSet.query !== trimmedQuery || searchingQuery === trimmedQuery); const destinationResults = useMemo(() => { const normalized = query.trim().toLocaleLowerCase("nl-BE"); @@ -115,16 +115,16 @@ export function CommandPalette({ isAdministrator }: { isAdministrator: boolean } setSelectedIndex(0); } - const hasAnyResults = destinationResults.length > 0 || displayedKnowledgeResults.length > 0; - const resultCount = destinationResults.length + displayedKnowledgeResults.length; + const hasAnyResults = destinationResults.length > 0 || displayedSearchResults.length > 0; + const resultCount = destinationResults.length + displayedSearchResults.length; const activeIndex = resultCount > 0 ? Math.min(selectedIndex, resultCount - 1) : -1; const activePath = activeIndex < 0 ? null : activeIndex < destinationResults.length ? destinationResults[activeIndex]?.path ?? null : (() => { - const item = displayedKnowledgeResults[activeIndex - destinationResults.length]; - return item ? `/knowledge/${item.id}/${item.slug}` : null; + const item = displayedSearchResults[activeIndex - destinationResults.length]; + return item ? safeSearchHref(item.href) : null; })(); const activeOptionId = activeIndex >= 0 ? `command-option-${activeIndex}` : undefined; @@ -177,7 +177,7 @@ export function CommandPalette({ isAdministrator }: { isAdministrator: boolean } setSelectedIndex(0); }} onKeyDown={handleInputKeyDown} - placeholder="Zoek in kennis of typ een opdracht…" + placeholder="Zoek in alle onderdelen of typ een opdracht…" aria-label="Zoeken en opdrachten" aria-controls="command-result-list" aria-activedescendant={activeOptionId} @@ -215,10 +215,10 @@ export function CommandPalette({ isAdministrator }: { isAdministrator: boolean } })} )} - {displayedKnowledgeResults.length > 0 && ( -
-

Kennisbank

- {displayedKnowledgeResults.map((item, itemIndex) => { + {displayedSearchResults.length > 0 && ( +
+

Zoekresultaten

+ {displayedSearchResults.map((item, itemIndex) => { const index = destinationResults.length + itemIndex; return ( ); @@ -244,7 +244,7 @@ export function CommandPalette({ isAdministrator }: { isAdministrator: boolean } {searching &&
Zoeken…
} {!searching && searchFailed && (
- Kennis zoeken is tijdelijk niet beschikbaar. Bestemmingen blijven beschikbaar. + Zoeken is tijdelijk niet beschikbaar. Bestemmingen blijven beschikbaar. )}
+ + {createdToken &&
+

Eenmalig serviceaccounttoken

+

Dit token wordt na het sluiten niet opnieuw getoond. Kopieer en bewaar het nu.

+ {createdToken.rawToken} +
+
} + {(accounts.data?.items.length ?? 0) === 0 ?

Nog geen serviceaccounts

Geregistreerde automatiseringen verschijnen hier.

:
+ {accounts.data?.items.map((account) => revokeMutation.mutate(id)} />)} +
} + ; +} + +function AccountRow({ account, revoking, onRevoke }: { account: ServiceAccountSummary; revoking: boolean; onRevoke: (id: string) => void }) { + const activeTokens = account.tokens.filter((token) => !token.revokedAt); + return
+
{account.name}{account.description}{account.tokens.length} token(s) · aangemaakt {formatDate(account.createdAt)}
+
{activeTokens.map((token) =>
+ Laatst gebruikt: {token.lastUsedAt ? formatDate(token.lastUsedAt) : "nooit"}{rotationHint(token) && · {rotationHint(token)}} + Scopes: {token.scopes.join(", ")} + +
)}{activeTokens.length === 0 && Geen actief token}
+
; +} + +function rotationHint(token: { createdAt: string; lastUsedAt: string | null }): string | null { + const referenceDate = new Date(token.lastUsedAt ?? token.createdAt); + const days = (Date.now() - referenceDate.getTime()) / 86_400_000; + if (!token.lastUsedAt && days > 30) return "Nooit gebruikt sinds aanmaak — controleer of dit token nog nodig is."; + if (token.lastUsedAt && days > 30) return `Niet meer gebruikt sinds ${formatDate(token.lastUsedAt)} — overweeg rotatie of intrekking.`; + return null; +} + +function formatDate(value: string) { return new Intl.DateTimeFormat("nl-BE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); } + +async function copyCreatedToken(token: CreatedServiceAccountToken, setMessage: (value: string | null) => void, setError: (value: string | null) => void) { + if (await copyText(token.rawToken)) setMessage("Token is naar het klembord gekopieerd."); + else setError("Kopiëren naar het klembord is mislukt. Selecteer en kopieer het token voordat je dit scherm verlaat."); +} diff --git a/src/frontend/src/pages/admin/AdminAuditPage.test.tsx b/src/frontend/src/pages/admin/AdminAuditPage.test.tsx new file mode 100644 index 0000000..adbd6c7 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminAuditPage.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryProvider } from "../../query/QueryProvider"; +import { MemoryRouter } from "react-router"; +import AdminAuditPage from "./AdminAuditPage"; +import { requestUrl } from "../../test-utils/request"; + +describe("AdminAuditPage", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("reads the administrator audit endpoint, filters events, and follows its cursor", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url === "/api/v1/admin/audit-log?limit=100") return jsonResponse({ items: [auditItem(10, "User", "page.created", "page"), auditItem(9, "ServiceAccount", "token.revoked", "service-token")], nextCursor: 9 }); + if (url === "/api/v1/admin/audit-log?limit=100&before=9") return jsonResponse({ items: [auditItem(8, "System", "cleanup.completed", "system")], nextCursor: null }); + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + + expect(await screen.findByText("Pagina Herstel bijgewerkt")).toBeInTheDocument(); + expect(screen.getByText("correlation-10")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Doel openen: page" })).toHaveAttribute("href", "/knowledge/page-1"); + await user.selectOptions(screen.getByLabelText("Actortype"), "ServiceAccount"); + expect(screen.queryByText("Pagina Herstel bijgewerkt")).not.toBeInTheDocument(); + expect(screen.getByText("service-token token.revoked")).toBeInTheDocument(); + await user.selectOptions(screen.getByLabelText("Actortype"), "all"); + await user.click(screen.getByRole("button", { name: "Oudere gebeurtenissen" })); + expect(await screen.findByText("system cleanup.completed")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith("/api/v1/admin/audit-log?limit=100&before=9", expect.anything()); + expect(fetchMock).not.toHaveBeenCalledWith("/api/v1/activity", expect.anything()); + }); +}); + +function auditItem(id: number, actorKind: string, action: string, targetType: string) { + return { id, actorKind, actorId: `actor-${id}`, source: targetType, action, targetType, targetId: "page-1", summary: `${targetType === "page" ? "Pagina Herstel bijgewerkt" : `${targetType} ${action}`}`, occurredAt: "2026-10-02T10:00:00Z", correlationId: `correlation-${id}` }; +} +function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); } diff --git a/src/frontend/src/pages/admin/AdminAuditPage.tsx b/src/frontend/src/pages/admin/AdminAuditPage.tsx new file mode 100644 index 0000000..a311cdd --- /dev/null +++ b/src/frontend/src/pages/admin/AdminAuditPage.tsx @@ -0,0 +1,59 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router"; +import { useAdminAuditPage } from "../../api/adminQueries"; +import type { AdminAuditItem } from "../../api/platformTypes"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { ErrorState, LoadingState } from "../v2/RouteStates"; + +export default function AdminAuditPage() { + useDocumentTitle("Beheeraudit"); + const [cursors, setCursors] = useState>([null]); + const [filter, setFilter] = useState(""); + const [actorFilter, setActorFilter] = useState("all"); + const before = cursors.at(-1) ?? null; + const audit = useAdminAuditPage(before); + const items = useMemo(() => { + const query = filter.trim().toLowerCase(); + return (audit.data?.items ?? []).filter((item) => (actorFilter === "all" || item.actorKind === actorFilter) && (!query || `${item.actorKind} ${item.actorId ?? ""} ${item.source} ${item.action} ${item.targetType ?? ""} ${item.targetId ?? ""} ${item.summary} ${item.correlationId ?? ""}`.toLowerCase().includes(query))); + }, [actorFilter, audit.data?.items, filter]); + + if (audit.isPending && !audit.data) return
; + if (audit.isError && !audit.data) return
void audit.refetch()} />
; + + return
+ + {audit.isError &&

Vernieuwen is mislukt. De zichtbare gebeurtenissen komen uit de laatst geladen serverpagina.

} +
+

Beveiligings- en beheertrail

Gebeurtenissen

Maximaal 100 gebeurtenissen per serverpagina. Alleen velden uit het auditcontract worden getoond.

+
setFilter(event.target.value)} />
+ {items.length === 0 ?

{audit.data?.items.length ? "Geen gebeurtenissen gevonden" : "Geen auditgebeurtenissen"}

{audit.data?.items.length ? "Pas de filtertekst aan." : "De server heeft geen gebeurtenissen op deze pagina geregistreerd."}

:
    + {items.map((item) => )} +
} + +
+
; +} + +function AuditRow({ item }: { item: AdminAuditItem }) { + const href = targetHref(item); + return
  • +
    {item.summary}

    {item.action}{item.targetType ? ` · ${item.targetType}` : ""}

    {href ? Doel openen: {item.targetType} : item.targetType && Doel: {item.targetType}{item.targetId ? ` · ${item.targetId}` : ""}}
    +
    Actor
    {item.actorKind}{item.actorId ? ` · ${item.actorId}` : ""}
    Bron
    {item.source}
    Tijd
    {item.correlationId &&
    Correlatie-ID
    {item.correlationId}
    }
    +
  • ; +} + +function targetHref(item: AdminAuditItem) { + if (!item.targetId) return null; + const id = encodeURIComponent(item.targetId); + switch (item.targetType?.toLowerCase()) { + case "page": return `/knowledge/${id}`; + case "project": return `/projects/${id}`; + case "runbook": return `/runbooks/${id}`; + default: return null; + } +} diff --git a/src/frontend/src/pages/admin/AdminBackupsPage.test.tsx b/src/frontend/src/pages/admin/AdminBackupsPage.test.tsx new file mode 100644 index 0000000..0a5b466 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminBackupsPage.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetCsrfCache } from "../../api/client"; +import { QueryProvider } from "../../query/QueryProvider"; +import { requestBodyObject, requestUrl } from "../../test-utils/request"; +import { MemoryRouter } from "react-router"; +import AdminBackupsPage from "./AdminBackupsPage"; + +describe("AdminBackupsPage", () => { + afterEach(() => { resetCsrfCache(); vi.unstubAllGlobals(); }); + + it("shows persisted backup evidence and protects a backup with its current ETag", async () => { + let protectedValue = false; + let protectionETag = "backup-v4"; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + if (url === "/api/v1/admin/backups") return jsonResponse({ items: [backup({ protected: protectedValue, protectionETag })] }); + if (url === "/api/v1/admin/backups/status") return jsonResponse(backupStatus); + if (url === "/api/v1/auth/csrf") return jsonResponse({ headerName: "X-CSRF-Token", token: "csrf" }); + if (url === "/api/v1/admin/backups/backup-42/protection" && init?.method === "PUT") { + expect(requestBodyObject(init)).toEqual({ protected: true }); + expect(new Headers(init.headers).get("If-Match")).toBe('"backup-v4"'); + protectedValue = true; + protectionETag = "backup-v5"; + return jsonResponse({ backupId: "backup-42", protected: true, protectionETag }); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + + expect(await screen.findByText("dagelijks om 02:00 UTC")).toBeInTheDocument(); + expect(screen.getByText("Geen hubplanning geregistreerd")).toBeInTheDocument(); + expect(screen.getByText("Verwacht uiterlijk").parentElement).toHaveTextContent(/3.*2026/); + expect(screen.getByText(/laatst volledig hersteld en geverifieerd/)).toBeInTheDocument(); + expect(screen.getByText(/Integriteit: 2.*2026/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Back-up beschermen" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/v1/admin/backups/backup-42/protection", expect.objectContaining({ method: "PUT" }))); + expect(await screen.findByText("Back-upbescherming bijgewerkt.")).toHaveAttribute("role", "status"); + expect(await screen.findByText("Beschermd")).toBeInTheDocument(); + }); +}); + +function backup(overrides: { protected?: boolean; protectionETag?: string } = {}) { + return { + backupId: "backup-42", createdAt: "2026-10-02T10:00:00Z", totalSizeBytes: 2048, tableCount: 18, + totalRowCount: 240, attachmentCount: 3, schemaVersion: "17", productVersion: "1.2.3", + protected: overrides.protected ?? false, protectionETag: overrides.protectionETag ?? "backup-v4", + integrityVerifiedAt: "2026-10-02T11:00:00Z", restoreVerifiedAt: "2026-10-02T12:00:00Z", lastRestoreSucceeded: true + }; +} + +const backupStatus = { + state: "Current", scheduleAuthority: "External", scheduleDescription: "dagelijks om 02:00 UTC", + nextScheduledRun: null, nextExpectedBy: "2026-10-03T02:00:00Z", lastSuccessfulBackupAt: "2026-10-02T10:00:00Z", + lastIntegrityVerifiedAt: "2026-10-02T11:00:00Z", lastVerifiedRestoreAt: "2026-10-02T12:00:00Z", restoreConfidence: "Verified", + storedBackupBytes: 2048, protectedCount: 0, expectedIntervalHours: 24, applicationRetentionCount: 1, + dailyRetentionExpectation: 7, weeklyRetentionExpectation: 4, monthlyRetentionExpectation: 12, restoreVerificationMaxAgeDays: 30 +}; +function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); } diff --git a/src/frontend/src/pages/admin/AdminBackupsPage.tsx b/src/frontend/src/pages/admin/AdminBackupsPage.tsx new file mode 100644 index 0000000..c599a90 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminBackupsPage.tsx @@ -0,0 +1,93 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Link } from "react-router"; +import { apiPost, apiPut, ifMatchHeader } from "../../api/client"; +import type { BackupSummary, TrashPurgeResponse } from "../../api/adminTypes"; +import { adminQueryKeys, useAdminBackups, useAdminBackupStatus } from "../../api/adminQueries"; +import type { AdminJob, BackupStatusResponse } from "../../api/platformTypes"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { ErrorState, LoadingState } from "../v2/RouteStates"; + +export default function AdminBackupsPage() { + useDocumentTitle("Back-ups en retentie"); + const client = useQueryClient(); + const backups = useAdminBackups(); + const backupStatus = useAdminBackupStatus(); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [confirmPurge, setConfirmPurge] = useState(false); + const createBackup = useMutation({ + mutationFn: () => apiPost("/api/v1/jobs/backups", {}, { "Idempotency-Key": createIdempotencyKey() }), + onSuccess: async (job) => { setMessage(`Back-uptaak ${job.id} is ingepland.`); setError(null); await client.invalidateQueries({ queryKey: adminQueryKeys.jobs }); }, + onError: () => { setError("Back-uptaak inplannen is mislukt."); setMessage(null); } + }); + const protectBackup = useMutation({ + mutationFn: ({ backup, protected: shouldProtect }: { backup: BackupSummary; protected: boolean }) => apiPut(`/api/v1/admin/backups/${encodeURIComponent(backup.backupId)}/protection`, { protected: shouldProtect }, ifMatchHeader(backup.protectionETag)), + onSuccess: async () => { setMessage("Back-upbescherming bijgewerkt."); setError(null); await Promise.all([client.invalidateQueries({ queryKey: adminQueryKeys.backups }), client.invalidateQueries({ queryKey: adminQueryKeys.backupStatus })]); }, + onError: () => { setError("Back-upbescherming kon niet worden gewijzigd. Vernieuw de lijst en probeer opnieuw."); setMessage(null); } + }); + const purgeTrash = useMutation({ + mutationFn: () => apiPost("/api/v1/admin/trash/purge"), + onSuccess: (result) => { setConfirmPurge(false); setMessage(result.purgedCount === 0 ? "Geen pagina's kwamen in aanmerking voor definitieve verwijdering." : `${result.purgedCount} pagina('s) definitief verwijderd na afloop van de bewaartermijn.`); setError(null); }, + onError: () => { setError("Prullenbak legen is mislukt."); setMessage(null); } + }); + + if (backups.isPending && !backups.data) return
    ; + if (backups.isError && !backups.data) return
    void backups.refetch()} />
    ; + + return
    + createBackup.mutate()}>{createBackup.isPending ? "Taak inplannen…" : "Back-uptaak inplannen"}} /> + {message &&

    {message}

    }{error &&

    {error}

    } + {backupStatus.isError &&

    Back-upverwachtingen en herstelbewijs konden niet worden geladen; de status is onbekend.

    } + {backupStatus.data && } +
    +

    Geregistreerde kopieën

    Back-ups

    + {backups.data?.items.length === 0 ?

    Nog geen applicatieback-ups geregistreerd.

    :
    {backups.data?.items.map((backup) => protectBackup.mutate({ backup, protected: protectedValue })} />)}
    } +
    +
    +

    Opschonen

    Prullenbak

    Verwijdert definitief alleen pagina's waarvan de bewaartermijn is verstreken. Pagina's binnen hun bewaartermijn blijven ongemoeid.

    + {confirmPurge ?
    +

    Dit verwijdert definitief alleen pagina's waarvan de bewaartermijn is verstreken. Deze actie kan niet ongedaan worden gemaakt.

    + + +
    : } +
    + {message?.includes("Back-uptaak") &&

    Volg de taak op de pagina Achtergrondtaken.

    } +

    De status toont opgeslagen integriteits- en herstelbewijzen. De externe planner voert back-ups uit; de hub voert geen restore uit via de webinterface.

    +
    ; +} + +function BackupRow({ backup, pending, onProtectionChange }: { backup: BackupSummary; pending: boolean; onProtectionChange: (protectedValue: boolean) => void }) { + return
    {backup.backupId}{formatDate(backup.createdAt)} · {formatBytes(backup.totalSizeBytes)}{backup.totalRowCount} rijen · {backup.attachmentCount} bijlagen · schema {backup.schemaVersion} · {backup.productVersion}Integriteit: {formatDate(backup.integrityVerifiedAt)} · hersteltest: {formatDate(backup.restoreVerifiedAt)} · resultaat: {backup.lastRestoreSucceeded === null ? "onbekend" : backup.lastRestoreSucceeded ? "geslaagd" : "mislukt"}
    {backup.protected ? "Beschermd" : "Retentie toegestaan"}
    ; +} + +function formatDate(value: string | null) { return value ? new Intl.DateTimeFormat("nl-BE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Geen bewijs geregistreerd"; } +function formatBytes(value: number) { if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; return `${(value / (1024 * 1024)).toFixed(1)} MB`; } +function createIdempotencyKey() { return globalThis.crypto?.randomUUID?.() ?? `backup-${Date.now()}-${Math.random().toString(16).slice(2)}`; } + +function BackupStatus({ status }: { status: BackupStatusResponse }) { + return
    +

    Back-upstatus en herstelvertrouwen

    +

    Status: {backupStateLabel(status.state)} · herstelvertrouwen: {restoreConfidenceLabel(status.restoreConfidence)}{status.lastVerifiedRestoreAt ? ` · laatst volledig hersteld en geverifieerd ${formatDate(status.lastVerifiedRestoreAt)}` : " · geen geregistreerde volledige herstelverificatie"}

    +
    +
    Plannerbevoegdheid
    {status.scheduleAuthority === "External" ? "Extern" : status.scheduleAuthority}
    +
    Extern schema
    {status.scheduleDescription || "Niet gerapporteerd"}
    +
    Volgende door de hub geplande run
    {status.nextScheduledRun ? formatDate(status.nextScheduledRun) : "Geen hubplanning geregistreerd"}
    +
    Verwacht uiterlijk
    {status.nextExpectedBy ? formatDate(status.nextExpectedBy) : "Onbekend"}
    +
    Laatste succesvolle back-up
    {status.lastSuccessfulBackupAt ? formatDate(status.lastSuccessfulBackupAt) : "Geen geregistreerde back-up"}
    +
    Laatste integriteitsverificatie
    {status.lastIntegrityVerifiedAt ? formatDate(status.lastIntegrityVerifiedAt) : "Geen verificatie geregistreerd"}
    +
    Opslag geregistreerd
    {formatBytes(status.storedBackupBytes)} · {status.protectedCount} beschermde kopieën
    +
    Werkelijke applicatieretentie
    {status.applicationRetentionCount} back-ups, beschermde kopieën uitgezonderd
    +
    Operatorverwachting
    {status.dailyRetentionExpectation} dagelijks · {status.weeklyRetentionExpectation} wekelijks · {status.monthlyRetentionExpectation} maandelijks; deze verwachtingen worden extern beheerd.
    +
    Verwachte interval en herstelgrens
    Elke {status.expectedIntervalHours} uur · herstelverificatie maximaal {status.restoreVerificationMaxAgeDays} dagen oud
    +
    +
    ; +} + +function backupStateLabel(state: string) { + switch (state) { case "Current": return "Binnen verwachte interval"; case "Stale": return "Achterstallig"; default: return "Onbekend"; } +} +function restoreConfidenceLabel(state: string) { + switch (state) { case "Verified": return "Herstel geverifieerd"; case "Stale": return "Herstelverificatie verouderd"; default: return "Geen recente verificatie"; } +} diff --git a/src/frontend/src/pages/admin/AdminIntegrationsPage.test.tsx b/src/frontend/src/pages/admin/AdminIntegrationsPage.test.tsx new file mode 100644 index 0000000..df1fbc2 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminIntegrationsPage.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetCsrfCache } from "../../api/client"; +import { MemoryRouter } from "react-router"; +import { QueryProvider } from "../../query/QueryProvider"; +import { requestBodyObject, requestUrl } from "../../test-utils/request"; +import type { DataSourceSummary } from "../../api/platformTypes"; +import AdminIntegrationsPage from "./AdminIntegrationsPage"; + +describe("AdminIntegrationsPage", () => { + afterEach(() => { resetCsrfCache(); vi.unstubAllGlobals(); }); + + it("registers a source, updates it with its ETag, and records an honest operator observation", async () => { + let source: DataSourceSummary | null = null; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + if (url === "/api/v1/admin/data-sources?limit=50" && !init?.method) { + return jsonResponse({ items: source ? [source] : [], nextCursor: null }); + } + if (url === "/api/v1/auth/csrf") return jsonResponse({ headerName: "X-CSRF-Token", token: "csrf" }); + if (url === "/api/v1/admin/data-sources" && init?.method === "POST") { + expect(requestBodyObject(init)).toEqual({ + sourceKey: "gitea-primary", + displayName: "Gitea primair", + kind: "Gitea", + expectedIntervalHours: 24, + }); + source = dataSource(); + return jsonResponse(source, 201); + } + if (url === "/api/v1/admin/data-sources/source-1" && init?.method === "PUT") { + expect(new Headers(init.headers).get("If-Match")).toBe('"source-v1"'); + expect(requestBodyObject(init)).toEqual({ displayName: "Gitea productie", enabled: true, expectedIntervalHours: 12 }); + source = { ...source!, displayName: "Gitea productie", expectedIntervalHours: 12, eTag: "source-v2" }; + return jsonResponse(source); + } + if (url === "/api/v1/admin/data-sources/source-1/observations" && init?.method === "POST") { + expect(new Headers(init.headers).get("If-Match")).toBe('"source-v2"'); + expect(requestBodyObject(init)).toEqual({ success: false, recordCount: 18, errorCode: "validation", correlationId: "sync-run-18" }); + source = { ...source!, state: "Failed", configurationState: "Reported", recordCount: 18, lastErrorCode: "validation", lastCorrelationId: "sync-run-18", eTag: "source-v3" }; + return jsonResponse(source); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + await user.click(await screen.findByRole("button", { name: "Bron toevoegen" })); + await user.type(screen.getByLabelText("Unieke broncode"), "gitea-primary"); + await user.type(screen.getByLabelText("Weergavenaam"), "Gitea primair"); + await user.selectOptions(screen.getByLabelText("Brontype"), "Gitea"); + await user.click(screen.getByRole("button", { name: "Bron registreren" })); + + expect(await screen.findByText("Externe gegevensbron geregistreerd.")).toHaveAttribute("role", "status"); + const row = screen.getByText("Gitea primair").closest("article"); + expect(row).not.toBeNull(); + await user.click(within(row!).getByText("Configuratie aanpassen")); + await user.clear(within(row!).getByLabelText("Weergavenaam")); + await user.type(within(row!).getByLabelText("Weergavenaam"), "Gitea productie"); + await user.clear(within(row!).getByLabelText("Verwachte interval (uur)")); + await user.type(within(row!).getByLabelText("Verwachte interval (uur)"), "12"); + await user.click(within(row!).getByRole("button", { name: "Configuratie opslaan" })); + + expect(await screen.findByText("Configuratie van Gitea primair opgeslagen.")).toHaveAttribute("role", "status"); + expect(await screen.findByText("Gitea productie")).toBeInTheDocument(); + await user.click(within(row!).getByText("Operatorwaarneming registreren")); + await user.click(within(row!).getByRole("checkbox", { name: "Externe run is geslaagd" })); + await user.selectOptions(within(row!).getByLabelText("Foutcategorie"), "validation"); + await user.type(within(row!).getByLabelText("Waargenomen recordaantal"), "18"); + await user.type(within(row!).getByLabelText("Correlatie-ID (optioneel)"), "sync-run-18"); + await user.click(within(row!).getByRole("button", { name: "Waarneming opslaan" })); + + expect(await screen.findByText("Operatorwaarneming voor Gitea productie geregistreerd.")).toHaveAttribute("role", "status"); + expect(await screen.findByText(/Gerapporteerde status: fout gemeld/)).toBeInTheDocument(); + expect(screen.getByText(/geen automatische controle of verbindingsproef/)).toBeInTheDocument(); + }); +}); + +function dataSource(): DataSourceSummary { + return { + id: "source-1", + sourceKey: "gitea-primary", + displayName: "Gitea primair", + kind: "Gitea", + enabled: true, + state: "Unknown", + configurationState: "Unknown", + syncAuthority: "External", + expectedIntervalHours: 24, + lastAttemptAt: null, + lastSuccessAt: null, + lastFailureAt: null, + dataObservedAt: null, + recordCount: null, + lastErrorCode: null, + lastCorrelationId: null, + eTag: "source-v1", + actions: ["Configure", "ReportObservation"], + }; +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} diff --git a/src/frontend/src/pages/admin/AdminIntegrationsPage.tsx b/src/frontend/src/pages/admin/AdminIntegrationsPage.tsx new file mode 100644 index 0000000..803d054 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminIntegrationsPage.tsx @@ -0,0 +1,166 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState, type FormEvent } from "react"; +import { apiPost, apiPut, ifMatchHeader } from "../../api/client"; +import { adminQueryKeys, useDataSources } from "../../api/adminQueries"; +import type { DataSourceErrorCode, DataSourceKind, DataSourceSummary, RegisterDataSourceRequest, ReportDataSourceRequest } from "../../api/platformTypes"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { ErrorState, LoadingState } from "../v2/RouteStates"; + +const registerableKinds: Array<{ value: DataSourceKind; label: string }> = [ + { value: "Gitea", label: "Gitea" }, + { value: "KnowledgeClient", label: "Kennisclient" }, + { value: "ManualIntake", label: "Handmatige kennisintake" }, + { value: "LeafWiki", label: "LeafWiki" }, + { value: "CuratedPackage", label: "Gecureerd pakket" } +]; +const errorCodes: Array<{ value: DataSourceErrorCode; label: string }> = [ + { value: "authentication", label: "Authenticatie" }, + { value: "connectivity", label: "Verbinding" }, + { value: "validation", label: "Validatie" }, + { value: "unexpected", label: "Onverwacht" } +]; + +export default function AdminIntegrationsPage() { + useDocumentTitle("Integraties"); + const client = useQueryClient(); + const [cursors, setCursors] = useState>([null]); + const cursor = cursors.at(-1) ?? null; + const sources = useDataSources(cursor); + const [registerOpen, setRegisterOpen] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const registerMutation = useMutation({ + mutationFn: (body: RegisterDataSourceRequest) => apiPost("/api/v1/admin/data-sources", body), + onSuccess: async () => { + setRegisterOpen(false); + setMessage("Externe gegevensbron geregistreerd."); + setError(null); + setCursors([null]); + await client.invalidateQueries({ queryKey: adminQueryKeys.sources }); + }, + onError: () => { setError("De bron kon niet worden geregistreerd."); setMessage(null); } + }); + + if (sources.isPending && !sources.data) return
    ; + if (sources.isError && !sources.data) return
    void sources.refetch()} />
    ; + + return
    + setRegisterOpen((open) => !open)}>{registerOpen ? "Bron toevoegen sluiten" : "Bron toevoegen"}} /> +
    Uitvoering is extern. Synchronisatie gebeurt buiten de hub. Een waarneming wordt door een beheerder gerapporteerd; de hub voert geen verbindingstest of bronprobe uit.
    + {message &&

    {message}

    } + {error &&

    {error}

    } + {registerOpen && { setError(null); registerMutation.mutate(body); }} />} + {sources.data?.items.length === 0 ?

    Geen gegevensbronnen geregistreerd

    De actuele status van externe synchronisatie is onbekend.

    :
    + {sources.data?.items.map((source) => void client.invalidateQueries({ queryKey: adminQueryKeys.sources })} onError={(text) => { setError(text); setMessage(null); }} onMessage={(text) => { setMessage(text); setError(null); }} />)} +
    } +
    + + Pagina {cursors.length} · maximaal 50 + +
    +
    ; +} + +function RegisterDataSourceForm({ pending, onSubmit }: { pending: boolean; onSubmit: (body: RegisterDataSourceRequest) => void }) { + return
    ) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSubmit({ sourceKey: formString(form, "sourceKey").trim(), displayName: formString(form, "displayName").trim(), kind: formString(form, "kind") as DataSourceKind, expectedIntervalHours: Number(formString(form, "expectedIntervalHours")) }); + }}> +

    Externe bron registreren

    +
    +
    +
    Inventarisbronnen worden automatisch geregistreerd bij een succesvolle inventarissynchronisatie.
    +
    + +
    ; +} + +function formString(form: FormData, name: string) { + const value = form.get(name); + return typeof value === "string" ? value : ""; +} + +function DataSourceRow({ source, onChanged, onError, onMessage }: { source: DataSourceSummary; onChanged: () => void; onError: (message: string) => void; onMessage: (message: string) => void }) { + const [displayName, setDisplayName] = useState(source.displayName); + const [enabled, setEnabled] = useState(source.enabled); + const [interval, setInterval] = useState(source.expectedIntervalHours); + const [recordCount, setRecordCount] = useState(source.recordCount?.toString() ?? ""); + const [observationSuccess, setObservationSuccess] = useState(true); + const [errorCode, setErrorCode] = useState("connectivity"); + const [correlationId, setCorrelationId] = useState(""); + + const configure = useMutation({ + mutationFn: () => apiPut(`/api/v1/admin/data-sources/${source.id}`, { displayName, enabled, expectedIntervalHours: interval }, ifMatchHeader(source.eTag)), + onSuccess: () => { onMessage(`Configuratie van ${source.displayName} opgeslagen.`); onChanged(); }, + onError: () => onError(`Configuratie van ${source.displayName} kon niet worden opgeslagen. De serverversie kan gewijzigd zijn; vernieuw de lijst.`) + }); + const observe = useMutation({ + mutationFn: (body: ReportDataSourceRequest) => apiPost(`/api/v1/admin/data-sources/${source.id}/observations`, body, ifMatchHeader(source.eTag)), + onSuccess: () => { onMessage(`Operatorwaarneming voor ${source.displayName} geregistreerd.`); onChanged(); }, + onError: () => onError(`Waarneming voor ${source.displayName} kon niet worden geregistreerd. Vernieuw de lijst en controleer de huidige versie.`) + }); + + function submitObservation(event: FormEvent) { + event.preventDefault(); + const count = recordCount.trim() === "" ? null : Number(recordCount); + observe.mutate({ success: observationSuccess, recordCount: count !== null && Number.isFinite(count) ? count : null, errorCode: observationSuccess ? null : errorCode, correlationId: correlationId.trim() || null }); + } + + return
    +
    + {source.displayName} + {source.sourceKey} · {source.kind} + Gerapporteerde status: {stateLabel(source.state)} · bevoegdheid: {source.syncAuthority === "External" ? "extern" : source.syncAuthority} + Configuratie: {configurationLabel(source.configurationState)} + Laatste poging: {formatDate(source.lastAttemptAt)} · laatste geslaagde poging: {formatDate(source.lastSuccessAt)} + Gegevens laatst waargenomen: {formatDate(source.dataObservedAt)}{source.recordCount === null ? "" : ` · ${source.recordCount} records`} + {source.lastErrorCode && Laatste foutcode: {source.lastErrorCode}{source.lastCorrelationId ? ` · correlatie ${source.lastCorrelationId}` : ""}} +
    +
    + Configuratie aanpassen +
    { event.preventDefault(); configure.mutate(); }}> +
    setDisplayName(event.target.value)} />
    +
    setInterval(Number(event.target.value))} />
    + + +
    +
    +
    + Operatorwaarneming registreren +

    Dit is een handmatige rapportage, geen automatische controle of verbindingsproef.

    +
    + + {!observationSuccess &&
    } +
    setRecordCount(event.target.value)} />Laat leeg als het aantal niet bekend is.
    +
    setCorrelationId(event.target.value)} />
    + +
    +
    +
    ; +} + +function stateLabel(state: string) { + switch (state) { + case "Healthy": return "geslaagd gemeld"; + case "Degraded": return "gedeeltelijk gemeld"; + case "Failed": return "fout gemeld"; + case "Unknown": return "onbekend"; + default: return state; + } +} + +function configurationLabel(state: string) { + switch (state) { + case "Unknown": return "geen poging geregistreerd"; + case "NeedsAuthorization": return "autorisatie vereist"; + case "Reported": return "waarneming gerapporteerd"; + default: return state; + } +} + +function formatDate(value: string | null) { + return value ? new Intl.DateTimeFormat("nl-BE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "geen waarneming"; +} diff --git a/src/frontend/src/pages/admin/AdminInventoryPage.tsx b/src/frontend/src/pages/admin/AdminInventoryPage.tsx new file mode 100644 index 0000000..47f0212 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminInventoryPage.tsx @@ -0,0 +1,61 @@ +import { useState, type FormEvent } from "react"; +import { apiPost } from "../../api/client"; +import type { ImportCuratedInventoryResponse } from "../../api/infrastructureTypes"; +import { InventoryReviewPanel } from "../../components/InventoryReviewPanel"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; + +export default function AdminInventoryPage() { + useDocumentTitle("Inventarisbeheer"); + const [file, setFile] = useState(null); + const [importing, setImporting] = useState(false); + const [refreshVersion, setRefreshVersion] = useState(0); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + async function importPackage() { + if (!file) return; + setImporting(true); + setMessage(null); + setError(null); + try { + const parsed: unknown = JSON.parse(await readTextFile(file)); + if (typeof parsed !== "object" || parsed === null || !("inventory" in parsed) || !("services" in parsed)) throw new Error("Het bestand is geen geldig gecureerd inventarispakket."); + const inventory = (parsed as { inventory?: unknown }).inventory; + const hash = typeof inventory === "object" && inventory !== null && "sourceHash" in inventory && typeof (inventory as { sourceHash?: unknown }).sourceHash === "string" ? (inventory as { sourceHash: string }).sourceHash : ""; + const result = await apiPost("/api/v1/infrastructure/inventory/curated-package", parsed, hash ? { "Idempotency-Key": `curated-inventory-${hash}` } : undefined); + setMessage(`Inventaris verwerkt: ${result.inventory.addedServices} nieuwe services, ${result.createdEditorialProfiles} profielen en ${result.confirmedServices} bevestigingen.`); + setFile(null); + setRefreshVersion((version) => version + 1); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "De gecureerde inventaris kon niet worden verwerkt."); + } finally { + setImporting(false); + } + } + + function submit(event: FormEvent) { event.preventDefault(); void importPackage(); } + + return
    + + {message &&

    {message}

    }{error &&

    {error}

    } +
    +

    Gestructureerde infrastructuur

    Gecureerd inventarispakket

    Alleen secret-vrije JSON uit de gecontroleerde generator. Bestaande menselijke tekst en overrides blijven behouden.

    +
    + + setFile(event.target.files?.[0] ?? null)} /> + +
    +
    + +
    ; +} + +function readTextFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : ""); + reader.onerror = () => reject(reader.error ?? new Error("Het bestand kon niet worden gelezen.")); + reader.readAsText(file); + }); +} diff --git a/src/frontend/src/pages/admin/AdminJobsPage.test.tsx b/src/frontend/src/pages/admin/AdminJobsPage.test.tsx new file mode 100644 index 0000000..fb4fd7e --- /dev/null +++ b/src/frontend/src/pages/admin/AdminJobsPage.test.tsx @@ -0,0 +1,165 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetCsrfCache } from "../../api/client"; +import { QueryProvider } from "../../query/QueryProvider"; +import { requestBodyObject, requestUrl } from "../../test-utils/request"; +import { MemoryRouter, Route, Routes } from "react-router"; +import AdminJobsPage from "./AdminJobsPage"; +import { CourseTaggingJobForm } from "./CourseTaggingJobForm"; + +describe("admin background jobs", () => { + afterEach(() => { resetCsrfCache(); vi.unstubAllGlobals(); }); + + it("submits course tagging as one idempotent server job and navigates to its status", async () => { + const created = job({ id: "job-course-1", type: "course-tagging" }); + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + if (url === "/api/v1/auth/csrf") return jsonResponse({ headerName: "X-CSRF-Token", token: "csrf" }); + if (url === "/api/v1/jobs/course-tagging" && init?.method === "POST") { + expect(requestBodyObject(init)).toEqual({ rootPageId: "b0c201a1-f077-49aa-a7ef-19a5d052193c", tag: "netwerk" }); + expect(new Headers(init.headers).get("Idempotency-Key")).toBeTruthy(); + return jsonResponse(created, 202); + } + if (url === "/api/v1/jobs?limit=50") return jsonResponse({ items: [created] }); + if (url === "/api/v1/jobs/job-course-1") return jsonResponse(created); + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render( + } /> + } /> + ); + + await user.type(screen.getByLabelText("ID van de hoofdpagina"), "b0c201a1-f077-49aa-a7ef-19a5d052193c"); + await user.type(screen.getByLabelText("Cursustag"), " Netwerk "); + await user.click(screen.getByRole("checkbox", { name: /volledige paginastructuur/ })); + await user.click(screen.getByRole("button", { name: "Cursustagtaak inplannen" })); + + expect(await screen.findByRole("heading", { name: "Cursustag · In wachtrij" })).toBeInTheDocument(); + expect(screen.getByText("0 / 4")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith("/api/v1/jobs/course-tagging", expect.objectContaining({ method: "POST" })); + }); + + it("loads a server job and sends a cancel request without claiming completion", async () => { + const queued = job({ id: "job-queued" }); + const cancelRequested = job({ id: "job-queued", cancelRequested: true }); + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + if (url === "/api/v1/jobs?limit=50") return jsonResponse({ items: [queued] }); + if (url === "/api/v1/jobs/job-queued") return jsonResponse(queued); + if (url === "/api/v1/auth/csrf") return jsonResponse({ headerName: "X-CSRF-Token", token: "csrf" }); + if (url === "/api/v1/jobs/job-queued/cancel" && init?.method === "POST") return jsonResponse(cancelRequested); + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + + expect(await screen.findByRole("heading", { name: "Back-up · In wachtrij" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Taak annuleren" })); + expect(await screen.findByText("Annulering aangevraagd; de taak stopt zodra dit veilig kan.")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith("/api/v1/jobs/job-queued/cancel", expect.objectContaining({ method: "POST" })); + expect(screen.getByRole("heading", { name: "Back-up · In wachtrij" })).toBeInTheDocument(); + }); + + it("applies valid SSE progress and terminal snapshots to the visible job", async () => { + const queued = job({ id: "job-stream" }); + const running = { ...queued, status: "running" as const, progress: 50, processed: 2, succeeded: 2, startedAt: "2026-10-02T10:01:00Z" }; + const succeeded = { ...running, status: "succeeded" as const, progress: 100, processed: 4, succeeded: 4, completedAt: "2026-10-02T10:02:00Z" }; + const eventSources = createEventSourceStub(); + vi.stubGlobal("EventSource", eventSources.MockEventSource); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url === "/api/v1/jobs?limit=50") return jsonResponse({ items: [queued] }); + if (url === "/api/v1/jobs/job-stream") return jsonResponse(queued); + throw new Error(`Unexpected request: ${url}`); + })); + + render(); + + expect(await screen.findByRole("heading", { name: "Back-up · In wachtrij" })).toBeInTheDocument(); + await waitFor(() => expect(eventSources.instances).toHaveLength(1)); + const source = eventSources.instances[0]!; + expect(source.url).toBe("/api/v1/jobs/job-stream/events"); + source.open(); + expect(await screen.findByText("Realtime taakupdates ontvangen.")).toBeInTheDocument(); + + source.dispatch("progress", running); + expect(await screen.findByRole("heading", { name: "Back-up · Bezig" })).toBeInTheDocument(); + expect(screen.getAllByText("50%").length).toBeGreaterThan(0); + expect(eventSources.instances).toHaveLength(1); + + source.dispatch("terminal", succeeded); + expect(await screen.findByRole("heading", { name: "Back-up · Geslaagd" })).toBeInTheDocument(); + await waitFor(() => expect(source.close).toHaveBeenCalled()); + }); + + it("falls back after an SSE error and continues polling the persisted job", async () => { + const queued = job({ id: "job-fallback" }); + const running = { ...queued, status: "running" as const, progress: 25, processed: 1, succeeded: 1, startedAt: "2026-10-02T10:01:00Z" }; + const eventSources = createEventSourceStub(); + let detailReads = 0; + vi.stubGlobal("EventSource", eventSources.MockEventSource); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url === "/api/v1/jobs?limit=50") return jsonResponse({ items: [queued] }); + if (url === "/api/v1/jobs/job-fallback") { + detailReads += 1; + return jsonResponse(detailReads > 1 ? running : queued); + } + throw new Error(`Unexpected request: ${url}`); + })); + + render(); + + expect(await screen.findByRole("heading", { name: "Back-up · In wachtrij" })).toBeInTheDocument(); + await waitFor(() => expect(eventSources.instances.length).toBeGreaterThan(0)); + eventSources.instances[0]!.fail(); + expect(await screen.findByText("Realtime taakupdates niet beschikbaar; status wordt periodiek opgehaald.")).toBeInTheDocument(); + + await waitFor(() => expect(detailReads).toBeGreaterThan(1), { timeout: 5_000, interval: 100 }); + expect(await screen.findByRole("heading", { name: "Back-up · Bezig" })).toBeInTheDocument(); + expect(screen.getAllByText("25%").length).toBeGreaterThan(0); + }, 10_000); +}); + +function job(overrides: Partial<{ + id: string; type: "course-tagging" | "backup"; status: "queued" | "running" | "succeeded" | "partially_succeeded" | "failed" | "cancelled"; cancelRequested: boolean; +}> = {}) { + const type = overrides.type ?? "backup"; + return { id: overrides.id ?? "job-1", type, status: overrides.status ?? "queued", actorId: "user-1", createdAt: "2026-10-02T10:00:00Z", startedAt: null, completedAt: null, progress: 0, total: 4, processed: 0, succeeded: 0, skipped: 0, failed: 0, errors: [], result: null, correlationId: null, cancelRequested: overrides.cancelRequested ?? false }; +} +function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } + +function createEventSourceStub() { + class MockEventSource { + readonly url: string; + readonly listeners = new Map void>>(); + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + readonly close = vi.fn(); + + constructor(url: string) { + this.url = url; + instances.push(this); + } + + addEventListener(type: string, listener: (event: Event) => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + open() { this.onopen?.(new Event("open")); } + fail() { this.onerror?.(new Event("error")); } + dispatch(type: string, data: unknown) { + const event = new MessageEvent(type, { data: JSON.stringify(data) }); + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + } + + const instances: MockEventSource[] = []; + return { MockEventSource, instances }; +} diff --git a/src/frontend/src/pages/admin/AdminJobsPage.tsx b/src/frontend/src/pages/admin/AdminJobsPage.tsx new file mode 100644 index 0000000..c92e4d8 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminJobsPage.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link, useSearchParams } from "react-router"; +import { apiPost } from "../../api/client"; +import { adminQueryKeys, isTerminalJob, useAdminJob, useAdminJobs } from "../../api/adminQueries"; +import type { AdminJob, AdminJobStatus } from "../../api/platformTypes"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { ErrorState, LoadingState } from "../v2/RouteStates"; + +export default function AdminJobsPage() { + useDocumentTitle("Achtergrondtaken"); + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedId = searchParams.get("jobId"); + const jobs = useAdminJobs(); + const selected = useAdminJob(selectedId); + const selectedStatus = selected.data?.status; + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const [streamStatus, setStreamStatus] = useState<{ jobId: string; state: "connected" | "polling" } | null>(null); + const eventSourceRef = useRef(null); + const createBackup = useMutation({ + mutationFn: () => apiPost("/api/v1/jobs/backups", {}, { "Idempotency-Key": createIdempotencyKey() }), + onSuccess: async (job) => { + setNotice("Back-uptaak is ingepland."); + setError(null); + await queryClient.invalidateQueries({ queryKey: adminQueryKeys.jobs }); + setSearchParams({ jobId: job.id }); + }, + onError: () => { setError("De back-uptaak kon niet worden ingepland."); setNotice(null); } + }); + const cancelJob = useMutation({ + mutationFn: (id: string) => apiPost(`/api/v1/jobs/${encodeURIComponent(id)}/cancel`), + onSuccess: (job) => { + applyJobSnapshot(queryClient, job); + setNotice(job.cancelRequested ? "Annulering aangevraagd; de taak stopt zodra dit veilig kan." : "Taak bijgewerkt."); + setError(null); + }, + onError: () => { setError("De taak kon niet worden geannuleerd. Vernieuw de status en probeer opnieuw."); setNotice(null); } + }); + + useEffect(() => { + if (!selectedId || typeof EventSource === "undefined") return; + let active = true; + const source = new EventSource(`/api/v1/jobs/${encodeURIComponent(selectedId)}/events`); + eventSourceRef.current = source; + const receive = (event: Event) => { + try { + const job = parseJobSnapshot((event as MessageEvent).data); + if (job?.id === selectedId) { + applyJobSnapshot(queryClient, job); + if (isTerminalJob(job.status)) { + source.close(); + if (eventSourceRef.current === source) eventSourceRef.current = null; + if (active) setStreamStatus({ jobId: selectedId, state: "polling" }); + } + } + } catch { + // A malformed SSE event is ignored; the bounded GET poll remains active. + } + }; + source.addEventListener("job", receive); + source.addEventListener("progress", receive); + source.addEventListener("terminal", receive); + source.onopen = () => { if (active) setStreamStatus({ jobId: selectedId, state: "connected" }); }; + source.onerror = () => { + source.close(); + if (eventSourceRef.current === source) eventSourceRef.current = null; + if (active) setStreamStatus({ jobId: selectedId, state: "polling" }); + }; + return () => { + active = false; + source.close(); + if (eventSourceRef.current === source) eventSourceRef.current = null; + }; + }, [queryClient, selectedId]); + + useEffect(() => { + if (selectedId && selectedStatus && isTerminalJob(selectedStatus)) { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + } + }, [selectedId, selectedStatus]); + + if (jobs.isPending && !jobs.data) return
    ; + if (jobs.isError && !jobs.data) return
    void jobs.refetch()} />
    ; + + const orderedJobs = [...(jobs.data?.items ?? [])].sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + const jobById = selected.data ?? orderedJobs.find((job) => job.id === selectedId) ?? null; + const streamState = !selectedId || typeof EventSource === "undefined" + ? "polling" + : streamStatus?.jobId === selectedId ? streamStatus.state : "connecting"; + + return
    + createBackup.mutate()}>{createBackup.isPending ? "Back-up inplannen…" : "Back-uptaak inplannen"}} /> + {notice &&

    {notice}

    }{error &&

    {error}

    } + {selectedId && selected.isError &&

    Taakdetails zijn niet bereikbaar. Automatische polling blijft actief.

    } + {selectedId && selected.isPending && !selected.data &&

    Taakdetails laden…

    } + {jobById &&
    +

    Taakdetails

    {jobTypeLabel(jobById.type)} · {statusLabel(jobById.status)}

    Taak-ID {jobById.id}{jobById.correlationId ? ` · correlatie ${jobById.correlationId}` : ""}

    + {!isTerminalJob(jobById.status) &&

    {streamState === "connected" ? "Realtime taakupdates ontvangen." : streamState === "connecting" ? "Realtime taakverbinding wordt geopend; periodiek ophalen blijft actief." : "Realtime taakupdates niet beschikbaar; status wordt periodiek opgehaald."}

    } + +
    +
    Aangemaakt
    {formatDate(jobById.createdAt)}
    Gestart
    {formatDate(jobById.startedAt)}
    Voltooid
    {formatDate(jobById.completedAt)}
    +
    Verwerkt
    {jobById.processed} / {jobById.total}
    Bijgewerkt / aangemaakt
    {jobById.succeeded}
    Overgeslagen
    {jobById.skipped}
    Mislukt
    {jobById.failed}
    +
    + {jobById.cancelRequested && !isTerminalJob(jobById.status) &&

    Annulering is aangevraagd en wordt door de worker verwerkt.

    } + {jobById.errors.length > 0 &&

    Geregistreerde fouten

      {jobById.errors.map((item, index) =>
    • {item.code}: {item.message}{item.itemId && · item {item.itemId}}
    • )}
    } + {(jobById.status === "queued" || jobById.status === "running") && !jobById.cancelRequested && } +
    } +
    +

    Maximaal 50 meest recente taken

    Taakgeschiedenis

    + {orderedJobs.length === 0 ?

    Geen achtergrondtaken

    Ingeplande cursustags en back-ups verschijnen hier met hun serverstatus.

    Cursustagtaak inplannen
    :
    + {orderedJobs.map((job) =>
    +
    {jobTypeLabel(job.type)}{statusLabel(job.status)}{job.cancelRequested && !isTerminalJob(job.status) ? " · annulering aangevraagd" : ""} · {formatDate(job.createdAt)}{job.processed} / {job.total} verwerkt · {job.succeeded} geslaagd · {job.skipped} overgeslagen · {job.failed} fouten
    +
    {(job.status === "queued" || job.status === "running") && !job.cancelRequested && }
    +
    )} +
    } +
    +
    ; +} + +function JobProgress({ job }: { job: AdminJob }) { + const progress = Math.max(0, Math.min(100, job.progress)); + return
    +
    {isTerminalJob(job.status) ? "Eindvoortgang" : "Voortgang"}{progress}%
    + {progress}% +
    ; +} + +function applyJobSnapshot(client: ReturnType, job: AdminJob) { + client.setQueryData(adminQueryKeys.job(job.id), job); + client.setQueryData<{ items: AdminJob[] }>(adminQueryKeys.jobs, (current) => { + if (!current) return { items: [job] }; + const exists = current.items.some((item) => item.id === job.id); + return { items: (exists ? current.items.map((item) => item.id === job.id ? job : item) : [job, ...current.items]).slice(0, 50) }; + }); +} + +function parseJobSnapshot(data: string): AdminJob | null { + const value: unknown = JSON.parse(data); + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const job = value as Record; + const statuses: AdminJobStatus[] = ["queued", "running", "succeeded", "partially_succeeded", "failed", "cancelled"]; + const counters = ["progress", "total", "processed", "succeeded", "skipped", "failed"]; + if (typeof job.id !== "string" || !["backup", "course-tagging"].includes(String(job.type)) || !statuses.includes(job.status as AdminJobStatus) || + typeof job.actorId !== "string" || !["createdAt"].every((key) => typeof job[key] === "string") || + !["startedAt", "completedAt", "correlationId"].every((key) => job[key] === null || typeof job[key] === "string") || + !counters.every((key) => Number.isInteger(job[key]) && Number(job[key]) >= 0) || Number(job.progress) > 100 || + typeof job.cancelRequested !== "boolean" || !Array.isArray(job.errors) || + !job.errors.every((item) => typeof item === "object" && item !== null && typeof (item as Record).code === "string" && typeof (item as Record).message === "string")) return null; + return value as AdminJob; +} + +function jobTypeLabel(type: AdminJob["type"]) { return type === "backup" ? "Back-up" : "Cursustag"; } +function statusLabel(status: AdminJob["status"]) { + switch (status) { + case "queued": return "In wachtrij"; + case "running": return "Bezig"; + case "succeeded": return "Geslaagd"; + case "partially_succeeded": return "Gedeeltelijk geslaagd"; + case "failed": return "Mislukt"; + case "cancelled": return "Geannuleerd"; + } +} +function formatDate(value: string | null) { return value ? new Intl.DateTimeFormat("nl-BE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Nog niet"; } +function createIdempotencyKey() { return globalThis.crypto?.randomUUID?.() ?? `job-${Date.now()}-${Math.random().toString(16).slice(2)}`; } diff --git a/src/frontend/src/pages/admin/AdminKnowledgePage.tsx b/src/frontend/src/pages/admin/AdminKnowledgePage.tsx new file mode 100644 index 0000000..56dc2d3 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminKnowledgePage.tsx @@ -0,0 +1,13 @@ +import { LeafWikiImportPanel } from "../../components/LeafWikiImportPanel"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { CourseTaggingJobForm } from "./CourseTaggingJobForm"; + +export default function AdminKnowledgePage() { + useDocumentTitle("Kennisimport"); + return
    + + + +
    ; +} diff --git a/src/frontend/src/pages/admin/AdminOverviewPage.tsx b/src/frontend/src/pages/admin/AdminOverviewPage.tsx new file mode 100644 index 0000000..096519a --- /dev/null +++ b/src/frontend/src/pages/admin/AdminOverviewPage.tsx @@ -0,0 +1,105 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { Activity, ArchiveRestore, CircleHelp, DatabaseBackup, Server, Wrench } from "lucide-react"; +import { Link, useOutletContext } from "react-router"; +import { apiGet } from "../../api/client"; +import { adminQueryKeys, useAdminBackups, useDataSources, usePlatformSettings, useSystemInfo } from "../../api/adminQueries"; +import type { PaginatedIngestionProposals } from "../../api/intakeTypes"; +import { useInfrastructureOverview } from "../../api/domainQueries"; +import type { SessionResponse } from "../../api/types"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { LoadingState } from "../v2/RouteStates"; + +export default function AdminOverviewPage() { + useDocumentTitle("Beheer"); + const [currentTime, setCurrentTime] = useState(0); + const { session } = useOutletContext<{ session: SessionResponse }>(); + const system = useSystemInfo(); + const backups = useAdminBackups(); + const inventory = useInfrastructureOverview(); + const dataSources = useDataSources(); + const platform = usePlatformSettings(); + const intake = useQuery({ + queryKey: [...adminQueryKeys.all, "intake-overview"], + queryFn: ({ signal }) => apiGet("/api/v1/ingestion/proposals?pageSize=50", signal), + enabled: session.features?.ingestionProposals === true + }); + + useEffect(() => { + const updateTime = () => setCurrentTime(Date.now()); + const initialUpdate = window.setTimeout(updateTime, 0); + const interval = window.setInterval(updateTime, 60_000); + return () => { window.clearTimeout(initialUpdate); window.clearInterval(interval); }; + }, []); + + const anyLoading = system.isPending || backups.isPending || inventory.isPending || dataSources.isPending || platform.isPending || (session.features?.ingestionProposals === true && intake.isPending); + if (anyLoading && !system.data && !backups.data && !inventory.data && !dataSources.data) { + return
    ; + } + + const inventorySnapshot = inventory.data?.latestSnapshot ?? null; + const freshnessHours = platform.data?.inventoryFreshnessPolicy.warningAfterHours; + const observedAt = inventorySnapshot?.capturedAt ? new Date(inventorySnapshot.capturedAt) : null; + const inventoryIsStale = observedAt && freshnessHours !== undefined && currentTime > 0 && currentTime - observedAt.getTime() > freshnessHours * 60 * 60 * 1000; + const latestBackup = backups.data?.items[0] ?? null; + const pendingIntake = intake.data?.items.filter((item) => ["Received", "Analyzing", "ReadyForReview", "ChangesRequested", "Failed"].includes(item.status)).length; + + return
    + +
    + + {system.data ? <> + {system.data.product} · {system.data.version} + Omgeving: {system.data.environment} · schema {system.data.schemaVersion} + Commit: {system.data.commit} + : null} + + + {backups.data && (latestBackup + ? <>Laatste back-up: {formatDate(latestBackup.createdAt)}{latestBackup.totalRowCount} rijen · {latestBackup.attachmentCount} bijlagenHet schema registreert geen externe plannerstatus. + : <>Geen geregistreerde back-upEr is nog geen applicatieback-up in de administratie.)} + + + {inventory.data && (inventorySnapshot + ? <>{inventoryIsStale ? "Waarschuwing: snapshot is ouder dan de ingestelde grens" : "Laatste waarneming geregistreerd"}{formatDate(inventorySnapshot.capturedAt)} · {inventorySnapshot.serviceCount} services{freshnessHours === undefined ? "Freshness-grens onbekend." : `Waarschuwingsgrens: ${freshnessHours} uur.`} + : <>OnbekendEr is nog geen inventarissnapshot ontvangen; nulwaarden betekenen geen bewijs.)} + + + {dataSources.data && (dataSources.data.items.length === 0 + ? <>Geen bronwaarnemingen geregistreerdDe actuele toestand is onbekend. + : <>{dataSources.data.items.length} bron(nen) in de eerste pagina{dataSources.data.items.filter((source) => source.state === "Unknown").length} met onbekende statusDe connector loopt buiten de hub; waarnemingen kunnen handmatig zijn gemeld.)} + + {session.features?.ingestionProposals && + {intake.data && <>{pendingIntake ?? 0} voorstel(len) die aandacht kunnen vragen{intake.data.total} voorstel(len) in de wachtrijgegevens.De status is afkomstig van de intake-API.} + } +
    +
    +

    Platformbeleid, ingestellimieten en freshnessverwachtingen.

    + Platforminstellingen openen +
    +
    +

    Elk signaal wordt apart geladen. Een fout of ontbrekende waarneming wordt niet als gezonde status weergegeven.

    +
    ; +} + +function OverviewCard({ icon: Icon, title, href, loading, error, children }: { + icon: typeof Server; + title: string; + href: string; + loading: boolean; + error: boolean; + children: React.ReactNode; +}) { + return
    +
    + {loading && !children &&

    Gegevens laden…

    } + {error &&

    Deze gegevensbron is niet bereikbaar; status onbekend.

    } + {!error && children} + Details bekijken +
    ; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("nl-BE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); +} diff --git a/src/frontend/src/pages/admin/AdminPlatformPage.tsx b/src/frontend/src/pages/admin/AdminPlatformPage.tsx new file mode 100644 index 0000000..6185b6a --- /dev/null +++ b/src/frontend/src/pages/admin/AdminPlatformPage.tsx @@ -0,0 +1,134 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { ApiError, apiPut, ifMatchHeader } from "../../api/client"; +import { adminQueryKeys, usePlatformSettings } from "../../api/adminQueries"; +import type { PlatformSettingsRequest, PlatformSettingsResponse } from "../../api/platformTypes"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { ErrorState, LoadingState } from "../v2/RouteStates"; + +export default function AdminPlatformPage() { + useDocumentTitle("Platforminstellingen"); + const queryClient = useQueryClient(); + const settings = usePlatformSettings(); + const [draft, setDraft] = useState(null); + const [dirty, setDirty] = useState(false); + const [conflict, setConflict] = useState(false); + const [saved, setSaved] = useState(false); + const current = settings.data; + const draftValue = draft ?? (current ? toRequest(current) : null); + + const saveMutation = useMutation({ + mutationFn: ({ eTag, body }: { eTag: string; body: PlatformSettingsRequest }) => + apiPut("/api/v1/admin/platform-settings", body, ifMatchHeader(eTag)), + onSuccess: (response) => { + queryClient.setQueryData(adminQueryKeys.platformSettings, response); + setDraft(toRequest(response)); + setDirty(false); + setConflict(false); + setSaved(true); + }, + onError: async (error) => { + setSaved(false); + if (error instanceof ApiError && error.status === 412) { + setConflict(true); + await queryClient.invalidateQueries({ queryKey: adminQueryKeys.platformSettings }); + } + } + }); + + if (settings.isPending && !settings.data) return
    ; + if (settings.isError && !settings.data) return
    void settings.refetch()} />
    ; + + if (!draftValue || !current) return
    void settings.refetch()} />
    ; + const editableDraft = draftValue; + + function update(key: K, value: PlatformSettingsRequest[K]) { + setDraft((old) => ({ ...(old ?? editableDraft), [key]: value })); + setDirty(true); + setSaved(false); + } + function updateIngestion(key: K, value: PlatformSettingsRequest["ingestionProposals"][K]) { + setDraft((old) => { + const base = old ?? editableDraft; + return { ...base, ingestionProposals: { ...base.ingestionProposals, [key]: value } }; + }); + setDirty(true); + setSaved(false); + } + function updateBackup(key: K, value: PlatformSettingsRequest["backupPolicyExpectation"][K]) { + setDraft((old) => { + const base = old ?? editableDraft; + return { ...base, backupPolicyExpectation: { ...base.backupPolicyExpectation, [key]: value } }; + }); + setDirty(true); + setSaved(false); + } + + return
    + + {current.isDefault &&

    Er is nog geen opgeslagen beheerbeleid. De waarden hieronder zijn de effectieve standaardinstellingen.

    } + {saved &&

    Platforminstellingen opgeslagen.

    } + {conflict &&
    +

    Platforminstellingen zijn elders gewijzigd. Je bewerkingen zijn bewaard.

    + + +
    } + {saveMutation.isError && !conflict &&

    Instellingen konden niet worden opgeslagen. Je bewerkingen blijven staan.

    } +
    { event.preventDefault(); saveMutation.mutate({ eTag: current.eTag, body: draftValue }); }}> +
    +

    Platformidentiteit

    +
    update("displayName", event.target.value)} />
    +
    update("primaryLocale", event.target.value)} />Bijvoorbeeld nl-BE.
    +
    update("timeZone", event.target.value)} />Gebruik een IANA-identificatie zoals Europe/Brussels.
    +
    +
    +

    Kennisintake

    + + updateIngestion("maximumSourceCount", value)} /> + updateIngestion("maximumSourceBytes", value)} /> + updateIngestion("failedRetryLimit", value)} /> +
    +
    +

    Back-upverwachting

    +

    Dit legt een externe operationele verwachting vast. De hub plant of voert deze back-ups niet automatisch uit.

    + updateBackup("expectedIntervalHours", value)} /> +
    updateBackup("scheduleDescription", event.target.value)} />
    + updateBackup("dailyRetentionCount", value)} /> + updateBackup("weeklyRetentionCount", value)} /> + updateBackup("monthlyRetentionCount", value)} /> + updateBackup("protectedBackupCount", value)} /> + updateBackup("restoreVerificationMaxAgeDays", value)} /> +
    +
    +

    Inventarisfreshness

    + update("inventoryFreshnessPolicy", { warningAfterHours: value })} /> +
    + +
    +

    Beleidsversie {current.eTag}{current.updatedAt ? ` · bijgewerkt ${new Date(current.updatedAt).toLocaleString("nl-BE")}` : ""}

    +
    ; +} + +function NumberField({ id, label, value, min, max, onChange }: { id: string; label: string; value: number; min: number; max: number; onChange: (value: number) => void }) { + return
    onChange(Number(event.target.value))} />
    ; +} + +function toRequest(response: PlatformSettingsResponse): PlatformSettingsRequest { + return { + displayName: response.displayName, + primaryLocale: response.primaryLocale, + timeZone: response.timeZone, + ingestionProposals: { ...response.ingestionProposals }, + backupPolicyExpectation: { + expectedIntervalHours: response.backupPolicyExpectation.expectedIntervalHours, + scheduleDescription: response.backupPolicyExpectation.scheduleDescription, + dailyRetentionCount: response.backupPolicyExpectation.dailyRetentionCount, + weeklyRetentionCount: response.backupPolicyExpectation.weeklyRetentionCount, + monthlyRetentionCount: response.backupPolicyExpectation.monthlyRetentionCount, + protectedBackupCount: response.backupPolicyExpectation.protectedBackupCount, + restoreVerificationMaxAgeDays: response.backupPolicyExpectation.restoreVerificationMaxAgeDays + }, + inventoryFreshnessPolicy: { ...response.inventoryFreshnessPolicy } + }; +} diff --git a/src/frontend/src/pages/admin/AdminSystemPage.test.tsx b/src/frontend/src/pages/admin/AdminSystemPage.test.tsx new file mode 100644 index 0000000..2f2be6f --- /dev/null +++ b/src/frontend/src/pages/admin/AdminSystemPage.test.tsx @@ -0,0 +1,80 @@ +import { render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router"; +import { QueryProvider } from "../../query/QueryProvider"; +import { requestUrl } from "../../test-utils/request"; +import type { SystemInfoResponse } from "../../api/platformTypes"; +import AdminSystemPage from "./AdminSystemPage"; + +describe("AdminSystemPage", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("shows the reported release identity and labels missing evidence as unknown", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url === "/api/v1/system/info") return jsonResponse(systemInfo); + if (url === "/api/v1/admin/staging-cleanup/status") return jsonResponse(cleanupStatus); + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const identity = await screen.findByRole("region", { name: "Runtime-identiteit" }); + expect(within(identity).getByText("Broncommit (SHA)").parentElement).toHaveTextContent("source-commit-123"); + expect(within(identity).getByText("Image repository").parentElement).toHaveTextContent("ghcr.io/itworx/knowledge-hub"); + expect(within(identity).getByText("Image tag").parentElement).toHaveTextContent("1.2.3"); + expect(within(identity).getByText("Image ID").parentElement).toHaveTextContent("sha256:container-id"); + expect(within(identity).getByText("Image digest").parentElement).toHaveTextContent("sha256:image-digest"); + expect(within(identity).getByText("Buildtijd").parentElement).toHaveTextContent(/1 okt 2026/); + expect(within(identity).getByText("OpenAPI-hash").parentElement).toHaveTextContent("openapi-hash-123"); + expect(within(identity).getByText("Frontend-buildhash").parentElement).toHaveTextContent("Onbekend"); + expect(within(identity).getByText("Deploytijd").parentElement).toHaveTextContent("Onbekend"); + expect(within(identity).getByText("Inventarissnapshot-ID").parentElement).toHaveTextContent("Onbekend"); + expect(within(identity).getByText("Back-up-ID").parentElement).toHaveTextContent("backup-42"); + expect(within(identity).getByText("Repositoryvergelijking").parentElement).toHaveTextContent("Onbekend"); + expect(within(identity).queryByText(/ahead|behind/i)).not.toBeInTheDocument(); + }); +}); + +const systemInfo: SystemInfoResponse = { + product: "ItWorx Knowledge Hub", + version: "1.2.3", + commit: "runtime-commit-456", + schemaVersion: "20261002", + environment: "Production", + primaryLocale: "nl-BE", + timeZone: "Europe/Brussels", + uptimeSeconds: 60, + release: { + semanticVersion: "1.2.3", + releaseTag: "v1.2.3", + sourceCommit: "source-commit-123", + imageRepository: "ghcr.io/itworx/knowledge-hub", + imageTag: "1.2.3", + imageId: "sha256:container-id", + imageDigest: "sha256:image-digest", + buildTimestamp: "2026-10-01T10:00:00Z", + deploymentTimestamp: null, + openApiVersion: "1.2.3", + openApiHash: "openapi-hash-123", + frontendBuildHash: null, + databaseMigrationRange: "Initial..PlatformFoundations", + inventorySnapshotId: null, + backupId: "backup-42", + }, +}; + +const cleanupStatus = { + activeSessionCount: 0, + expiredPendingCleanupCount: 0, + runInProgress: false, + lastRunStartedAt: null, + lastRunCompletedAt: null, + lastRunTrigger: null, + lastResult: null, +}; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} diff --git a/src/frontend/src/pages/admin/AdminSystemPage.tsx b/src/frontend/src/pages/admin/AdminSystemPage.tsx new file mode 100644 index 0000000..7d28659 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminSystemPage.tsx @@ -0,0 +1,86 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { apiPost } from "../../api/client"; +import { adminQueryKeys, useStagingCleanupStatus, useSystemInfo } from "../../api/adminQueries"; +import type { RunStagingCleanupResponse } from "../../api/platformTypes"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { ErrorState, LoadingState } from "../v2/RouteStates"; + +export default function AdminSystemPage() { + useDocumentTitle("Systeembeheer"); + const client = useQueryClient(); + const info = useSystemInfo(); + const cleanup = useStagingCleanupStatus(); + const [runResult, setRunResult] = useState(null); + const runCleanup = useMutation({ + mutationFn: (dryRun: boolean) => apiPost("/api/v1/admin/staging-cleanup/run", { dryRun }), + onSuccess: async (result) => { setRunResult(result); await client.invalidateQueries({ queryKey: adminQueryKeys.stagingCleanup }); }, + onError: () => setRunResult(null) + }); + + if (info.isPending && cleanup.isPending) return
    ; + if (info.isError && cleanup.isError) return
    { void info.refetch(); void cleanup.refetch(); }} />
    ; + + return
    + + {info.isError &&

    Systeeminformatie niet bereikbaar; runtime-identiteit onbekend.

    } + {info.data &&
    +

    Runtime-identiteit

    +
    +
    Product en versie
    {info.data.product} · {info.data.version}
    +
    Omgeving
    {info.data.environment}
    +
    Commit
    {info.data.commit}
    +
    Database schema
    {info.data.schemaVersion}
    +
    Locale en tijdzone
    {info.data.primaryLocale} · {info.data.timeZone}
    +
    Semantische releaseversie
    {releaseValue(info.data.release.semanticVersion)}
    +
    Releasetag
    {releaseValue(info.data.release.releaseTag)}
    +
    Broncommit (SHA)
    {releaseValue(info.data.release.sourceCommit)}
    +
    Image repository
    {releaseValue(info.data.release.imageRepository)}
    +
    Image tag
    {releaseValue(info.data.release.imageTag)}
    +
    Image ID
    {releaseValue(info.data.release.imageId)}
    +
    Image digest
    {releaseValue(info.data.release.imageDigest)}
    +
    Buildtijd
    {releaseTimestamp(info.data.release.buildTimestamp)}
    +
    Deploytijd
    {releaseTimestamp(info.data.release.deploymentTimestamp)}
    +
    OpenAPI-versie
    {releaseValue(info.data.release.openApiVersion)}
    +
    OpenAPI-hash
    {releaseValue(info.data.release.openApiHash)}
    +
    Frontend-buildhash
    {releaseValue(info.data.release.frontendBuildHash)}
    +
    Database migraties
    {releaseValue(info.data.release.databaseMigrationRange)}
    +
    Inventarissnapshot-ID
    {releaseValue(info.data.release.inventorySnapshotId)}
    +
    Back-up-ID
    {releaseValue(info.data.release.backupId)}
    +
    Repositoryvergelijking
    Onbekend
    +
    Uptime
    {formatUptime(info.data.uptimeSeconds)}
    +
    +
    } + {cleanup.isError &&

    Staging-cleanup-status niet bereikbaar; opschoontoestand onbekend.

    } + {cleanup.data &&
    +

    Achtergrondonderhoud

    Staging-cleanup

    Deze taak ruimt alleen verlopen pagina-aanmaaksessies en hun niet-gedeelde attachmentreferenties op.

    +
    +
    Open sessies
    {cleanup.data.activeSessionCount}
    +
    Verlopen, wachtend op cleanup
    {cleanup.data.expiredPendingCleanupCount}
    +
    Cleanup actief
    {cleanup.data.runInProgress ? "Ja" : "Nee"}
    +
    Laatste start
    {formatDate(cleanup.data.lastRunStartedAt)}
    +
    Laatste voltooiing
    {formatDate(cleanup.data.lastRunCompletedAt)}
    +
    Trigger
    {cleanup.data.lastRunTrigger ?? "Nog geen geregistreerde run"}
    +
    + {cleanup.data.lastResult && } +
    + + +
    + {runCleanup.isError &&

    Cleanup kon niet worden uitgevoerd.

    } + {runResult && } +
    } +
    ; +} + +function CleanupResult({ title, result }: { title: string; result: RunStagingCleanupResponse["result"] }) { + return

    {title}

      +
    • Sessies verlopen: {result.sessionsExpired}
    • Sessies geclaimd: {result.sessionsClaimed}
    • Concurrent verloren: {result.sessionsLostRace}
    • Attachmentreferenties verwijderd: {result.attachmentReferencesRemoved}
    • Losse blobs verwijderd: {result.blobsRemoved}
    • +
    {result.warnings.length > 0 &&
    Waarschuwingen
      {result.warnings.map((warning, index) =>
    • {warning}
    • )}
    }
    ; +} + +function formatDate(value: string | null) { return value ? new Intl.DateTimeFormat("nl-BE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Nog niet"; } +function releaseValue(value: string | null) { return value ?? "Onbekend"; } +function releaseTimestamp(value: string | null) { return value ? formatDate(value) : "Onbekend"; } +function formatUptime(seconds: number) { const days = Math.floor(seconds / 86400); const hours = Math.floor(seconds % 86400 / 3600); const minutes = Math.floor(seconds % 3600 / 60); return `${days} d · ${hours} u · ${minutes} min`; } diff --git a/src/frontend/src/pages/admin/AdminTaxonomiesPage.test.tsx b/src/frontend/src/pages/admin/AdminTaxonomiesPage.test.tsx new file mode 100644 index 0000000..5469de4 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminTaxonomiesPage.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryProvider } from "../../query/QueryProvider"; +import { MemoryRouter } from "react-router"; +import AdminTaxonomiesPage from "./AdminTaxonomiesPage"; +import { requestUrl } from "../../test-utils/request"; + +describe("AdminTaxonomiesPage", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("renders classifications from the existing service and override catalogs", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url === "/api/v1/infrastructure/services?limit=100") return jsonResponse({ items: [service("Compute"), service("Compute"), service("Storage")], nextCursor: "next-page", totalCount: 103 }); + if (url.startsWith("/api/v1/infrastructure/overrides/catalog?targetKind=")) { + const target = new URL(url, "http://localhost").searchParams.get("targetKind"); + return jsonResponse({ targetKind: target, fields: target === "Service" ? [{ fieldName: "health", displayLabel: "Healthstatus", valueKind: "Enum", maxStringLength: 0, allowedEnumValues: ["Unknown", "Healthy"] }] : [] }); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect(await screen.findByText("Compute")).toBeInTheDocument(); + expect(screen.getByText("2 van 3 geladen services")).toBeInTheDocument(); + expect(screen.getByText("Healthstatus")).toBeInTheDocument(); + expect(screen.getByText("Service · Unknown, Healthy")).toBeInTheDocument(); + expect(screen.getByText(/aantallen hierboven zijn alleen voor de geladen pagina/)).toBeInTheDocument(); + }); +}); + +function service(category: string) { + return { id: `service-${category}`, sourceKey: "test", name: "test", detectedName: "test", category, summary: "", runtimeKind: "container", runtimeStatus: "running", health: "Healthy", presence: "Present", confidence: "High", imageReference: "", projectId: null, backupStatus: "Unknown", isFavorite: false, isHidden: false, componentCount: 0, endpointCount: 0, volumeCount: 0, databaseCount: 0, lastSeenAt: "2026-10-02T10:00:00Z" }; +} +function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); } diff --git a/src/frontend/src/pages/admin/AdminTaxonomiesPage.tsx b/src/frontend/src/pages/admin/AdminTaxonomiesPage.tsx new file mode 100644 index 0000000..56cffa5 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminTaxonomiesPage.tsx @@ -0,0 +1,49 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router"; +import { apiGet } from "../../api/client"; +import { adminQueryKeys } from "../../api/adminQueries"; +import type { InfrastructureServiceListResponse, ManualOverrideFieldCatalogResponse } from "../../api/infrastructureTypes"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { ErrorState, LoadingState } from "../v2/RouteStates"; + +const overrideTargets = ["Service", "Host", "Component", "Endpoint", "Network", "Volume", "Database", "Deployment", "Backup"] as const; + +export default function AdminTaxonomiesPage() { + useDocumentTitle("Classificaties"); + const data = useQuery({ + queryKey: adminQueryKeys.taxonomies, + queryFn: async ({ signal }) => { + const params = new URLSearchParams({ limit: "100" }); + const [services, catalogs] = await Promise.all([ + apiGet(`/api/v1/infrastructure/services?${params}`, signal), + Promise.all(overrideTargets.map((target) => apiGet(`/api/v1/infrastructure/overrides/catalog?targetKind=${target}`, signal))) + ]); + return { services, catalogs }; + } + }); + + if (data.isPending) return
    ; + if (data.isError) return
    void data.refetch()} />
    ; + + const categories = new Map(); + for (const service of data.data.services.items) { + const category = service.category.trim() || "Geen categorie"; + categories.set(category, (categories.get(category) ?? 0) + 1); + } + const enumFields = data.data.catalogs.flatMap((catalog) => catalog.fields.filter((field) => field.valueKind === "Enum").map((field) => ({ ...field, targetKind: catalog.targetKind }))); + + return
    + +
    +

    Gelezen uit infrastructuurcatalogus

    Servicecategorieën

    Waarden komen van de eerste geladen pagina in de servicecatalogus; categorieën blijven vrije profieltekst.

    Servicecatalogus openen
    + {categories.size === 0 ?

    Geen servicecategorieën gevonden

    De geladen catalogus bevat nog geen servicegegevens.

    :
    {[...categories.entries()].sort(([left], [right]) => left.localeCompare(right, "nl-BE")).map(([category, count]) =>
    {category}{count} van {data.data.services.items.length} geladen services
    )}
    } + {data.data.services.nextCursor &&

    Er zijn meer services dan deze pagina toont; de aantallen hierboven zijn alleen voor de geladen pagina.

    } +
    +
    +

    Servervalidatie

    Vaste classificatiewaarden

    Deze enumwaarden komen rechtstreeks uit de backend catalogus. Ze zijn onderdeel van fact-contracten en zijn niet als operatorbeleid bewerkbaar.

    + {enumFields.length === 0 ?

    De servercatalogus bevat geen enumwaarden.

    :
    {enumFields.map((field) =>
    {field.displayLabel}{field.targetKind} · {field.allowedEnumValues?.join(", ") ?? "geen toegestane waarden geretourneerd"}
    )}
    } +
    +

    De huidige API biedt geen algemene taxonomy CRUD. Servicecategorieën wijzig je in het betreffende servicedossier; stabiele enumwaarden blijven serverbeheerd.

    +
    ; +} diff --git a/src/frontend/src/pages/admin/AdminUsersPage.tsx b/src/frontend/src/pages/admin/AdminUsersPage.tsx new file mode 100644 index 0000000..6098b71 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminUsersPage.tsx @@ -0,0 +1,89 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState, type FormEvent } from "react"; +import { apiPatch, apiPost } from "../../api/client"; +import type { IssueRecoveryTokenResponse } from "../../api/adminTypes"; +import { adminQueryKeys, useAdminUsers } from "../../api/adminQueries"; +import { copyText } from "../../lib/clipboard"; +import { PageHeader } from "../../components/ProductPrimitives"; +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import { ErrorState, LoadingState } from "../v2/RouteStates"; + +type RecoveryToken = { userEmail: string; token: string; expiresAt: string }; + +export default function AdminUsersPage() { + useDocumentTitle("Gebruikersbeheer"); + const client = useQueryClient(); + const users = useAdminUsers(); + const [createOpen, setCreateOpen] = useState(false); + const [recoveryToken, setRecoveryToken] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const invalidate = () => client.invalidateQueries({ queryKey: adminQueryKeys.users }); + const createMutation = useMutation({ + mutationFn: (body: { displayName: string; email: string; password: string; role: string }) => apiPost("/api/v1/admin/users", body), + onSuccess: async () => { setCreateOpen(false); setMessage("Gebruiker is aangemaakt."); setError(null); await invalidate(); }, + onError: () => { setError("Gebruiker aanmaken is mislukt."); setMessage(null); } + }); + const updateMutation = useMutation({ + mutationFn: ({ id, patch }: { id: string; email: string; patch: { role?: string; enabled?: boolean } }) => apiPatch(`/api/v1/admin/users/${id}`, patch), + onSuccess: async () => { setMessage("Gebruiker bijgewerkt."); setError(null); await invalidate(); }, + onError: () => { setError("Gebruiker bijwerken is mislukt."); setMessage(null); } + }); + const recoveryMutation = useMutation({ + mutationFn: ({ userId, userEmail }: { userId: string; userEmail: string }) => apiPost(`/api/v1/admin/users/${userId}/recovery-tokens`).then((response) => ({ ...response, userEmail })), + onSuccess: (token) => { setRecoveryToken(token); setMessage(`Herstelcode voor ${token.userEmail} is aangemaakt. Deel hem veilig; hij wordt niet opnieuw getoond.`); setError(null); }, + onError: () => { setError("Herstelcode aanmaken is mislukt."); setMessage(null); } + }); + + if (users.isPending && !users.data) return
    ; + if (users.isError && !users.data) return
    void users.refetch()} />
    ; + + function submitCreate(event: FormEvent) { + event.preventDefault(); + const data = new FormData(event.currentTarget); + createMutation.mutate({ displayName: formString(data, "displayName").trim(), email: formString(data, "email").trim(), password: formString(data, "password"), role: formString(data, "role") || "Reader" }); + } + + return
    + setCreateOpen((open) => !open)}>{createOpen ? "Toevoegen sluiten" : "Gebruiker toevoegen"}} /> + {message &&

    {message}

    } + {error &&

    {error}

    } + {createOpen &&
    +

    Gebruiker toevoegen

    +
    +
    +
    +
    + +
    } + {(users.data?.length ?? 0) === 0 ?

    Geen gebruikers gevonden

    Voeg een account toe om iemand toegang te geven.

    :
    + {users.data?.map((user) =>
    +
    {user.displayName}{user.email}Laatst aangemeld: {formatDate(user.lastLoginAt)}
    + + + +
    )} +
    } + {recoveryToken &&
    +

    Eenmalige herstelcode voor {recoveryToken.userEmail}

    +

    Deel deze code veilig. Geldig tot {formatDate(recoveryToken.expiresAt)}. De code wordt na het sluiten niet opnieuw getoond.

    + {recoveryToken.token} +
    +
    } +
    ; +} + +function formString(form: FormData, name: string) { + const value = form.get(name); + return typeof value === "string" ? value : ""; +} + +function formatDate(value: string | null) { + return value ? new Intl.DateTimeFormat("nl-BE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Nog niet"; +} + +async function copyRecoveryToken(token: RecoveryToken, setMessage: (value: string | null) => void, setError: (value: string | null) => void) { + if (await copyText(token.token)) setMessage("Herstelcode naar het klembord gekopieerd."); + else setError("Kopiëren is mislukt. Selecteer en kopieer de herstelcode voordat je dit scherm verlaat."); +} diff --git a/src/frontend/src/pages/admin/AdminWorkspaceLayout.tsx b/src/frontend/src/pages/admin/AdminWorkspaceLayout.tsx new file mode 100644 index 0000000..833dc14 --- /dev/null +++ b/src/frontend/src/pages/admin/AdminWorkspaceLayout.tsx @@ -0,0 +1,32 @@ +import { NavLink, Outlet, useOutletContext } from "react-router"; +import type { SessionResponse } from "../../api/types"; + +const workspaces = [ + ["/admin", "Overzicht", true], + ["/admin/users", "Gebruikers"], + ["/admin/access", "Toegang"], + ["/admin/integrations", "Integraties"], + ["/admin/inventory", "Inventaris"], + ["/admin/knowledge", "Kennis"], + ["/admin/taxonomies", "Classificaties"], + ["/admin/jobs", "Achtergrondtaken"], + ["/admin/backups", "Back-ups"], + ["/admin/audit", "Audit"], + ["/admin/system", "Systeem"], + ["/admin/platform", "Platform"] +] as const; + +export default function AdminWorkspaceLayout() { + const context = useOutletContext<{ session: SessionResponse }>(); + return
    + + +
    ; +} diff --git a/src/frontend/src/pages/admin/CourseTaggingJobForm.tsx b/src/frontend/src/pages/admin/CourseTaggingJobForm.tsx new file mode 100644 index 0000000..2064045 --- /dev/null +++ b/src/frontend/src/pages/admin/CourseTaggingJobForm.tsx @@ -0,0 +1,64 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState, type FormEvent } from "react"; +import { Link, useNavigate } from "react-router"; +import { apiPost } from "../../api/client"; +import { adminQueryKeys } from "../../api/adminQueries"; +import type { AdminJob } from "../../api/platformTypes"; + +const pageIdPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; + +export function CourseTaggingJobForm() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [rootPageId, setRootPageId] = useState(""); + const [tag, setTag] = useState(""); + const [confirmed, setConfirmed] = useState(false); + const [error, setError] = useState(null); + const createJob = useMutation({ + mutationFn: () => apiPost("/api/v1/jobs/course-tagging", { rootPageId: rootPageId.trim(), tag: tag.trim().toLowerCase() }, { "Idempotency-Key": createIdempotencyKey() }), + onSuccess: async (job) => { + setError(null); + await queryClient.invalidateQueries({ queryKey: adminQueryKeys.jobs }); + await navigate(`/admin/jobs?jobId=${encodeURIComponent(job.id)}`); + }, + onError: () => setError("De cursustagtaak kon niet worden ingepland. Controleer het pagina-ID en probeer opnieuw.") + }); + + function submit(event: FormEvent) { + event.preventDefault(); + setError(null); + if (!pageIdPattern.test(rootPageId.trim())) { + setError("Voer een geldig pagina-ID in."); + return; + } + const normalizedTag = tag.trim().toLowerCase(); + if (normalizedTag.length < 1 || normalizedTag.length > 64 || Array.from(normalizedTag).some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + })) { + setError("Een cursustag moet 1 tot 64 tekens bevatten en mag geen controletekens bevatten."); + return; + } + if (!confirmed) { + setError("Bevestig dat de tag aan de volledige paginastructuur mag worden toegevoegd."); + return; + } + createJob.mutate(); + } + + return
    +

    Achtergrondtaak

    Cursustag aan paginastructuur toevoegen

    De server neemt bij het inplannen een momentopname van de hoofdpagina en alle niet-verwijderde onderliggende pagina's. Bestaande tags blijven behouden; reeds aanwezige tags en pagina's met de taglimiet worden overgeslagen.

    + {error &&

    {error}

    } +
    +
    setRootPageId(event.target.value)} autoCapitalize="none" spellCheck={false} required maxLength={36} placeholder="00000000-0000-0000-0000-000000000000" />
    +
    setTag(event.target.value)} autoCapitalize="none" spellCheck={false} required maxLength={64} />De server verwijdert witruimte aan de randen en zet de tag om naar kleine letters.
    + + +
    +

    Volg voortgang en annulering in Achtergrondtaken.

    +
    ; +} + +function createIdempotencyKey() { + return globalThis.crypto?.randomUUID?.() ?? `job-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} diff --git a/src/frontend/src/pages/knowledge/PageEditorPage.test.tsx b/src/frontend/src/pages/knowledge/PageEditorPage.test.tsx index fb97562..e55e7fe 100644 --- a/src/frontend/src/pages/knowledge/PageEditorPage.test.tsx +++ b/src/frontend/src/pages/knowledge/PageEditorPage.test.tsx @@ -81,7 +81,11 @@ function detail(overrides: Partial = {}): PageDetail { } describe("PageEditorPage", () => { + let restoreCodeMirrorRangeLayout: (() => void) | undefined; + afterEach(() => { + restoreCodeMirrorRangeLayout?.(); + restoreCodeMirrorRangeLayout = undefined; resetCsrfCache(); vi.unstubAllGlobals(); }); @@ -202,6 +206,53 @@ describe("PageEditorPage", () => { expect(commitBody!.markdown).not.toContain("pending-attachment:"); }); + it("keeps a trailing separator and the editor caret through autosave before inserting an attachment", async () => { + const clientRectsDescriptor = Object.getOwnPropertyDescriptor(Range.prototype, "getClientRects"); + const boundingRectDescriptor = Object.getOwnPropertyDescriptor(Range.prototype, "getBoundingClientRect"); + Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [] }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { configurable: true, value: () => new DOMRect() }); + restoreCodeMirrorRangeLayout = () => { + if (clientRectsDescriptor) Object.defineProperty(Range.prototype, "getClientRects", clientRectsDescriptor); + else Reflect.deleteProperty(Range.prototype, "getClientRects"); + if (boundingRectDescriptor) Object.defineProperty(Range.prototype, "getBoundingClientRect", boundingRectDescriptor); + else Reflect.deleteProperty(Range.prototype, "getBoundingClientRect"); + }; + + const savedDrafts: Array<{ markdown: string }> = []; + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/templates") return jsonResponse([]); + if (input === "/api/v1/auth/csrf") return jsonResponse({ token: "test-token", headerName: "X-CSRF-Token" }); + if (input.includes("/api/v1/drafts/") && init?.method === "PUT") { + const savedDraft = JSON.parse(init.body as string) as { markdown: string }; + savedDrafts.push(savedDraft); + return jsonResponse({ draftKey: "new-test", pageId: null, baseRevisionNumber: null, title: "", markdown: savedDraft.markdown, updatedAt: "2026-01-01T00:01:00Z", expiresAt: "2026-01-15T00:00:00Z", baseRevisionIsStale: false }); + } + if (input.includes("/api/v1/drafts/")) return notFoundResponse(); + if (input === "/api/v1/pages/staged" && init?.method === "POST") return jsonResponse({ sessionId, expiresAt: "2026-01-02T00:00:00Z" }); + if (input === `/api/v1/pages/staged/${sessionId}/attachments` && init?.method === "POST") { + return jsonResponse({ id: "attachment-1", filename: "diagram.png", mediaType: "image/png", deliveryPolicy: "InlinePreviewEligible", byteLength: 3, description: null, altText: null, createdAt: "2026-01-01T00:00:00Z" }); + } + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + const { container } = renderEditor({ mode: "create", parentPageId: null, onSaved: vi.fn(), onCancel: vi.fn() }); + + await screen.findByRole("heading", { name: "Nieuwe pagina" }); + await user.type(screen.getByLabelText("Paginatitel"), "Nieuwe pagina"); + const editor = screen.getByRole("textbox", { name: "Inhoud (Markdown)" }); + await user.type(editor, "Voor de afbeelding: "); + + await waitFor(() => expect(savedDrafts.some((draft) => draft.markdown === "Voor de afbeelding: ")).toBe(true), { timeout: 4000 }); + expect(editor.querySelector(".cm-line")?.textContent).toBe("Voor de afbeelding: "); + + const fileInput = container.querySelector('input[type="file"]'); + expect(fileInput).not.toBeNull(); + await user.upload(fileInput!, new File(["png"], "diagram.png", { type: "image/png" })); + + expect(editor.querySelector(".cm-line")?.textContent).toBe("Voor de afbeelding: ![diagram.png](/api/v1/attachments/attachment-1/content)"); + }); + it("lets the user discard a staging session and its already-uploaded attachments before publishing", async () => { const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { if (input === "/api/v1/templates") return jsonResponse([]); diff --git a/src/frontend/src/pages/knowledge/PageEditorPage.tsx b/src/frontend/src/pages/knowledge/PageEditorPage.tsx index 07b288a..dde8fa5 100644 --- a/src/frontend/src/pages/knowledge/PageEditorPage.tsx +++ b/src/frontend/src/pages/knowledge/PageEditorPage.tsx @@ -78,6 +78,12 @@ export function PageEditorPage({ mode, pageId, parentPageId, templateKey, resume const [beginSessionIdempotencyKey] = useState(() => newUuid()); const [loading, setLoading] = useState(mode === "edit"); const [title, setTitle] = useState(""); const [markdown, setMarkdown] = useState(""); + // CodeMirror's React wrapper queues controlled-value echoes while its typing latch + // is active. A delayed mobile input can leave an older echo queued and replace the + // live document (including a final space) after React has already saved the newer + // value. Keep the initial document stable and dispatch intentional replacements + // directly; ordinary status/state rerenders then cannot rewrite the editor or caret. + const [editorInitialMarkdown, setEditorInitialMarkdown] = useState(""); const [documentType, setDocumentType] = useState("WikiPage"); const [documentStatus, setDocumentStatus] = useState("Draft"); const [changeSummary, setChangeSummary] = useState(""); const [tagsInput, setTagsInput] = useState(""); const [eTag, setETag] = useState(null); const [baseRevision, setBaseRevision] = useState(null); @@ -105,7 +111,7 @@ export function PageEditorPage({ mode, pageId, parentPageId, templateKey, resume if (mode === "edit" && pageId) { const page = await apiGet(`/api/v1/pages/${pageId}`); if (cancelled) return; - setTitle(page.title); setMarkdown(page.markdown); setDocumentType(page.documentType); setDocumentStatus(page.documentStatus); + setTitle(page.title); setMarkdown(page.markdown); setEditorInitialMarkdown(page.markdown); setDocumentType(page.documentType); setDocumentStatus(page.documentStatus); setTagsInput(page.tags.join(", ")); setETag(page.eTag); setBaseRevision(page.currentRevisionNumber); setPublishedFingerprint(buildFingerprint(page.title, page.markdown, page.tags.join(", "), page.documentType, page.documentStatus, "")); try { @@ -166,8 +172,27 @@ export function PageEditorPage({ mode, pageId, parentPageId, templateKey, resume return () => window.clearTimeout(timeout); }, [viewMode, markdown]); + function replaceEditorMarkdown(value: string) { + setMarkdown(value); + setEditorInitialMarkdown(value); + const view = editorRef.current?.view; + if (!view) return; + const current = view.state.doc.toString(); + if (current === value) return; + view.dispatch({ changes: { from: 0, to: current.length, insert: value }, selection: { anchor: value.length } }); + } + + function changeViewMode(nextMode: ViewMode) { + if (nextMode === viewMode) return; + // Write/split/preview switch the editor's mount point. Seed the next editor + // instance with the latest document while keeping the current instance's value + // stable during typing and status-only rerenders. + setEditorInitialMarkdown(markdown); + setViewMode(nextMode); + } + function restoreDraft(draft: DraftResponse) { - setTitle(draft.title); setMarkdown(draft.markdown); + setTitle(draft.title); replaceEditorMarkdown(draft.markdown); if (draft.tags) setTagsInput(draft.tags.join(", ")); if (draft.documentType) setDocumentType(draft.documentType); if (draft.documentStatus) setDocumentStatus(draft.documentStatus); @@ -365,17 +390,17 @@ export function PageEditorPage({ mode, pageId, parentPageId, templateKey, resume

    {mode === "create" ? "Nieuwe kennispagina" : "Document bewerken"}

    setTitle(event.target.value)} />
    {saving ? "Publiceren…" : lastSavedAt ? `Concept ${lastSavedAt.toLocaleTimeString("nl-BE", { hour: "2-digit", minute: "2-digit" })}` : dirty ? "Niet-opgeslagen wijzigingen" : "Opgeslagen"}
    {mode === "edit" && (metadata.provenance.length > 0 || metadata.workflow.length > 0) &&
    {knowledgeLayerLabels[layer]}

    Laat de vier importmarkeringen en de bronsectie intact; schrijf eigen aanvullingen uitsluitend binnen het gedeelte voor eigen notities.

    } {recoverableDraft &&
    Er is een nieuwer lokaal concept van {new Date(recoverableDraft.updatedAt).toLocaleString("nl-BE")}
    } - {conflict &&
    Versieconflict

    De gepubliceerde versie is intussen gewijzigd. Uw tekst is niet verloren.

    } + {conflict &&
    Versieconflict

    De gepubliceerde versie is intussen gewijzigd. Uw tekst is niet verloren.

    } {error &&
    {error}
    } {stagingStatusText &&
    {stagingStatusText}
    }
    { const file = event.target.files?.[0]; if (file) void uploadAttachment(file); }} />
    -
    {mode === "edit" && }{mode === "create" && templates.length > 0 && }
    +
    {mode === "edit" && }{mode === "create" && templates.length > 0 && }
    event.preventDefault()} onDrop={(event) => { event.preventDefault(); const file = event.dataTransfer.files[0]; if (file) void uploadAttachment(file); }}> {viewMode === "split" ? -
    +
    Server-equivalent voorbeeld{previewPending && Bijwerken…}
    -
    : viewMode === "write" ?
    :
    Server-equivalent voorbeeld{previewPending && Bijwerken…}
    } + : viewMode === "write" ?
    :
    Server-equivalent voorbeeld{previewPending && Bijwerken…}
    } {outlineIsDrawer && outlineOpen &&
    diff --git a/src/frontend/src/pages/projects/DecisionDetailView.tsx b/src/frontend/src/pages/projects/DecisionDetailView.tsx index 7aa4b88..8e28f58 100644 --- a/src/frontend/src/pages/projects/DecisionDetailView.tsx +++ b/src/frontend/src/pages/projects/DecisionDetailView.tsx @@ -1,6 +1,8 @@ import { CalendarDays, FileText, GitBranch, Hash } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { apiGet, apiPost, ApiError, ifMatchHeader } from "../../api/client"; +import { queryKeys } from "../../api/domainQueries"; import type { DecisionDetail } from "../../api/projectTypes"; import { decisionStatusLabels } from "../../api/projectTypes"; import { PageHeader, StatusBadge } from "../../components/ProductPrimitives"; @@ -17,6 +19,7 @@ export type DecisionDetailViewProps = { type State = { kind: "loading" } | { kind: "error" } | { kind: "ready"; decision: DecisionDetail }; export function DecisionDetailView({ decisionId, canEdit, onBack, onNavigateToPage, onNavigateToDecision }: DecisionDetailViewProps) { + const queryClient = useQueryClient(); const [state, setState] = useState({ kind: "loading" }); const [actionError, setActionError] = useState(null); @@ -65,6 +68,9 @@ export function DecisionDetailView({ decisionId, canEdit, onBack, onNavigateToPa setActionError(null); try { await apiPost(`/api/v1/decisions/${decisionId}/${action}`, undefined, ifMatchHeader(state.decision.eTag)); + await queryClient.invalidateQueries({ queryKey: queryKeys.projects }); + void queryClient.invalidateQueries({ queryKey: queryKeys.unifiedSearch }); + void queryClient.invalidateQueries({ queryKey: queryKeys.activity }); await load(); } catch (error) { setActionError(error instanceof ApiError ? error.message : "Statuswijziging is mislukt."); diff --git a/src/frontend/src/pages/v2/ActivityPage.test.tsx b/src/frontend/src/pages/v2/ActivityPage.test.tsx index f29eb0f..3bb7bb7 100644 --- a/src/frontend/src/pages/v2/ActivityPage.test.tsx +++ b/src/frontend/src/pages/v2/ActivityPage.test.tsx @@ -1,3 +1,4 @@ +import { QueryProvider } from "../../query/QueryProvider"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -43,7 +44,7 @@ describe("ActivityPage", () => { }), ); const user = userEvent.setup(); - render(); + render(); expect( await screen.findByText("Pagina 'Herstel' aangemaakt."), diff --git a/src/frontend/src/pages/v2/ActivityPage.tsx b/src/frontend/src/pages/v2/ActivityPage.tsx index 3f4ae7a..9e75757 100644 --- a/src/frontend/src/pages/v2/ActivityPage.tsx +++ b/src/frontend/src/pages/v2/ActivityPage.tsx @@ -5,11 +5,11 @@ import { Rocket, UserRound, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { apiGet } from "../../api/client"; +import { useState } from "react"; +import { useActivity } from "../../api/domainQueries"; +import { CachedDataNotice } from "../../components/CachedDataNotice"; import type { ActivityItem, - ActivityListResponse, } from "../../api/activityTypes"; import { PageHeader, StatusBadge } from "../../components/ProductPrimitives"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; @@ -20,42 +20,13 @@ type Filter = "all" | "deployment" | "documentation" | "inventory"; export default function ActivityPage() { useDocumentTitle("Activiteit"); const [filter, setFilter] = useState("all"); - const [state, setState] = useState< - | { kind: "loading" } - | { kind: "error" } - | { kind: "ready"; items: ActivityItem[] } - >({ kind: "loading" }); + const query = useActivity(); + const state = query.data ? { kind: "ready" as const, items: query.data.items } : { kind: query.isError ? "error" as const : "loading" as const }; + function load() { void query.refetch(); } - function load() { - setState({ kind: "loading" }); - apiGet("/api/v1/activity?limit=100") - .then((response) => setState({ kind: "ready", items: response.items })) - .catch(() => setState({ kind: "error" })); - } - - useEffect(() => { - let cancelled = false; - apiGet("/api/v1/activity?limit=100") - .then((response) => { - if (!cancelled) setState({ kind: "ready", items: response.items }); - }) - .catch(() => { - if (!cancelled) setState({ kind: "error" }); - }); - return () => { - cancelled = true; - }; - }, []); - - const visibleItems = useMemo( - () => - state.kind === "ready" - ? state.items.filter( - (item) => filter === "all" || classify(item) === filter, - ) - : [], - [filter, state], - ); + const visibleItems = state.kind === "ready" + ? state.items.filter((item) => filter === "all" || classify(item) === filter) + : []; if (state.kind === "loading") return ( @@ -76,10 +47,11 @@ export default function ActivityPage() { return (
    +

    Geen activiteit in deze weergave

    - Nieuwe server-side auditgebeurtenissen verschijnen hier + Nieuwe operationele wijzigingen verschijnen hier automatisch.

    diff --git a/src/frontend/src/pages/v2/InfrastructureOverviewPage.test.tsx b/src/frontend/src/pages/v2/InfrastructureOverviewPage.test.tsx index 0970335..e3edc22 100644 --- a/src/frontend/src/pages/v2/InfrastructureOverviewPage.test.tsx +++ b/src/frontend/src/pages/v2/InfrastructureOverviewPage.test.tsx @@ -2,6 +2,7 @@ import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import InfrastructureOverviewPage from "./InfrastructureOverviewPage"; +import { QueryProvider } from "../../query/QueryProvider"; describe("InfrastructureOverviewPage", () => { afterEach(() => vi.unstubAllGlobals()); @@ -31,7 +32,7 @@ describe("InfrastructureOverviewPage", () => { }), })); - render(); + render(); expect((await screen.findAllByText("Onbekend")).length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText("Niet geïnventariseerd")).toHaveLength(2); @@ -64,7 +65,7 @@ describe("InfrastructureOverviewPage", () => { }), })); - render(); + render(); const statusCard = (await screen.findByText("Status")).closest(".metric-card"); expect(statusCard).toHaveTextContent("Niet aangetroffen"); diff --git a/src/frontend/src/pages/v2/InfrastructureOverviewPage.tsx b/src/frontend/src/pages/v2/InfrastructureOverviewPage.tsx index 9ed6a27..c1e17e9 100644 --- a/src/frontend/src/pages/v2/InfrastructureOverviewPage.tsx +++ b/src/frontend/src/pages/v2/InfrastructureOverviewPage.tsx @@ -1,8 +1,7 @@ import { Box, Cpu, HardDrive, MemoryStick, Network, Server, ShieldCheck } from "lucide-react"; -import { useEffect, useState } from "react"; import { Link } from "react-router"; -import { apiGet } from "../../api/client"; -import type { InfrastructureOverview } from "../../api/infrastructureTypes"; +import { useInfrastructureOverview } from "../../api/domainQueries"; +import { CachedDataNotice } from "../../components/CachedDataNotice"; import { MetricCard, PageHeader, StatusBadge } from "../../components/ProductPrimitives"; import { statusValueLabels } from "../../components/statusLabels"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; @@ -13,18 +12,10 @@ const formatBytes = (value: number) => new Intl.NumberFormat("nl-BE", { style: " export default function InfrastructureOverviewPage() { useDocumentTitle("Infrastructuur"); - const [state, setState] = useState<{ kind: "loading" } | { kind: "error" } | { kind: "ready"; data: InfrastructureOverview }>({ kind: "loading" }); - function load() { setState({ kind: "loading" }); apiGet("/api/v1/infrastructure/overview").then((data) => setState({ kind: "ready", data })).catch(() => setState({ kind: "error" })); } - useEffect(() => { - let cancelled = false; - apiGet("/api/v1/infrastructure/overview") - .then((data) => { if (!cancelled) setState({ kind: "ready", data }); }) - .catch(() => { if (!cancelled) setState({ kind: "error" }); }); - return () => { cancelled = true; }; - }, []); - if (state.kind === "loading") return
    ; - if (state.kind === "error") return
    ; - const overview = state.data; + const query = useInfrastructureOverview(); + if (!query.data && query.isError) return
    { void query.refetch(); }} />
    ; + if (!query.data) return
    ; + const overview = query.data; const host = overview.hosts[0]; const hostPresent = host?.presence === "Present"; const systemStatus = !host @@ -35,12 +26,13 @@ export default function InfrastructureOverviewPage() { ? "Degraded" : "Healthy"; return
    + { void query.refetch(); }} />