Initial commit: AMS Backend - Deno + Oak + MongoDB

This commit is contained in:
FluxKit
2026-02-19 14:02:53 +00:00
commit 656a37efda
36 changed files with 7648 additions and 0 deletions

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM denoland/deno:2.0.0
WORKDIR /app
COPY deno.json deno.lock ./
RUN deno install
COPY src ./src
EXPOSE 8000
CMD ["deno", "task", "start"]

11
deno.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@ams/backend",
"tasks": {
"start": "deno run --allow-net --allow-env --allow-read --allow-sys --allow-run src/main.ts"
},
"imports": {
"@std/dotenv": "jsr:@std/dotenv@^0.225.6",
"@oak/oak": "jsr:@oak/oak@^17.2.0",
"mongodb": "npm:mongodb@^6.10.0"
}
}

311
deno.lock generated Normal file
View File

@@ -0,0 +1,311 @@
{
"version": "4",
"specifiers": {
"jsr:@oak/commons@1": "1.0.1",
"jsr:@oak/oak@17.2.0": "17.2.0",
"jsr:@oak/oak@^17.2.0": "17.2.0",
"jsr:@std/assert@1": "1.0.18",
"jsr:@std/bytes@1": "1.0.6",
"jsr:@std/crypto@1": "1.0.5",
"jsr:@std/dotenv@~0.225.6": "0.225.6",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/http@1": "1.0.24",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/media-types@1": "1.1.0",
"jsr:@std/path@1": "1.1.4",
"npm:mongodb@6.10.0": "6.10.0",
"npm:mongodb@^6.10.0": "6.21.0",
"npm:path-to-regexp@^6.3.0": "6.3.0"
},
"jsr": {
"@oak/commons@1.0.1": {
"integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c",
"dependencies": [
"jsr:@std/assert",
"jsr:@std/bytes",
"jsr:@std/crypto",
"jsr:@std/encoding@1",
"jsr:@std/http",
"jsr:@std/media-types"
]
},
"@oak/oak@17.2.0": {
"integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1",
"dependencies": [
"jsr:@oak/commons",
"jsr:@std/assert",
"jsr:@std/bytes",
"jsr:@std/http",
"jsr:@std/media-types",
"jsr:@std/path",
"npm:path-to-regexp"
]
},
"@std/assert@1.0.18": {
"integrity": "270245e9c2c13b446286de475131dc688ca9abcd94fc5db41d43a219b34d1c78"
},
"@std/bytes@1.0.6": {
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
},
"@std/crypto@1.0.5": {
"integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40"
},
"@std/dotenv@0.225.6": {
"integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/http@1.0.24": {
"integrity": "4dd59afd7cfd6e2e96e175b67a5a829b449ae55f08575721ec691e5d85d886d4",
"dependencies": [
"jsr:@std/encoding@^1.0.10"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": {
"@mongodb-js/saslprep@1.4.5": {
"integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==",
"dependencies": [
"sparse-bitfield"
]
},
"@types/webidl-conversions@7.0.3": {
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
},
"@types/whatwg-url@11.0.5": {
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"dependencies": [
"@types/webidl-conversions"
]
},
"bson@6.10.4": {
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="
},
"memory-pager@1.5.0": {
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
},
"mongodb-connection-string-url@3.0.2": {
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"dependencies": [
"@types/whatwg-url",
"whatwg-url"
]
},
"mongodb@6.10.0": {
"integrity": "sha512-gP9vduuYWb9ZkDM546M+MP2qKVk5ZG2wPF63OvSRuUbqCR+11ZCAE1mOfllhlAG0wcoJY5yDL/rV3OmYEwXIzg==",
"dependencies": [
"@mongodb-js/saslprep",
"bson",
"mongodb-connection-string-url"
]
},
"mongodb@6.21.0": {
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
"dependencies": [
"@mongodb-js/saslprep",
"bson",
"mongodb-connection-string-url"
]
},
"path-to-regexp@6.3.0": {
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="
},
"punycode@2.3.1": {
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
},
"sparse-bitfield@3.0.3": {
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"dependencies": [
"memory-pager"
]
},
"tr46@5.1.1": {
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dependencies": [
"punycode"
]
},
"webidl-conversions@7.0.0": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
},
"whatwg-url@14.2.0": {
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dependencies": [
"tr46",
"webidl-conversions"
]
}
},
"remote": {
"https://deno.land/std@0.200.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3",
"https://deno.land/std@0.200.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
"https://deno.land/std@0.200.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
"https://deno.land/std@0.200.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8",
"https://deno.land/std@0.200.0/bytes/bytes_list.ts": "31d664f4d42fa922066405d0e421c56da89d751886ee77bbe25a88bf0310c9d0",
"https://deno.land/std@0.200.0/bytes/concat.ts": "d26d6f3d7922e6d663dacfcd357563b7bf4a380ce5b9c2bbe0c8586662f25ce2",
"https://deno.land/std@0.200.0/bytes/copy.ts": "939d89e302a9761dcf1d9c937c7711174ed74c59eef40a1e4569a05c9de88219",
"https://deno.land/std@0.200.0/bytes/ends_with.ts": "4228811ebc71615d27f065c54b5e815ec1972538772b0f413c0efe05245b472e",
"https://deno.land/std@0.200.0/bytes/equals.ts": "fc190cce412b2136979181b163ec7e05f7e7a947e39102eee4b8c0d62519ddf9",
"https://deno.land/std@0.200.0/bytes/includes_needle.ts": "76a8163126fb2f8bf86fd7f22192c3bb04bf6a20b987a095127c2ca08adf3ba6",
"https://deno.land/std@0.200.0/bytes/index_of_needle.ts": "9c06610e9611b5647ac25952e71a22e09227c9f1b8cbeeb33399bf8bf8a7f649",
"https://deno.land/std@0.200.0/bytes/last_index_of_needle.ts": "f1602f221c3b678bc4f1e1c88a70a15ab7da32c21751dbbc6c957c956951d784",
"https://deno.land/std@0.200.0/bytes/mod.ts": "e869bba1e7a2e3a9cc6c2d55471888429a544e70a840c087672e656e7ba21815",
"https://deno.land/std@0.200.0/bytes/repeat.ts": "6f5e490d8d72bcbf8d84a6bb04690b9b3eb5822c5a11687bca73a2318a842294",
"https://deno.land/std@0.200.0/bytes/starts_with.ts": "3e607a70c9c09f5140b7a7f17a695221abcc7244d20af3eb47ccbb63f5885135",
"https://deno.land/std@0.200.0/crypto/keystack.ts": "877ab0f19eb7d37ad6495190d3c3e39f58e9c52e0b6a966f82fd6df67ca55f90",
"https://deno.land/std@0.200.0/crypto/timing_safe_equal.ts": "7b0a4d2ef1c17590e0ad6c0cb1776369d2ba80cd99e945005e117851690507fe",
"https://deno.land/std@0.200.0/encoding/base64.ts": "144ae6234c1fbe5b68666c711dc15b1e9ee2aef6d42b3b4345bf9a6c91d70d0d",
"https://deno.land/std@0.200.0/encoding/base64url.ts": "2ed4ba122b20fedf226c5d337cf22ee2024fa73a8f85d915d442af7e9ce1fae1",
"https://deno.land/std@0.200.0/http/_negotiation/common.ts": "14d1a52427ab258a4b7161cd80e1d8a207b7cc64b46e911780f57ead5f4323c6",
"https://deno.land/std@0.200.0/http/_negotiation/encoding.ts": "ff747d107277c88cb7a6a62a08eeb8d56dad91564cbcccb30694d5dc126dcc53",
"https://deno.land/std@0.200.0/http/_negotiation/language.ts": "7bcddd8db3330bdb7ce4fc00a213c5547c1968139864201efd67ef2d0d51887d",
"https://deno.land/std@0.200.0/http/_negotiation/media_type.ts": "58847517cd549384ad677c0fe89e0a4815be36fe7a303ea63cee5f6a1d7e1692",
"https://deno.land/std@0.200.0/http/cookie_map.ts": "64601025a7d24c3ebd80a169ccc99145bdbfc60606935348e1d4366d0bf9010d",
"https://deno.land/std@0.200.0/http/etag.ts": "807382795850cde5c437c74bcc09392bc0fc56de348fc1271f383f4b28935b9f",
"https://deno.land/std@0.200.0/http/http_errors.ts": "bbda34819060af86537cecc9dc8e045f877130808b7e7acde4197c5328e852d0",
"https://deno.land/std@0.200.0/http/http_status.ts": "8a7bcfe3ac025199ad804075385e57f63d055b2aed539d943ccc277616d6f932",
"https://deno.land/std@0.200.0/http/method.ts": "e66c2a015cb46c21ab0bb3589aa4fca43143a506cb324ffdfd42d2edef7bc0c4",
"https://deno.land/std@0.200.0/http/negotiation.ts": "46e74a6bad4b857333a58dc5b50fe8e5a4d5267e97292293ea65f980bd918086",
"https://deno.land/std@0.200.0/http/server_sent_event.ts": "29f707c1afa5278ac0315ac115ee679d6b93596d5af3fad5ef33f04254ca76c1",
"https://deno.land/std@0.200.0/http/user_agent.ts": "35d3c70d0926b0e121b8c1bbc324b3522479158acaa4f0c43928362b7bf4e6f4",
"https://deno.land/std@0.200.0/io/buf_reader.ts": "2bccff0878537ef201c5051fc0db0ce8196388c5ea69d2be6be1900fe48c5f4b",
"https://deno.land/std@0.200.0/io/buf_writer.ts": "48c33c8f00b61dcbc7958706741cec8e59810bd307bc6a326cbd474fe8346dfd",
"https://deno.land/std@0.200.0/io/buffer.ts": "4d6883daeb2e698579c4064170515683d69f40f3de019bfe46c5cf31e74ae793",
"https://deno.land/std@0.200.0/io/copy_n.ts": "c055296297b9d4897d90d1ac056b072dc02614e60c67f438e23fbce052ea4c69",
"https://deno.land/std@0.200.0/io/limited_reader.ts": "6c9a216f8eef39c1ee2a6b37a29372c8fc63455b2eeb91f06d9646f8f759fc8b",
"https://deno.land/std@0.200.0/io/mod.ts": "2665bcccc1fd6e8627cca167c3e92aaecbd9897556b6f69e6d258070ef63fd9b",
"https://deno.land/std@0.200.0/io/multi_reader.ts": "9c2a0a31686c44b277e16da1d97b4686a986edcee48409b84be25eedbc39b271",
"https://deno.land/std@0.200.0/io/read_delim.ts": "c02b93cc546ae8caad8682ae270863e7ace6daec24c1eddd6faabc95a9d876a3",
"https://deno.land/std@0.200.0/io/read_int.ts": "7cb8bcdfaf1107586c3bacc583d11c64c060196cb070bb13ae8c2061404f911f",
"https://deno.land/std@0.200.0/io/read_lines.ts": "c526c12a20a9386dc910d500f9cdea43cba974e853397790bd146817a7eef8cc",
"https://deno.land/std@0.200.0/io/read_long.ts": "f0aaa420e3da1261c5d33c5e729f09922f3d9fa49f046258d4ff7a00d800c71e",
"https://deno.land/std@0.200.0/io/read_range.ts": "46a2263d0f8369b6d9abb0b25d99ceb65ff08d621fc57bcc53832e6979295043",
"https://deno.land/std@0.200.0/io/read_short.ts": "805cb329574b850b84bf14a92c052c59b5977a492cd780c41df8ad40826c1a20",
"https://deno.land/std@0.200.0/io/read_string_delim.ts": "5dc9f53bdf78e7d4ee1e56b9b60352238ab236a71c3e3b2a713c3d78472a53ce",
"https://deno.land/std@0.200.0/io/slice_long_to_bytes.ts": "48d9bace92684e880e46aa4a2520fc3867f9d7ce212055f76ecc11b22f9644b7",
"https://deno.land/std@0.200.0/io/string_reader.ts": "da0f68251b3d5b5112485dfd4d1b1936135c9b4d921182a7edaf47f74c25cc8f",
"https://deno.land/std@0.200.0/io/string_writer.ts": "8a03c5858c24965a54c6538bed15f32a7c72f5704a12bda56f83a40e28e5433e",
"https://deno.land/std@0.200.0/media_types/_db.ts": "7606d83e31f23ce1a7968cbaee852810c2cf477903a095696cdc62eaab7ce570",
"https://deno.land/std@0.200.0/media_types/_util.ts": "916efbd30b6148a716f110e67a4db29d6949bf4048997b754415dd7e42c52378",
"https://deno.land/std@0.200.0/media_types/content_type.ts": "ad98a5aa2d95f5965b2796072284258710a25e520952376ed432b0937ce743bc",
"https://deno.land/std@0.200.0/media_types/extension.ts": "a7cd28c9417143387cdfed27d4e8607ebcf5b1ec27eb8473d5b000144689fe65",
"https://deno.land/std@0.200.0/media_types/extensions_by_type.ts": "43806d6a52a0d6d965ada9d20e60a982feb40bc7a82268178d94edb764694fed",
"https://deno.land/std@0.200.0/media_types/format_media_type.ts": "f5e1073c05526a6f5a516ac5c5587a1abd043bf1039c71cde1166aa4328c8baf",
"https://deno.land/std@0.200.0/media_types/get_charset.ts": "18b88274796fda5d353806bf409eb1d2ddb3f004eb4bd311662c4cdd8ac173db",
"https://deno.land/std@0.200.0/media_types/mod.ts": "d3f0b99f85053bc0b98ecc24eaa3546dfa09b856dc0bbaf60d8956d2cdd710c8",
"https://deno.land/std@0.200.0/media_types/parse_media_type.ts": "8cb0144385c555c9ce81881b7cee3fbb746f23b4af988fecdb7bd01ef8cc35b1",
"https://deno.land/std@0.200.0/media_types/type_by_extension.ts": "daa801eb0f11cdf199445d0f1b656cf116d47dcf9e5b85cc1e6b4469f5ee0432",
"https://deno.land/std@0.200.0/media_types/vendor/mime-db.v1.52.0.ts": "6925bbcae81ca37241e3f55908d0505724358cda3384eaea707773b2c7e99586",
"https://deno.land/std@0.200.0/path/_basename.ts": "057d420c9049821f983f784fd87fa73ac471901fb628920b67972b0f44319343",
"https://deno.land/std@0.200.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
"https://deno.land/std@0.200.0/path/_dirname.ts": "355e297236b2218600aee7a5301b937204c62e12da9db4b0b044993d9e658395",
"https://deno.land/std@0.200.0/path/_extname.ts": "eaaa5aae1acf1f03254d681bd6a8ce42a9cb5b7ff2213a9d4740e8ab31283664",
"https://deno.land/std@0.200.0/path/_format.ts": "4a99270d6810f082e614309164fad75d6f1a483b68eed97c830a506cc589f8b4",
"https://deno.land/std@0.200.0/path/_from_file_url.ts": "7e4e5626089785adddb061f1b9f4932d6b21c7df778e7449531a11e32048245c",
"https://deno.land/std@0.200.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b",
"https://deno.land/std@0.200.0/path/_is_absolute.ts": "05dac10b5e93c63198b92e3687baa2be178df5321c527dc555266c0f4f51558c",
"https://deno.land/std@0.200.0/path/_join.ts": "fd78555bc34d5f188918fc7018dfe8fe2df5bbad94a3b30a433666c03934d77f",
"https://deno.land/std@0.200.0/path/_normalize.ts": "a19ec8706b2707f9dd974662a5cd89fad438e62ab1857e08b314a8eb49a34d81",
"https://deno.land/std@0.200.0/path/_parse.ts": "0f9b0ff43682dd9964eb1c4398610c4e165d8db9d3ac9d594220217adf480cfa",
"https://deno.land/std@0.200.0/path/_relative.ts": "27bdeffb5311a47d85be26d37ad1969979359f7636c5cd9fcf05dcd0d5099dc5",
"https://deno.land/std@0.200.0/path/_resolve.ts": "7a3616f1093735ed327e758313b79c3c04ea921808ca5f19ddf240cb68d0adf6",
"https://deno.land/std@0.200.0/path/_to_file_url.ts": "739bfda583598790b2e77ce227f2bb618f6ebdb939788cea47555b43970ec58c",
"https://deno.land/std@0.200.0/path/_to_namespaced_path.ts": "0d5f4caa2ed98ef7a8786286df6af804b50e38859ae897b5b5b4c8c5930a75c8",
"https://deno.land/std@0.200.0/path/_util.ts": "4e191b1bac6b3bf0c31aab42e5ca2e01a86ab5a0d2e08b75acf8585047a86221",
"https://deno.land/std@0.200.0/path/basename.ts": "6f08fbb90dbfcf320765b3abb01f995b1723f75e2534acfd5380e202c802a3aa",
"https://deno.land/std@0.200.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000",
"https://deno.land/std@0.200.0/path/dirname.ts": "098996822a31b4c46e1eb52a19540d3c6f9f54b772fc8a197939eeabc29fca2f",
"https://deno.land/std@0.200.0/path/extname.ts": "9b83c62fd16505739541f7a3ab447d8972da39dbf668d47af2f93206c2480893",
"https://deno.land/std@0.200.0/path/format.ts": "cb22f95cc7853d590b87708cc9441785e760d711188facff3d225305a8213aca",
"https://deno.land/std@0.200.0/path/from_file_url.ts": "a6221cfc928928ec4d9786d767dfac98fa2ab746af0786446c9834a07b98817e",
"https://deno.land/std@0.200.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1",
"https://deno.land/std@0.200.0/path/is_absolute.ts": "6b3d36352eb7fa29edb53f9e7b09b1aeb022a3c5465764f6cc5b8c41f9736197",
"https://deno.land/std@0.200.0/path/join.ts": "4a2867ff2f3c81ffc9eb3d56dade16db6f8bd3854f269306d23dad4115089c84",
"https://deno.land/std@0.200.0/path/mod.ts": "7765507696cb321994cdacfc19ee3ba61e8e3ebf4bd98fa75a276cf5dc18ce2a",
"https://deno.land/std@0.200.0/path/normalize.ts": "7d992cd262b2deefa842d93a8ba2ed51f3949ba595b1d07f627ac2cddbc74808",
"https://deno.land/std@0.200.0/path/parse.ts": "031fe488b3497fb8312fc1dc3c3d6c2d80707edd9c661e18ee9fd20f95edf322",
"https://deno.land/std@0.200.0/path/posix.ts": "0a1c1952d132323a88736d03e92bd236f3ed5f9f079e5823fae07c8d978ee61b",
"https://deno.land/std@0.200.0/path/relative.ts": "7db80c5035016174267da16321a742d76e875215c317859a383b12f413c6f5d6",
"https://deno.land/std@0.200.0/path/resolve.ts": "103b62207726a27f28177f397008545804ecb20aaf00623af1f622b18cd80b9f",
"https://deno.land/std@0.200.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1",
"https://deno.land/std@0.200.0/path/to_file_url.ts": "dd32f7a01bbf3b15b5df46796659984b372973d9b2d7d59bcf0eb990763a0cb5",
"https://deno.land/std@0.200.0/path/to_namespaced_path.ts": "4e643ab729bf49ccdc166ad48d2de262ff462938fcf2a44a4425588f4a0bd690",
"https://deno.land/std@0.200.0/path/win32.ts": "8b3f80ef7a462511d5e8020ff490edcaa0a0d118f1b1e9da50e2916bdd73f9dd",
"https://deno.land/std@0.200.0/streams/_common.ts": "f45cba84f0d813de3326466095539602364a9ba521f804cc758f7a475cda692d",
"https://deno.land/std@0.200.0/streams/buffer.ts": "6cd773d22cf21bb988a98cc551b5abfc4c3b03516f93eaa3fb6f2f6e16032deb",
"https://deno.land/std@0.200.0/streams/byte_slice_stream.ts": "c46d7c74836fc8c1a9acd9fe211cbe1bbaaee1b36087c834fb03af4991135c3a",
"https://deno.land/std@0.200.0/streams/copy.ts": "75cbc795ff89291df22ddca5252de88b2e16d40c85d02840593386a8a1454f71",
"https://deno.land/std@0.200.0/streams/delimiter_stream.ts": "f69e849b3d1f59f02424497273f411105a6f76a9f13da92aeeb9a2d554236814",
"https://deno.land/std@0.200.0/streams/early_zip_readable_streams.ts": "4005fa74162b943f79899e5d7cb96adcbc0a6b867f9144974ed12d30e0a556e1",
"https://deno.land/std@0.200.0/streams/iterate_reader.ts": "bbec1d45c2df2c0c5920bad0549351446fdc8e0886d99e95959b259dbcdb6072",
"https://deno.land/std@0.200.0/streams/limited_bytes_transform_stream.ts": "05dc592ffaab83257494d22dd53917e56243c26e5e3129b3f13ddbbbc4785048",
"https://deno.land/std@0.200.0/streams/limited_transform_stream.ts": "d69ab790232c1b86f53621ad41ef03c235f2abb4b7a1cd51960ad6e12ee55e38",
"https://deno.land/std@0.200.0/streams/merge_readable_streams.ts": "dc2db0cbf1b14d999aa2aa2a2a1ba24ce58953878f29845ed9319321d0a01fab",
"https://deno.land/std@0.200.0/streams/mod.ts": "c07ec010e700b9ea887dc36ca08729828bc7912f711e4054e24d33fd46282252",
"https://deno.land/std@0.200.0/streams/read_all.ts": "ee319772fb0fd28302f97343cc48dfcf948f154fd0d755d8efe65814b70533be",
"https://deno.land/std@0.200.0/streams/readable_stream_from_iterable.ts": "a355e97ba8671a4611ae9671c1f33c4a7e6a1b42fbdc9a399338ac3d6f875364",
"https://deno.land/std@0.200.0/streams/readable_stream_from_reader.ts": "bfc416c4576a30aac6b9af22c9dc292c20c6742141ee7c55b5e85460beb0c54e",
"https://deno.land/std@0.200.0/streams/reader_from_iterable.ts": "55f68110dce3f8f2c87b834d95f153bc904257fc65175f9f2abe78455cb8047c",
"https://deno.land/std@0.200.0/streams/reader_from_stream_reader.ts": "fa4971e5615a010e49492c5d1688ca1a4d17472a41e98b498ab89a64ebd7ac73",
"https://deno.land/std@0.200.0/streams/text_delimiter_stream.ts": "20e680ab8b751390e359288ce764f9c47d164af11a263870746eeca4bc7d976b",
"https://deno.land/std@0.200.0/streams/text_line_stream.ts": "0f2c4b33a5fdb2476f2e060974cba1347cefe99a4af33c28a57524b1a34750fa",
"https://deno.land/std@0.200.0/streams/to_transform_stream.ts": "89fd367cafb3b6d80d61e2f4f1fcf66cc75723ecee8d474b495f022264ec6c3b",
"https://deno.land/std@0.200.0/streams/writable_stream_from_writer.ts": "56fff5c82fb736fdd669b567cc0b2bbbe0351002cd13254eae26c366e2bed89a",
"https://deno.land/std@0.200.0/streams/write_all.ts": "aec90152978581ea62d56bb53a5cbf487e6a89c902f87c5969681ffbdf32b998",
"https://deno.land/std@0.200.0/streams/writer_from_stream_writer.ts": "07c7ee025151a190f37fc42cbb01ff93afc949119ebddc6e0d0df14df1bf6950",
"https://deno.land/std@0.200.0/streams/zip_readable_streams.ts": "a9d81aa451240f79230add674809dbee038d93aabe286e2d9671e66591fc86ca",
"https://deno.land/x/bcrypt@v0.4.1/mod.ts": "ff09bdae282583cf5f7d87efe37ddcecef7f14f6d12e8b8066a3058db8c6c2f7",
"https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/base64.ts": "b8266450a4f1eb6960f60f2f7986afc4dde6b45bd2d7ee7ba10789e67e17b9f7",
"https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/bcrypt.ts": "ec221648cc6453ea5e3803bc817c01157dada06aa6f7a0ba6b9f87aae32b21e2",
"https://deno.land/x/bcrypt@v0.4.1/src/main.ts": "08d201b289c8d9c46f8839c69cd6625b213863db29775c7a200afc3b540e64f8",
"https://deno.land/x/bcrypt@v0.4.1/src/worker.ts": "5a73bdfee9c9e622f47c9733d374b627dce52fb3ec1e74c8226698b3fc57ffac",
"https://deno.land/x/oak@v12.6.1/application.ts": "3028d3f6fa5ee743de013881550d054372c11d83c45099c2d794033786d27008",
"https://deno.land/x/oak@v12.6.1/body.ts": "1899761b97fc9d776f3710b2637fb047ba29b968609afc6c0e5219b1108e703c",
"https://deno.land/x/oak@v12.6.1/buf_reader.ts": "26640736541598dbd9f2b84a9d0595756afff03c9ca55b66eef1911f7798b56d",
"https://deno.land/x/oak@v12.6.1/content_disposition.ts": "8b8c3cb2fba7138cd5b7f82fc3b5ea39b33db924a824b28261659db7e164621e",
"https://deno.land/x/oak@v12.6.1/context.ts": "895a2d40186b89c28ba3947bf08a9335f1a11fc33133f760082536b74c53d1ca",
"https://deno.land/x/oak@v12.6.1/deps.ts": "267ef76c25592101fe1f6c6d7730664015a9179c974da4f7441297d9367a9514",
"https://deno.land/x/oak@v12.6.1/etag.ts": "32e47726b41698aefdd71faac5aaf2907c9bdd41ef18a7693863be4f8fee115d",
"https://deno.land/x/oak@v12.6.1/forwarded.ts": "e656f96a85574e2a6ee54dc35efc9f72d543c9ae0923760ef426ee7369eef01c",
"https://deno.land/x/oak@v12.6.1/headers.ts": "769fd042d34fbcd5667cbd745b5c65d335cc8430e822dbf1f87d65313cab4b47",
"https://deno.land/x/oak@v12.6.1/helpers.ts": "6b03c6a2be06ec775d54449e442a2bac234aa952948ca758356eab6dc87af618",
"https://deno.land/x/oak@v12.6.1/http_server_native.ts": "98e12c50a959553cfc144bc00999c969fa69ca781cbd96bec563f55691ab82db",
"https://deno.land/x/oak@v12.6.1/http_server_native_request.ts": "552b174b5e13e92de8897d5b6f716b1e5a53543115481d65a651a41e4ca29ec9",
"https://deno.land/x/oak@v12.6.1/isMediaType.ts": "62d638abcf837ece3a8f07a4b7ca59794135cb0d4b73194c7d5837efd4161005",
"https://deno.land/x/oak@v12.6.1/mediaTyper.ts": "042b853fc8e9c3f6c628dd389e03ef481552bf07242efc3f8a1af042102a6105",
"https://deno.land/x/oak@v12.6.1/middleware.ts": "c7f7a0424a6dd99a00e4b8d7d6e131efc0facc8dea781845d713b63df8ef1862",
"https://deno.land/x/oak@v12.6.1/middleware/proxy.ts": "6f2799cf60d926e7a8d13ff757a59d7f0f930407db7ee9b81e7c064138eb89ff",
"https://deno.land/x/oak@v12.6.1/mod.ts": "f6aa47ad1b6099470c9a884cccad9d3ac0fd242ba940896291ab76cd26cf554b",
"https://deno.land/x/oak@v12.6.1/multipart.ts": "1484e01b98f5135f2aa09f7d0ce1e7be39109bf9f045ac660e941619d04e3d29",
"https://deno.land/x/oak@v12.6.1/range.ts": "1ca15fc1ac21c650c34e6997a75af2af9d9d8eb6fe2d5d1dadeac3dfd4a9c152",
"https://deno.land/x/oak@v12.6.1/request.ts": "32409827e285ee65889b22bbaaea5d6b280258124c2e9a4f724baa8e6d6375b7",
"https://deno.land/x/oak@v12.6.1/response.ts": "094d950a5158f5b3446ca8a7b6e975dd23afb42b38c38517cc2f41dc75b16b4c",
"https://deno.land/x/oak@v12.6.1/router.ts": "0f53d6249f9e8f89f2522b2b810b9302d0f22593c184b16b24b03bf2b7d42ea1",
"https://deno.land/x/oak@v12.6.1/send.ts": "5ec49f106294593f468317a0c885da4f3274bf6d0fe9e16a7304391730b4f4fb",
"https://deno.land/x/oak@v12.6.1/structured_clone.ts": "c3888b14d1eec558345bfbf13d0993d59bd45aaa8588444e35dd558c3a921cd8",
"https://deno.land/x/oak@v12.6.1/testing.ts": "37d684d57bb8e5150fb5eb2677e66b04dcb422709cf2c5a74c1df94d52aa02e2",
"https://deno.land/x/oak@v12.6.1/util.ts": "0a3fdffb114859c2de84e1783efa3a544af4d2af8c6f08e0d25655de9d3e69bb",
"https://deno.land/x/path_to_regexp@v6.2.1/index.ts": "894060567837bae8fc9c5cbd4d0a05e9024672083d5883b525c031eea940e556"
},
"workspace": {
"dependencies": [
"jsr:@oak/oak@^17.2.0",
"jsr:@std/dotenv@~0.225.6",
"npm:mongodb@^6.10.0"
]
}
}

33
src/db/mongo.ts Normal file
View File

@@ -0,0 +1,33 @@
import { MongoClient } from "npm:mongodb@6.10.0";
const MONGO_URI = Deno.env.get("MONGO_URI");
if (!MONGO_URI) {
throw new Error("MONGO_URI environment variable is required");
}
const DB_NAME = new URL(MONGO_URI).pathname.replace("/", "") || "ams";
let client: MongoClient | null = null;
export async function getMongoClient(): Promise<MongoClient> {
if (!client) {
console.log("Connecting to MongoDB...");
client = new MongoClient(MONGO_URI);
await client.connect();
console.log("✅ MongoDB connected successfully");
}
return client;
}
export async function getDB() {
const mongoClient = await getMongoClient();
return mongoClient.db(DB_NAME);
}
export async function closeMongoConnection() {
if (client) {
await client.close();
client = null;
console.log("MongoDB connection closed");
}
}

100
src/main.ts Normal file
View File

@@ -0,0 +1,100 @@
import { Application } from "@oak/oak";
import "@std/dotenv/load";
import { getMongoClient } from "./db/mongo.ts";
import { corsMiddleware } from "./middleware/cors.ts";
import { authRouter } from "./routes/auth.ts";
import { tasksRouter } from "./routes/tasks.ts";
import { agentTasksRouter } from "./routes/agenttasks.ts";
import { secretsRouter, secretFoldersRouter } from "./routes/secrets.ts";
import { secretAuditLogsRouter } from "./routes/secretAuditLogs.ts";
import { workspaceRouter } from "./routes/workspace.ts";
import { labelsRouter } from "./routes/labels.ts";
import { cronJobsRouter } from "./routes/cronjobs.ts";
import { quickTextsRouter } from "./routes/quicktexts.ts";
import { transcribeRouter } from "./routes/transcribe.ts";
import { messagingRouter } from "./routes/messaging.ts";
import { tokensRouter } from "./routes/tokens.ts";
import { settingsRouter } from "./routes/settings.ts";
import { commentsRouter } from "./routes/comments.ts";
import { attachmentsRouter } from "./routes/attachments.ts";
import { agentsRouter } from "./routes/agents.ts";
import { logsRouter } from "./routes/logs.ts";
import { userSettingsRouter } from "./routes/usersettings.ts";
import gitlabRouter from "./routes/gitlab.ts";
import { dockerRouter } from "./routes/docker.ts";
import { exportRouter } from "./routes/export.ts";
import { appUpdateRouter } from "./routes/appUpdate.ts";
import { handleWebSocket } from "./ws/handler.ts";
const app = new Application();
const PORT = parseInt(Deno.env.get("PORT") || "8000");
// Connect to MongoDB
await getMongoClient();
// Middleware
app.use(corsMiddleware);
// Routes
app.use(authRouter.routes());
app.use(authRouter.allowedMethods());
app.use(tasksRouter.routes());
app.use(tasksRouter.allowedMethods());
app.use(agentTasksRouter.routes());
app.use(agentTasksRouter.allowedMethods());
app.use(secretsRouter.routes());
app.use(secretsRouter.allowedMethods());
app.use(secretFoldersRouter.routes());
app.use(secretFoldersRouter.allowedMethods());
app.use(secretAuditLogsRouter.routes());
app.use(secretAuditLogsRouter.allowedMethods());
app.use(workspaceRouter.routes());
app.use(workspaceRouter.allowedMethods());
app.use(labelsRouter.routes());
app.use(labelsRouter.allowedMethods());
app.use(cronJobsRouter.routes());
app.use(cronJobsRouter.allowedMethods());
app.use(quickTextsRouter.routes());
app.use(quickTextsRouter.allowedMethods());
app.use(transcribeRouter.routes());
app.use(transcribeRouter.allowedMethods());
app.use(messagingRouter.routes());
app.use(messagingRouter.allowedMethods());
app.use(tokensRouter.routes());
app.use(tokensRouter.allowedMethods());
app.use(settingsRouter.routes());
app.use(settingsRouter.allowedMethods());
app.use(commentsRouter.routes());
app.use(commentsRouter.allowedMethods());
app.use(attachmentsRouter.routes());
app.use(attachmentsRouter.allowedMethods());
app.use(agentsRouter.routes());
app.use(agentsRouter.allowedMethods());
app.use(logsRouter.routes());
app.use(logsRouter.allowedMethods());
app.use(userSettingsRouter.routes());
app.use(userSettingsRouter.allowedMethods());
app.use(gitlabRouter.routes());
app.use(gitlabRouter.allowedMethods());
app.use(dockerRouter.routes());
app.use(dockerRouter.allowedMethods());
app.use(exportRouter.routes());
app.use(exportRouter.allowedMethods());
app.use(appUpdateRouter.routes());
app.use(appUpdateRouter.allowedMethods());
console.log(`AMS Backend running on port ${PORT}`);
// Use Deno.serve for WebSocket support
Deno.serve({ port: PORT }, async (req: Request): Promise<Response> => {
const url = new URL(req.url);
// Handle WebSocket upgrades
if (url.pathname === "/ws" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
return handleWebSocket(req);
}
// Forward to Oak for regular HTTP
const oakResponse = await app.handle(req);
return oakResponse || new Response("Not Found", { status: 404 });
});

46
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Context, Next } from "@oak/oak";
import { verifyJWT } from "../utils/jwt.ts";
export interface AuthState {
user: {
id: string;
email: string;
username: string;
role: string;
};
}
export async function authMiddleware(ctx: Context, next: Next) {
const authHeader = ctx.request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
ctx.response.status = 401;
ctx.response.body = { error: "No token provided" };
return;
}
const token = authHeader.substring(7);
// Service-API-Key für Agent-Zugriff (kein JWT nötig)
const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY");
if (serviceKey && token === serviceKey) {
ctx.state.user = {
id: "service:pixel",
email: "pixel@agentenbude.de",
username: "Pixel (Agent)",
role: "agent",
};
await next();
return;
}
try {
const payload = await verifyJWT(token);
ctx.state.user = payload;
await next();
} catch {
ctx.response.status = 401;
ctx.response.body = { error: "Invalid or expired token" };
}
}

49
src/middleware/cors.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Context, Next } from "@oak/oak";
const ALLOWED_ORIGINS = [
"http://localhost:5173",
"https://localhost:5173",
"http://127.0.0.1:5173",
"https://127.0.0.1:5173",
"http://localhost:3000",
"https://localhost:3000",
"https://ams.kronos-soulution.de",
"http://ams.kronos-soulution.de",
"https://api.ams.kronos-soulution.de"
];
const ALLOWED_DOMAIN_SUFFIX = ".kronos-soulution.de";
function isAllowedOrigin(origin: string): boolean {
if (ALLOWED_ORIGINS.includes(origin)) {
return true;
}
try {
const url = new URL(origin);
return url.hostname === "kronos-soulution.de" ||
url.hostname.endsWith(ALLOWED_DOMAIN_SUFFIX);
} catch {
return false;
}
}
export async function corsMiddleware(ctx: Context, next: Next) {
const origin = ctx.request.headers.get("origin") || "";
if (isAllowedOrigin(origin)) {
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
}
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
ctx.response.headers.set("Access-Control-Max-Age", "86400");
if (ctx.request.method === "OPTIONS") {
ctx.response.status = 204;
return;
}
await next();
}

375
src/routes/agents.ts Normal file
View File

@@ -0,0 +1,375 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const agentsRouter = new Router({ prefix: "/api/agents" });
interface Agent {
_id: ObjectId;
name: string;
emoji: string;
role: string;
status: "online" | "idle" | "offline" | "busy";
supervisor?: string; // ID of supervising agent
discordId?: string;
containerId?: string;
containerIp?: string;
lastSeen?: Date;
createdAt: Date;
updatedAt: Date;
}
// Get all agents
agentsRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const agents = await db.collection<Agent>("agents")
.find({})
.sort({ name: 1 })
.toArray();
ctx.response.body = { agents };
});
// Get single agent
agentsRouter.get("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const id = ctx.params.id;
let agent;
try {
agent = await db.collection<Agent>("agents").findOne({ _id: new ObjectId(id) });
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid agent ID" };
return;
}
if (!agent) {
ctx.response.status = 404;
ctx.response.body = { error: "Agent not found" };
return;
}
ctx.response.body = { agent };
});
// Create agent (admin only)
agentsRouter.post("/", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const body = await ctx.request.body.json();
const { name, emoji, role, status, supervisor, discordId, containerId, containerIp } = body;
if (!name || !role) {
ctx.response.status = 400;
ctx.response.body = { error: "Name and role are required" };
return;
}
const db = await getDB();
const now = new Date();
const result = await db.collection<Agent>("agents").insertOne({
name,
emoji: emoji || "🤖",
role,
status: status || "offline",
supervisor,
discordId,
containerId,
containerIp,
lastSeen: now,
createdAt: now,
updatedAt: now
} as Agent);
ctx.response.status = 201;
ctx.response.body = {
message: "Agent created",
id: result.insertedId.toString()
};
});
// Update agent
agentsRouter.put("/:id", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const id = ctx.params.id;
const body = await ctx.request.body.json();
const { name, emoji, role, status, supervisor, discordId, containerId, containerIp } = body;
const db = await getDB();
const updateFields: Partial<Agent> = { updatedAt: new Date() };
if (name !== undefined) updateFields.name = name;
if (emoji !== undefined) updateFields.emoji = emoji;
if (role !== undefined) updateFields.role = role;
if (status !== undefined) updateFields.status = status;
if (supervisor !== undefined) updateFields.supervisor = supervisor;
if (discordId !== undefined) updateFields.discordId = discordId;
if (containerId !== undefined) updateFields.containerId = containerId;
if (containerIp !== undefined) updateFields.containerIp = containerIp;
try {
const result = await db.collection<Agent>("agents").updateOne(
{ _id: new ObjectId(id) },
{ $set: updateFields }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Agent not found" };
return;
}
ctx.response.body = { message: "Agent updated" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid agent ID" };
}
});
// Update agent status (can be called by agents themselves)
agentsRouter.patch("/:id/status", authMiddleware, async (ctx) => {
const id = ctx.params.id;
const body = await ctx.request.body.json();
const { status } = body;
if (!["online", "idle", "offline", "busy"].includes(status)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid status. Must be: online, idle, offline, or busy" };
return;
}
const db = await getDB();
try {
const result = await db.collection<Agent>("agents").updateOne(
{ _id: new ObjectId(id) },
{ $set: { status, lastSeen: new Date(), updatedAt: new Date() } }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Agent not found" };
return;
}
ctx.response.body = { message: "Status updated" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid agent ID" };
}
});
// ============ AGENT FILES ============
interface AgentFile {
_id: ObjectId;
agentId: string;
filename: string;
content: string;
updatedBy: string;
createdAt: Date;
updatedAt: Date;
}
const ALLOWED_FILENAMES = [
"SOUL.md", "MEMORY.md", "AGENTS.md", "USER.md", "TOOLS.md",
"HEARTBEAT.md", "IDENTITY.md", "WORKFLOW.md", "BOOTSTRAP.md", "README.md",
"CHECKLIST.md"
];
// Validate file path: must end with .md, no "..", max depth 2
function isValidFilePath(filepath: string): boolean {
if (!filepath.endsWith(".md")) return false;
if (filepath.includes("..")) return false;
const parts = filepath.split("/");
if (parts.length > 2) return false;
return parts.every(p => p.length > 0 && !p.startsWith("."));
}
// GET /api/agents/:id/files - List agent's MD files
// Optional query: ?path=memory (filter by subdirectory prefix)
agentsRouter.get("/:id/files", authMiddleware, async (ctx) => {
const agentId = ctx.params.id;
const pathFilter = ctx.request.url.searchParams.get("path");
const db = await getDB();
try {
const agent = await db.collection("agents").findOne({ _id: new ObjectId(agentId) });
if (!agent) {
ctx.response.status = 404;
ctx.response.body = { error: "Agent not found" };
return;
}
const query: Record<string, unknown> = { agentId };
if (pathFilter) {
query.filename = { $regex: `^${pathFilter}/` };
}
const files = await db.collection<AgentFile>("agent_files")
.find(query)
.project({ content: 0 })
.sort({ filename: 1 })
.toArray();
ctx.response.body = { files };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid agent ID" };
}
});
// GET /api/agents/:id/files/:filename - Read file content
// Supports subdirectory paths via query ?path=memory/2026-02-08.md
// or direct :filename for root-level files
agentsRouter.get("/:id/files/:filename", authMiddleware, async (ctx) => {
const agentId = ctx.params.id;
const pathParam = ctx.request.url.searchParams.get("path");
const filename = pathParam || ctx.params.filename;
if (!isValidFilePath(filename)) {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültiger Dateipfad. Nur .md-Dateien, max. 1 Unterordner." };
return;
}
const db = await getDB();
try {
const file = await db.collection<AgentFile>("agent_files")
.findOne({ agentId, filename });
if (!file) {
ctx.response.status = 404;
ctx.response.body = { error: "File not found" };
return;
}
ctx.response.body = { file };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid agent ID" };
}
});
// PUT /api/agents/:id/files/:filename - Create or update file
// Supports subdirectory paths via query ?path=memory/2026-02-08.md
agentsRouter.put("/:id/files/:filename", authMiddleware, async (ctx) => {
const agentId = ctx.params.id;
const body = await ctx.request.body.json();
const pathParam = body.path || ctx.request.url.searchParams.get("path") || ctx.params.filename;
const filename = pathParam;
const { content } = body;
if (!isValidFilePath(filename)) {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültiger Dateipfad. Nur .md-Dateien, max. 1 Unterordner." };
return;
}
if (content === undefined) {
ctx.response.status = 400;
ctx.response.body = { error: "Content is required" };
return;
}
if (content.length > 500 * 1024) {
ctx.response.status = 400;
ctx.response.body = { error: "File too large. Maximum 500KB." };
return;
}
const db = await getDB();
try {
// Verify agent exists
const agent = await db.collection("agents").findOne({ _id: new ObjectId(agentId) });
if (!agent) {
ctx.response.status = 404;
ctx.response.body = { error: "Agent not found" };
return;
}
const now = new Date();
const result = await db.collection<AgentFile>("agent_files").updateOne(
{ agentId, filename },
{
$set: { content, updatedBy: ctx.state.user.id, updatedAt: now },
$setOnInsert: { agentId, filename, createdAt: now }
},
{ upsert: true }
);
const isNew = result.upsertedCount > 0;
ctx.response.status = isNew ? 201 : 200;
ctx.response.body = { message: isNew ? "File created" : "File updated" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid agent ID" };
}
});
// DELETE /api/agents/:id/files/:filename - Delete file
// Supports subdirectory paths via query ?path=memory/2026-02-08.md
agentsRouter.delete("/:id/files/:filename", authMiddleware, async (ctx) => {
const pathParam = ctx.request.url.searchParams.get("path") || ctx.params.filename;
const agentId = ctx.params.id;
const filename = pathParam;
const db = await getDB();
try {
const result = await db.collection("agent_files").deleteOne({ agentId, filename });
if (result.deletedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "File not found" };
return;
}
ctx.response.body = { message: "File deleted" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid agent ID" };
}
});
// Delete agent (admin only)
agentsRouter.delete("/:id", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const id = ctx.params.id;
const db = await getDB();
try {
const result = await db.collection<Agent>("agents").deleteOne({ _id: new ObjectId(id) });
if (result.deletedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Agent not found" };
return;
}
// Cascade delete agent files
await db.collection("agent_files").deleteMany({ agentId: id });
ctx.response.body = { message: "Agent deleted" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid agent ID" };
}
});

281
src/routes/agenttasks.ts Normal file
View File

@@ -0,0 +1,281 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { getNextNumber } from "./tasks.ts";
export const agentTasksRouter = new Router({ prefix: "/api/agent-tasks" });
interface AgentTask {
_id: ObjectId;
number?: number;
message: string;
projectId?: string;
projectName?: string;
agentId?: string;
agentName?: string;
linkedTaskIds: string[];
linkedTaskTitles: string[];
status: "pending" | "in_progress" | "done" | "rejected";
createdBy: string;
createdByName: string;
result?: string;
createdAt: Date;
updatedAt: Date;
}
// Neuen Auftrag erstellen
agentTasksRouter.post("/", authMiddleware, async (ctx) => {
const db = await getDB();
const body = await ctx.request.body.json();
const { message, projectId, projectName, agentId, agentName, linkedTaskIds, linkedTaskTitles } = body;
if (!message || !message.trim()) {
ctx.response.status = 400;
ctx.response.body = { error: "Nachricht ist erforderlich" };
return;
}
const now = new Date();
const number = await getNextNumber("agent_task_number");
const doc: Omit<AgentTask, "_id"> = {
number,
message: message.trim(),
projectId: projectId || undefined,
projectName: projectName || undefined,
agentId: agentId || undefined,
agentName: agentName || undefined,
linkedTaskIds: linkedTaskIds || [],
linkedTaskTitles: linkedTaskTitles || [],
status: "pending",
createdBy: ctx.state.user.id,
createdByName: ctx.state.user.username,
createdAt: now,
updatedAt: now,
};
const result = await db.collection<AgentTask>("agent_tasks").insertOne(doc as AgentTask);
ctx.response.status = 201;
ctx.response.body = {
message: "Auftrag erstellt",
id: result.insertedId.toString(),
};
});
// Alle Aufträge laden (mit optionalen Filtern)
agentTasksRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const url = ctx.request.url;
const filter: Record<string, unknown> = {};
const status = url.searchParams.get("status");
if (status) filter.status = status;
const agentId = url.searchParams.get("agentId");
if (agentId) filter.agentId = agentId;
const tasks = await db.collection<AgentTask>("agent_tasks")
.find(filter)
.sort({ createdAt: -1 })
.limit(100)
.toArray();
ctx.response.body = { tasks };
});
// Pending Aufträge für Agenten (Kurzform)
agentTasksRouter.get("/pending", authMiddleware, async (ctx) => {
const db = await getDB();
const tasks = await db.collection<AgentTask>("agent_tasks")
.find({ status: "pending" })
.sort({ createdAt: 1 })
.toArray();
ctx.response.body = { tasks };
});
// Auftrag nach Nummer laden
agentTasksRouter.get("/by-number/:number", authMiddleware, async (ctx) => {
const db = await getDB();
const num = parseInt(ctx.params.number);
if (isNaN(num)) {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige Nummer" };
return;
}
const task = await db.collection<AgentTask>("agent_tasks").findOne({ number: num });
if (!task) {
ctx.response.status = 404;
ctx.response.body = { error: "Auftrag nicht gefunden" };
return;
}
ctx.response.body = { task };
});
// Einzelnen Auftrag laden
agentTasksRouter.get("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const id = ctx.params.id;
try {
const task = await db.collection<AgentTask>("agent_tasks")
.findOne({ _id: new ObjectId(id) });
if (!task) {
ctx.response.status = 404;
ctx.response.body = { error: "Auftrag nicht gefunden" };
return;
}
ctx.response.body = { task };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
// OpenClaw Agent-Proxy — startet isolierten Agent-Run über WireGuard VPN (hooks/agent endpoint)
agentTasksRouter.post("/wake", authMiddleware, async (ctx) => {
const openclawBaseUrl = Deno.env.get("OPENCLAW_WAKE_URL"); // e.g. http://10.10.0.3:18789/hooks/wake
const openclawToken = Deno.env.get("OPENCLAW_WAKE_TOKEN");
if (!openclawBaseUrl || !openclawToken) {
ctx.response.status = 503;
ctx.response.body = { error: "OpenClaw Wake nicht konfiguriert" };
return;
}
// Pending Tasks laden für den Prompt
const db = await getDB();
const pendingTasks = await db.collection<AgentTask>("agent_tasks")
.find({ status: "pending" })
.sort({ createdAt: 1 })
.toArray();
if (pendingTasks.length === 0) {
ctx.response.body = { message: "Keine pending Tasks" };
return;
}
// Auto-Timeout: Tasks die >30 Minuten in_progress sind, auf pending zurücksetzen
const TIMEOUT_MS = 30 * 60 * 1000;
const timeoutThreshold = new Date(Date.now() - TIMEOUT_MS);
await db.collection<AgentTask>("agent_tasks").updateMany(
{ status: "in_progress", updatedAt: { $lt: timeoutThreshold } },
{ $set: { status: "pending", updatedAt: new Date() } }
);
// Lock: Nur ein Task gleichzeitig pro Agent
const inProgressCount = await db.collection<AgentTask>("agent_tasks")
.countDocuments({ status: "in_progress" });
if (inProgressCount > 0) {
ctx.response.body = { message: "Agent arbeitet bereits an einem Task", blocked: true };
return;
}
// Prompt mit Task-Details bauen
const taskList = pendingTasks.map((t, i) =>
`${i + 1}. [${t._id}] "${t.message}" (Projekt: ${t.projectName || ""}, von: ${t.createdByName})`
).join("\n");
const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY") || "";
// Nur den ERSTEN pending Task bearbeiten (1 Task pro Run)
const firstTask = pendingTasks[0];
const prompt = `WICHTIG: Lade zuerst CHECKLIST.md und WORKFLOW.md aus deinem Workspace und halte dich strikt daran!
WICHTIG: KEINE Sub-Agenten spawnen (sessions_spawn)! Du bearbeitest Tasks SELBST, einzeln, nacheinander. Nur 1 Task pro Run!
Dein Agent-Task:
[${firstTask._id}] "${firstTask.message}" (Projekt: ${firstTask.projectName || ""}, von: ${firstTask.createdByName})
Bearbeite diesen Task nach der CHECKLIST.md:
1. Task im AMS auf "in_progress" setzen: curl -X PUT https://api.ams.agentenbude.de/api/tasks/<LINKED_TASK_ID> -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"in_progress"}'
2. Feature-Branch erstellen (feature/beschreibung)
3. Backend ZUERST implementieren (bei Full-Stack)
4. Kein \`any\`, kein \`console.log\`, keine Secrets im Code
5. Dark Theme beachten! Alle UI-Elemente müssen zum bestehenden Dark-Mode-Design passen (Farben: #1a1a2e Hintergrund, rgba(255,255,255,0.05-0.15) für Karten/Borders, helle Textfarben)
6. Self-Review: npm run build (FE) / deno check (BE)
7. Conventional Commits auf DEUTSCH
8. MR erstellen und mergen
9. Tag + Deploy
10. Agent-Task auf "done" setzen: curl -X PUT https://api.ams.agentenbude.de/api/agent-tasks/<ID> -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"done","result":"<Zusammenfassung>"}'
11. Task-Kommentar mit Zusammenfassung + MR + Version
12. Melde dem Chef das Ergebnis.
API-Auth: Bearer ${serviceKey}
AMS-API: https://api.ams.agentenbude.de/api`;
// /hooks/agent statt /hooks/wake aufrufen
const agentUrl = openclawBaseUrl.replace("/hooks/wake", "/hooks/agent");
try {
const response = await fetch(agentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${openclawToken}`,
},
body: JSON.stringify({
message: prompt,
name: "AMS-Task",
sessionKey: "hook:ams-task",
wakeMode: "now",
deliver: true,
channel: "telegram",
to: "7150608398",
}),
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
ctx.response.body = { message: "Agent gestartet", tasks: pendingTasks.length };
} else {
ctx.response.status = 502;
ctx.response.body = { error: "Agent-Start fehlgeschlagen", status: response.status };
}
} catch {
ctx.response.status = 502;
ctx.response.body = { error: "OpenClaw nicht erreichbar" };
}
});
// Auftrag-Status aktualisieren (Agent nimmt an / erledigt / lehnt ab)
agentTasksRouter.put("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const id = ctx.params.id;
const body = await ctx.request.body.json();
const { status, result } = body;
const validStatuses = ["pending", "in_progress", "done", "rejected"];
if (status && !validStatuses.includes(status)) {
ctx.response.status = 400;
ctx.response.body = { error: `Ungültiger Status. Erlaubt: ${validStatuses.join(", ")}` };
return;
}
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
if (status) updateFields.status = status;
if (result !== undefined) updateFields.result = result;
try {
const res = await db.collection<AgentTask>("agent_tasks").updateOne(
{ _id: new ObjectId(id) },
{ $set: updateFields }
);
if (res.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Auftrag nicht gefunden" };
return;
}
ctx.response.body = { message: "Auftrag aktualisiert" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});

153
src/routes/appUpdate.ts Normal file
View File

@@ -0,0 +1,153 @@
import { Router } from "@oak/oak";
import { getMongoClient } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { ObjectId } from "mongodb";
const router = new Router({ prefix: "/api/app" });
interface AppRelease {
_id?: ObjectId;
version: string;
versionCode: number;
downloadUrl: string;
releaseNotes: string;
size?: number;
createdAt: Date;
updatedAt: Date;
}
async function getReleasesCollection() {
const client = await getMongoClient();
const db = client.db("ams");
return db.collection<AppRelease>("app_releases");
}
// Öffentlich: Version prüfen (App ruft das ohne Auth auf)
router.get("/update/check", async (ctx) => {
const currentVersion = ctx.request.url.searchParams.get("currentVersion") || "0";
const currentCode = parseInt(ctx.request.url.searchParams.get("currentCode") || "0");
const col = await getReleasesCollection();
const latest = await col.findOne({}, { sort: { versionCode: -1 } });
if (!latest) {
ctx.response.body = { updateAvailable: false };
return;
}
const updateAvailable = latest.versionCode > currentCode;
ctx.response.body = {
updateAvailable,
currentVersion,
latestVersion: latest.version,
latestVersionCode: latest.versionCode,
downloadUrl: latest.downloadUrl,
releaseNotes: latest.releaseNotes,
size: latest.size,
};
});
// Öffentlich: Download-Redirect zur APK
router.get("/update/download", async (ctx) => {
const col = await getReleasesCollection();
const latest = await col.findOne({}, { sort: { versionCode: -1 } });
if (!latest) {
ctx.response.status = 404;
ctx.response.body = { error: "Kein Release verfügbar" };
return;
}
ctx.response.redirect(latest.downloadUrl);
});
// Admin: Neues Release anlegen
router.post("/update/release", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { version, versionCode, downloadUrl, releaseNotes, size } = body;
if (!version || !versionCode || !downloadUrl) {
ctx.response.status = 400;
ctx.response.body = { error: "version, versionCode und downloadUrl sind Pflichtfelder" };
return;
}
const col = await getReleasesCollection();
const now = new Date();
const result = await col.insertOne({
version,
versionCode,
downloadUrl,
releaseNotes: releaseNotes || "",
size: size || 0,
createdAt: now,
updatedAt: now,
});
ctx.response.status = 201;
ctx.response.body = { id: result.insertedId, version, versionCode };
});
// Admin: Alle Releases auflisten
router.get("/update/releases", authMiddleware, async (ctx) => {
const col = await getReleasesCollection();
const releases = await col.find({}).sort({ versionCode: -1 }).toArray();
ctx.response.body = { releases };
});
// Admin: Release löschen
router.delete("/update/release/:id", authMiddleware, async (ctx) => {
const id = ctx.params.id;
const col = await getReleasesCollection();
await col.deleteOne({ _id: new ObjectId(id) });
ctx.response.body = { deleted: true };
});
// Service-API: Release per API-Key anlegen (für CI-Pipeline)
router.post("/update/release/ci", async (ctx) => {
const apiKey = ctx.request.headers.get("X-API-Key") ||
ctx.request.url.searchParams.get("apiKey");
const expectedKey = Deno.env.get("AMS_SERVICE_API_KEY");
if (!apiKey || apiKey !== expectedKey) {
ctx.response.status = 401;
ctx.response.body = { error: "Ungültiger API-Key" };
return;
}
const body = await ctx.request.body.json();
const { version, versionCode, downloadUrl, releaseNotes, size } = body;
if (!version || !versionCode || !downloadUrl) {
ctx.response.status = 400;
ctx.response.body = { error: "version, versionCode und downloadUrl sind Pflichtfelder" };
return;
}
const col = await getReleasesCollection();
const now = new Date();
// Upsert by versionCode
await col.updateOne(
{ versionCode },
{
$set: {
version,
versionCode,
downloadUrl,
releaseNotes: releaseNotes || "",
size: size || 0,
updatedAt: now,
},
$setOnInsert: { createdAt: now },
},
{ upsert: true }
);
ctx.response.status = 201;
ctx.response.body = { success: true, version, versionCode };
});
export const appUpdateRouter = router;

241
src/routes/attachments.ts Normal file
View File

@@ -0,0 +1,241 @@
import { Router } from "@oak/oak";
import { ObjectId, GridFSBucket } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const attachmentsRouter = new Router({ prefix: "/api/attachments" });
// Valid parent types for attachments
const VALID_PARENT_TYPES = ["task", "comment", "project", "agent"] as const;
type ParentType = typeof VALID_PARENT_TYPES[number];
interface AttachmentMeta {
_id: ObjectId;
parentType: ParentType;
parentId: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
gridfsFileId?: ObjectId;
data?: string; // legacy base64 — kept for backward compat during migration
uploadedBy: string;
createdAt: Date;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
async function getGridFSBucket(): Promise<GridFSBucket> {
const db = await getDB();
return new GridFSBucket(db, { bucketName: "attachments" });
}
// Helper: read GridFS file to base64
async function gridfsToBase64(fileId: ObjectId): Promise<string> {
const bucket = await getGridFSBucket();
const stream = bucket.openDownloadStream(fileId);
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
}
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return btoa(String.fromCharCode(...combined));
}
// Helper: write base64 to GridFS, return fileId
async function base64ToGridFS(base64: string, filename: string): Promise<ObjectId> {
const bucket = await getGridFSBucket();
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const uploadStream = bucket.openUploadStream(filename);
const fileId = uploadStream.id;
await new Promise<void>((resolve, reject) => {
uploadStream.on("finish", resolve);
uploadStream.on("error", reject);
uploadStream.write(bytes);
uploadStream.end();
});
return fileId as ObjectId;
}
// ==========================================
// SPECIFIC ROUTES FIRST (before wildcards!)
// ==========================================
// Get single attachment with data
attachmentsRouter.get("/file/:attachmentId", authMiddleware, async (ctx) => {
const attachmentId = ctx.params.attachmentId;
const db = await getDB();
try {
const attachment = await db.collection<AttachmentMeta>("attachments").findOne({
_id: new ObjectId(attachmentId)
});
if (!attachment) {
ctx.response.status = 404;
ctx.response.body = { error: "Attachment not found" };
return;
}
// Read data: prefer GridFS, fall back to legacy base64 field
let data: string;
if (attachment.gridfsFileId) {
data = await gridfsToBase64(attachment.gridfsFileId);
} else if (attachment.data) {
data = attachment.data;
} else {
ctx.response.status = 500;
ctx.response.body = { error: "Attachment data not found" };
return;
}
ctx.response.body = {
attachment: {
_id: attachment._id,
parentType: attachment.parentType,
parentId: attachment.parentId,
filename: attachment.filename,
originalName: attachment.originalName,
mimeType: attachment.mimeType,
size: attachment.size,
data
}
};
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid attachment ID" };
}
});
// Delete attachment
attachmentsRouter.delete("/:attachmentId", authMiddleware, async (ctx) => {
const attachmentId = ctx.params.attachmentId;
const db = await getDB();
if (!/^[a-f\d]{24}$/i.test(attachmentId)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid attachment ID format" };
return;
}
try {
const attachment = await db.collection<AttachmentMeta>("attachments").findOne({
_id: new ObjectId(attachmentId)
});
if (!attachment) {
ctx.response.status = 404;
ctx.response.body = { error: "Attachment not found" };
return;
}
if (attachment.uploadedBy !== ctx.state.user.id && ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Not authorized to delete this attachment" };
return;
}
// Delete GridFS file if exists
if (attachment.gridfsFileId) {
try {
const bucket = await getGridFSBucket();
await bucket.delete(attachment.gridfsFileId);
} catch {
// GridFS file may already be deleted, continue
}
}
await db.collection("attachments").deleteOne({ _id: new ObjectId(attachmentId) });
ctx.response.body = { message: "Attachment deleted" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid attachment ID" };
}
});
// ==========================================
// WILDCARD ROUTES
// ==========================================
// Get attachments for any parent (metadata only, no data)
attachmentsRouter.get("/:parentType/:parentId", authMiddleware, async (ctx) => {
const { parentType, parentId } = ctx.params;
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
return;
}
const db = await getDB();
const attachments = await db.collection<AttachmentMeta>("attachments")
.find({ parentType, parentId })
.project({ data: 0, gridfsFileId: 0 })
.sort({ createdAt: -1 })
.toArray();
ctx.response.body = { attachments };
});
// Upload attachment — stores in GridFS
attachmentsRouter.post("/:parentType/:parentId", authMiddleware, async (ctx) => {
const { parentType, parentId } = ctx.params;
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
return;
}
const body = await ctx.request.body.json();
const { filename, data, mimeType } = body;
if (!filename || !data) {
ctx.response.status = 400;
ctx.response.body = { error: "Filename and data are required" };
return;
}
const sizeBytes = Math.round(data.length * 0.75);
if (sizeBytes > MAX_FILE_SIZE) {
ctx.response.status = 400;
ctx.response.body = { error: "File too large. Maximum 5MB allowed." };
return;
}
const db = await getDB();
const storedFilename = `${Date.now()}-${filename}`;
// Store binary data in GridFS
const gridfsFileId = await base64ToGridFS(data, storedFilename);
const result = await db.collection<AttachmentMeta>("attachments").insertOne({
parentType: parentType as ParentType,
parentId,
filename: storedFilename,
originalName: filename,
mimeType: mimeType || "application/octet-stream",
size: sizeBytes,
gridfsFileId,
uploadedBy: ctx.state.user.id,
createdAt: new Date()
} as AttachmentMeta);
ctx.response.status = 201;
ctx.response.body = {
message: "Attachment uploaded",
id: result.insertedId.toString()
};
});

211
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,211 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { signJWT } from "../utils/jwt.ts";
import { hashPassword, verifyPassword } from "../utils/password.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const authRouter = new Router({ prefix: "/api/auth" });
interface User {
_id: ObjectId;
email: string;
username: string;
password: string;
role: "admin" | "agent" | "user";
gitlabToken?: string;
createdAt: Date;
updatedAt: Date;
}
// Register
authRouter.post("/register", async (ctx) => {
const db = await getDB();
// Check if registration is allowed
const settings = await db.collection("settings").findOne({ _id: "global" });
if (settings && !settings.allowRegistration) {
ctx.response.status = 403;
ctx.response.body = { error: "Registrierung ist derzeit deaktiviert" };
return;
}
const body = await ctx.request.body.json();
const { email, username, password } = body;
if (!email || !username || !password) {
ctx.response.status = 400;
ctx.response.body = { error: "Email, username and password are required" };
return;
}
if (password.length < 8) {
ctx.response.status = 400;
ctx.response.body = { error: "Password must be at least 8 characters" };
return;
}
const users = db.collection<User>("users");
// Check if user exists
const existing = await users.findOne({ $or: [{ email }, { username }] });
if (existing) {
ctx.response.status = 409;
ctx.response.body = { error: "User with this email or username already exists" };
return;
}
const hashedPassword = await hashPassword(password);
const now = new Date();
const result = await users.insertOne({
email,
username,
password: hashedPassword,
role: "user",
createdAt: now,
updatedAt: now
} as User);
const token = await signJWT({
id: result.insertedId.toString(),
email,
username,
role: "user"
});
ctx.response.status = 201;
ctx.response.body = {
message: "User registered successfully",
token,
user: { id: result.insertedId.toString(), email, username, role: "user" }
};
});
// Login
authRouter.post("/login", async (ctx) => {
const body = await ctx.request.body.json();
const { email, password } = body;
if (!email || !password) {
ctx.response.status = 400;
ctx.response.body = { error: "Email and password are required" };
return;
}
const db = await getDB();
const users = db.collection<User>("users");
const user = await users.findOne({ email });
if (!user) {
ctx.response.status = 401;
ctx.response.body = { error: "Invalid credentials" };
return;
}
const validPassword = await verifyPassword(password, user.password);
if (!validPassword) {
ctx.response.status = 401;
ctx.response.body = { error: "Invalid credentials" };
return;
}
const token = await signJWT({
id: user._id.toString(),
email: user.email,
username: user.username,
role: user.role
});
ctx.response.body = {
message: "Login successful",
token,
user: {
id: user._id.toString(),
email: user.email,
username: user.username,
role: user.role
}
};
});
// Get current user (with gitlabToken)
authRouter.get("/me", authMiddleware, async (ctx) => {
const db = await getDB();
const users = db.collection<User>("users");
const user = await users.findOne({ _id: new ObjectId(ctx.state.user.id) });
ctx.response.body = {
user: ctx.state.user,
gitlabToken: user?.gitlabToken || ''
};
});
// List all users (id, username, email) for sharing dialogs
authRouter.get("/users", authMiddleware, async (ctx) => {
const db = await getDB();
const users = await db.collection<User>("users")
.find({}, { projection: { _id: 1, username: 1, email: 1, role: 1 } })
.sort({ username: 1 })
.toArray();
ctx.response.body = { users: users.map(u => ({ _id: u._id.toString(), username: u.username, email: u.email, role: u.role })) };
});
// Update current user (gitlabToken)
authRouter.put("/me", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { gitlabToken } = body;
const db = await getDB();
const users = db.collection<User>("users");
await users.updateOne(
{ _id: new ObjectId(ctx.state.user.id) },
{ $set: { gitlabToken: gitlabToken || '', updatedAt: new Date() } }
);
ctx.response.body = { message: "Settings updated successfully" };
});
// Change password
authRouter.post("/change-password", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { currentPassword, newPassword } = body;
if (!currentPassword || !newPassword) {
ctx.response.status = 400;
ctx.response.body = { error: "Current and new password are required" };
return;
}
if (newPassword.length < 8) {
ctx.response.status = 400;
ctx.response.body = { error: "New password must be at least 8 characters" };
return;
}
const db = await getDB();
const users = db.collection<User>("users");
const user = await users.findOne({ _id: new ObjectId(ctx.state.user.id) });
if (!user) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
return;
}
const validPassword = await verifyPassword(currentPassword, user.password);
if (!validPassword) {
ctx.response.status = 401;
ctx.response.body = { error: "Current password is incorrect" };
return;
}
const hashedPassword = await hashPassword(newPassword);
await users.updateOne(
{ _id: user._id },
{ $set: { password: hashedPassword, updatedAt: new Date() } }
);
ctx.response.body = { message: "Password changed successfully" };
});

274
src/routes/comments.ts Normal file
View File

@@ -0,0 +1,274 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const commentsRouter = new Router({ prefix: "/api/comments" });
// Valid parent types for comments
const VALID_PARENT_TYPES = ["task", "project", "agent"] as const;
type ParentType = typeof VALID_PARENT_TYPES[number];
interface Quote {
text: string;
commentId?: string;
authorName?: string;
}
interface Comment {
_id: ObjectId;
parentType: ParentType;
parentId: string;
parentCommentId?: string; // For replies to other comments
userId: string;
username: string;
content: string;
codeBlock?: { language: string; code: string };
quote?: Quote; // For quoting other comments
createdAt: Date;
}
// Get comments for any parent
// GET /api/comments/:parentType/:parentId
// Returns flat list with parentCommentId for replies - frontend builds tree
commentsRouter.get("/:parentType/:parentId", authMiddleware, async (ctx) => {
const { parentType, parentId } = ctx.params;
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
return;
}
const db = await getDB();
const comments = await db.collection<Comment>("comments")
.find({ parentType, parentId })
.sort({ createdAt: 1 }) // oldest first for conversation flow
.toArray();
ctx.response.body = { comments };
});
// Add comment to any parent
// POST /api/comments/:parentType/:parentId
// Body: { content, codeBlock?, parentCommentId?, quote? }
commentsRouter.post("/:parentType/:parentId", authMiddleware, async (ctx) => {
const { parentType, parentId } = ctx.params;
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
return;
}
const body = await ctx.request.body.json();
const { content, codeBlock, parentCommentId, quote } = body;
if (!content?.trim() && !codeBlock?.code?.trim()) {
ctx.response.status = 400;
ctx.response.body = { error: "Content or code block is required" };
return;
}
const db = await getDB();
// Validate parentCommentId if provided
if (parentCommentId) {
try {
const parentComment = await db.collection<Comment>("comments").findOne({
_id: new ObjectId(parentCommentId),
parentType,
parentId
});
if (!parentComment) {
ctx.response.status = 400;
ctx.response.body = { error: "Parent comment not found or not in same thread" };
return;
}
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid parent comment ID" };
return;
}
}
// Build quote object if provided
let quoteData: Quote | undefined;
if (quote?.text) {
quoteData = {
text: quote.text,
commentId: quote.commentId,
authorName: quote.authorName
};
}
const result = await db.collection<Comment>("comments").insertOne({
parentType: parentType as ParentType,
parentId,
parentCommentId: parentCommentId || undefined,
userId: ctx.state.user.id,
username: ctx.state.user.username,
content: content || "",
codeBlock: codeBlock?.code ? codeBlock : undefined,
quote: quoteData,
createdAt: new Date()
} as Comment);
ctx.response.status = 201;
ctx.response.body = {
message: "Comment added",
id: result.insertedId.toString()
};
});
// Delete comment (and all replies)
// DELETE /api/comments/:commentId
commentsRouter.delete("/:commentId", authMiddleware, async (ctx) => {
const commentId = ctx.params.commentId;
const db = await getDB();
try {
const comment = await db.collection<Comment>("comments").findOne({
_id: new ObjectId(commentId)
});
if (!comment) {
ctx.response.status = 404;
ctx.response.body = { error: "Comment not found" };
return;
}
// Only author or admin can delete
if (comment.userId !== ctx.state.user.id && ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Not authorized to delete this comment" };
return;
}
// Find all replies (recursively) to delete
const commentsToDelete = [commentId];
const findReplies = async (parentIds: string[]) => {
const replies = await db.collection<Comment>("comments")
.find({ parentCommentId: { $in: parentIds } })
.toArray();
if (replies.length > 0) {
const replyIds = replies.map(r => r._id.toString());
commentsToDelete.push(...replyIds);
await findReplies(replyIds);
}
};
await findReplies([commentId]);
// Delete all attachments for these comments
await db.collection("attachments").deleteMany({
parentType: "comment",
parentId: { $in: commentsToDelete }
});
// Delete all label assignments for these comments
await db.collection("label_assignments").deleteMany({
parentType: "comment",
parentId: { $in: commentsToDelete }
});
// Delete all comments (original + replies)
await db.collection("comments").deleteMany({
_id: { $in: commentsToDelete.map(id => new ObjectId(id)) }
});
ctx.response.body = {
message: "Comment, replies, and attachments deleted",
deletedCount: commentsToDelete.length
};
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid comment ID" };
}
});
// Update comment
// PATCH /api/comments/:commentId
commentsRouter.patch("/:commentId", authMiddleware, async (ctx) => {
const commentId = ctx.params.commentId;
const body = await ctx.request.body.json();
const { content, codeBlock, quote } = body;
const db = await getDB();
try {
const comment = await db.collection<Comment>("comments").findOne({
_id: new ObjectId(commentId)
});
if (!comment) {
ctx.response.status = 404;
ctx.response.body = { error: "Comment not found" };
return;
}
// Only author can edit
if (comment.userId !== ctx.state.user.id) {
ctx.response.status = 403;
ctx.response.body = { error: "Not authorized to edit this comment" };
return;
}
const updates: Partial<Comment> = {};
if (content !== undefined) updates.content = content;
if (codeBlock !== undefined) updates.codeBlock = codeBlock?.code ? codeBlock : undefined;
if (quote !== undefined) {
updates.quote = quote?.text ? {
text: quote.text,
commentId: quote.commentId,
authorName: quote.authorName
} : undefined;
}
await db.collection("comments").updateOne(
{ _id: new ObjectId(commentId) },
{ $set: updates }
);
ctx.response.body = { message: "Comment updated" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid comment ID" };
}
});
// Get single comment by ID (for API access)
// GET /api/comments/single/:commentId
commentsRouter.get("/single/:commentId", authMiddleware, async (ctx) => {
const commentId = ctx.params.commentId;
const db = await getDB();
try {
const comment = await db.collection<Comment>("comments").findOne({
_id: new ObjectId(commentId)
});
if (!comment) {
ctx.response.status = 404;
ctx.response.body = { error: "Comment not found" };
return;
}
ctx.response.body = { comment };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid comment ID" };
}
});
// Get replies for a comment
// GET /api/comments/replies/:commentId
commentsRouter.get("/replies/:commentId", authMiddleware, async (ctx) => {
const commentId = ctx.params.commentId;
const db = await getDB();
const replies = await db.collection<Comment>("comments")
.find({ parentCommentId: commentId })
.sort({ createdAt: 1 })
.toArray();
ctx.response.body = { replies };
});

366
src/routes/cronjobs.ts Normal file
View File

@@ -0,0 +1,366 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const cronJobsRouter = new Router({ prefix: "/api/cronjobs" });
interface CronJob {
_id: ObjectId;
name: string;
description: string;
intervalMinutes: number;
enabled: boolean;
lastRun: Date | null;
lastResult: string | null;
nextRun: Date | null;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
// In-Memory Timer Storage
const activeTimers = new Map<string, number>();
function calculateNextRun(intervalMinutes: number): Date {
return new Date(Date.now() + intervalMinutes * 60 * 1000);
}
async function executeWakeCheck(jobId: string) {
const db = await getDB();
// Prüfen ob pending Agent-Tasks existieren
const pendingCount = await db.collection("agent_tasks")
.countDocuments({ status: "pending" });
const now = new Date();
// Job-Daten für nextRun-Berechnung laden
const currentJob = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(jobId) });
const nextRun = currentJob ? calculateNextRun(currentJob.intervalMinutes) : null;
if (pendingCount === 0) {
await db.collection<CronJob>("cronjobs").updateOne(
{ _id: new ObjectId(jobId) },
{ $set: { lastRun: now, lastResult: `Keine pending Tasks (${now.toISOString()})`, nextRun } }
);
return;
}
// Lock: Kein Wake wenn bereits ein Task in Bearbeitung
// Auto-Timeout: Tasks die >30 Minuten in_progress sind, auf pending zurücksetzen
const TIMEOUT_MS = 30 * 60 * 1000;
const timeoutThreshold = new Date(Date.now() - TIMEOUT_MS);
const staleResult = await db.collection("agent_tasks").updateMany(
{ status: "in_progress", updatedAt: { $lt: timeoutThreshold } },
{ $set: { status: "pending", updatedAt: new Date() } }
);
if (staleResult.modifiedCount > 0) {
// deno-lint-ignore no-console
console.log(`[CronJobs] ${staleResult.modifiedCount} hängende Task(s) auf pending zurückgesetzt`);
}
const inProgressCount = await db.collection("agent_tasks")
.countDocuments({ status: "in_progress" });
if (inProgressCount > 0) {
await db.collection<CronJob>("cronjobs").updateOne(
{ _id: new ObjectId(jobId) },
{ $set: { lastRun: now, lastResult: `Agent arbeitet bereits (${inProgressCount} in_progress)`, nextRun } }
);
return;
}
// Wake-Webhook aufrufen
const openclawBaseUrl = Deno.env.get("OPENCLAW_WAKE_URL");
const openclawToken = Deno.env.get("OPENCLAW_WAKE_TOKEN");
const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY") || "";
if (!openclawBaseUrl || !openclawToken) {
await db.collection<CronJob>("cronjobs").updateOne(
{ _id: new ObjectId(jobId) },
{ $set: { lastRun: now, lastResult: "Fehler: OpenClaw nicht konfiguriert" } }
);
return;
}
// Pending Tasks laden für Prompt
const pendingTasks = await db.collection("agent_tasks")
.find({ status: "pending" })
.sort({ createdAt: 1 })
.toArray();
const taskList = pendingTasks.map((t, i) =>
`${i + 1}. [${t._id}] "${t.message}" (Projekt: ${t.projectName || ""}, von: ${t.createdByName})`
).join("\n");
const prompt = `WICHTIG: Lade zuerst CHECKLIST.md und WORKFLOW.md aus deinem Workspace und halte dich strikt daran!
Neue Agent-Tasks im AMS (pending):
${taskList}
Bearbeite jeden Task nach der CHECKLIST.md:
1. Task im AMS auf "in_progress" setzen
2. Feature-Branch erstellen
3. Backend ZUERST implementieren (bei Full-Stack)
4. Dark Theme beachten!
5. Self-Review: npm run build / deno check
6. Conventional Commits auf DEUTSCH
7. MR erstellen und mergen
8. Tag + Deploy
9. Agent-Task auf "done" setzen: curl -X PUT https://api.ams.agentenbude.de/api/agent-tasks/<ID> -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"done","result":"<Zusammenfassung>"}'
10. Task-Kommentar + Chef informieren
API-Auth: Bearer ${serviceKey}
AMS-API: https://api.ams.agentenbude.de/api`;
const agentUrl = openclawBaseUrl.replace("/hooks/wake", "/hooks/agent");
try {
const response = await fetch(agentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${openclawToken}`,
},
body: JSON.stringify({
message: prompt,
name: "AMS-Task",
sessionKey: "hook:ams-task",
wakeMode: "now",
deliver: true,
channel: "telegram",
to: "7150608398",
}),
signal: AbortSignal.timeout(5000),
});
const resultText = response.ok
? `Agent gestartet (${pendingCount} Tasks, ${now.toISOString()})`
: `Fehler: HTTP ${response.status}`;
// nextRun basierend auf intervalMinutes des Jobs berechnen
const jobDoc = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(jobId) });
const nextRun = jobDoc ? calculateNextRun(jobDoc.intervalMinutes) : null;
await db.collection<CronJob>("cronjobs").updateOne(
{ _id: new ObjectId(jobId) },
{ $set: { lastRun: now, lastResult: resultText, nextRun } }
);
} catch (err) {
const jobDoc = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(jobId) }).catch(() => null);
const nextRun = jobDoc ? calculateNextRun(jobDoc.intervalMinutes) : null;
await db.collection<CronJob>("cronjobs").updateOne(
{ _id: new ObjectId(jobId) },
{ $set: { lastRun: now, lastResult: `Fehler: ${String(err)}`, nextRun } }
);
}
}
function startTimer(job: CronJob) {
stopTimer(job._id.toString());
const id = job._id.toString();
const intervalMs = job.intervalMinutes * 60 * 1000;
// Berechne Delay bis zum nächsten Lauf
let initialDelay = intervalMs;
if (job.nextRun) {
const msUntilNext = new Date(job.nextRun).getTime() - Date.now();
if (msUntilNext <= 0) {
// Überfällig → sofort ausführen, dann normales Intervall
executeWakeCheck(id);
initialDelay = intervalMs;
} else {
initialDelay = msUntilNext;
}
}
// Erster Lauf nach initialDelay, dann reguläres Intervall
const firstTimer = setTimeout(() => {
executeWakeCheck(id);
// Ab jetzt reguläres Intervall
const recurringTimer = setInterval(() => {
executeWakeCheck(id);
}, intervalMs);
activeTimers.set(id, recurringTimer as unknown as number);
}, initialDelay);
activeTimers.set(id, firstTimer as unknown as number);
}
function stopTimer(jobId: string) {
const existing = activeTimers.get(jobId);
if (existing) {
clearInterval(existing);
activeTimers.delete(jobId);
}
}
// Beim Server-Start: aktive Jobs laden und Timer starten
export async function initCronJobs() {
try {
const db = await getDB();
const jobs = await db.collection<CronJob>("cronjobs")
.find({ enabled: true })
.toArray();
for (const job of jobs) {
startTimer(job);
}
if (jobs.length > 0) {
// deno-lint-ignore no-console
console.log(`[CronJobs] ${jobs.length} aktive Job(s) gestartet`);
}
} catch {
// DB noch nicht bereit — wird beim ersten Request initialisiert
}
}
// Alle CronJobs listen
cronJobsRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const jobs = await db.collection<CronJob>("cronjobs")
.find()
.sort({ createdAt: -1 })
.toArray();
// Timer-Status hinzufügen
const jobsWithStatus = jobs.map(j => ({
...j,
timerActive: activeTimers.has(j._id.toString()),
}));
ctx.response.body = { jobs: jobsWithStatus };
});
// CronJob erstellen
cronJobsRouter.post("/", authMiddleware, async (ctx) => {
const db = await getDB();
const body = await ctx.request.body.json();
const { name, description, intervalMinutes, enabled } = body;
if (!name || !intervalMinutes || intervalMinutes < 1) {
ctx.response.status = 400;
ctx.response.body = { error: "Name und intervalMinutes (>= 1) erforderlich" };
return;
}
const now = new Date();
const doc: Omit<CronJob, "_id"> = {
name,
description: description || "",
intervalMinutes: Number(intervalMinutes),
enabled: enabled !== false,
lastRun: null,
lastResult: null,
nextRun: enabled !== false ? calculateNextRun(Number(intervalMinutes)) : null,
createdBy: ctx.state.user.id,
createdAt: now,
updatedAt: now,
};
const result = await db.collection<CronJob>("cronjobs").insertOne(doc as CronJob);
const insertedJob = await db.collection<CronJob>("cronjobs").findOne({ _id: result.insertedId });
if (insertedJob && insertedJob.enabled) {
startTimer(insertedJob);
}
ctx.response.status = 201;
ctx.response.body = { message: "CronJob erstellt", id: result.insertedId.toString() };
});
// CronJob aktualisieren
cronJobsRouter.put("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const id = ctx.params.id;
const body = await ctx.request.body.json();
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined) updateFields.name = body.name;
if (body.description !== undefined) updateFields.description = body.description;
if (body.intervalMinutes !== undefined) {
if (Number(body.intervalMinutes) < 1) {
ctx.response.status = 400;
ctx.response.body = { error: "intervalMinutes muss >= 1 sein" };
return;
}
updateFields.intervalMinutes = Number(body.intervalMinutes);
}
if (body.enabled !== undefined) {
updateFields.enabled = body.enabled;
if (body.enabled) {
const interval = body.intervalMinutes || (await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(id) }))?.intervalMinutes || 30;
updateFields.nextRun = calculateNextRun(Number(interval));
} else {
updateFields.nextRun = null;
}
}
try {
const res = await db.collection<CronJob>("cronjobs").updateOne(
{ _id: new ObjectId(id) },
{ $set: updateFields }
);
if (res.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "CronJob nicht gefunden" };
return;
}
// Timer neu starten/stoppen
const updatedJob = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(id) });
if (updatedJob) {
if (updatedJob.enabled) {
startTimer(updatedJob);
} else {
stopTimer(id);
}
}
ctx.response.body = { message: "CronJob aktualisiert" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
// CronJob löschen
cronJobsRouter.delete("/:id", authMiddleware, async (ctx) => {
const id = ctx.params.id;
stopTimer(id);
const db = await getDB();
try {
await db.collection<CronJob>("cronjobs").deleteOne({ _id: new ObjectId(id) });
ctx.response.body = { message: "CronJob gelöscht" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
// CronJob manuell ausführen
cronJobsRouter.post("/:id/run", authMiddleware, async (ctx) => {
const id = ctx.params.id;
const db = await getDB();
const job = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(id) });
if (!job) {
ctx.response.status = 404;
ctx.response.body = { error: "CronJob nicht gefunden" };
return;
}
await executeWakeCheck(id);
ctx.response.body = { message: "CronJob manuell ausgeführt" };
});

240
src/routes/docker.ts Normal file
View File

@@ -0,0 +1,240 @@
import { Router, Context, Next } from "@oak/oak";
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
export const dockerRouter = new Router({ prefix: "/api/docker" });
/**
* Admin-only guard — must be used AFTER authMiddleware.
* Rejects non-admin users with 403.
*/
async function adminOnly(ctx: Context, next: Next) {
const user = (ctx.state as AuthState).user;
if (user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
await next();
}
/**
* Validates the container id parameter to prevent command injection.
* Allows alphanumeric, hyphens, underscores, and dots (Docker container names/IDs).
*/
function validateContainerId(id: string): boolean {
return /^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$/.test(id);
}
// List all Docker containers
dockerRouter.get("/containers", authMiddleware, adminOnly, async (ctx) => {
try {
const cmd = new Deno.Command("docker", {
args: ["ps", "-a", "--format", "json"],
stdout: "piped",
stderr: "piped",
});
const { stdout, stderr, success } = await cmd.output();
if (!success) {
const errorText = new TextDecoder().decode(stderr);
ctx.response.status = 500;
ctx.response.body = { error: "Failed to list containers", details: errorText };
return;
}
const output = new TextDecoder().decode(stdout);
const lines = output.trim().split("\n").filter(line => line.length > 0);
const containers = lines.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
}).filter(c => c !== null);
ctx.response.body = { success: true, containers };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Error listing containers:", message);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error", message };
}
});
// Get container details
dockerRouter.get("/containers/:id", authMiddleware, adminOnly, async (ctx) => {
try {
const { id } = ctx.params;
if (!id || !validateContainerId(id)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid container ID" };
return;
}
const cmd = new Deno.Command("docker", {
args: ["inspect", id],
stdout: "piped",
stderr: "piped",
});
const { stdout, stderr, success } = await cmd.output();
if (!success) {
const errorText = new TextDecoder().decode(stderr);
ctx.response.status = 404;
ctx.response.body = { error: "Container not found", details: errorText };
return;
}
const output = new TextDecoder().decode(stdout);
const containerData = JSON.parse(output);
ctx.response.body = { success: true, container: containerData[0] };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Error getting container:", message);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error", message };
}
});
// Start container
dockerRouter.post("/containers/:id/start", authMiddleware, adminOnly, async (ctx) => {
try {
const { id } = ctx.params;
if (!id || !validateContainerId(id)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid container ID" };
return;
}
const cmd = new Deno.Command("docker", {
args: ["start", id],
stdout: "piped",
stderr: "piped",
});
const { stderr, success } = await cmd.output();
if (!success) {
const errorText = new TextDecoder().decode(stderr);
ctx.response.status = 500;
ctx.response.body = { error: "Failed to start container", details: errorText };
return;
}
ctx.response.body = { success: true, message: `Container ${id} started` };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Error starting container:", message);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error", message };
}
});
// Stop container
dockerRouter.post("/containers/:id/stop", authMiddleware, adminOnly, async (ctx) => {
try {
const { id } = ctx.params;
if (!id || !validateContainerId(id)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid container ID" };
return;
}
const cmd = new Deno.Command("docker", {
args: ["stop", id],
stdout: "piped",
stderr: "piped",
});
const { stderr, success } = await cmd.output();
if (!success) {
const errorText = new TextDecoder().decode(stderr);
ctx.response.status = 500;
ctx.response.body = { error: "Failed to stop container", details: errorText };
return;
}
ctx.response.body = { success: true, message: `Container ${id} stopped` };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Error stopping container:", message);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error", message };
}
});
// Restart container
dockerRouter.post("/containers/:id/restart", authMiddleware, adminOnly, async (ctx) => {
try {
const { id } = ctx.params;
if (!id || !validateContainerId(id)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid container ID" };
return;
}
const cmd = new Deno.Command("docker", {
args: ["restart", id],
stdout: "piped",
stderr: "piped",
});
const { stderr, success } = await cmd.output();
if (!success) {
const errorText = new TextDecoder().decode(stderr);
ctx.response.status = 500;
ctx.response.body = { error: "Failed to restart container", details: errorText };
return;
}
ctx.response.body = { success: true, message: `Container ${id} restarted` };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Error restarting container:", message);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error", message };
}
});
// Get container logs
dockerRouter.get("/containers/:id/logs", authMiddleware, adminOnly, async (ctx) => {
try {
const { id } = ctx.params;
if (!id || !validateContainerId(id)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid container ID" };
return;
}
const tailParam = ctx.request.url.searchParams.get("tail") || "100";
const tail = Math.min(Math.max(parseInt(tailParam, 10) || 100, 1), 5000).toString();
const cmd = new Deno.Command("docker", {
args: ["logs", "--tail", tail, id],
stdout: "piped",
stderr: "piped",
});
const { stdout, stderr, success } = await cmd.output();
if (!success) {
const errorText = new TextDecoder().decode(stderr);
ctx.response.status = 404;
ctx.response.body = { error: "Failed to get logs", details: errorText };
return;
}
const logs = new TextDecoder().decode(stdout);
ctx.response.body = { success: true, logs };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Error getting logs:", message);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error", message };
}
});

138
src/routes/export.ts Normal file
View File

@@ -0,0 +1,138 @@
import { Router } from "@oak/oak";
import { getDB } from "../db/mongo.ts";
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
import type { Context, Next } from "@oak/oak";
export const exportRouter = new Router({ prefix: "/api/data" });
async function adminOnly(ctx: Context, next: Next) {
const user = (ctx.state as AuthState).user;
if (user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
await next();
}
// Export all data as JSON
exportRouter.get("/export", authMiddleware, adminOnly, async (ctx) => {
try {
const db = await getDB();
const url = ctx.request.url;
// Which collections to export (default: all)
const collectionsParam = url.searchParams.get("collections");
const available = ["tasks", "projects", "agents", "labels", "logs", "users"];
const requested = collectionsParam
? collectionsParam.split(",").filter((c) => available.includes(c))
: available;
const data: Record<string, unknown[]> = {};
for (const col of requested) {
data[col] = await db.collection(col).find({}).toArray();
}
const exportPayload = {
version: "1.0",
exportedAt: new Date().toISOString(),
collections: requested,
data,
};
ctx.response.headers.set(
"Content-Disposition",
`attachment; filename="ams-export-${new Date().toISOString().slice(0, 10)}.json"`
);
ctx.response.type = "application/json";
ctx.response.body = exportPayload;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
ctx.response.status = 500;
ctx.response.body = { error: "Export failed", message };
}
});
// Import data from JSON
exportRouter.post("/import", authMiddleware, adminOnly, async (ctx) => {
try {
const body = await ctx.request.body.json();
const { data, mode } = body as {
data: Record<string, unknown[]>;
mode: "merge" | "replace";
};
if (!data || typeof data !== "object") {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid import data" };
return;
}
const db = await getDB();
const available = ["tasks", "projects", "agents", "labels", "logs"];
const results: Record<string, { inserted: number; skipped: number }> = {};
for (const [collection, documents] of Object.entries(data)) {
// Skip users for security
if (!available.includes(collection) || !Array.isArray(documents)) continue;
if (mode === "replace") {
await db.collection(collection).deleteMany({});
}
let inserted = 0;
let skipped = 0;
for (const doc of documents) {
try {
const { _id, ...rest } = doc as Record<string, unknown>;
if (mode === "merge" && _id) {
// Try to find existing by _id, skip if exists
const existing = await db
.collection(collection)
.findOne({ _id });
if (existing) {
skipped++;
continue;
}
}
await db.collection(collection).insertOne(rest);
inserted++;
} catch {
skipped++;
}
}
results[collection] = { inserted, skipped };
}
ctx.response.body = {
success: true,
message: "Import completed",
results,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
ctx.response.status = 500;
ctx.response.body = { error: "Import failed", message };
}
});
// Get export stats (what's available to export)
exportRouter.get("/stats", authMiddleware, adminOnly, async (ctx) => {
try {
const db = await getDB();
const collections = ["tasks", "projects", "agents", "labels", "logs", "users"];
const stats: Record<string, number> = {};
for (const col of collections) {
stats[col] = await db.collection(col).countDocuments({});
}
ctx.response.body = { stats };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
ctx.response.status = 500;
ctx.response.body = { error: "Failed to get stats", message };
}
});

697
src/routes/gitlab.ts Normal file
View File

@@ -0,0 +1,697 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { authMiddleware } from "../middleware/auth.ts";
import { getDB } from "../db/mongo.ts";
import {
fetchGitLabIssues,
importGitLabIssue,
fullSync,
syncTaskToGitLab,
createGitLabIssueFromTask,
getMappingForTask,
getMappingsForProject,
deleteMapping,
type GitLabIssue,
type IssueMappingDoc,
} from "../utils/gitlabSync.ts";
const router = new Router();
// GitLab API configuration
const GITLAB_URL = Deno.env.get("GITLAB_URL") || "https://gitlab.agentenbude.de";
const GITLAB_TOKEN = Deno.env.get("GITLAB_TOKEN") || "";
// Get user's GitLab token or fall back to global
async function getUserGitLabToken(userId: string): Promise<string> {
const db = await getDB();
const user = await db.collection("users").findOne({ _id: new ObjectId(userId) });
return user?.gitlabToken || GITLAB_TOKEN;
}
// Unicode-safe base64 decode (handles UTF-8 content)
function base64Decode(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
// Unicode-safe base64 encode (handles UTF-8 content)
function base64Encode(text: string): string {
const bytes = new TextEncoder().encode(text);
return btoa(String.fromCharCode(...bytes));
}
interface GitLabProject {
id: number;
name: string;
name_with_namespace: string;
path_with_namespace: string;
description: string | null;
default_branch: string;
web_url: string;
last_activity_at: string;
}
interface GitLabBranch {
name: string;
commit: {
id: string;
short_id: string;
title: string;
author_name: string;
authored_date: string;
};
default: boolean;
protected: boolean;
}
interface GitLabCommit {
id: string;
short_id: string;
title: string;
message: string;
author_name: string;
author_email: string;
authored_date: string;
committed_date: string;
web_url: string;
}
interface GitLabTreeItem {
id: string;
name: string;
type: "tree" | "blob";
path: string;
mode: string;
}
// Helper function to call GitLab API
async function gitlabFetch<T>(endpoint: string, options: RequestInit = {}, token?: string): Promise<T> {
const url = `${GITLAB_URL}/api/v4${endpoint}`;
const headers = {
"PRIVATE-TOKEN": token || GITLAB_TOKEN,
"Content-Type": "application/json",
...options.headers,
};
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
const error = await response.text();
throw new Error(`GitLab API error (${response.status}): ${error}`);
}
return response.json();
}
// GET /api/gitlab/projects - List all accessible projects
router.get("/api/gitlab/projects", authMiddleware, async (ctx) => {
try {
const token = await getUserGitLabToken(ctx.state.user.id);
const projects = await gitlabFetch<GitLabProject[]>("/projects?membership=true&order_by=last_activity_at&per_page=100", {}, token);
ctx.response.body = projects.map(p => ({
id: p.id,
name: p.name,
fullName: p.name_with_namespace,
path: p.path_with_namespace,
description: p.description,
defaultBranch: p.default_branch,
webUrl: p.web_url,
lastActivity: p.last_activity_at,
}));
} catch (error: unknown) {
console.error("GitLab projects error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// GET /api/gitlab/projects/:id - Get single project details
router.get("/api/gitlab/projects/:id", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
try {
const project = await gitlabFetch<GitLabProject>(`/projects/${projectId}`);
ctx.response.body = {
id: project.id,
name: project.name,
fullName: project.name_with_namespace,
path: project.path_with_namespace,
description: project.description,
defaultBranch: project.default_branch,
webUrl: project.web_url,
lastActivity: project.last_activity_at,
};
} catch (error: unknown) {
console.error("GitLab project error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// GET /api/gitlab/projects/:id/branches - List branches
router.get("/api/gitlab/projects/:id/branches", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
try {
const branches = await gitlabFetch<GitLabBranch[]>(`/projects/${projectId}/repository/branches?per_page=100`);
ctx.response.body = branches.map(b => ({
name: b.name,
isDefault: b.default,
isProtected: b.protected,
lastCommit: {
id: b.commit.id,
shortId: b.commit.short_id,
title: b.commit.title,
author: b.commit.author_name,
date: b.commit.authored_date,
},
}));
} catch (error: unknown) {
console.error("GitLab branches error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// GET /api/gitlab/projects/:id/commits - List commits
router.get("/api/gitlab/projects/:id/commits", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
const branch = ctx.request.url.searchParams.get("branch") || "main";
const perPage = ctx.request.url.searchParams.get("per_page") || "50";
const page = ctx.request.url.searchParams.get("page") || "1";
try {
const commits = await gitlabFetch<GitLabCommit[]>(
`/projects/${projectId}/repository/commits?ref_name=${encodeURIComponent(branch)}&per_page=${perPage}&page=${page}`
);
ctx.response.body = commits.map(c => ({
id: c.id,
shortId: c.short_id,
title: c.title,
message: c.message,
author: c.author_name,
authorEmail: c.author_email,
date: c.authored_date,
webUrl: c.web_url,
}));
} catch (error: unknown) {
console.error("GitLab commits error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// GET /api/gitlab/projects/:id/tree - List files/folders in a path
router.get("/api/gitlab/projects/:id/tree", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
const branch = ctx.request.url.searchParams.get("branch") || "main";
const path = ctx.request.url.searchParams.get("path") || "";
try {
let endpoint = `/projects/${projectId}/repository/tree?ref=${encodeURIComponent(branch)}&per_page=100`;
if (path) {
endpoint += `&path=${encodeURIComponent(path)}`;
}
const items = await gitlabFetch<GitLabTreeItem[]>(endpoint);
// Sort: folders first, then files, both alphabetically
const sorted = items.sort((a, b) => {
if (a.type === "tree" && b.type === "blob") return -1;
if (a.type === "blob" && b.type === "tree") return 1;
return a.name.localeCompare(b.name);
});
ctx.response.body = sorted.map(item => ({
id: item.id,
name: item.name,
path: item.path,
type: item.type === "tree" ? "folder" : "file",
}));
} catch (error: unknown) {
console.error("GitLab tree error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// GET /api/gitlab/projects/:id/file - Get file content
router.get("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
const branch = ctx.request.url.searchParams.get("branch") || "main";
const path = ctx.request.url.searchParams.get("path") || "";
if (!path) {
ctx.response.status = 400;
ctx.response.body = { error: "Path parameter is required" };
return;
}
try {
const encodedPath = encodeURIComponent(path);
const file = await gitlabFetch<{ file_name: string; file_path: string; size: number; encoding: string; content: string; ref: string }>(
`/projects/${projectId}/repository/files/${encodedPath}?ref=${encodeURIComponent(branch)}`
);
// Unicode-safe base64 decode
const content = base64Decode(file.content);
ctx.response.body = {
name: file.file_name,
path: file.file_path,
size: file.size,
content: content,
branch: file.ref,
};
} catch (error: unknown) {
console.error("GitLab file error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// PUT /api/gitlab/projects/:id/file - Update file content
router.put("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
const body = await ctx.request.body.json();
const { path, branch, content, commitMessage } = body;
if (!path || !branch || content === undefined || !commitMessage) {
ctx.response.status = 400;
ctx.response.body = { error: "path, branch, content and commitMessage are required" };
return;
}
try {
const token = await getUserGitLabToken(ctx.state.user.id);
const encodedPath = encodeURIComponent(path);
const encodedContent = base64Encode(content);
await gitlabFetch(
`/projects/${projectId}/repository/files/${encodedPath}`,
{
method: "PUT",
body: JSON.stringify({
branch: branch,
content: encodedContent,
encoding: "base64",
commit_message: commitMessage,
}),
},
token
);
ctx.response.body = { success: true, message: "File updated successfully" };
} catch (error: unknown) {
console.error("GitLab file update error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// POST /api/gitlab/projects/:id/file - Create new file
router.post("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
const body = await ctx.request.body.json();
const { path, branch, content, commitMessage } = body;
if (!path || !branch || content === undefined || !commitMessage) {
ctx.response.status = 400;
ctx.response.body = { error: "path, branch, content and commitMessage are required" };
return;
}
try {
const token = await getUserGitLabToken(ctx.state.user.id);
const encodedPath = encodeURIComponent(path);
const encodedContent = base64Encode(content);
await gitlabFetch(
`/projects/${projectId}/repository/files/${encodedPath}`,
{
method: "POST",
body: JSON.stringify({
branch: branch,
content: encodedContent,
encoding: "base64",
commit_message: commitMessage,
}),
},
token
);
ctx.response.body = { success: true, message: "File created successfully" };
} catch (error: unknown) {
console.error("GitLab file create error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// DELETE /api/gitlab/projects/:id/file - Delete file
router.delete("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
const body = await ctx.request.body.json();
const { path, branch, commitMessage } = body;
if (!path || !branch || !commitMessage) {
ctx.response.status = 400;
ctx.response.body = { error: "path, branch and commitMessage are required" };
return;
}
try {
const token = await getUserGitLabToken(ctx.state.user.id);
const encodedPath = encodeURIComponent(path);
await gitlabFetch(
`/projects/${projectId}/repository/files/${encodedPath}`,
{
method: "DELETE",
body: JSON.stringify({
branch: branch,
commit_message: commitMessage,
}),
},
token
);
ctx.response.body = { success: true, message: "File deleted successfully" };
} catch (error: unknown) {
console.error("GitLab file delete error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// GET /api/gitlab/projects/:id/commit/:sha - Get single commit details
router.get("/api/gitlab/projects/:id/commit/:sha", authMiddleware, async (ctx) => {
const projectId = ctx.params.id;
const sha = ctx.params.sha;
try {
const commit = await gitlabFetch<GitLabCommit & { stats: { additions: number; deletions: number; total: number } }>(
`/projects/${projectId}/repository/commits/${sha}`
);
const diff = await gitlabFetch<Array<{ old_path: string; new_path: string; diff: string }>>(
`/projects/${projectId}/repository/commits/${sha}/diff`
);
ctx.response.body = {
id: commit.id,
shortId: commit.short_id,
title: commit.title,
message: commit.message,
author: commit.author_name,
authorEmail: commit.author_email,
date: commit.authored_date,
webUrl: commit.web_url,
stats: commit.stats,
diff: diff.map(d => ({
oldPath: d.old_path,
newPath: d.new_path,
diff: d.diff,
})),
};
} catch (error: unknown) {
console.error("GitLab commit detail error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// ============ GITLAB ISSUES SYNC ============
// GET /api/gitlab/projects/:id/issues - List GitLab issues
router.get("/api/gitlab/projects/:id/issues", authMiddleware, async (ctx) => {
const projectId = parseInt(ctx.params.id);
const state = ctx.request.url.searchParams.get("state") || "all";
const page = parseInt(ctx.request.url.searchParams.get("page") || "1");
const perPage = parseInt(ctx.request.url.searchParams.get("per_page") || "50");
try {
const token = await getUserGitLabToken(ctx.state.user.id);
const issues = await fetchGitLabIssues(projectId, { state, page, perPage }, token);
// Check which issues are already mapped
const db = await getDB();
const mappings = await db.collection<IssueMappingDoc>("gitlab_issue_mappings")
.find({ gitlabProjectId: projectId })
.toArray();
const mappedIids = new Set(mappings.map(m => m.gitlabIssueIid));
ctx.response.body = issues.map((issue: GitLabIssue) => ({
id: issue.id,
iid: issue.iid,
title: issue.title,
description: issue.description,
state: issue.state,
labels: issue.labels,
author: issue.author?.name || issue.author?.username,
assignees: issue.assignees?.map(a => a.name || a.username) || [],
createdAt: issue.created_at,
updatedAt: issue.updated_at,
dueDate: issue.due_date,
webUrl: issue.web_url,
isMapped: mappedIids.has(issue.iid),
amsTaskId: mappings.find(m => m.gitlabIssueIid === issue.iid)?.amsTaskId || null,
}));
} catch (error: unknown) {
console.error("GitLab issues error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// POST /api/gitlab/issues/import - Import specific GitLab issues as AMS tasks
router.post("/api/gitlab/issues/import", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { gitlabProjectId, issueIids, amsProjectId } = body;
if (!gitlabProjectId || !amsProjectId) {
ctx.response.status = 400;
ctx.response.body = { error: "gitlabProjectId and amsProjectId are required" };
return;
}
try {
const token = await getUserGitLabToken(ctx.state.user.id);
const results: { iid: number; taskId: string; status: string }[] = [];
if (issueIids && Array.isArray(issueIids)) {
// Import specific issues
for (const iid of issueIids) {
const issues = await fetchGitLabIssues(gitlabProjectId, { state: "all", perPage: 1 }, token);
// Fetch single issue by IID
const issue = await (async () => {
try {
return await (await fetch(`${Deno.env.get("GITLAB_URL") || "https://gitlab.agentenbude.de"}/api/v4/projects/${gitlabProjectId}/issues/${iid}`, {
headers: { "PRIVATE-TOKEN": token },
})).json() as GitLabIssue;
} catch {
return null;
}
})();
if (issue) {
const { taskId } = await importGitLabIssue(issue, amsProjectId, ctx.state.user.id, token);
results.push({ iid, taskId, status: "imported" });
} else {
results.push({ iid, taskId: "", status: "not_found" });
}
}
}
ctx.response.body = { results, count: results.filter(r => r.status === "imported").length };
} catch (error: unknown) {
console.error("GitLab import error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// POST /api/gitlab/issues/sync - Full sync: import all issues from GitLab project
router.post("/api/gitlab/issues/sync", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { gitlabProjectId, amsProjectId } = body;
if (!gitlabProjectId || !amsProjectId) {
ctx.response.status = 400;
ctx.response.body = { error: "gitlabProjectId and amsProjectId are required" };
return;
}
try {
const result = await fullSync(gitlabProjectId, amsProjectId, ctx.state.user.id, ctx.state.user.id);
ctx.response.body = result;
} catch (error: unknown) {
console.error("GitLab sync error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// POST /api/gitlab/issues/push - Push AMS task changes to GitLab
router.post("/api/gitlab/issues/push", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { amsTaskId } = body;
if (!amsTaskId) {
ctx.response.status = 400;
ctx.response.body = { error: "amsTaskId is required" };
return;
}
try {
const success = await syncTaskToGitLab(amsTaskId, ctx.state.user.id);
if (success) {
ctx.response.body = { message: "Task synced to GitLab" };
} else {
ctx.response.status = 404;
ctx.response.body = { error: "No mapping found or sync not enabled" };
}
} catch (error: unknown) {
console.error("GitLab push error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// POST /api/gitlab/issues/create - Create GitLab issue from AMS task
router.post("/api/gitlab/issues/create", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { amsTaskId, gitlabProjectId, amsProjectId } = body;
if (!amsTaskId || !gitlabProjectId) {
ctx.response.status = 400;
ctx.response.body = { error: "amsTaskId and gitlabProjectId are required" };
return;
}
try {
const issue = await createGitLabIssueFromTask(amsTaskId, gitlabProjectId, amsProjectId || "", ctx.state.user.id);
ctx.response.body = {
message: "GitLab issue created",
issue: {
iid: issue.iid,
webUrl: issue.web_url,
title: issue.title,
},
};
} catch (error: unknown) {
console.error("GitLab issue create error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// GET /api/gitlab/mappings/:taskId - Get mapping for a task
router.get("/api/gitlab/mappings/:taskId", authMiddleware, async (ctx) => {
try {
const mapping = await getMappingForTask(ctx.params.taskId);
if (!mapping) {
ctx.response.status = 404;
ctx.response.body = { error: "No mapping found" };
return;
}
ctx.response.body = { mapping };
} catch (error: unknown) {
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// GET /api/gitlab/mappings/project/:projectId - Get all mappings for AMS project
router.get("/api/gitlab/mappings/project/:projectId", authMiddleware, async (ctx) => {
try {
const mappings = await getMappingsForProject(ctx.params.projectId);
ctx.response.body = { mappings };
} catch (error: unknown) {
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// DELETE /api/gitlab/mappings/:taskId - Remove mapping (unlink)
router.delete("/api/gitlab/mappings/:taskId", authMiddleware, async (ctx) => {
try {
const deleted = await deleteMapping(ctx.params.taskId);
if (deleted) {
ctx.response.body = { message: "Mapping removed" };
} else {
ctx.response.status = 404;
ctx.response.body = { error: "No mapping found" };
}
} catch (error: unknown) {
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
// POST /api/gitlab/webhook - GitLab webhook for issue events (bidirectional sync)
router.post("/api/gitlab/webhook", async (ctx) => {
// Verify webhook token
const webhookToken = ctx.request.headers.get("X-Gitlab-Token");
const expectedToken = Deno.env.get("GITLAB_WEBHOOK_SECRET") || "";
if (expectedToken && webhookToken !== expectedToken) {
ctx.response.status = 401;
ctx.response.body = { error: "Invalid webhook token" };
return;
}
try {
const body = await ctx.request.body.json();
const eventType = body.object_kind;
if (eventType === "issue") {
const issue = body.object_attributes as GitLabIssue & { action: string };
const db = await getDB();
const mapping = await db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({
gitlabProjectId: issue.project_id,
gitlabIssueIid: issue.iid,
});
if (mapping && mapping.syncDirection !== "ams_to_gitlab") {
// Sync GitLab changes to AMS task
const updateFields: Record<string, unknown> = {
updatedAt: new Date(),
};
if (issue.title) updateFields.title = `[GL#${issue.iid}] ${issue.title}`;
if (issue.description !== undefined) updateFields.description = issue.description || "";
if (issue.state) {
updateFields.status = issue.state === "closed" ? "done" : "todo";
}
if (issue.due_date) updateFields.dueDate = new Date(issue.due_date);
await db.collection("tasks").updateOne(
{ _id: new ObjectId(mapping.amsTaskId) },
{ $set: updateFields }
);
await db.collection("gitlab_issue_mappings").updateOne(
{ _id: mapping._id },
{ $set: { lastSyncedAt: new Date() } }
);
}
}
ctx.response.body = { received: true };
} catch (error: unknown) {
console.error("GitLab webhook error:", error);
ctx.response.status = 500;
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
}
});
export default router;

375
src/routes/labels.ts Normal file
View File

@@ -0,0 +1,375 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const labelsRouter = new Router({ prefix: "/api/labels" });
// Valid parent types for label assignments
const VALID_PARENT_TYPES = ["task", "project", "agent", "comment", "attachment"] as const;
type ParentType = typeof VALID_PARENT_TYPES[number];
interface Label {
_id: ObjectId;
name: string;
color: string;
createdAt: Date;
}
interface LabelAssignment {
_id: ObjectId;
labelId: string;
parentType: ParentType;
parentId: string;
createdAt: Date;
}
// ============ LABEL MANAGEMENT ============
// Get all labels
labelsRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const labels = await db.collection<Label>("labels")
.find({})
.sort({ name: 1 })
.toArray();
ctx.response.body = { labels };
});
// Create label (admin only)
labelsRouter.post("/", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const body = await ctx.request.body.json();
const { name, color } = body;
if (!name) {
ctx.response.status = 400;
ctx.response.body = { error: "Name is required" };
return;
}
const db = await getDB();
// Check if label with same name exists
const existing = await db.collection<Label>("labels").findOne({ name });
if (existing) {
ctx.response.status = 400;
ctx.response.body = { error: "Label with this name already exists" };
return;
}
const result = await db.collection<Label>("labels").insertOne({
name,
color: color || "#6366f1",
createdAt: new Date()
} as Label);
ctx.response.status = 201;
ctx.response.body = {
message: "Label created",
id: result.insertedId.toString()
};
});
// Update label (admin only)
labelsRouter.put("/:id", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const id = ctx.params.id;
const body = await ctx.request.body.json();
const { name, color } = body;
const db = await getDB();
try {
const updateFields: Partial<Label> = {};
if (name !== undefined) updateFields.name = name;
if (color !== undefined) updateFields.color = color;
const result = await db.collection<Label>("labels").updateOne(
{ _id: new ObjectId(id) },
{ $set: updateFields }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Label not found" };
return;
}
ctx.response.body = { message: "Label updated" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid label ID" };
}
});
// Get label usage count
// GET /api/labels/:id/usage
labelsRouter.get("/:id/usage", authMiddleware, async (ctx) => {
const id = ctx.params.id;
const db = await getDB();
try {
new ObjectId(id);
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid label ID" };
return;
}
const assignmentCount = await db.collection("label_assignments").countDocuments({ labelId: id });
const taskCount = await db.collection("tasks").countDocuments({ labels: id });
ctx.response.body = {
labelId: id,
usage: {
assignments: assignmentCount,
tasks: taskCount,
total: assignmentCount + taskCount,
},
};
});
// Delete label (admin only) - supports optional replacement
// Query params: ?replacementId=... to replace with another label instead of just removing
labelsRouter.delete("/:id", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const id = ctx.params.id;
const replacementId = ctx.request.url.searchParams.get("replacementId");
const db = await getDB();
try {
// Verify label exists
const label = await db.collection<Label>("labels").findOne({ _id: new ObjectId(id) });
if (!label) {
ctx.response.status = 404;
ctx.response.body = { error: "Label not found" };
return;
}
// If replacement requested, verify replacement label exists
if (replacementId) {
const replacement = await db.collection<Label>("labels").findOne({ _id: new ObjectId(replacementId) });
if (!replacement) {
ctx.response.status = 404;
ctx.response.body = { error: "Replacement label not found" };
return;
}
// Replace in label_assignments: update labelId, skip duplicates
const assignments = await db.collection("label_assignments").find({ labelId: id }).toArray();
for (const assignment of assignments) {
const duplicate = await db.collection("label_assignments").findOne({
labelId: replacementId,
parentType: assignment.parentType,
parentId: assignment.parentId,
});
if (duplicate) {
await db.collection("label_assignments").deleteOne({ _id: assignment._id });
} else {
await db.collection("label_assignments").updateOne(
{ _id: assignment._id },
{ $set: { labelId: replacementId } }
);
}
}
// Replace in tasks.labels array
const tasksWithLabel = await db.collection("tasks").find({ labels: id }).toArray();
for (const task of tasksWithLabel) {
const taskLabels = (task.labels as string[]).filter((l: string) => l !== id);
if (!taskLabels.includes(replacementId)) {
taskLabels.push(replacementId);
}
await db.collection("tasks").updateOne(
{ _id: task._id },
{ $set: { labels: taskLabels } }
);
}
} else {
// Just remove: delete assignments and pull from tasks
await db.collection("label_assignments").deleteMany({ labelId: id });
await db.collection("tasks").updateMany(
{ labels: id },
// deno-lint-ignore no-explicit-any
{ $pull: { labels: id } } as any
);
}
// Delete the label itself
await db.collection<Label>("labels").deleteOne({ _id: new ObjectId(id) });
ctx.response.body = {
message: replacementId ? "Label ersetzt und gelöscht" : "Label und Zuweisungen gelöscht",
};
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid label ID" };
}
});
// ============ LABEL ASSIGNMENTS ============
// Get labels for a parent
// GET /api/labels/for/:parentType/:parentId
labelsRouter.get("/for/:parentType/:parentId", authMiddleware, async (ctx) => {
const { parentType, parentId } = ctx.params;
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
return;
}
const db = await getDB();
// Get assignments
const assignments = await db.collection<LabelAssignment>("label_assignments")
.find({ parentType, parentId })
.toArray();
// Get label details
const labelIds = assignments.map(a => new ObjectId(a.labelId));
const labels = labelIds.length > 0
? await db.collection<Label>("labels").find({ _id: { $in: labelIds } }).toArray()
: [];
ctx.response.body = { labels };
});
// Assign label to a parent
// POST /api/labels/assign/:parentType/:parentId
labelsRouter.post("/assign/:parentType/:parentId", authMiddleware, async (ctx) => {
const { parentType, parentId } = ctx.params;
const body = await ctx.request.body.json();
const { labelId } = body;
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
return;
}
if (!labelId) {
ctx.response.status = 400;
ctx.response.body = { error: "labelId is required" };
return;
}
const db = await getDB();
// Check if label exists
try {
const label = await db.collection<Label>("labels").findOne({ _id: new ObjectId(labelId) });
if (!label) {
ctx.response.status = 404;
ctx.response.body = { error: "Label not found" };
return;
}
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid label ID" };
return;
}
// Check if already assigned
const existing = await db.collection<LabelAssignment>("label_assignments").findOne({
labelId, parentType, parentId
});
if (existing) {
ctx.response.body = { message: "Label already assigned" };
return;
}
await db.collection<LabelAssignment>("label_assignments").insertOne({
labelId,
parentType: parentType as ParentType,
parentId,
createdAt: new Date()
} as LabelAssignment);
ctx.response.status = 201;
ctx.response.body = { message: "Label assigned" };
});
// Remove label from a parent
// DELETE /api/labels/assign/:parentType/:parentId/:labelId
labelsRouter.delete("/assign/:parentType/:parentId/:labelId", authMiddleware, async (ctx) => {
const { parentType, parentId, labelId } = ctx.params;
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
return;
}
const db = await getDB();
const result = await db.collection("label_assignments").deleteOne({
labelId, parentType, parentId
});
if (result.deletedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Assignment not found" };
return;
}
ctx.response.body = { message: "Label removed" };
});
// Set all labels for a parent (replace existing)
// PUT /api/labels/for/:parentType/:parentId
labelsRouter.put("/for/:parentType/:parentId", authMiddleware, async (ctx) => {
const { parentType, parentId } = ctx.params;
const body = await ctx.request.body.json();
const { labelIds } = body; // Array of label IDs
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
return;
}
if (!Array.isArray(labelIds)) {
ctx.response.status = 400;
ctx.response.body = { error: "labelIds must be an array" };
return;
}
const db = await getDB();
// Remove all existing assignments
await db.collection("label_assignments").deleteMany({ parentType, parentId });
// Add new assignments
if (labelIds.length > 0) {
const assignments = labelIds.map(labelId => ({
labelId,
parentType: parentType as ParentType,
parentId,
createdAt: new Date()
}));
await db.collection("label_assignments").insertMany(assignments);
}
ctx.response.body = { message: "Labels updated", count: labelIds.length };
});

188
src/routes/logs.ts Normal file
View File

@@ -0,0 +1,188 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const logsRouter = new Router({ prefix: "/api/logs" });
interface LogEntry {
_id: ObjectId;
agentId: string;
agentName: string;
level: "info" | "warn" | "error" | "debug";
message: string;
metadata?: Record<string, unknown>;
timestamp: Date;
}
// Get logs (with filters)
logsRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const url = ctx.request.url;
const filter: Record<string, unknown> = {};
const agentId = url.searchParams.get("agentId");
if (agentId) filter.agentId = agentId;
const level = url.searchParams.get("level");
if (level) filter.level = level;
const limit = parseInt(url.searchParams.get("limit") || "100");
const offset = parseInt(url.searchParams.get("offset") || "0");
// Time filter (last X hours)
const hours = url.searchParams.get("hours");
if (hours) {
const since = new Date(Date.now() - parseInt(hours) * 60 * 60 * 1000);
filter.timestamp = { $gte: since };
}
const logs = await db.collection<LogEntry>("logs")
.find(filter)
.sort({ timestamp: -1 })
.skip(offset)
.limit(limit)
.toArray();
const total = await db.collection<LogEntry>("logs").countDocuments(filter);
ctx.response.body = { logs, total, limit, offset };
});
// Create log entry (can be called by agents)
logsRouter.post("/", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { agentId, agentName, level, message, metadata } = body;
if (!message) {
ctx.response.status = 400;
ctx.response.body = { error: "Message is required" };
return;
}
const db = await getDB();
const result = await db.collection<LogEntry>("logs").insertOne({
agentId: agentId || ctx.state.user.id,
agentName: agentName || ctx.state.user.username,
level: level || "info",
message,
metadata,
timestamp: new Date()
} as LogEntry);
ctx.response.status = 201;
ctx.response.body = {
message: "Log created",
id: result.insertedId.toString()
};
});
// Bulk create logs (for batch uploads)
logsRouter.post("/bulk", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { entries } = body;
if (!Array.isArray(entries) || entries.length === 0) {
ctx.response.status = 400;
ctx.response.body = { error: "Entries array is required" };
return;
}
const db = await getDB();
const docs = entries.map((entry: Partial<LogEntry>) => ({
agentId: entry.agentId || ctx.state.user.id,
agentName: entry.agentName || ctx.state.user.username,
level: entry.level || "info",
message: entry.message || "",
metadata: entry.metadata,
timestamp: entry.timestamp ? new Date(entry.timestamp) : new Date()
}));
const result = await db.collection<LogEntry>("logs").insertMany(docs);
ctx.response.status = 201;
ctx.response.body = {
message: "Logs created",
count: result.insertedCount
};
});
// Get log statistics
logsRouter.get("/stats", authMiddleware, async (ctx) => {
const db = await getDB();
const url = ctx.request.url;
const hours = parseInt(url.searchParams.get("hours") || "24");
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
const stats = await db.collection<LogEntry>("logs").aggregate([
{ $match: { timestamp: { $gte: since } } },
{
$group: {
_id: { agentId: "$agentId", level: "$level" },
count: { $sum: 1 },
agentName: { $first: "$agentName" }
}
},
{
$group: {
_id: "$_id.agentId",
agentName: { $first: "$agentName" },
levels: {
$push: { level: "$_id.level", count: "$count" }
},
total: { $sum: "$count" }
}
},
{ $sort: { total: -1 } }
]).toArray();
// Overall stats
const overall = await db.collection<LogEntry>("logs").aggregate([
{ $match: { timestamp: { $gte: since } } },
{
$group: {
_id: "$level",
count: { $sum: 1 }
}
}
]).toArray();
ctx.response.body = {
byAgent: stats,
overall: overall.reduce((acc, item) => {
acc[item._id] = item.count;
return acc;
}, {} as Record<string, number>),
hours,
since: since.toISOString()
};
});
// Delete old logs (admin only)
logsRouter.delete("/cleanup", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const url = ctx.request.url;
const days = parseInt(url.searchParams.get("days") || "30");
const before = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const db = await getDB();
const result = await db.collection<LogEntry>("logs").deleteMany({
timestamp: { $lt: before }
});
ctx.response.body = {
message: "Old logs deleted",
deleted: result.deletedCount,
olderThan: before.toISOString()
};
});

70
src/routes/messaging.ts Normal file
View File

@@ -0,0 +1,70 @@
import { Router } from "@oak/oak";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const messagingRouter = new Router({ prefix: "/api/messaging" });
interface UserSettings {
userId: string;
telegramBotToken?: string;
telegramDefaultChatId?: string;
}
// Nachricht via Telegram Bot senden
messagingRouter.post("/telegram", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const body = await ctx.request.body.json();
const { text, chatId } = body;
if (!text) {
ctx.response.status = 400;
ctx.response.body = { error: "Text ist erforderlich" };
return;
}
// User-Settings laden
const settings = await db.collection<UserSettings>("user_settings")
.findOne({ userId });
if (!settings?.telegramBotToken) {
ctx.response.status = 400;
ctx.response.body = { error: "Telegram Bot-Token nicht konfiguriert. Bitte in den Einstellungen hinterlegen." };
return;
}
const targetChatId = chatId || settings.telegramDefaultChatId;
if (!targetChatId) {
ctx.response.status = 400;
ctx.response.body = { error: "Keine Chat-ID angegeben und kein Standard-Chat konfiguriert." };
return;
}
try {
const telegramUrl = `https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`;
const response = await fetch(telegramUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: targetChatId,
text,
parse_mode: "Markdown",
}),
});
const result = await response.json();
if (!result.ok) {
console.error("Telegram API Fehler:", result);
ctx.response.status = 502;
ctx.response.body = { error: "Telegram-Nachricht konnte nicht gesendet werden", details: result.description };
return;
}
ctx.response.body = { message: "Nachricht gesendet", messageId: result.result.message_id };
} catch (err) {
console.error("Telegram Fehler:", err);
ctx.response.status = 500;
ctx.response.body = { error: "Interner Fehler beim Senden" };
}
});

120
src/routes/quicktexts.ts Normal file
View File

@@ -0,0 +1,120 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const quickTextsRouter = new Router({ prefix: "/api/quicktexts" });
interface QuickText {
_id: ObjectId;
userId: string;
title: string;
text: string;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
// Alle Quick-Texts des Users laden
quickTextsRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const texts = await db.collection<QuickText>("quicktexts")
.find({ userId })
.sort({ sortOrder: 1, createdAt: 1 })
.toArray();
ctx.response.body = { quickTexts: texts };
});
// Quick-Text erstellen
quickTextsRouter.post("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const body = await ctx.request.body.json();
const { title, text } = body;
if (!title || !text) {
ctx.response.status = 400;
ctx.response.body = { error: "Titel und Text sind erforderlich" };
return;
}
// Nächste sortOrder ermitteln
const last = await db.collection<QuickText>("quicktexts")
.find({ userId })
.sort({ sortOrder: -1 })
.limit(1)
.toArray();
const sortOrder = last.length > 0 ? (last[0].sortOrder || 0) + 1 : 0;
const now = new Date();
const result = await db.collection<QuickText>("quicktexts").insertOne({
userId,
title,
text,
sortOrder,
createdAt: now,
updatedAt: now,
} as QuickText);
ctx.response.status = 201;
ctx.response.body = { message: "Quick-Text erstellt", id: result.insertedId.toString() };
});
// Quick-Text aktualisieren
quickTextsRouter.put("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const id = ctx.params.id;
const body = await ctx.request.body.json();
const { title, text, sortOrder } = body;
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
if (title !== undefined) updateFields.title = title;
if (text !== undefined) updateFields.text = text;
if (sortOrder !== undefined) updateFields.sortOrder = sortOrder;
try {
const result = await db.collection<QuickText>("quicktexts").updateOne(
{ _id: new ObjectId(id), userId },
{ $set: updateFields }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Quick-Text nicht gefunden" };
return;
}
ctx.response.body = { message: "Quick-Text aktualisiert" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
// Quick-Text löschen
quickTextsRouter.delete("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const id = ctx.params.id;
try {
const result = await db.collection<QuickText>("quicktexts").deleteOne(
{ _id: new ObjectId(id), userId }
);
if (result.deletedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Quick-Text nicht gefunden" };
return;
}
ctx.response.body = { message: "Quick-Text gelöscht" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});

View File

@@ -0,0 +1,131 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
// --- Interfaces ---
type AuditAction =
| "secret.created"
| "secret.read"
| "secret.updated"
| "secret.deleted"
| "secret.password_viewed"
| "folder.created"
| "folder.updated"
| "folder.deleted";
interface SecretAuditLog {
_id: ObjectId;
userId: string;
userName: string;
action: AuditAction;
targetType: "secret" | "folder";
targetId: string;
targetName: string;
details: Record<string, unknown>;
ip: string;
createdAt: Date;
}
// --- Helper: Audit-Log schreiben ---
export async function logSecretAudit(params: {
userId: string;
userName: string;
action: AuditAction;
targetType: "secret" | "folder";
targetId: string;
targetName: string;
details?: Record<string, unknown>;
ip?: string;
}): Promise<void> {
const db = await getDB();
await db.collection<SecretAuditLog>("secret_audit_logs").insertOne({
userId: params.userId,
userName: params.userName,
action: params.action,
targetType: params.targetType,
targetId: params.targetId,
targetName: params.targetName,
details: params.details || {},
ip: params.ip || "",
createdAt: new Date(),
} as SecretAuditLog);
}
// --- Router: Audit-Logs abfragen ---
export const secretAuditLogsRouter = new Router({ prefix: "/api/secret-audit-logs" });
secretAuditLogsRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const params = ctx.request.url.searchParams;
const page = Math.max(parseInt(params.get("page") || "1"), 1);
const limit = Math.min(Math.max(parseInt(params.get("limit") || "50"), 1), 200);
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = {};
const action = params.get("action");
if (action) filter.action = action;
const targetType = params.get("targetType");
if (targetType && ["secret", "folder"].includes(targetType)) {
filter.targetType = targetType;
}
const targetId = params.get("targetId");
if (targetId) filter.targetId = targetId;
const userId = params.get("userId");
if (userId) filter.userId = userId;
// Datumsfilter
const from = params.get("from");
const to = params.get("to");
if (from || to) {
const dateFilter: Record<string, Date> = {};
if (from) dateFilter.$gte = new Date(from);
if (to) dateFilter.$lte = new Date(to);
filter.createdAt = dateFilter;
}
const [logs, total] = await Promise.all([
db.collection<SecretAuditLog>("secret_audit_logs")
.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.toArray(),
db.collection<SecretAuditLog>("secret_audit_logs")
.countDocuments(filter),
]);
ctx.response.body = {
logs,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
};
});
// Logs für ein bestimmtes Secret/Folder
secretAuditLogsRouter.get("/target/:targetId", authMiddleware, async (ctx) => {
const db = await getDB();
const targetId = ctx.params.targetId;
const params = ctx.request.url.searchParams;
const limit = Math.min(Math.max(parseInt(params.get("limit") || "50"), 1), 200);
const logs = await db.collection<SecretAuditLog>("secret_audit_logs")
.find({ targetId })
.sort({ createdAt: -1 })
.limit(limit)
.toArray();
ctx.response.body = { logs };
});

576
src/routes/secrets.ts Normal file
View File

@@ -0,0 +1,576 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { logSecretAudit } from "./secretAuditLogs.ts";
export const secretsRouter = new Router({ prefix: "/api/secrets" });
export const secretFoldersRouter = new Router({ prefix: "/api/secret-folders" });
// --- Interfaces ---
type SharingMode = "private" | "users" | "all";
interface SecretFolder {
_id: ObjectId;
userId: string;
name: string;
parentId: string | null;
sharing: SharingMode;
sharedWith: string[];
createdAt: Date;
updatedAt: Date;
}
type SecretType = "login" | "note" | "card" | "ssh-key" | "other";
interface Secret {
_id: ObjectId;
userId: string;
folderId: string | null;
type: SecretType;
name: string;
username: string;
passwordEncrypted: string;
passwordIv: string;
passwordTag: string;
url: string;
notes: string;
cardHolder: string;
cardNumber: string;
cardExpiry: string;
cardCvv: string;
sshPublicKey: string;
sshPrivateKeyEncrypted: string;
sshPrivateKeyIv: string;
sshPrivateKeyTag: string;
sharing: SharingMode;
sharedWith: string[];
createdAt: Date;
updatedAt: Date;
}
// --- Encryption helpers ---
function getEncryptionKey(): CryptoKey | Promise<CryptoKey> {
const keyHex = Deno.env.get("SECRETS_ENCRYPTION_KEY");
if (!keyHex) {
throw new Error("SECRETS_ENCRYPTION_KEY nicht gesetzt");
}
const keyBytes = new Uint8Array(keyHex.match(/.{1,2}/g)!.map((b: string) => parseInt(b, 16)));
return crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]);
}
async function encrypt(plaintext: string): Promise<{ ciphertext: string; iv: string; tag: string }> {
if (!plaintext) return { ciphertext: "", iv: "", tag: "" };
const key = await getEncryptionKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
const buf = new Uint8Array(encrypted);
// AES-GCM appends 16-byte tag
const ciphertext = buf.slice(0, buf.length - 16);
const tag = buf.slice(buf.length - 16);
return {
ciphertext: toHex(ciphertext),
iv: toHex(iv),
tag: toHex(tag),
};
}
async function decrypt(ciphertext: string, ivHex: string, tagHex: string): Promise<string> {
if (!ciphertext) return "";
const key = await getEncryptionKey();
const iv = fromHex(ivHex);
const ct = fromHex(ciphertext);
const tag = fromHex(tagHex);
const combined = new Uint8Array(ct.length + tag.length);
combined.set(ct);
combined.set(tag, ct.length);
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, combined);
return new TextDecoder().decode(decrypted);
}
function toHex(buf: Uint8Array): string {
return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
}
function fromHex(hex: string): Uint8Array {
return new Uint8Array(hex.match(/.{1,2}/g)!.map((b) => parseInt(b, 16)));
}
// --- Password Generator ---
secretsRouter.get("/generate-password", authMiddleware, (ctx) => {
const params = ctx.request.url.searchParams;
const length = Math.min(Math.max(parseInt(params.get("length") || "16"), 4), 128);
const uppercase = params.get("uppercase") !== "false";
const lowercase = params.get("lowercase") !== "false";
const numbers = params.get("numbers") !== "false";
const symbols = params.get("symbols") !== "false";
let charset = "";
if (uppercase) charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (lowercase) charset += "abcdefghijklmnopqrstuvwxyz";
if (numbers) charset += "0123456789";
if (symbols) charset += "!@#$%^&*()_+-=[]{}|;:,.<>?";
if (!charset) charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const randomValues = crypto.getRandomValues(new Uint8Array(length));
const password = Array.from(randomValues).map((v) => charset[v % charset.length]).join("");
ctx.response.body = { password };
});
// --- Secrets CRUD ---
secretsRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const params = ctx.request.url.searchParams;
const search = params.get("search") || "";
const folderId = params.get("folder") || "";
// Eigene + geteilte Secrets (all oder user in sharedWith)
const accessFilter = { $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] };
const filter: Record<string, unknown> = { ...accessFilter };
if (folderId) filter.folderId = folderId;
if (search) {
filter.$and = [
{ $or: [
{ name: { $regex: search, $options: "i" } },
{ username: { $regex: search, $options: "i" } },
{ url: { $regex: search, $options: "i" } },
{ notes: { $regex: search, $options: "i" } },
]}
];
}
const secrets = await db.collection<Secret>("secrets")
.find(filter)
.sort({ name: 1 })
.toArray();
// Liste OHNE Passwörter — nur hasPassword-Flag
const mapped = secrets.map((s) => ({
_id: s._id,
userId: s.userId,
folderId: s.folderId,
type: s.type,
name: s.name,
username: s.username || "",
hasPassword: !!(s.passwordEncrypted),
url: s.url || "",
notes: s.notes || "",
cardHolder: s.cardHolder || "",
cardNumber: s.cardNumber || "",
cardExpiry: s.cardExpiry || "",
cardCvv: s.cardCvv || "",
sshPublicKey: s.sshPublicKey || "",
hasSshPrivateKey: !!(s.sshPrivateKeyEncrypted),
sharing: s.sharing || "private",
sharedWith: s.sharedWith || [],
createdAt: s.createdAt,
updatedAt: s.updatedAt,
}));
ctx.response.body = { secrets: mapped };
});
// --- Einzelnes Secret abrufen (mit Passwort) ---
secretsRouter.get("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const id = ctx.params.id;
try {
const s = await db.collection<Secret>("secrets").findOne({
_id: new ObjectId(id),
$or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }],
});
if (!s) {
ctx.response.status = 404;
ctx.response.body = { error: "Secret nicht gefunden" };
return;
}
// Einzelabruf OHNE Passwort
ctx.response.body = {
secret: {
_id: s._id,
userId: s.userId,
folderId: s.folderId,
type: s.type,
name: s.name,
username: s.username || "",
hasPassword: !!(s.passwordEncrypted),
url: s.url || "",
notes: s.notes || "",
cardHolder: s.cardHolder || "",
cardNumber: s.cardNumber || "",
cardExpiry: s.cardExpiry || "",
cardCvv: s.cardCvv || "",
sshPublicKey: s.sshPublicKey || "",
hasSshPrivateKey: !!(s.sshPrivateKeyEncrypted),
sharing: s.sharing || "private",
sharedWith: s.sharedWith || [],
createdAt: s.createdAt,
updatedAt: s.updatedAt,
},
};
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
// --- Passwort/sensible Daten abrufen (mit Audit-Log) ---
secretsRouter.get("/:id/reveal", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const id = ctx.params.id;
try {
const s = await db.collection<Secret>("secrets").findOne({
_id: new ObjectId(id),
$or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }],
});
if (!s) {
ctx.response.status = 404;
ctx.response.body = { error: "Secret nicht gefunden" };
return;
}
let password = "";
let sshPrivateKey = "";
let cardCvv = "";
try { password = await decrypt(s.passwordEncrypted, s.passwordIv, s.passwordTag); } catch { /* empty */ }
try { sshPrivateKey = await decrypt(s.sshPrivateKeyEncrypted || "", s.sshPrivateKeyIv || "", s.sshPrivateKeyTag || ""); } catch { /* empty */ }
cardCvv = s.cardCvv || "";
// Audit-Log: Passwort angesehen
await logSecretAudit({
userId,
userName: ctx.state.user.username || ctx.state.user.email || userId,
action: "secret.password_viewed",
targetType: "secret",
targetId: id,
targetName: s.name,
ip: ctx.request.ip || "",
});
ctx.response.body = { password, sshPrivateKey, cardCvv };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
secretsRouter.post("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const body = await ctx.request.body.json();
const { name, type, folderId, username, password, url, notes, cardHolder, cardNumber, cardExpiry, cardCvv, sshPublicKey, sshPrivateKey, sharing, sharedWith } = body;
if (!name || !type) {
ctx.response.status = 400;
ctx.response.body = { error: "Name und Typ sind erforderlich" };
return;
}
const validTypes: SecretType[] = ["login", "note", "card", "ssh-key", "other"];
if (!validTypes.includes(type)) {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültiger Typ" };
return;
}
const encPassword = await encrypt(password || "");
const encSshKey = await encrypt(sshPrivateKey || "");
const now = new Date();
const result = await db.collection<Secret>("secrets").insertOne({
userId,
folderId: folderId || null,
type,
name,
username: username || "",
passwordEncrypted: encPassword.ciphertext,
passwordIv: encPassword.iv,
passwordTag: encPassword.tag,
url: url || "",
notes: notes || "",
cardHolder: cardHolder || "",
cardNumber: cardNumber || "",
cardExpiry: cardExpiry || "",
cardCvv: cardCvv || "",
sshPublicKey: sshPublicKey || "",
sshPrivateKeyEncrypted: encSshKey.ciphertext,
sshPrivateKeyIv: encSshKey.iv,
sshPrivateKeyTag: encSshKey.tag,
sharing: (["private", "users", "all"].includes(sharing) ? sharing : "private") as SharingMode,
sharedWith: Array.isArray(sharedWith) ? sharedWith : [],
createdAt: now,
updatedAt: now,
} as Secret);
const ip = ctx.request.ip || "";
await logSecretAudit({
userId,
userName: ctx.state.user.email || ctx.state.user.name || userId,
action: "secret.created",
targetType: "secret",
targetId: result.insertedId.toString(),
targetName: name,
details: { type, folderId: folderId || null, sharing: sharing || "private" },
ip,
});
ctx.response.status = 201;
ctx.response.body = { message: "Secret erstellt", id: result.insertedId.toString() };
});
secretsRouter.put("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const id = ctx.params.id;
const body = await ctx.request.body.json();
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined) updateFields.name = body.name;
if (body.type !== undefined) updateFields.type = body.type;
if (body.folderId !== undefined) updateFields.folderId = body.folderId || null;
if (body.username !== undefined) updateFields.username = body.username;
if (body.url !== undefined) updateFields.url = body.url;
if (body.notes !== undefined) updateFields.notes = body.notes;
if (body.cardHolder !== undefined) updateFields.cardHolder = body.cardHolder;
if (body.cardNumber !== undefined) updateFields.cardNumber = body.cardNumber;
if (body.cardExpiry !== undefined) updateFields.cardExpiry = body.cardExpiry;
if (body.cardCvv !== undefined) updateFields.cardCvv = body.cardCvv;
if (body.sshPublicKey !== undefined) updateFields.sshPublicKey = body.sshPublicKey;
if (body.sharing !== undefined && ["private", "users", "all"].includes(body.sharing)) updateFields.sharing = body.sharing;
if (body.sharedWith !== undefined) updateFields.sharedWith = Array.isArray(body.sharedWith) ? body.sharedWith : [];
if (body.password !== undefined) {
const enc = await encrypt(body.password);
updateFields.passwordEncrypted = enc.ciphertext;
updateFields.passwordIv = enc.iv;
updateFields.passwordTag = enc.tag;
}
if (body.sshPrivateKey !== undefined) {
const enc = await encrypt(body.sshPrivateKey);
updateFields.sshPrivateKeyEncrypted = enc.ciphertext;
updateFields.sshPrivateKeyIv = enc.iv;
updateFields.sshPrivateKeyTag = enc.tag;
}
try {
const result = await db.collection<Secret>("secrets").updateOne(
{ _id: new ObjectId(id), $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] },
{ $set: updateFields }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Secret nicht gefunden" };
return;
}
const changedFields = Object.keys(updateFields).filter((k) => k !== "updatedAt");
await logSecretAudit({
userId,
userName: ctx.state.user.email || ctx.state.user.name || userId,
action: "secret.updated",
targetType: "secret",
targetId: id,
targetName: body.name || id,
details: { changedFields, passwordChanged: body.password !== undefined },
ip: ctx.request.ip || "",
});
ctx.response.body = { message: "Secret aktualisiert" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
secretsRouter.delete("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const id = ctx.params.id;
try {
const result = await db.collection<Secret>("secrets").deleteOne(
{ _id: new ObjectId(id), $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] }
);
if (result.deletedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Secret nicht gefunden" };
return;
}
await logSecretAudit({
userId,
userName: ctx.state.user.email || ctx.state.user.name || userId,
action: "secret.deleted",
targetType: "secret",
targetId: id,
targetName: id,
ip: ctx.request.ip || "",
});
ctx.response.body = { message: "Secret gelöscht" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
// --- Secret Folders CRUD ---
secretFoldersRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const folders = await db.collection<SecretFolder>("secret_folders")
.find({ $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] })
.sort({ name: 1 })
.toArray();
ctx.response.body = { folders: folders.map(f => ({ ...f, sharing: f.sharing || "private", sharedWith: f.sharedWith || [] })) };
});
secretFoldersRouter.post("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const body = await ctx.request.body.json();
const { name, parentId } = body;
if (!name) {
ctx.response.status = 400;
ctx.response.body = { error: "Name ist erforderlich" };
return;
}
const now = new Date();
const result = await db.collection<SecretFolder>("secret_folders").insertOne({
userId,
name,
parentId: parentId || null,
sharing: (["private", "users", "all"].includes(body.sharing) ? body.sharing : "private") as SharingMode,
sharedWith: Array.isArray(body.sharedWith) ? body.sharedWith : [],
createdAt: now,
updatedAt: now,
} as SecretFolder);
await logSecretAudit({
userId,
userName: ctx.state.user.email || ctx.state.user.name || userId,
action: "folder.created",
targetType: "folder",
targetId: result.insertedId.toString(),
targetName: name,
details: { parentId: parentId || null },
ip: ctx.request.ip || "",
});
ctx.response.status = 201;
ctx.response.body = { message: "Ordner erstellt", id: result.insertedId.toString() };
});
secretFoldersRouter.put("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const id = ctx.params.id;
const body = await ctx.request.body.json();
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined) updateFields.name = body.name;
if (body.parentId !== undefined) updateFields.parentId = body.parentId || null;
if (body.sharing !== undefined && ["private", "users", "all"].includes(body.sharing)) updateFields.sharing = body.sharing;
if (body.sharedWith !== undefined) updateFields.sharedWith = Array.isArray(body.sharedWith) ? body.sharedWith : [];
try {
const result = await db.collection<SecretFolder>("secret_folders").updateOne(
{ _id: new ObjectId(id), $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] },
{ $set: updateFields }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Ordner nicht gefunden" };
return;
}
await logSecretAudit({
userId,
userName: ctx.state.user.email || ctx.state.user.name || userId,
action: "folder.updated",
targetType: "folder",
targetId: id,
targetName: body.name || id,
details: { changedFields: Object.keys(updateFields).filter((k) => k !== "updatedAt") },
ip: ctx.request.ip || "",
});
ctx.response.body = { message: "Ordner aktualisiert" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
secretFoldersRouter.delete("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const id = ctx.params.id;
try {
// Secrets in diesem Ordner auf null setzen
await db.collection<Secret>("secrets").updateMany(
{ folderId: id, $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] },
{ $set: { folderId: null, updatedAt: new Date() } }
);
// Unterordner auf null setzen
await db.collection<SecretFolder>("secret_folders").updateMany(
{ parentId: id, $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] },
{ $set: { parentId: null, updatedAt: new Date() } }
);
const result = await db.collection<SecretFolder>("secret_folders").deleteOne(
{ _id: new ObjectId(id), $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] }
);
if (result.deletedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Ordner nicht gefunden" };
return;
}
await logSecretAudit({
userId,
userName: ctx.state.user.email || ctx.state.user.name || userId,
action: "folder.deleted",
targetType: "folder",
targetId: id,
targetName: id,
ip: ctx.request.ip || "",
});
ctx.response.body = { message: "Ordner gelöscht" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});

72
src/routes/settings.ts Normal file
View File

@@ -0,0 +1,72 @@
import { Router } from "@oak/oak";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const settingsRouter = new Router({ prefix: "/api/settings" });
interface Settings {
_id: string;
allowRegistration: boolean;
updatedAt: Date;
}
const DEFAULT_SETTINGS: Omit<Settings, "_id"> = {
allowRegistration: false,
updatedAt: new Date()
};
// Get settings (public - needed for login page to know if registration is allowed)
settingsRouter.get("/", async (ctx) => {
const db = await getDB();
const settings = db.collection<Settings>("settings");
let doc = await settings.findOne({ _id: "global" });
if (!doc) {
// Initialize default settings
await settings.insertOne({ _id: "global", ...DEFAULT_SETTINGS } as Settings);
doc = { _id: "global", ...DEFAULT_SETTINGS };
}
ctx.response.body = {
allowRegistration: doc.allowRegistration
};
});
// Update settings (admin only)
settingsRouter.put("/", authMiddleware, async (ctx) => {
// Check if user is admin
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const body = await ctx.request.body.json();
const { allowRegistration } = body;
if (typeof allowRegistration !== "boolean") {
ctx.response.status = 400;
ctx.response.body = { error: "allowRegistration must be a boolean" };
return;
}
const db = await getDB();
const settings = db.collection<Settings>("settings");
await settings.updateOne(
{ _id: "global" },
{
$set: {
allowRegistration,
updatedAt: new Date()
}
},
{ upsert: true }
);
ctx.response.body = {
message: "Settings updated",
allowRegistration
};
});

884
src/routes/tasks.ts Normal file
View File

@@ -0,0 +1,884 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { logTaskChanges } from "../utils/taskChangelog.ts";
import { events, AMS_EVENTS } from "../utils/eventEmitter.ts";
export const tasksRouter = new Router({ prefix: "/api/tasks" });
// --- Counter Helper für Nummernkreise ---
export async function getNextNumber(counterName: string): Promise<number> {
const db = await getDB();
const result = await db.collection("counters").findOneAndUpdate(
{ _id: counterName },
{ $inc: { seq: 1 } },
{ upsert: true, returnDocument: "after" }
);
return (result as unknown as { seq: number }).seq;
}
interface Reminder {
enabled: boolean;
datetime: Date; // Next trigger time
interval: 'once' | 'daily' | 'hourly' | 'minutes';
intervalValue?: number; // For 'minutes': every X minutes
lastNotified?: Date;
}
interface Task {
_id: ObjectId;
number?: number;
title: string;
description?: string;
status: "backlog" | "todo" | "in_progress" | "review" | "done";
priority: "low" | "medium" | "high" | "urgent";
assignee?: string; // Agent ID
project?: string; // Project ID
labels: string[];
dueDate?: Date;
reminder?: Reminder;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
interface GitLabProjectRef {
projectId: number;
path: string;
url: string;
name: string;
}
interface Project {
_id: ObjectId;
name: string;
description?: string;
color: string;
rules?: string;
// GitLab Integration - Multiple projects
gitlabProjects?: GitLabProjectRef[];
// Legacy single project (for backwards compatibility)
gitlabProjectId?: number;
gitlabUrl?: string;
gitlabPath?: string;
createdAt: Date;
updatedAt: Date;
}
// ============ TASKS ============
// Get all tasks (with optional filters)
tasksRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const url = ctx.request.url;
const filter: Record<string, unknown> = {};
const status = url.searchParams.get("status");
if (status) filter.status = status;
const assignee = url.searchParams.get("assignee");
if (assignee) filter.assignee = assignee;
const project = url.searchParams.get("project");
if (project) filter.project = project;
const priority = url.searchParams.get("priority");
if (priority) filter.priority = priority;
const tasks = await db.collection<Task>("tasks")
.find(filter)
.sort({ priority: -1, createdAt: -1 })
.toArray();
ctx.response.body = { tasks };
});
// Get task by number
tasksRouter.get("/by-number/:number", authMiddleware, async (ctx) => {
const db = await getDB();
const num = parseInt(ctx.params.number);
if (isNaN(num)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid number" };
return;
}
const task = await db.collection<Task>("tasks").findOne({ number: num });
if (!task) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
ctx.response.body = { task };
});
// Get task by ID
tasksRouter.get("/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const id = ctx.params.id;
try {
const task = await db.collection<Task>("tasks").findOne({ _id: new ObjectId(id) });
if (!task) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
ctx.response.body = { task };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// Create task
tasksRouter.post("/", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { title, description, status, priority, assignee, project, labels, dueDate } = body;
if (!title) {
ctx.response.status = 400;
ctx.response.body = { error: "Title is required" };
return;
}
const db = await getDB();
const now = new Date();
const number = await getNextNumber("task_number");
const result = await db.collection<Task>("tasks").insertOne({
number,
title,
description: description || "",
status: status || "backlog",
priority: priority || "medium",
assignee,
project,
labels: labels || [],
dueDate: dueDate ? new Date(dueDate) : undefined,
createdBy: ctx.state.user.id,
createdAt: now,
updatedAt: now
} as Task);
const taskId = result.insertedId.toString();
events.emit(AMS_EVENTS.TASK_CREATED, { taskId, title, status: status || "backlog", priority: priority || "medium" });
ctx.response.status = 201;
ctx.response.body = {
message: "Task created",
id: taskId
};
});
// Update task
tasksRouter.put("/:id", authMiddleware, async (ctx) => {
const id = ctx.params.id;
const body = await ctx.request.body.json();
const { title, description, status, priority, assignee, project, labels, dueDate } = body;
const db = await getDB();
const updateFields: Partial<Task> = { updatedAt: new Date() };
if (title !== undefined) updateFields.title = title;
if (description !== undefined) updateFields.description = description;
if (status !== undefined) updateFields.status = status;
if (priority !== undefined) updateFields.priority = priority;
if (assignee !== undefined) updateFields.assignee = assignee;
if (project !== undefined) updateFields.project = project;
if (labels !== undefined) updateFields.labels = labels;
if (dueDate !== undefined) updateFields.dueDate = dueDate ? new Date(dueDate) : undefined;
try {
// Load old task for changelog
const oldTask = await db.collection<Task>("tasks").findOne({ _id: new ObjectId(id) });
if (!oldTask) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
const result = await db.collection<Task>("tasks").updateOne(
{ _id: new ObjectId(id) },
{ $set: updateFields }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
// Log changes
await logTaskChanges(id, oldTask as unknown as Record<string, unknown>, updateFields, ctx.state.user.id);
events.emit(AMS_EVENTS.TASK_UPDATED, { taskId: id, changes: updateFields });
ctx.response.body = { message: "Task updated" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// Update task status only (for drag & drop)
tasksRouter.patch("/:id/status", authMiddleware, async (ctx) => {
const id = ctx.params.id;
const body = await ctx.request.body.json();
const { status } = body;
if (!["backlog", "todo", "in_progress", "review", "done"].includes(status)) {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid status" };
return;
}
const db = await getDB();
try {
const oldTask = await db.collection<Task>("tasks").findOne({ _id: new ObjectId(id) });
if (!oldTask) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
const result = await db.collection<Task>("tasks").updateOne(
{ _id: new ObjectId(id) },
{ $set: { status, updatedAt: new Date() } }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
await logTaskChanges(id, oldTask as unknown as Record<string, unknown>, { status }, ctx.state.user.id);
events.emit(AMS_EVENTS.TASK_STATUS_CHANGED, { taskId: id, oldStatus: oldTask.status, newStatus: status });
ctx.response.body = { message: "Status updated" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// Delete task (with cascade delete of comments and attachments)
tasksRouter.delete("/:id", authMiddleware, async (ctx) => {
const id = ctx.params.id;
const db = await getDB();
try {
// First, get all comments for this task to delete their attachments
const comments = await db.collection("comments")
.find({ $or: [{ taskId: id }, { parentType: "task", parentId: id }] })
.toArray();
// Delete attachments for each comment
for (const comment of comments) {
await db.collection("attachments").deleteMany({
parentType: "comment",
parentId: comment._id.toString()
});
}
// Delete all comments for this task (both old taskId format and new parentType format)
await db.collection("comments").deleteMany({
$or: [{ taskId: id }, { parentType: "task", parentId: id }]
});
// Delete all attachments directly on task (both old taskId format and new parentType format)
await db.collection("attachments").deleteMany({
$or: [{ taskId: id }, { parentType: "task", parentId: id }]
});
// Finally delete the task
const result = await db.collection<Task>("tasks").deleteOne({ _id: new ObjectId(id) });
if (result.deletedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
events.emit(AMS_EVENTS.TASK_DELETED, { taskId: id });
ctx.response.body = { message: "Task and related data deleted" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// Get task changelog
tasksRouter.get("/:id/changelog", authMiddleware, async (ctx) => {
const taskId = ctx.params.id;
const db = await getDB();
try {
// Validate task exists
const task = await db.collection("tasks").findOne({ _id: new ObjectId(taskId) });
if (!task) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
const entries = await db.collection("task_changelog")
.find({ taskId })
.sort({ changedAt: -1 })
.limit(100)
.toArray();
// Resolve changedBy IDs to names
const userIds = [...new Set(entries.map(e => e.changedBy).filter(id => id && !id.startsWith("service:")))];
const nameMap: Record<string, string> = {};
// Service accounts
for (const entry of entries) {
if (entry.changedBy?.startsWith("service:")) {
const agentName = entry.changedBy.replace("service:", "");
nameMap[entry.changedBy] = agentName.charAt(0).toUpperCase() + agentName.slice(1);
}
}
// Lookup users
if (userIds.length > 0) {
const objectIds = userIds.reduce<ObjectId[]>((acc, id) => {
try { acc.push(new ObjectId(id)); } catch { /* skip invalid */ }
return acc;
}, []);
if (objectIds.length > 0) {
const users = await db.collection("users").find({ _id: { $in: objectIds } }).toArray();
for (const user of users) {
nameMap[user._id.toString()] = user.name || user.email || user._id.toString();
}
// Also check agents collection
const agents = await db.collection("agents").find({ _id: { $in: objectIds } }).toArray();
for (const agent of agents) {
nameMap[agent._id.toString()] = agent.emoji ? `${agent.emoji} ${agent.name}` : agent.name;
}
}
}
const enrichedEntries = entries.map(e => ({
...e,
changedByName: nameMap[e.changedBy] || e.changedBy,
}));
ctx.response.body = { changelog: enrichedEntries };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// ============ PROJECTS ============
// Get all projects
tasksRouter.get("/projects/list", authMiddleware, async (ctx) => {
const db = await getDB();
const projects = await db.collection<Project>("projects")
.find({})
.sort({ name: 1 })
.toArray();
ctx.response.body = { projects };
});
// Create project
tasksRouter.post("/projects", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const body = await ctx.request.body.json();
const { name, description, color, gitlabProjectId, gitlabUrl, gitlabPath } = body;
if (!name) {
ctx.response.status = 400;
ctx.response.body = { error: "Name is required" };
return;
}
const db = await getDB();
const now = new Date();
const result = await db.collection<Project>("projects").insertOne({
name,
description: description || "",
color: color || "#6366f1",
gitlabProjectId: gitlabProjectId || undefined,
gitlabUrl: gitlabUrl || undefined,
gitlabPath: gitlabPath || undefined,
createdAt: now,
updatedAt: now
} as Project);
ctx.response.status = 201;
ctx.response.body = {
message: "Project created",
id: result.insertedId.toString()
};
});
// Update project
tasksRouter.put("/projects/:id", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const id = ctx.params.id;
const body = await ctx.request.body.json();
const { name, description, color, rules, gitlabProjectId, gitlabUrl, gitlabPath, gitlabProjects } = body;
const db = await getDB();
const updateFields: Partial<Project> = { updatedAt: new Date() };
if (name !== undefined) updateFields.name = name;
if (description !== undefined) updateFields.description = description;
if (color !== undefined) updateFields.color = color;
if (rules !== undefined) (updateFields as any).rules = rules;
// GitLab fields - Multiple projects (new)
if (gitlabProjects !== undefined) updateFields.gitlabProjects = gitlabProjects || [];
// Legacy single project fields (for backwards compatibility)
if (gitlabProjectId !== undefined) updateFields.gitlabProjectId = gitlabProjectId || undefined;
if (gitlabUrl !== undefined) updateFields.gitlabUrl = gitlabUrl || undefined;
if (gitlabPath !== undefined) updateFields.gitlabPath = gitlabPath || undefined;
try {
const result = await db.collection<Project>("projects").updateOne(
{ _id: new ObjectId(id) },
{ $set: updateFields }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Project not found" };
return;
}
ctx.response.body = { message: "Project updated" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid project ID" };
}
});
// Delete project
tasksRouter.delete("/projects/:id", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const id = ctx.params.id;
const db = await getDB();
try {
const result = await db.collection<Project>("projects").deleteOne({ _id: new ObjectId(id) });
if (result.deletedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Project not found" };
return;
}
ctx.response.body = { message: "Project deleted" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid project ID" };
}
});
// ============ GITLAB COMMITS ============
interface TaskCommit {
_id: ObjectId;
taskId: string;
commitSha: string;
shortId: string;
gitlabProjectId: number;
gitlabProjectPath: string;
commitTitle: string;
commitAuthor: string;
commitDate: string;
commitUrl: string;
linkedBy: string;
linkedByName: string;
linkedAt: Date;
}
// GET /api/tasks/:id/commits - Verknüpfte Commits abrufen
tasksRouter.get("/:id/commits", authMiddleware, async (ctx) => {
const taskId = ctx.params.id;
const db = await getDB();
try {
const task = await db.collection("tasks").findOne({ _id: new ObjectId(taskId) });
if (!task) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
const commits = await db.collection<TaskCommit>("task_commits")
.find({ taskId })
.sort({ linkedAt: -1 })
.toArray();
ctx.response.body = { commits };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// POST /api/tasks/:id/commits - Commit mit Task verknüpfen
tasksRouter.post("/:id/commits", authMiddleware, async (ctx) => {
const taskId = ctx.params.id;
const body = await ctx.request.body.json();
const { commitSha, shortId, gitlabProjectId, gitlabProjectPath, commitTitle, commitAuthor, commitDate, commitUrl } = body;
if (!commitSha || !gitlabProjectId) {
ctx.response.status = 400;
ctx.response.body = { error: "commitSha and gitlabProjectId are required" };
return;
}
const db = await getDB();
try {
const task = await db.collection("tasks").findOne({ _id: new ObjectId(taskId) });
if (!task) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
// Prüfen ob schon verknüpft
const existing = await db.collection<TaskCommit>("task_commits").findOne({
taskId,
commitSha,
gitlabProjectId,
});
if (existing) {
ctx.response.status = 409;
ctx.response.body = { error: "Commit already linked to this task" };
return;
}
const now = new Date();
const result = await db.collection<TaskCommit>("task_commits").insertOne({
taskId,
commitSha,
shortId: shortId || commitSha.substring(0, 8),
gitlabProjectId,
gitlabProjectPath: gitlabProjectPath || "",
commitTitle: commitTitle || "",
commitAuthor: commitAuthor || "",
commitDate: commitDate || now.toISOString(),
commitUrl: commitUrl || "",
linkedBy: ctx.state.user.id,
linkedByName: ctx.state.user.username,
linkedAt: now,
} as TaskCommit);
// Changelog-Eintrag
await db.collection("task_changelog").insertOne({
taskId,
field: "commits",
oldValue: null,
newValue: `${shortId || commitSha.substring(0, 8)} - ${commitTitle}`,
changedBy: ctx.state.user.id,
changedAt: now,
});
// Log-Eintrag
await db.collection("logs").insertOne({
agentId: ctx.state.user.id,
agentName: ctx.state.user.username,
level: "info",
message: `Commit ${shortId || commitSha.substring(0, 8)} mit Task "${task.title}" verknüpft`,
metadata: { taskId, commitSha, gitlabProjectId, gitlabProjectPath },
timestamp: now,
});
events.emit(AMS_EVENTS.TASK_UPDATED, { taskId, changes: { commits: "added" } });
ctx.response.status = 201;
ctx.response.body = { message: "Commit linked", id: result.insertedId.toString() };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// DELETE /api/tasks/:id/commits/:commitId - Commit-Verknüpfung entfernen
tasksRouter.delete("/:id/commits/:commitId", authMiddleware, async (ctx) => {
const taskId = ctx.params.id;
const commitId = ctx.params.commitId;
const db = await getDB();
try {
const commit = await db.collection<TaskCommit>("task_commits").findOne({
_id: new ObjectId(commitId),
taskId,
});
if (!commit) {
ctx.response.status = 404;
ctx.response.body = { error: "Commit link not found" };
return;
}
await db.collection<TaskCommit>("task_commits").deleteOne({ _id: new ObjectId(commitId) });
const now = new Date();
// Changelog-Eintrag
await db.collection("task_changelog").insertOne({
taskId,
field: "commits",
oldValue: `${commit.shortId} - ${commit.commitTitle}`,
newValue: null,
changedBy: ctx.state.user.id,
changedAt: now,
});
// Log-Eintrag
await db.collection("logs").insertOne({
agentId: ctx.state.user.id,
agentName: ctx.state.user.username,
level: "info",
message: `Commit ${commit.shortId} von Task entfernt`,
metadata: { taskId, commitSha: commit.commitSha },
timestamp: now,
});
ctx.response.body = { message: "Commit unlinked" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid ID" };
}
});
// ============ REMINDERS ============
// Set/update reminder for a task
// PATCH /api/tasks/:id/reminder
tasksRouter.patch("/:id/reminder", authMiddleware, async (ctx) => {
const taskId = ctx.params.id;
const body = await ctx.request.body.json();
const { datetime, interval, intervalValue } = body;
if (!datetime) {
ctx.response.status = 400;
ctx.response.body = { error: "datetime is required" };
return;
}
const validIntervals = ['once', 'daily', 'hourly', 'minutes'];
if (interval && !validIntervals.includes(interval)) {
ctx.response.status = 400;
ctx.response.body = { error: `Invalid interval. Must be one of: ${validIntervals.join(', ')}` };
return;
}
const db = await getDB();
const reminder: Reminder = {
enabled: true,
datetime: new Date(datetime),
interval: interval || 'once',
intervalValue: interval === 'minutes' ? (intervalValue || 30) : undefined,
lastNotified: undefined
};
try {
const result = await db.collection<Task>("tasks").updateOne(
{ _id: new ObjectId(taskId) },
{ $set: { reminder, updatedAt: new Date() } }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
ctx.response.body = { message: "Reminder set", reminder };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// Delete reminder from a task
// DELETE /api/tasks/:id/reminder
tasksRouter.delete("/:id/reminder", authMiddleware, async (ctx) => {
const taskId = ctx.params.id;
const db = await getDB();
try {
const result = await db.collection<Task>("tasks").updateOne(
{ _id: new ObjectId(taskId) },
{ $unset: { reminder: "" }, $set: { updatedAt: new Date() } }
);
if (result.matchedCount === 0) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
ctx.response.body = { message: "Reminder removed" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// Get due reminders (for cron/polling)
// GET /api/tasks/reminders/due
tasksRouter.get("/reminders/due", authMiddleware, async (ctx) => {
const db = await getDB();
const now = new Date();
// Find all tasks with enabled reminders where datetime <= now
const tasks = await db.collection<Task>("tasks")
.find({
"reminder.enabled": true,
"reminder.datetime": { $lte: now }
})
.toArray();
ctx.response.body = { tasks, count: tasks.length };
});
// Mark reminder as notified and schedule next (for recurring)
// POST /api/tasks/:id/reminder/notified
tasksRouter.post("/:id/reminder/notified", authMiddleware, async (ctx) => {
const taskId = ctx.params.id;
const db = await getDB();
try {
const task = await db.collection<Task>("tasks").findOne({ _id: new ObjectId(taskId) });
if (!task) {
ctx.response.status = 404;
ctx.response.body = { error: "Task not found" };
return;
}
if (!task.reminder) {
ctx.response.status = 400;
ctx.response.body = { error: "Task has no reminder" };
return;
}
const now = new Date();
let nextDatetime: Date | null = null;
// Calculate next reminder time based on interval
switch (task.reminder.interval) {
case 'once':
// Disable after one-time notification
await db.collection<Task>("tasks").updateOne(
{ _id: new ObjectId(taskId) },
{ $set: { "reminder.enabled": false, "reminder.lastNotified": now } }
);
break;
case 'daily':
nextDatetime = new Date(task.reminder.datetime);
nextDatetime.setDate(nextDatetime.getDate() + 1);
break;
case 'hourly':
nextDatetime = new Date(task.reminder.datetime);
nextDatetime.setHours(nextDatetime.getHours() + 1);
break;
case 'minutes':
nextDatetime = new Date(task.reminder.datetime);
nextDatetime.setMinutes(nextDatetime.getMinutes() + (task.reminder.intervalValue || 30));
break;
}
if (nextDatetime) {
await db.collection<Task>("tasks").updateOne(
{ _id: new ObjectId(taskId) },
{ $set: { "reminder.datetime": nextDatetime, "reminder.lastNotified": now } }
);
}
ctx.response.body = {
message: "Reminder marked as notified",
nextDatetime: nextDatetime?.toISOString() || null,
disabled: task.reminder.interval === 'once'
};
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Invalid task ID" };
}
});
// ============ MIGRATION: Nummernkreise ============
// POST /api/tasks/migrate/numbers - Bestehende Tasks/Agent-Tasks mit Nummern versehen
tasksRouter.post("/migrate/numbers", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const db = await getDB();
let taskCount = 0;
let agentTaskCount = 0;
// Tasks ohne Nummer (sortiert nach createdAt)
const tasksWithoutNumber = await db.collection("tasks")
.find({ number: { $exists: false } })
.sort({ createdAt: 1 })
.toArray();
for (const task of tasksWithoutNumber) {
const num = await getNextNumber("task_number");
await db.collection("tasks").updateOne(
{ _id: task._id },
{ $set: { number: num } }
);
taskCount++;
}
// Agent-Tasks ohne Nummer
const agentTasksWithoutNumber = await db.collection("agent_tasks")
.find({ number: { $exists: false } })
.sort({ createdAt: 1 })
.toArray();
for (const at of agentTasksWithoutNumber) {
const num = await getNextNumber("agent_task_number");
await db.collection("agent_tasks").updateOne(
{ _id: at._id },
{ $set: { number: num } }
);
agentTaskCount++;
}
ctx.response.body = {
message: "Migration abgeschlossen",
tasksNumbered: taskCount,
agentTasksNumbered: agentTaskCount,
};
});

346
src/routes/tokens.ts Normal file
View File

@@ -0,0 +1,346 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const tokensRouter = new Router({ prefix: "/api/tokens" });
interface TokenUsage {
_id?: ObjectId;
agentId: ObjectId;
agentName?: string;
model: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
cost?: number;
sessionId?: string;
timestamp: Date;
createdAt: Date;
}
interface TokenLimit {
_id?: ObjectId;
agentId?: ObjectId;
dailyLimit?: number;
monthlyLimit?: number;
alertThreshold?: number;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}
// POST /api/tokens - Track token usage
tokensRouter.post("/", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
const { agentId, agentName, model, inputTokens, outputTokens, sessionId } = body;
if (!agentId || !model || inputTokens === undefined || outputTokens === undefined) {
ctx.response.status = 400;
ctx.response.body = { error: "agentId, model, inputTokens, and outputTokens are required" };
return;
}
const db = await getDB();
const now = new Date();
const totalTokens = inputTokens + outputTokens;
// Calculate cost (example pricing: $0.01 per 1K tokens)
const cost = (totalTokens / 1000) * 0.01;
const usage: TokenUsage = {
agentId: new ObjectId(agentId),
agentName,
model,
inputTokens,
outputTokens,
totalTokens,
cost,
sessionId,
timestamp: now,
createdAt: now
};
const result = await db.collection<TokenUsage>("token_usage").insertOne(usage);
// Check limits and send alerts if needed
await checkLimits(agentId, db);
ctx.response.status = 201;
ctx.response.body = {
message: "Token usage recorded",
id: result.insertedId.toString(),
totalTokens,
cost
};
});
// GET /api/tokens - Get token usage with filters
tokensRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const url = new URL(ctx.request.url);
// Parse query params
const agentId = url.searchParams.get("agentId");
const model = url.searchParams.get("model");
const startDate = url.searchParams.get("startDate");
const endDate = url.searchParams.get("endDate");
const page = parseInt(url.searchParams.get("page") || "1");
const limit = parseInt(url.searchParams.get("limit") || "100");
const skip = (page - 1) * limit;
// Build query
const query: Record<string, unknown> = {};
if (agentId) {
query.agentId = new ObjectId(agentId);
}
if (model) {
query.model = model;
}
if (startDate || endDate) {
query.timestamp = {};
if (startDate) query.timestamp.$gte = new Date(startDate);
if (endDate) query.timestamp.$lte = new Date(endDate);
}
// Get usage records
const usage = await db.collection<TokenUsage>("token_usage")
.find(query)
.sort({ timestamp: -1 })
.skip(skip)
.limit(limit)
.toArray();
// Get total count
const total = await db.collection<TokenUsage>("token_usage").countDocuments(query);
ctx.response.body = {
usage,
page,
limit,
total,
totalPages: Math.ceil(total / limit)
};
});
// GET /api/tokens/stats - Get usage statistics
tokensRouter.get("/stats", authMiddleware, async (ctx) => {
const db = await getDB();
const url = new URL(ctx.request.url);
const agentId = url.searchParams.get("agentId");
const period = url.searchParams.get("period") || "day"; // day, week, month
// Calculate date range
let startDate = new Date();
if (period === "day") {
startDate.setHours(0, 0, 0, 0);
} else if (period === "week") {
startDate.setDate(startDate.getDate() - 7);
} else if (period === "month") {
startDate.setMonth(startDate.getMonth() - 1);
}
const query: Record<string, unknown> = {
timestamp: { $gte: startDate }
};
if (agentId) {
query.agentId = new ObjectId(agentId);
}
// Aggregate statistics
const stats = await db.collection<TokenUsage>("token_usage").aggregate([
{ $match: query },
{
$group: {
_id: agentId ? "$model" : "$agentId",
totalTokens: { $sum: "$totalTokens" },
totalCost: { $sum: "$cost" },
inputTokens: { $sum: "$inputTokens" },
outputTokens: { $sum: "$outputTokens" },
count: { $sum: 1 },
agentName: { $first: "$agentName" }
}
},
{ $sort: { totalTokens: -1 } }
]).toArray();
// Overall totals
const totals = await db.collection<TokenUsage>("token_usage").aggregate([
{ $match: query },
{
$group: {
_id: null,
totalTokens: { $sum: "$totalTokens" },
totalCost: { $sum: "$cost" },
inputTokens: { $sum: "$inputTokens" },
outputTokens: { $sum: "$outputTokens" },
count: { $sum: 1 }
}
}
]).toArray();
ctx.response.body = {
period,
startDate: startDate.toISOString(),
stats,
totals: totals[0] || { totalTokens: 0, totalCost: 0, count: 0 }
};
});
// GET /api/tokens/timeseries - Zeitreihe für Charts (pro Tag/Woche)
tokensRouter.get("/timeseries", authMiddleware, async (ctx) => {
const db = await getDB();
const url = new URL(ctx.request.url);
const agentId = url.searchParams.get("agentId");
const days = parseInt(url.searchParams.get("days") || "30");
const groupBy = url.searchParams.get("groupBy") || "day"; // day | agent
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
const matchStage: Record<string, unknown> = {
timestamp: { $gte: startDate }
};
if (agentId) matchStage.agentId = new ObjectId(agentId);
if (groupBy === "agent") {
// Gruppiert nach Agent + Tag
const data = await db.collection<TokenUsage>("token_usage").aggregate([
{ $match: matchStage },
{
$group: {
_id: {
date: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } },
agentId: "$agentId",
agentName: "$agentName"
},
totalTokens: { $sum: "$totalTokens" },
inputTokens: { $sum: "$inputTokens" },
outputTokens: { $sum: "$outputTokens" },
totalCost: { $sum: "$cost" },
count: { $sum: 1 }
}
},
{ $sort: { "_id.date": 1 } }
]).toArray();
ctx.response.body = { data, days, groupBy };
} else {
// Gruppiert nur nach Tag
const data = await db.collection<TokenUsage>("token_usage").aggregate([
{ $match: matchStage },
{
$group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } },
totalTokens: { $sum: "$totalTokens" },
inputTokens: { $sum: "$inputTokens" },
outputTokens: { $sum: "$outputTokens" },
totalCost: { $sum: "$cost" },
count: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
]).toArray();
ctx.response.body = { data, days, groupBy };
}
});
// GET /api/tokens/limits - Get limits for agents
tokensRouter.get("/limits", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const db = await getDB();
const limits = await db.collection<TokenLimit>("token_limits").find({}).toArray();
ctx.response.body = { limits };
});
// PUT /api/tokens/limits/:agentId - Set/update limits for an agent
tokensRouter.put("/limits/:agentId", authMiddleware, async (ctx) => {
if (ctx.state.user.role !== "admin") {
ctx.response.status = 403;
ctx.response.body = { error: "Admin access required" };
return;
}
const agentId = ctx.params.agentId;
const body = await ctx.request.body.json();
const { dailyLimit, monthlyLimit, alertThreshold, enabled } = body;
const db = await getDB();
const now = new Date();
const limit: Partial<TokenLimit> = {
agentId: new ObjectId(agentId),
dailyLimit,
monthlyLimit,
alertThreshold,
enabled: enabled !== undefined ? enabled : true,
updatedAt: now
};
const result = await db.collection<TokenLimit>("token_limits").updateOne(
{ agentId: new ObjectId(agentId) },
{ $set: limit, $setOnInsert: { createdAt: now } },
{ upsert: true }
);
ctx.response.body = {
message: "Limits updated",
modified: result.modifiedCount > 0
};
});
// Helper function to check limits
async function checkLimits(agentId: string, db: Awaited<ReturnType<typeof getDB>>): Promise<void> {
const limit = await db.collection<TokenLimit>("token_limits").findOne({
agentId: new ObjectId(agentId),
enabled: true
});
if (!limit) return;
const now = new Date();
// Check daily usage
if (limit.dailyLimit) {
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
const dailyUsage = await db.collection<TokenUsage>("token_usage").aggregate([
{
$match: {
agentId: new ObjectId(agentId),
timestamp: { $gte: startOfDay }
}
},
{
$group: {
_id: null,
total: { $sum: "$totalTokens" }
}
}
]).toArray();
const usage = dailyUsage[0]?.total || 0;
if (limit.alertThreshold && usage >= limit.dailyLimit * (limit.alertThreshold / 100)) {
console.log(`⚠️ Agent ${agentId} reached ${limit.alertThreshold}% of daily limit`);
// TODO: Send alert (email, webhook, etc.)
}
}
}

82
src/routes/transcribe.ts Normal file
View File

@@ -0,0 +1,82 @@
import { Router } from "@oak/oak";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const transcribeRouter = new Router({ prefix: "/api/transcribe" });
interface UserSettings {
_id: string;
userId: string;
sttProvider: "openai" | "custom";
openaiApiKey?: string;
customSttUrl?: string;
telegramBotToken?: string;
telegramDefaultChatId?: string;
updatedAt: Date;
}
// Audio transkribieren via OpenAI Whisper
transcribeRouter.post("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
// User-Settings laden
const settings = await db.collection<UserSettings>("user_settings")
.findOne({ userId });
if (!settings?.openaiApiKey) {
ctx.response.status = 400;
ctx.response.body = { error: "OpenAI API-Key nicht konfiguriert. Bitte in den Einstellungen hinterlegen." };
return;
}
// Multipart-Body parsen
const contentType = ctx.request.headers.get("content-type") || "";
if (!contentType.includes("multipart/form-data")) {
ctx.response.status = 400;
ctx.response.body = { error: "Multipart form-data mit Audio-Datei erforderlich" };
return;
}
try {
const formBody = ctx.request.body;
const formData = await formBody.formData();
const audioFile = formData.get("audio");
if (!audioFile || !(audioFile instanceof File)) {
ctx.response.status = 400;
ctx.response.body = { error: "Audio-Datei fehlt im Request" };
return;
}
// An OpenAI Whisper API senden
const whisperForm = new FormData();
whisperForm.append("file", audioFile, audioFile.name || "audio.webm");
whisperForm.append("model", "whisper-1");
whisperForm.append("language", "de");
const whisperResponse = await fetch("https://api.openai.com/v1/audio/transcriptions", {
method: "POST",
headers: {
"Authorization": `Bearer ${settings.openaiApiKey}`,
},
body: whisperForm,
});
if (!whisperResponse.ok) {
const err = await whisperResponse.text();
console.error("Whisper API Fehler:", err);
ctx.response.status = 502;
ctx.response.body = { error: "Transkription fehlgeschlagen", details: err };
return;
}
const result = await whisperResponse.json();
ctx.response.body = { text: result.text };
} catch (err) {
console.error("Transkription Fehler:", err);
ctx.response.status = 500;
ctx.response.body = { error: "Interner Fehler bei der Transkription" };
}
});

View File

@@ -0,0 +1,80 @@
import { Router } from "@oak/oak";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const userSettingsRouter = new Router({ prefix: "/api/user-settings" });
interface UserSettings {
userId: string;
sttProvider: "openai" | "custom";
openaiApiKey?: string;
customSttUrl?: string;
telegramBotToken?: string;
telegramDefaultChatId?: string;
updatedAt: Date;
}
// User-Einstellungen laden
userSettingsRouter.get("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const settings = await db.collection<UserSettings>("user_settings")
.findOne({ userId });
if (!settings) {
ctx.response.body = {
settings: {
sttProvider: "openai",
openaiApiKey: "",
customSttUrl: "",
telegramBotToken: "",
telegramDefaultChatId: "",
},
};
return;
}
// API-Keys maskiert zurückgeben
ctx.response.body = {
settings: {
sttProvider: settings.sttProvider || "openai",
openaiApiKey: settings.openaiApiKey ? "••••" + settings.openaiApiKey.slice(-4) : "",
customSttUrl: settings.customSttUrl || "",
telegramBotToken: settings.telegramBotToken ? "••••" + settings.telegramBotToken.slice(-4) : "",
telegramDefaultChatId: settings.telegramDefaultChatId || "",
},
};
});
// User-Einstellungen aktualisieren
userSettingsRouter.put("/", authMiddleware, async (ctx) => {
const db = await getDB();
const userId = ctx.state.user.id;
const body = await ctx.request.body.json();
const { sttProvider, openaiApiKey, customSttUrl, telegramBotToken, telegramDefaultChatId } = body;
const updateFields: Record<string, unknown> = {
userId,
updatedAt: new Date(),
};
if (sttProvider !== undefined) updateFields.sttProvider = sttProvider;
// Nur aktualisieren wenn nicht maskierter Wert
if (openaiApiKey !== undefined && !openaiApiKey.startsWith("••••")) {
updateFields.openaiApiKey = openaiApiKey;
}
if (customSttUrl !== undefined) updateFields.customSttUrl = customSttUrl;
if (telegramBotToken !== undefined && !telegramBotToken.startsWith("••••")) {
updateFields.telegramBotToken = telegramBotToken;
}
if (telegramDefaultChatId !== undefined) updateFields.telegramDefaultChatId = telegramDefaultChatId;
await db.collection<UserSettings>("user_settings").updateOne(
{ userId },
{ $set: updateFields },
{ upsert: true }
);
ctx.response.body = { message: "Einstellungen gespeichert" };
});

337
src/routes/workspace.ts Normal file
View File

@@ -0,0 +1,337 @@
import { Router } from "@oak/oak";
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
import { authMiddleware } from "../middleware/auth.ts";
export const workspaceRouter = new Router({ prefix: "/api/workspace" });
interface WorkspaceFile {
_id: ObjectId;
agentId: string;
agentName: string;
filename: string;
content: string;
updatedBy: string;
updatedByName: string;
createdAt: Date;
updatedAt: Date;
version: number;
}
interface WorkspaceFileHistory {
_id: ObjectId;
fileId: string;
agentId: string;
filename: string;
content: string;
version: number;
changedBy: string;
changedByName: string;
changeType: "create" | "update" | "restore";
createdAt: Date;
}
// Alle Dateien eines Agents auflisten
workspaceRouter.get("/files", authMiddleware, async (ctx) => {
const db = await getDB();
const url = ctx.request.url;
const agentId = url.searchParams.get("agentId");
const filter: Record<string, unknown> = {};
if (agentId) filter.agentId = agentId;
const files = await db.collection<WorkspaceFile>("workspace_files")
.find(filter)
.project({ content: 0 }) // Content nicht in Liste laden (Performance)
.sort({ filename: 1 })
.toArray();
ctx.response.body = { files };
});
// Einzelne Datei laden (mit Content)
workspaceRouter.get("/files/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const id = ctx.params.id;
try {
const file = await db.collection<WorkspaceFile>("workspace_files")
.findOne({ _id: new ObjectId(id) });
if (!file) {
ctx.response.status = 404;
ctx.response.body = { error: "Datei nicht gefunden" };
return;
}
ctx.response.body = { file };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
// Datei nach Name laden (für Agenten)
workspaceRouter.get("/files/by-name/:agentId/:filename", authMiddleware, async (ctx) => {
const db = await getDB();
const { agentId, filename } = ctx.params;
const file = await db.collection<WorkspaceFile>("workspace_files")
.findOne({ agentId, filename });
if (!file) {
ctx.response.status = 404;
ctx.response.body = { error: "Datei nicht gefunden" };
return;
}
ctx.response.body = { file };
});
// Datei erstellen oder aktualisieren (Upsert)
workspaceRouter.put("/files", authMiddleware, async (ctx) => {
const db = await getDB();
const body = await ctx.request.body.json();
const { agentId, agentName, filename, content } = body;
if (!agentId || !filename || content === undefined) {
ctx.response.status = 400;
ctx.response.body = { error: "agentId, filename und content sind erforderlich" };
return;
}
const now = new Date();
const existing = await db.collection<WorkspaceFile>("workspace_files")
.findOne({ agentId, filename });
if (existing) {
// History-Eintrag erstellen (alten Stand sichern)
await db.collection<WorkspaceFileHistory>("workspace_files_history").insertOne({
fileId: existing._id.toString(),
agentId,
filename,
content: existing.content,
version: existing.version,
changedBy: ctx.state.user.id,
changedByName: ctx.state.user.username,
changeType: "update",
createdAt: now,
} as WorkspaceFileHistory);
// Datei aktualisieren
await db.collection<WorkspaceFile>("workspace_files").updateOne(
{ _id: existing._id },
{
$set: {
content,
updatedBy: ctx.state.user.id,
updatedByName: ctx.state.user.username,
updatedAt: now,
version: existing.version + 1,
},
}
);
// Log-Eintrag
await db.collection("logs").insertOne({
agentId,
agentName: agentName || agentId,
level: "info",
message: `Workspace-Datei "${filename}" aktualisiert (v${existing.version + 1})`,
metadata: { action: "workspace_file_update", filename, version: existing.version + 1, changedBy: ctx.state.user.username },
timestamp: now,
});
ctx.response.body = {
message: "Datei aktualisiert",
version: existing.version + 1,
id: existing._id.toString(),
};
} else {
// Neue Datei erstellen
const doc: Omit<WorkspaceFile, "_id"> = {
agentId,
agentName: agentName || agentId,
filename,
content,
updatedBy: ctx.state.user.id,
updatedByName: ctx.state.user.username,
createdAt: now,
updatedAt: now,
version: 1,
};
const result = await db.collection<WorkspaceFile>("workspace_files").insertOne(doc as WorkspaceFile);
// History-Eintrag
await db.collection<WorkspaceFileHistory>("workspace_files_history").insertOne({
fileId: result.insertedId.toString(),
agentId,
filename,
content,
version: 1,
changedBy: ctx.state.user.id,
changedByName: ctx.state.user.username,
changeType: "create",
createdAt: now,
} as WorkspaceFileHistory);
// Log-Eintrag
await db.collection("logs").insertOne({
agentId,
agentName: agentName || agentId,
level: "info",
message: `Workspace-Datei "${filename}" erstellt`,
metadata: { action: "workspace_file_create", filename, version: 1, changedBy: ctx.state.user.username },
timestamp: now,
});
ctx.response.status = 201;
ctx.response.body = {
message: "Datei erstellt",
version: 1,
id: result.insertedId.toString(),
};
}
});
// Datei löschen
workspaceRouter.delete("/files/:id", authMiddleware, async (ctx) => {
const db = await getDB();
const id = ctx.params.id;
try {
const file = await db.collection<WorkspaceFile>("workspace_files")
.findOne({ _id: new ObjectId(id) });
if (!file) {
ctx.response.status = 404;
ctx.response.body = { error: "Datei nicht gefunden" };
return;
}
// History-Eintrag
await db.collection<WorkspaceFileHistory>("workspace_files_history").insertOne({
fileId: id,
agentId: file.agentId,
filename: file.filename,
content: file.content,
version: file.version,
changedBy: ctx.state.user.id,
changedByName: ctx.state.user.username,
changeType: "update",
createdAt: new Date(),
} as WorkspaceFileHistory);
await db.collection<WorkspaceFile>("workspace_files").deleteOne({ _id: new ObjectId(id) });
// Log-Eintrag
await db.collection("logs").insertOne({
agentId: file.agentId,
agentName: file.agentName,
level: "warn",
message: `Workspace-Datei "${file.filename}" gelöscht`,
metadata: { action: "workspace_file_delete", filename: file.filename, deletedBy: ctx.state.user.username },
timestamp: new Date(),
});
ctx.response.body = { message: "Datei gelöscht" };
} catch {
ctx.response.status = 400;
ctx.response.body = { error: "Ungültige ID" };
}
});
// Datei-History laden
workspaceRouter.get("/files/:id/history", authMiddleware, async (ctx) => {
const db = await getDB();
const id = ctx.params.id;
const history = await db.collection<WorkspaceFileHistory>("workspace_files_history")
.find({ fileId: id })
.sort({ createdAt: -1 })
.limit(50)
.toArray();
ctx.response.body = { history };
});
// Bulk-Sync: Mehrere Dateien auf einmal hochladen (Agent → AMS Backup)
workspaceRouter.post("/sync", authMiddleware, async (ctx) => {
const db = await getDB();
const body = await ctx.request.body.json();
const { agentId, agentName, files } = body;
if (!agentId || !Array.isArray(files) || files.length === 0) {
ctx.response.status = 400;
ctx.response.body = { error: "agentId und files[] sind erforderlich" };
return;
}
const now = new Date();
let created = 0;
let updated = 0;
for (const file of files) {
if (!file.filename || file.content === undefined) continue;
const existing = await db.collection<WorkspaceFile>("workspace_files")
.findOne({ agentId, filename: file.filename });
if (existing) {
// Nur updaten wenn Content sich geändert hat
if (existing.content !== file.content) {
await db.collection<WorkspaceFileHistory>("workspace_files_history").insertOne({
fileId: existing._id.toString(),
agentId,
filename: file.filename,
content: existing.content,
version: existing.version,
changedBy: ctx.state.user.id,
changedByName: ctx.state.user.username,
changeType: "update",
createdAt: now,
} as WorkspaceFileHistory);
await db.collection<WorkspaceFile>("workspace_files").updateOne(
{ _id: existing._id },
{
$set: {
content: file.content,
updatedBy: ctx.state.user.id,
updatedByName: ctx.state.user.username,
updatedAt: now,
version: existing.version + 1,
},
}
);
updated++;
}
} else {
await db.collection<WorkspaceFile>("workspace_files").insertOne({
agentId,
agentName: agentName || agentId,
filename: file.filename,
content: file.content,
updatedBy: ctx.state.user.id,
updatedByName: ctx.state.user.username,
createdAt: now,
updatedAt: now,
version: 1,
} as WorkspaceFile);
created++;
}
}
// Log-Eintrag
await db.collection("logs").insertOne({
agentId,
agentName: agentName || agentId,
level: "info",
message: `Workspace-Sync: ${created} erstellt, ${updated} aktualisiert (${files.length} Dateien)`,
metadata: { action: "workspace_sync", created, updated, total: files.length, syncedBy: ctx.state.user.username },
timestamp: now,
});
ctx.response.body = { message: "Sync abgeschlossen", created, updated, total: files.length };
});

50
src/utils/eventEmitter.ts Normal file
View File

@@ -0,0 +1,50 @@
type EventHandler = (data: unknown) => void;
class EventEmitter {
private handlers: Map<string, Set<EventHandler>> = new Map();
on(event: string, handler: EventHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
off(event: string, handler: EventHandler): void {
this.handlers.get(event)?.delete(handler);
}
emit(event: string, data: unknown): void {
this.handlers.get(event)?.forEach((handler) => {
try {
handler(data);
} catch (err) {
console.error(`Event handler error for "${event}":`, err);
}
});
// Also emit to wildcard listeners
this.handlers.get("*")?.forEach((handler) => {
try {
handler({ event, data });
} catch (err) {
console.error(`Wildcard handler error:`, err);
}
});
}
}
// Singleton
export const events = new EventEmitter();
// Event types
export const AMS_EVENTS = {
TASK_CREATED: "task:created",
TASK_UPDATED: "task:updated",
TASK_DELETED: "task:deleted",
TASK_STATUS_CHANGED: "task:status_changed",
AGENT_STATUS_CHANGED: "agent:status_changed",
COMMENT_CREATED: "comment:created",
COMMENT_DELETED: "comment:deleted",
ATTACHMENT_UPLOADED: "attachment:uploaded",
ATTACHMENT_DELETED: "attachment:deleted",
} as const;

380
src/utils/gitlabSync.ts Normal file
View File

@@ -0,0 +1,380 @@
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
const GITLAB_URL = Deno.env.get("GITLAB_URL") || "https://gitlab.agentenbude.de";
const GITLAB_TOKEN = Deno.env.get("GITLAB_TOKEN") || "";
// ============ Interfaces ============
export interface GitLabIssue {
id: number;
iid: number;
title: string;
description: string | null;
state: "opened" | "closed";
labels: string[];
milestone: { id: number; title: string } | null;
assignees: { id: number; username: string; name: string }[];
author: { id: number; username: string; name: string };
created_at: string;
updated_at: string;
closed_at: string | null;
due_date: string | null;
web_url: string;
project_id: number;
}
export interface IssueMappingDoc {
_id?: ObjectId;
gitlabProjectId: number;
gitlabIssueIid: number;
gitlabIssueId: number;
amsTaskId: string;
amsProjectId: string;
lastSyncedAt: Date;
syncDirection: "gitlab_to_ams" | "ams_to_gitlab" | "bidirectional";
gitlabWebUrl: string;
}
// ============ GitLab API ============
async function getUserGitLabToken(userId: string): Promise<string> {
const db = await getDB();
const user = await db.collection("users").findOne({ _id: new ObjectId(userId) }).catch(() => null);
return user?.gitlabToken || GITLAB_TOKEN;
}
export async function gitlabApiFetch<T>(endpoint: string, options: RequestInit = {}, token?: string): Promise<T> {
const url = `${GITLAB_URL}/api/v4${endpoint}`;
const headers: Record<string, string> = {
"PRIVATE-TOKEN": token || GITLAB_TOKEN,
"Content-Type": "application/json",
...(options.headers as Record<string, string> || {}),
};
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
const error = await response.text();
throw new Error(`GitLab API error (${response.status}): ${error}`);
}
return response.json();
}
// ============ Status Mapping ============
const GITLAB_TO_AMS_STATUS: Record<string, string> = {
opened: "todo",
closed: "done",
};
const AMS_TO_GITLAB_STATE: Record<string, string> = {
backlog: "reopen",
todo: "reopen",
in_progress: "reopen",
review: "reopen",
done: "close",
};
const GITLAB_TO_AMS_LABEL_PRIORITY: Record<string, string> = {
"priority::urgent": "urgent",
"priority::high": "high",
"priority::medium": "medium",
"priority::low": "low",
};
function mapGitLabPriority(labels: string[]): string {
for (const label of labels) {
const lower = label.toLowerCase();
if (GITLAB_TO_AMS_LABEL_PRIORITY[lower]) {
return GITLAB_TO_AMS_LABEL_PRIORITY[lower];
}
}
return "medium";
}
// ============ Sync Functions ============
/**
* Fetch issues from a GitLab project
*/
export async function fetchGitLabIssues(
gitlabProjectId: number,
options: { state?: string; page?: number; perPage?: number; updatedAfter?: string } = {},
token?: string
): Promise<GitLabIssue[]> {
const params = new URLSearchParams();
if (options.state) params.set("state", options.state);
params.set("page", String(options.page || 1));
params.set("per_page", String(options.perPage || 50));
if (options.updatedAfter) params.set("updated_after", options.updatedAfter);
params.set("order_by", "updated_at");
params.set("sort", "desc");
return gitlabApiFetch<GitLabIssue[]>(
`/projects/${gitlabProjectId}/issues?${params.toString()}`,
{},
token
);
}
/**
* Import a single GitLab issue as an AMS task
*/
export async function importGitLabIssue(
issue: GitLabIssue,
amsProjectId: string,
createdBy: string,
token?: string
): Promise<{ taskId: string; mapping: IssueMappingDoc }> {
const db = await getDB();
// Check if already mapped
const existing = await db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({
gitlabProjectId: issue.project_id,
gitlabIssueIid: issue.iid,
});
if (existing) {
// Update existing task
await syncGitLabIssueToTask(issue, existing.amsTaskId);
return { taskId: existing.amsTaskId, mapping: existing };
}
// Create new AMS task
const now = new Date();
const task = {
title: `[GL#${issue.iid}] ${issue.title}`,
description: issue.description || "",
status: GITLAB_TO_AMS_STATUS[issue.state] || "todo",
priority: mapGitLabPriority(issue.labels),
project: amsProjectId,
labels: [] as string[],
dueDate: issue.due_date ? new Date(issue.due_date) : undefined,
createdBy,
createdAt: now,
updatedAt: now,
gitlabIssueUrl: issue.web_url,
};
const result = await db.collection("tasks").insertOne(task);
const taskId = result.insertedId.toString();
// Store mapping
const mapping: IssueMappingDoc = {
gitlabProjectId: issue.project_id,
gitlabIssueIid: issue.iid,
gitlabIssueId: issue.id,
amsTaskId: taskId,
amsProjectId,
lastSyncedAt: now,
syncDirection: "bidirectional",
gitlabWebUrl: issue.web_url,
};
await db.collection("gitlab_issue_mappings").insertOne(mapping);
return { taskId, mapping };
}
/**
* Sync a GitLab issue's data to an existing AMS task
*/
async function syncGitLabIssueToTask(issue: GitLabIssue, amsTaskId: string): Promise<void> {
const db = await getDB();
const now = new Date();
const updateFields: Record<string, unknown> = {
title: `[GL#${issue.iid}] ${issue.title}`,
description: issue.description || "",
status: GITLAB_TO_AMS_STATUS[issue.state] || "todo",
priority: mapGitLabPriority(issue.labels),
updatedAt: now,
gitlabIssueUrl: issue.web_url,
};
if (issue.due_date) {
updateFields.dueDate = new Date(issue.due_date);
}
await db.collection("tasks").updateOne(
{ _id: new ObjectId(amsTaskId) },
{ $set: updateFields }
);
await db.collection<IssueMappingDoc>("gitlab_issue_mappings").updateOne(
{ amsTaskId },
{ $set: { lastSyncedAt: now } }
);
}
/**
* Push AMS task changes back to GitLab
*/
export async function syncTaskToGitLab(
amsTaskId: string,
userId?: string
): Promise<boolean> {
const db = await getDB();
const mapping = await db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({ amsTaskId });
if (!mapping || mapping.syncDirection === "gitlab_to_ams") return false;
const task = await db.collection("tasks").findOne({ _id: new ObjectId(amsTaskId) });
if (!task) return false;
const token = userId ? await getUserGitLabToken(userId) : GITLAB_TOKEN;
// Clean title (remove [GL#X] prefix)
const cleanTitle = (task.title as string).replace(/^\[GL#\d+\]\s*/, "");
const stateEvent = AMS_TO_GITLAB_STATE[task.status as string];
const body: Record<string, unknown> = {
title: cleanTitle,
description: task.description || "",
};
if (stateEvent) {
body.state_event = stateEvent;
}
if (task.dueDate) {
body.due_date = new Date(task.dueDate as string).toISOString().split("T")[0];
}
await gitlabApiFetch(
`/projects/${mapping.gitlabProjectId}/issues/${mapping.gitlabIssueIid}`,
{ method: "PUT", body: JSON.stringify(body) },
token
);
await db.collection<IssueMappingDoc>("gitlab_issue_mappings").updateOne(
{ amsTaskId },
{ $set: { lastSyncedAt: new Date() } }
);
return true;
}
/**
* Create a new GitLab issue from an AMS task
*/
export async function createGitLabIssueFromTask(
amsTaskId: string,
gitlabProjectId: number,
amsProjectId: string,
userId?: string
): Promise<GitLabIssue> {
const db = await getDB();
const task = await db.collection("tasks").findOne({ _id: new ObjectId(amsTaskId) });
if (!task) throw new Error("Task not found");
const token = userId ? await getUserGitLabToken(userId) : GITLAB_TOKEN;
const body: Record<string, unknown> = {
title: task.title as string,
description: task.description || "",
};
if (task.dueDate) {
body.due_date = new Date(task.dueDate as string).toISOString().split("T")[0];
}
const issue = await gitlabApiFetch<GitLabIssue>(
`/projects/${gitlabProjectId}/issues`,
{ method: "POST", body: JSON.stringify(body) },
token
);
// Update task title with GL prefix
await db.collection("tasks").updateOne(
{ _id: new ObjectId(amsTaskId) },
{ $set: { title: `[GL#${issue.iid}] ${task.title}`, gitlabIssueUrl: issue.web_url, updatedAt: new Date() } }
);
// Store mapping
await db.collection("gitlab_issue_mappings").insertOne({
gitlabProjectId: issue.project_id,
gitlabIssueIid: issue.iid,
gitlabIssueId: issue.id,
amsTaskId,
amsProjectId,
lastSyncedAt: new Date(),
syncDirection: "bidirectional",
gitlabWebUrl: issue.web_url,
});
return issue;
}
/**
* Full sync: import all issues from a GitLab project, update existing mappings
*/
export async function fullSync(
gitlabProjectId: number,
amsProjectId: string,
createdBy: string,
userId?: string
): Promise<{ imported: number; updated: number; total: number }> {
const token = userId ? await getUserGitLabToken(userId) : GITLAB_TOKEN;
let page = 1;
let imported = 0;
let updated = 0;
let total = 0;
while (true) {
const issues = await fetchGitLabIssues(gitlabProjectId, { state: "all", page, perPage: 100 }, token);
if (issues.length === 0) break;
const db = await getDB();
for (const issue of issues) {
const existing = await db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({
gitlabProjectId: issue.project_id,
gitlabIssueIid: issue.iid,
});
if (existing) {
await syncGitLabIssueToTask(issue, existing.amsTaskId);
updated++;
} else {
await importGitLabIssue(issue, amsProjectId, createdBy, token);
imported++;
}
total++;
}
if (issues.length < 100) break;
page++;
}
return { imported, updated, total };
}
/**
* Get mapping for an AMS task
*/
export async function getMappingForTask(amsTaskId: string): Promise<IssueMappingDoc | null> {
const db = await getDB();
return db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({ amsTaskId });
}
/**
* Get all mappings for a project
*/
export async function getMappingsForProject(amsProjectId: string): Promise<IssueMappingDoc[]> {
const db = await getDB();
return db.collection<IssueMappingDoc>("gitlab_issue_mappings").find({ amsProjectId }).toArray();
}
/**
* Delete a mapping
*/
export async function deleteMapping(amsTaskId: string): Promise<boolean> {
const db = await getDB();
const result = await db.collection("gitlab_issue_mappings").deleteOne({ amsTaskId });
return result.deletedCount > 0;
}

98
src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,98 @@
const encoder = new TextEncoder();
interface JWTPayload {
id: string;
email: string;
username: string;
role: string;
exp: number;
iat: number;
}
function base64UrlEncode(data: Uint8Array): string {
return btoa(String.fromCharCode(...data))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function base64UrlDecode(str: string): Uint8Array {
str = str.replace(/-/g, "+").replace(/_/g, "/");
while (str.length % 4) str += "=";
return Uint8Array.from(atob(str), c => c.charCodeAt(0));
}
/**
* Validates that the JWT secret meets minimum security requirements.
* Must be set via JWT_SECRET environment variable.
* Minimum length: 32 characters.
*/
function getSecret(): string {
const secret = Deno.env.get("JWT_SECRET");
if (!secret) {
console.error("❌ FATAL: JWT_SECRET environment variable is not set.");
console.error(" Set a strong secret: export JWT_SECRET=$(openssl rand -base64 48)");
Deno.exit(1);
}
if (secret.length < 32) {
console.error("❌ FATAL: JWT_SECRET must be at least 32 characters long.");
Deno.exit(1);
}
return secret;
}
// Validate secret at module load time (fail fast on startup)
const JWT_SECRET = getSecret();
async function getKey(): Promise<CryptoKey> {
return await crypto.subtle.importKey(
"raw",
encoder.encode(JWT_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
export async function signJWT(payload: Omit<JWTPayload, "exp" | "iat">): Promise<string> {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload: JWTPayload = {
...payload,
iat: now,
exp: now + 86400 * 7 // 7 days
};
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(fullPayload)));
const data = `${headerB64}.${payloadB64}`;
const key = await getKey();
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifyJWT(token: string): Promise<JWTPayload> {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("Invalid token format");
const [headerB64, payloadB64, signatureB64] = parts;
const data = `${headerB64}.${payloadB64}`;
const key = await getKey();
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify("HMAC", key, new Uint8Array(signature).buffer as ArrayBuffer, encoder.encode(data));
if (!valid) throw new Error("Invalid signature");
const payload: JWTPayload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error("Token expired");
}
return payload;
}

57
src/utils/password.ts Normal file
View File

@@ -0,0 +1,57 @@
const encoder = new TextEncoder();
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const hash = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: 100000,
hash: "SHA-256"
},
keyMaterial,
256
);
const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, "0")).join("");
const hashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
return `${saltHex}:${hashHex}`;
}
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [saltHex, hashHex] = storedHash.split(":");
const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map(byte => parseInt(byte, 16)));
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const hash = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: 100000,
hash: "SHA-256"
},
keyMaterial,
256
);
const computedHashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
return computedHashHex === hashHex;
}

View File

@@ -0,0 +1,58 @@
import { ObjectId } from "mongodb";
import { getDB } from "../db/mongo.ts";
interface ChangelogEntry {
taskId: string;
field: string;
oldValue: unknown;
newValue: unknown;
changedBy: string;
changedAt: Date;
}
const TRACKED_FIELDS = ["title", "description", "status", "priority", "assignee", "project", "labels", "dueDate"];
/**
* Compare old and new task data, write changelog entries for changed fields.
*/
export async function logTaskChanges(
taskId: string,
oldTask: Record<string, unknown>,
newFields: Record<string, unknown>,
changedBy: string
): Promise<void> {
const db = await getDB();
const entries: ChangelogEntry[] = [];
const now = new Date();
for (const field of TRACKED_FIELDS) {
if (!(field in newFields)) continue;
const oldVal = oldTask[field];
const newVal = newFields[field];
// Compare arrays (labels)
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
const oldSorted = [...oldVal].sort().join(",");
const newSorted = [...newVal].sort().join(",");
if (oldSorted === newSorted) continue;
} else if (oldVal === newVal) {
continue;
} else if (oldVal == null && newVal == null) {
continue;
}
entries.push({
taskId,
field,
oldValue: oldVal ?? null,
newValue: newVal ?? null,
changedBy,
changedAt: now,
});
}
if (entries.length > 0) {
await db.collection("task_changelog").insertMany(entries);
}
}

137
src/ws/connectionManager.ts Normal file
View File

@@ -0,0 +1,137 @@
import { events } from "../utils/eventEmitter.ts";
interface WsClient {
socket: WebSocket;
userId: string;
username: string;
connectedAt: Date;
messagesSent: number;
messagesReceived: number;
}
interface WsStats {
startedAt: string;
totalConnections: number;
totalDisconnections: number;
totalBroadcasts: number;
totalMessagesSent: number;
totalMessagesReceived: number;
currentConnections: number;
users: Array<{
userId: string;
username: string;
connectedAt: string;
messagesSent: number;
messagesReceived: number;
}>;
}
class ConnectionManager {
private clients: Map<WebSocket, WsClient> = new Map();
private readonly startedAt = new Date();
private totalConnections = 0;
private totalDisconnections = 0;
private totalBroadcasts = 0;
private totalMessagesSent = 0;
private totalMessagesReceived = 0;
add(socket: WebSocket, userId: string, username: string): void {
this.clients.set(socket, {
socket,
userId,
username,
connectedAt: new Date(),
messagesSent: 0,
messagesReceived: 0,
});
this.totalConnections++;
console.log(`[WS] Client connected: ${username} (${this.clients.size} total)`);
}
remove(socket: WebSocket): void {
const client = this.clients.get(socket);
if (client) {
this.totalDisconnections++;
console.log(`[WS] Client disconnected: ${client.username} (${this.clients.size - 1} total)`);
}
this.clients.delete(socket);
}
trackReceived(socket: WebSocket): void {
const client = this.clients.get(socket);
if (client) {
client.messagesReceived++;
this.totalMessagesReceived++;
}
}
broadcast(event: string, data: unknown): void {
const message = JSON.stringify({ event, data, timestamp: new Date().toISOString() });
this.totalBroadcasts++;
for (const [socket, client] of this.clients) {
if (socket.readyState === WebSocket.OPEN) {
try {
socket.send(message);
client.messagesSent++;
this.totalMessagesSent++;
} catch {
// Client will be cleaned up on close
}
}
}
}
sendTo(userId: string, event: string, data: unknown): void {
const message = JSON.stringify({ event, data, timestamp: new Date().toISOString() });
for (const [socket, client] of this.clients) {
if (client.userId === userId && socket.readyState === WebSocket.OPEN) {
try {
socket.send(message);
client.messagesSent++;
this.totalMessagesSent++;
} catch {
// Will clean up on close
}
}
}
}
getConnectedCount(): number {
return this.clients.size;
}
getConnectedUsers(): Array<{ userId: string; username: string; connectedAt: Date }> {
return [...this.clients.values()].map((c) => ({
userId: c.userId,
username: c.username,
connectedAt: c.connectedAt,
}));
}
getStats(): WsStats {
return {
startedAt: this.startedAt.toISOString(),
totalConnections: this.totalConnections,
totalDisconnections: this.totalDisconnections,
totalBroadcasts: this.totalBroadcasts,
totalMessagesSent: this.totalMessagesSent,
totalMessagesReceived: this.totalMessagesReceived,
currentConnections: this.clients.size,
users: [...this.clients.values()].map((c) => ({
userId: c.userId,
username: c.username,
connectedAt: c.connectedAt.toISOString(),
messagesSent: c.messagesSent,
messagesReceived: c.messagesReceived,
})),
};
}
}
export const wsManager = new ConnectionManager();
// Bridge: EventEmitter → WebSocket broadcast
events.on("*", (payload: unknown) => {
const { event, data } = payload as { event: string; data: unknown };
wsManager.broadcast(event, data);
});

69
src/ws/handler.ts Normal file
View File

@@ -0,0 +1,69 @@
import { verifyJWT } from "../utils/jwt.ts";
import { wsManager } from "./connectionManager.ts";
export function handleWebSocket(req: Request): Response {
// Parse URL - handle both full URLs and relative paths
let url: URL;
try {
url = new URL(req.url, "http://localhost");
} catch {
return new Response("Invalid URL", { status: 400 });
}
const token = url.searchParams.get("token");
if (!token) {
return new Response("Missing token", { status: 401 });
}
let socket: WebSocket;
let response: Response;
try {
const upgrade = Deno.upgradeWebSocket(req);
socket = upgrade.socket;
response = upgrade.response;
} catch (e) {
console.error("WebSocket upgrade failed:", e);
return new Response("WebSocket upgrade failed", { status: 400 });
}
// Authenticate async after upgrade
(async () => {
try {
const payload = await verifyJWT(token);
wsManager.add(socket, payload.id, payload.username || payload.email);
socket.onmessage = (e) => {
wsManager.trackReceived(socket);
try {
const msg = JSON.parse(e.data);
if (msg.type === "ping") {
socket.send(JSON.stringify({ event: "pong", timestamp: new Date().toISOString() }));
}
} catch {
// Ignore malformed messages
}
};
socket.onclose = () => {
wsManager.remove(socket);
};
socket.onerror = () => {
wsManager.remove(socket);
};
// Send welcome
socket.send(JSON.stringify({
event: "connected",
data: { userId: payload.id, connectedUsers: wsManager.getConnectedCount() },
timestamp: new Date().toISOString()
}));
} catch {
socket.close(4001, "Invalid token");
}
})();
return response;
}