Initial commit: AMS Backend - Deno + Oak + MongoDB
This commit is contained in:
12
Dockerfile
Normal file
12
Dockerfile
Normal 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
11
deno.json
Normal 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
311
deno.lock
generated
Normal 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
33
src/db/mongo.ts
Normal 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
100
src/main.ts
Normal 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
46
src/middleware/auth.ts
Normal 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
49
src/middleware/cors.ts
Normal 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
375
src/routes/agents.ts
Normal 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
281
src/routes/agenttasks.ts
Normal 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
153
src/routes/appUpdate.ts
Normal 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
241
src/routes/attachments.ts
Normal 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
211
src/routes/auth.ts
Normal 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
274
src/routes/comments.ts
Normal 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
366
src/routes/cronjobs.ts
Normal 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
240
src/routes/docker.ts
Normal 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
138
src/routes/export.ts
Normal 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
697
src/routes/gitlab.ts
Normal 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
375
src/routes/labels.ts
Normal 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
188
src/routes/logs.ts
Normal 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
70
src/routes/messaging.ts
Normal 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
120
src/routes/quicktexts.ts
Normal 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" };
|
||||
}
|
||||
});
|
||||
131
src/routes/secretAuditLogs.ts
Normal file
131
src/routes/secretAuditLogs.ts
Normal 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
576
src/routes/secrets.ts
Normal 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
72
src/routes/settings.ts
Normal 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
884
src/routes/tasks.ts
Normal 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
346
src/routes/tokens.ts
Normal 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
82
src/routes/transcribe.ts
Normal 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" };
|
||||
}
|
||||
});
|
||||
80
src/routes/usersettings.ts
Normal file
80
src/routes/usersettings.ts
Normal 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
337
src/routes/workspace.ts
Normal 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
50
src/utils/eventEmitter.ts
Normal 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
380
src/utils/gitlabSync.ts
Normal 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
98
src/utils/jwt.ts
Normal 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
57
src/utils/password.ts
Normal 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;
|
||||
}
|
||||
|
||||
58
src/utils/taskChangelog.ts
Normal file
58
src/utils/taskChangelog.ts
Normal 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
137
src/ws/connectionManager.ts
Normal 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
69
src/ws/handler.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user