Compare commits
532 Commits
fcf8aa8652
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9888b1b5d7 | |||
| 1bf1411c66 | |||
| 5946aa47d5 | |||
| 2f861cd6d7 | |||
| 23b233bda3 | |||
| adfff6cfe4 | |||
| 269464943e | |||
| e8f018f2c6 | |||
| b151951448 | |||
| 2e2e81b3e1 | |||
| b873c0e4ae | |||
| 9dc16674e2 | |||
| e6e2688b56 | |||
| 28aa74b4b0 | |||
| 8e37441782 | |||
| 6a0e7c947f | |||
| 3c1a2d9c41 | |||
| 92c86ec6ba | |||
| 5ef039a6bc | |||
| 96b8f25747 | |||
| 42ab5ead26 | |||
| 5aaa62dca7 | |||
| d583971afd | |||
| d660a45bb5 | |||
| d1f3b9ffcd | |||
| d93321275c | |||
| 629b9d9ca5 | |||
| 7e3b1108e2 | |||
| b3fbbbacfe | |||
| 3a100fa1f1 | |||
| fbeb93046d | |||
| 0cce8a2011 | |||
| 7a53f5bee1 | |||
| ea30ceb1f1 | |||
| cd33777d75 | |||
| c73a489075 | |||
| 7ddb572f5d | |||
| 1a3101066e | |||
| 043bcb65d8 | |||
| d31fccbe0e | |||
| 41bc522b5b | |||
| 75bd0c29f3 | |||
| 3ffa3f5793 | |||
| 59e55f8740 | |||
| f1359d63ba | |||
| bbfcd44407 | |||
| 5da5a5597b | |||
| b1ef6a85d6 | |||
| a795794f94 | |||
| 4e27e05512 | |||
| 71b6f8f181 | |||
| 38684dd903 | |||
| 716bc651c4 | |||
| 27f12e4659 | |||
| a7c6ffe4dd | |||
| ae5c5c24eb | |||
| e8ec50e0fc | |||
| 1f8667c7da | |||
| bed41dcbdf | |||
| 6694ab84a1 | |||
| f721e97ff1 | |||
| d9f9fa0743 | |||
| 7b72fac679 | |||
| 726c780416 | |||
| 69effa446a | |||
| c5bca9db44 | |||
| 6565538b3b | |||
| ecb704f24e | |||
| 86532f5e08 | |||
| 5c60d44283 | |||
| 3eacb7e580 | |||
| 4151098c12 | |||
| 93f8e85568 | |||
| 92c272bbea | |||
| ed4e41f7dc | |||
| d7d77769ff | |||
| 362df8f766 | |||
| 43f0f3d092 | |||
| ff851db5d1 | |||
| b4bfa4ba49 | |||
| dacbb5f15e | |||
| 34b7957132 | |||
| cc46a389e7 | |||
| 9dc39d06af | |||
| 43c4c5102a | |||
| 0992c73842 | |||
| 0808853d45 | |||
| f071e89fc2 | |||
| b546ae3759 | |||
| a2fb1f38ee | |||
| 2195ccfa1a | |||
| b28f266cbe | |||
| 736ddf647d | |||
| 2188d6645e | |||
| 151bf3d322 | |||
| 076a6cd567 | |||
| c93796554a | |||
| f94974c438 | |||
| 78788a89ba | |||
| a9c50208cf | |||
| 31e1420cdc | |||
| 4e34fa6da9 | |||
| e655af178b | |||
| 6d7c3037fc | |||
| fc855f52f9 | |||
| 932508f935 | |||
| bc33b909cb | |||
| bde78c51e0 | |||
| cd34d99982 | |||
| 3004be3c9d | |||
| 78783ad20c | |||
| be123d7081 | |||
| 2096c853ee | |||
| 2c51caa928 | |||
| b88ed51286 | |||
| 5c5492d26e | |||
| 9f61eea9f6 | |||
| 1a963f9e66 | |||
| fc38c80804 | |||
| 31b6e38459 | |||
| 0194af8a64 | |||
| 96714ab068 | |||
| 7c3758298f | |||
| d40590acef | |||
| 0bdf40e1c6 | |||
| 9dcbc5a951 | |||
| 3c7af3aa93 | |||
| 1b41ee512f | |||
| f7377aba96 | |||
| dd969b5184 | |||
| 86d8a44d4f | |||
| 86d729b837 | |||
| cf09c93110 | |||
| 63d9566ee4 | |||
| e37906d92e | |||
| ced3333430 | |||
| 4265f5175a | |||
| d0bbfbb744 | |||
| c85ee384c9 | |||
| 642a8587b5 | |||
| 4716023abc | |||
| ba3b172223 | |||
| 34d7b187af | |||
| 487dc6d1e7 | |||
| 8b7671d310 | |||
| 24e57f558e | |||
| c4ad3bc2c4 | |||
| fa6b0a241d | |||
| 82c9b5cf53 | |||
| bb1144f392 | |||
| 48c6f9277c | |||
| 56da89fb0e | |||
| 8442ac82f1 | |||
| 283894a197 | |||
| 41c2191280 | |||
| f3dba93d81 | |||
| 62aa56b007 | |||
| 72250c7c75 | |||
| 2dfc47d67e | |||
| 798c2c4373 | |||
| e97c03587d | |||
| 004a624f23 | |||
| 15b6e8614c | |||
| 80376c90b3 | |||
| 111e5d546f | |||
| 43418d46fd | |||
| e4f2d49e96 | |||
| 898ad1785b | |||
| 8aa5db39fd | |||
| db0b77ef8f | |||
| 0d123d8264 | |||
| 4abba96515 | |||
| c49fae8776 | |||
| 9a750eb2b1 | |||
| 4c2a7574e4 | |||
| f115b0a307 | |||
| c34c06d28d | |||
| 48042bde47 | |||
| 7fb207cfce | |||
| 11b330c268 | |||
| fb53c8be90 | |||
| b29dc33708 | |||
| 7cb79dacd5 | |||
| 14362cbc0e | |||
| 91f4202e88 | |||
| e5bb8e65e8 | |||
| f86dc265eb | |||
| ae31a19275 | |||
| e72b68b4a3 | |||
| 6b08ce6b6a | |||
| e0a3ff5ca9 | |||
| b3baf603ee | |||
| ad2bbab7b6 | |||
| a3a1ec4430 | |||
| c0c44adaaa | |||
| 8a4e196864 | |||
| 34e2614e36 | |||
| ac8ef371ff | |||
| e37aecab18 | |||
| ecc1423a4f | |||
| 39fcf58d1b | |||
| 497be5fac9 | |||
| 855e764911 | |||
| e151984ce2 | |||
| 11431bbf4e | |||
| fcac514d9f | |||
| da4dcdca32 | |||
| 3cce3b2871 | |||
| a2c5307713 | |||
| ef7ec776eb | |||
| 9fd829eceb | |||
| 96cd79dec5 | |||
| fde1673bdd | |||
| f781874eee | |||
| 9333b7a9c3 | |||
| 6db0056329 | |||
| e0fedde560 | |||
| f7441ccba5 | |||
| 27d1c5ba9f | |||
| 8354ab4df4 | |||
| 88d0619184 | |||
| 6111494460 | |||
| 73e3749960 | |||
| f57bdfa151 | |||
| 34b519eebb | |||
| 66fb265f22 | |||
| ec7326cfe1 | |||
| 67ed5e542d | |||
| 6ec27fdbf2 | |||
| 9513675d85 | |||
| 8e2329be53 | |||
| 19214bfd66 | |||
| 53e61c6dcd | |||
| 728f698f9e | |||
| 511a7de627 | |||
| 9d82f15c53 | |||
| b0918fd946 | |||
| 7b31b462a0 | |||
| 021faedfa3 | |||
| 0b30c5e66c | |||
| 824f8a7ff2 | |||
| 5914ec6cd5 | |||
| 30c63bbef6 | |||
| 7be1a296c6 | |||
| e524786ac0 | |||
| 0ee2b1538a | |||
| dd6e2f8bd7 | |||
| f66f32ee9d | |||
| de308b7397 | |||
| 8402e57323 | |||
| 1212f6ddfb | |||
| ac2299226a | |||
| 607dab4f26 | |||
| 3b8f9b595e | |||
| 84a0280c52 | |||
| dc36e59d17 | |||
| 9bb689b7e6 | |||
| d01a50a4b1 | |||
| 51e75187ed | |||
| e37fd3bbe4 | |||
| 11fa490599 | |||
| 27ef21a4f0 | |||
| b3643ddee9 | |||
| 68b7660ce3 | |||
| 2d61911d98 | |||
| 9f642901ab | |||
| add7400b78 | |||
| 65cc5200ea | |||
| ede93a7774 | |||
| bc020e9f64 | |||
| bad4659d5b | |||
| e3b33ef596 | |||
| 39255f2c9e | |||
| 030991cb9a | |||
| fa9b554f50 | |||
| 788714ecec | |||
| 08ca17c876 | |||
| c157e9cbca | |||
| 9005a05bd7 | |||
| 98081ae5eb | |||
| c99e35438c | |||
| 1241a14ea5 | |||
| 0712d18824 | |||
| 71040dcd33 | |||
| 0923d9b051 | |||
| 909301a4de | |||
| d548ce4199 | |||
| 0188a46afb | |||
| d6be61cdcf | |||
| 6e6525a416 | |||
| 6a6b3e8cee | |||
| 09ac22f692 | |||
| 5a476ac97d | |||
| 4f2a963834 | |||
| aa7bd79c51 | |||
| 7701a34d7f | |||
| d35e3f4705 | |||
| 5d71a371d6 | |||
| f75aef2a4a | |||
| 5264528940 | |||
| 084183f3a4 | |||
| e05d3e1554 | |||
| 06f868abeb | |||
| aed428312f | |||
| 32851ca9fb | |||
| cbee0b534f | |||
| 8f44d907a5 | |||
| 24ce8ccd20 | |||
| 786993d8ca | |||
| 2b9788bdb0 | |||
| 91b5ce990f | |||
| 936b4ccc51 | |||
| 9e3f15ce4e | |||
| 7523f47468 | |||
| 6de8b33dd1 | |||
| 79c01c85fa | |||
| 735cab2018 | |||
| b4e8b74afb | |||
| 4b06933576 | |||
| 89a6b90ca6 | |||
| f9b9cf0383 | |||
| 2de4d03d81 | |||
| d2c2fd92cc | |||
| 032df7f401 | |||
| 474f09ce88 | |||
| e920dd1b3f | |||
| 5ddf8bbc3c | |||
| 14cde7b3ee | |||
| 581162cdb8 | |||
| dc27fc5500 | |||
| 51649c874b | |||
| 4d7836540a | |||
| 3419e18d7f | |||
| a9b71b9d23 | |||
| e8a18c0025 | |||
| 3e9a988aaf | |||
| 01f05e4399 | |||
| 7c17e484c1 | |||
| ea39418738 | |||
| 7f88ed0ed2 | |||
| 44659a9dd7 | |||
| 87d7da0198 | |||
| 9675c1f896 | |||
| 9736476a0c | |||
| 03d420c984 | |||
| 6b52719079 | |||
| a5b7d62969 | |||
| ef9e3699b2 | |||
| 440367b69d | |||
| 801a5a43f5 | |||
| 9c23068a4f | |||
| d359b7b734 | |||
| bd37ff807e | |||
| 40d2342086 | |||
| adf3bf8301 | |||
| 1b5ccd4dec | |||
| b5d8f9aed3 | |||
| c8171b0a1e | |||
| 7e15ef3725 | |||
| e3a3802f5b | |||
| 93e319e9fb | |||
| 6626d2a8f9 | |||
| 3dbc470158 | |||
| e5d0386cfb | |||
| ff071af2a0 | |||
| fcdcbc51e3 | |||
| 7b8f8d4b5a | |||
| f385c612f5 | |||
| 9166d9dade | |||
| 7ae5bc0fd5 | |||
| 242ed1101e | |||
| 8b2e9ac328 | |||
| 084d09e9bd | |||
| 646143ce5a | |||
| 00d802f965 | |||
| ebb7575f2c | |||
| d0539d0f2f | |||
| 8e92a93aa8 | |||
| f794347827 | |||
| 1af160eed0 | |||
| eb118ebf92 | |||
| dbb476cc3b | |||
| 9345efc3f0 | |||
| c4e993e3f8 | |||
| a58d1aa403 | |||
| d7ed5ce8c5 | |||
| 512088ab93 | |||
| 32b5e0223d | |||
| 9354cbf775 | |||
| 756d068b4f | |||
| c02a7bd8a6 | |||
| b6d3fad6ab | |||
| 27479ee553 | |||
| 82a5d62f44 | |||
| bc23c6815a | |||
| 7dd2dc89a9 | |||
| 57462899f6 | |||
| f23b872c54 | |||
| 55f7195edd | |||
| b14be8583d | |||
| 67ad7c236b | |||
| f89ce46631 | |||
| fc71117bf2 | |||
| ea752088f6 | |||
| edadf39445 | |||
| 1c3cec2c06 | |||
| 746daaef6d | |||
| 441d5740bd | |||
| ee5241a7bc | |||
| e3ab428b91 | |||
| c7ab569b2b | |||
| 645973141c | |||
| 68692ade4e | |||
| 49908d72d0 | |||
| 1b5c2a156c | |||
| 159d07efd5 | |||
| 06431be40d | |||
| 9f3e5bbf9f | |||
| a66b76001b | |||
| 3188054462 | |||
| 5fd65e8a38 | |||
| 34d2529e04 | |||
| 928556aa89 | |||
| 720493f26b | |||
| ab13254636 | |||
| 104a506b6f | |||
| 92290b9035 | |||
| b5d855d117 | |||
| 1bd57da627 | |||
| f9c03c30d9 | |||
| f2b225106d | |||
| 29d3ec60d0 | |||
| bbf038d228 | |||
| c967d80aed | |||
| 11c0c1df38 | |||
| f849fd729a | |||
| 85949dbf8e | |||
| 6fba87fdd9 | |||
| c7236ef7e8 | |||
| 307af5c901 | |||
| 625906f75a | |||
| 129072e0f0 | |||
| dbc4e59e24 | |||
| cf476ea986 | |||
| c989af42f5 | |||
| d3247ef090 | |||
| 90c7f9d8ec | |||
| c43d39fd7f | |||
| 8aca75118c | |||
| 6bf2692faa | |||
| 2d85ef310a | |||
| 774a0ba6db | |||
| 566a8bf84e | |||
| 3567845235 | |||
| c4d8da6d0d | |||
| fa8010cf91 | |||
| 16de384831 | |||
| a01e6cb88e | |||
| a58cd16f01 | |||
| f514667ef9 | |||
| 9e712465af | |||
| bf22d436fb | |||
| f689b892de | |||
| 2f2338c973 | |||
| 10eb0ce5f9 | |||
| 32616504a6 | |||
| 4bce3724f2 | |||
| 322e2d9cb3 | |||
| c1a8b9d936 | |||
| c374600833 | |||
| 87b00a94c0 | |||
| 978f0297eb | |||
| 959986356b | |||
| f126b40574 | |||
| fa4027d027 | |||
| 9da9b323fc | |||
| eb263ce7a4 | |||
| aece5f7414 | |||
| ddabda6f05 | |||
| bcbceba31c | |||
| 3a2567b44d | |||
| df0a9d6cf0 | |||
| 38363b2837 | |||
| 96f94475f6 | |||
| 3fd3336f6c | |||
| eaba087d11 | |||
| ed2cc234b8 | |||
| ffd3fd1d7c | |||
| 23694b6555 | |||
| 8979aa8e43 | |||
| c433bc021e | |||
| f4ed1eb10c | |||
| 9c8663a0f1 | |||
| d1632fca17 | |||
| 65177d3ff7 | |||
| 559d6a351c | |||
| 8fd11998e4 | |||
| 4ce649aa71 | |||
| 5ee3cc0104 | |||
| b36712247b | |||
| cf2cabd098 | |||
| 8ee02bd2e4 | |||
| d9687725e5 | |||
| 6c3911ca47 | |||
| 30807d1ce1 | |||
| 82c28a2b6e | |||
| 86624d72dd | |||
| 9218664400 | |||
| 8fa5d9061a | |||
| 84002f5719 | |||
| 86b11c7e5f | |||
| 8003dcac39 | |||
| 778c44226e | |||
| 79891063dd | |||
| 2c9b0dc448 | |||
| 3133615044 | |||
| 2bc0f87325 | |||
| 4ee38d6f0b | |||
| 992d4f2a6b | |||
| 8f5f9641c7 | |||
| 7cdb53051f | |||
| d834753a98 | |||
| 395011d0f4 | |||
| 9e1660f954 | |||
| 13ff930b5e | |||
| 5d1c837f49 | |||
| 1dd9662037 | |||
| 4626edb232 | |||
| 3c29b621ac | |||
| 755570d474 | |||
| e890b1490a | |||
| d15de16c47 |
@@ -0,0 +1,227 @@
|
|||||||
|
# AGENTS.go.md — Go Agent Rules
|
||||||
|
|
||||||
|
Applies to: `ai-compliance-sdk/` (Go/Gin service)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||||
|
|
||||||
|
**BEFORE every `git push`, run ALL of the following from the module root. A single failure blocks the push.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Format (gofmt is non-negotiable — unformatted code fails CI)
|
||||||
|
gofmt -l . | grep -q . && echo "FORMATTING ERRORS — run: gofmt -w ." && exit 1 || true
|
||||||
|
|
||||||
|
# 2. Vet (catches suspicious code that compiles but is likely wrong)
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# 3. Lint (golangci-lint aggregates 50+ linters — the de-facto standard)
|
||||||
|
golangci-lint run --timeout=5m ./...
|
||||||
|
|
||||||
|
# 4. Tests with race detector
|
||||||
|
go test -race -count=1 ./...
|
||||||
|
|
||||||
|
# 5. Build verification (catches import errors, missing implementations)
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-liner pre-push gate:**
|
||||||
|
```bash
|
||||||
|
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race -count=1 ./... && go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why each check matters
|
||||||
|
|
||||||
|
| Check | Catches | Time |
|
||||||
|
|-------|---------|------|
|
||||||
|
| `gofmt` | Formatting violations (CI rejects unformatted code) | <1s |
|
||||||
|
| `go vet` | Printf format mismatches, unreachable code, shadowed vars | <5s |
|
||||||
|
| `golangci-lint` | 50+ static analysis checks (errcheck, staticcheck, etc.) | 10-30s |
|
||||||
|
| `go test -race` | Race conditions (invisible without this flag) | 10-60s |
|
||||||
|
| `go build` | Import errors, interface mismatches | <5s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## golangci-lint Configuration
|
||||||
|
|
||||||
|
Config lives in `.golangci.yml` at the repo root. Minimum required linters:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck # unchecked errors are bugs
|
||||||
|
- gosimple # code simplification
|
||||||
|
- govet # go vet findings
|
||||||
|
- ineffassign # useless assignments
|
||||||
|
- staticcheck # advanced static analysis (SA*, S*, QF*)
|
||||||
|
- unused # unused code
|
||||||
|
- gofmt # formatting
|
||||||
|
- goimports # import organization
|
||||||
|
- gocritic # opinionated style checks
|
||||||
|
- noctx # HTTP requests without context
|
||||||
|
- bodyclose # unclosed HTTP response bodies
|
||||||
|
- exhaustive # exhaustive switch on enums
|
||||||
|
- wrapcheck # errors from external packages must be wrapped
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
errcheck:
|
||||||
|
check-blank: true # blank identifier for errors is a bug
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never suppress with `//nolint:` without a comment explaining why it's safe.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Structure (Hexagonal Architecture)
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-compliance-sdk/
|
||||||
|
├── cmd/
|
||||||
|
│ └── server/main.go # thin: parse flags, wire deps, call app.Run()
|
||||||
|
├── internal/
|
||||||
|
│ ├── app/ # dependency wiring
|
||||||
|
│ ├── domain/ # pure business logic, no framework deps
|
||||||
|
│ ├── ports/ # interfaces (repositories, external services)
|
||||||
|
│ ├── adapters/
|
||||||
|
│ │ ├── http/ # Gin handlers (≤30 LOC per handler)
|
||||||
|
│ │ ├── postgres/ # DB adapters implementing ports
|
||||||
|
│ │ └── external/ # third-party API clients
|
||||||
|
│ └── services/ # orchestration between domain + ports
|
||||||
|
└── pkg/ # exported, reusable packages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handler constraint — max 30 lines per handler:**
|
||||||
|
```go
|
||||||
|
func (h *RiskHandler) GetRisk(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
risk, err := h.service.Get(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, risk)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```go
|
||||||
|
// REQUIRED: wrap errors with context
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get risk %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// REQUIRED: define sentinel errors in domain package
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
var ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
|
||||||
|
// REQUIRED: check errors — never use _ for error returns
|
||||||
|
result, err := service.Do(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
// handle it
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`errcheck` linter enforces this — zero tolerance for unchecked errors.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/
|
||||||
|
├── domain/
|
||||||
|
│ ├── risk.go
|
||||||
|
│ └── risk_test.go # unit: pure functions, no I/O
|
||||||
|
├── adapters/
|
||||||
|
│ ├── http/
|
||||||
|
│ │ ├── handler.go
|
||||||
|
│ │ └── handler_test.go # httptest-based, mock service
|
||||||
|
│ └── postgres/
|
||||||
|
│ ├── repo.go
|
||||||
|
│ └── repo_test.go # integration: testcontainers or real DB
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test naming convention:**
|
||||||
|
```go
|
||||||
|
func TestRiskService_Get_ReturnsRisk(t *testing.T) {}
|
||||||
|
func TestRiskService_Get_NotFound_ReturnsError(t *testing.T) {}
|
||||||
|
func TestRiskService_Get_DBError_WrapsError(t *testing.T) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Table-driven tests are mandatory for functions with multiple cases:**
|
||||||
|
```go
|
||||||
|
func TestValidateInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", "ok", false},
|
||||||
|
{"empty", "", true},
|
||||||
|
{"too long", strings.Repeat("x", 300), true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pre-push: unit tests only (fast)
|
||||||
|
go test -race -count=1 -run "^TestUnit" ./...
|
||||||
|
|
||||||
|
# CI: all tests
|
||||||
|
go test -race -count=1 -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -func=coverage.out | grep total
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context Propagation
|
||||||
|
|
||||||
|
Every function that does I/O (DB, HTTP, file) **must** accept and pass `context.Context` as the first argument:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// REQUIRED
|
||||||
|
func (r *RiskRepo) Get(ctx context.Context, id uuid.UUID) (*Risk, error) {
|
||||||
|
return r.db.QueryRowContext(ctx, query, id).Scan(...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORBIDDEN — no context
|
||||||
|
func (r *RiskRepo) Get(id uuid.UUID) (*Risk, error) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
`noctx` linter enforces HTTP client context. Manual review required for DB calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls That Break CI
|
||||||
|
|
||||||
|
| Pitfall | Prevention |
|
||||||
|
|---------|------------|
|
||||||
|
| Unformatted code | `gofmt -w .` before commit |
|
||||||
|
| Unchecked error return from `rows.Close()` / `resp.Body.Close()` | `errcheck` + `bodyclose` linters |
|
||||||
|
| Goroutine leak (goroutine started but never stopped) | `-race` test flag |
|
||||||
|
| Shadowed `err` variable in nested scope | `govet -shadow` |
|
||||||
|
| HTTP response body not closed | `bodyclose` linter |
|
||||||
|
| `interface{}` instead of `any` (Go 1.18+) | `gocritic` |
|
||||||
|
| Missing context on DB/HTTP calls | `noctx` linter |
|
||||||
|
| Returning concrete type from constructor instead of interface | breaks testability |
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# AGENTS.python.md — Python Agent Rules
|
||||||
|
|
||||||
|
Applies to: `backend-compliance/`, `ai-compliance-sdk/` (Python path), `compliance-tts-service/`, `document-crawler/`, `dsms-gateway/` (Python services)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||||
|
|
||||||
|
**BEFORE every `git push`, run ALL of the following from the service directory. A single failure blocks the push.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Fast lint (Ruff — catches syntax errors, unused imports, style violations)
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
# 2. Auto-fix safe issues, then re-check
|
||||||
|
ruff check --fix . && ruff check .
|
||||||
|
|
||||||
|
# 3. Type checking (mypy strict on new modules, standard on legacy)
|
||||||
|
mypy . --ignore-missing-imports --no-error-summary
|
||||||
|
|
||||||
|
# 4. Unit tests only (fast, no external deps)
|
||||||
|
pytest tests/unit/ -x -q --no-header
|
||||||
|
|
||||||
|
# 5. Verify the service starts (catches import errors, missing env vars with defaults)
|
||||||
|
python -c "import app" 2>/dev/null || python -c "import main" 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-liner pre-push gate (run from service root):**
|
||||||
|
```bash
|
||||||
|
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why each check matters
|
||||||
|
|
||||||
|
| Check | Catches | Time |
|
||||||
|
|-------|---------|------|
|
||||||
|
| `ruff check` | Syntax errors, unused imports, undefined names | <2s |
|
||||||
|
| `mypy` | Type mismatches, wrong argument types | 5-15s |
|
||||||
|
| `pytest -x` | Logic errors, regressions | 10-60s |
|
||||||
|
| import check | Missing packages, circular imports | <1s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style (Ruff)
|
||||||
|
|
||||||
|
Config lives in `pyproject.toml`. Do **not** add per-file `# noqa` suppressions without a comment explaining why.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "TCH"]
|
||||||
|
ignore = ["E501"] # line length handled by formatter
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["S101"] # assert is fine in tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blocked patterns:**
|
||||||
|
- `from module import *` — always name imports explicitly
|
||||||
|
- Bare `except:` — use `except Exception as e:` at minimum
|
||||||
|
- `print()` in production code — use `logger`
|
||||||
|
- Mutable default arguments: `def f(x=[])` → `def f(x=None)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Annotations
|
||||||
|
|
||||||
|
All new functions **must** have complete type annotations. Use `from __future__ import annotations` for forward references.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Required
|
||||||
|
async def get_tenant(tenant_id: str, db: AsyncSession) -> TenantModel | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# Required for complex types
|
||||||
|
from typing import Sequence
|
||||||
|
def list_risks(filters: dict[str, str]) -> Sequence[RiskModel]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mypy rules:**
|
||||||
|
- `--disallow-untyped-defs` on new files
|
||||||
|
- `--strict` on new modules (not legacy)
|
||||||
|
- Never use `type: ignore` without a comment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FastAPI-Specific Rules
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Handlers stay thin — delegate to service layer
|
||||||
|
@router.get("/risks/{risk_id}", response_model=RiskResponse)
|
||||||
|
async def get_risk(risk_id: UUID, service: RiskService = Depends(get_risk_service)):
|
||||||
|
return await service.get(risk_id) # ≤5 lines per handler
|
||||||
|
|
||||||
|
# Always use response_model — never return raw dicts from endpoints
|
||||||
|
# Always validate input with Pydantic — no manual dict parsing
|
||||||
|
# Use HTTPException with specific status codes, never bare 500
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── unit/ # Pure logic tests, no DB/HTTP (run on every push)
|
||||||
|
├── integration/ # Requires running services (run in CI only)
|
||||||
|
└── contracts/ # OpenAPI snapshot tests (run on API changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unit test requirements:**
|
||||||
|
- Every new function → at least one happy-path test
|
||||||
|
- Every bug fix → regression test that would have caught it
|
||||||
|
- Mock all I/O: DB calls, HTTP calls, filesystem reads
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unit tests only (fast, for pre-push)
|
||||||
|
pytest tests/unit/ -x -q
|
||||||
|
|
||||||
|
# Run with coverage (for CI)
|
||||||
|
pytest tests/ --cov=. --cov-report=term-missing --cov-fail-under=70
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check new package license before adding
|
||||||
|
pip show <package> | grep -E "License|Home-page"
|
||||||
|
|
||||||
|
# After adding to requirements.txt — verify no GPL/AGPL
|
||||||
|
pip-licenses --fail-on="GPL;AGPL" 2>/dev/null || echo "Check licenses manually"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never add:**
|
||||||
|
- GPL/AGPL licensed packages
|
||||||
|
- Packages with known CVEs (`pip audit`)
|
||||||
|
- Packages that only exist for dev (`pytest`, `ruff`) to production requirements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls That Break CI
|
||||||
|
|
||||||
|
| Pitfall | Prevention |
|
||||||
|
|---------|------------|
|
||||||
|
| `const x = ...` inside dict literal (wrong language!) | Run ruff before push |
|
||||||
|
| Pydantic v1 syntax in v2 project | Use `model_config`, not `class Config` |
|
||||||
|
| Sync function called inside async without `run_in_executor` | mypy + async linter |
|
||||||
|
| Missing `await` on coroutine | mypy catches this |
|
||||||
|
| `datetime.utcnow()` (deprecated) | Use `datetime.now(timezone.utc)` |
|
||||||
|
| Bare `except:` swallowing errors silently | ruff B001/E722 catches this |
|
||||||
|
| Unused imports left in committed code | ruff F401 catches this |
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# AGENTS.typescript.md — TypeScript/Next.js Agent Rules
|
||||||
|
|
||||||
|
Applies to: `pitch-deck/`, `admin-v2/` (Next.js apps in this repo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||||
|
|
||||||
|
**BEFORE every `git push`, run ALL of the following from the Next.js app directory. A single failure blocks the push.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Type check (catches the class of bug that broke ChatFAB.tsx — const inside object)
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# 2. Lint (ESLint with TypeScript-aware rules)
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# 3. Production build (THE most important check — passes lint/types but still fails build)
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-liner pre-push gate:**
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit && npm run lint && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Why `npm run build` is mandatory:** Next.js performs additional checks during build (server component boundaries, missing env vars referenced in code, RSC/client component violations) that `tsc` and ESLint alone do not catch. The ChatFAB syntax error (`const` inside object literal) is exactly the kind of error caught only by build.
|
||||||
|
|
||||||
|
### Why each check matters
|
||||||
|
|
||||||
|
| Check | Catches | Time |
|
||||||
|
|-------|---------|------|
|
||||||
|
| `tsc --noEmit` | Type errors, wrong prop types, missing members | 5-20s |
|
||||||
|
| `eslint` | React hooks rules, import order, unused vars | 5-15s |
|
||||||
|
| `next build` | Server/client boundary violations, missing deps, syntax errors in JSX, env var issues | 30-120s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Configuration
|
||||||
|
|
||||||
|
`tsconfig.json` must have strict mode enabled:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never use `// @ts-ignore` or `// @ts-expect-error` without a comment explaining why it's unavoidable.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESLint Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"plugin:@typescript-eslint/recommended-type-checked"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
|
"@typescript-eslint/await-thenable": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "error",
|
||||||
|
"no-console": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`@typescript-eslint/no-floating-promises`** — catches `await`-less async calls that silently swallow errors.
|
||||||
|
**`react-hooks/exhaustive-deps`** — catches missing deps in `useEffect`/`useCallback` (source of stale closure bugs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next.js 15 Rules (App Router)
|
||||||
|
|
||||||
|
### Server vs Client boundary
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Server Component (default) — no 'use client' needed
|
||||||
|
// Can: fetch data, access DB, read env vars, import server-only packages
|
||||||
|
async function Page() {
|
||||||
|
const data = await fetchData() // direct async/await
|
||||||
|
return <ClientComponent data={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Component — must have 'use client' at top
|
||||||
|
'use client'
|
||||||
|
// Can: use hooks, handle events, access browser APIs
|
||||||
|
// Cannot: import server-only packages (nodemailer, fs, db pool)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common violation:** Importing `lib/email.ts` (which imports nodemailer) from a client component → use `lib/email-templates.ts` instead.
|
||||||
|
|
||||||
|
### Route Handler typing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Always type request and use NextResponse
|
||||||
|
export async function GET(request: Request): Promise<NextResponse> {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
return NextResponse.json({ data })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Server-only env vars: access directly
|
||||||
|
const secret = process.env.PITCH_ADMIN_SECRET // fine in server components
|
||||||
|
|
||||||
|
// Client env vars: must be prefixed NEXT_PUBLIC_
|
||||||
|
const url = process.env.NEXT_PUBLIC_API_URL // accessible in browser
|
||||||
|
|
||||||
|
// Never access server-only env vars in 'use client' components
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── (route-group)/
|
||||||
|
│ ├── page.tsx # Server Component — data fetching
|
||||||
|
│ └── _components/ # Colocated components for this route
|
||||||
|
│ ├── ClientThing.tsx # 'use client' when needed
|
||||||
|
│ └── ServerThing.tsx # Server by default
|
||||||
|
components/
|
||||||
|
│ └── ui/ # Shared presentational components
|
||||||
|
lib/
|
||||||
|
│ ├── server-only-module.ts # import 'server-only' at top
|
||||||
|
│ └── shared-module.ts # safe for both server and client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Push `'use client'` boundary as deep as possible (toward leaves)
|
||||||
|
- Never import server-only modules from client components
|
||||||
|
- Colocate `_components/` and `_hooks/` per route when they're route-specific
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Type check (fastest, run first)
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# Unit tests (Vitest)
|
||||||
|
npx vitest run
|
||||||
|
|
||||||
|
# E2E tests (Playwright — CI only, requires running server)
|
||||||
|
npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test every:**
|
||||||
|
- Custom hook (`usePresenterMode`, `useSlideNavigation`)
|
||||||
|
- Utility function (`lib/auth.ts` helpers, `lib/email-templates.ts`)
|
||||||
|
- API route handler (mock DB, assert response shape)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls That Break CI
|
||||||
|
|
||||||
|
| Pitfall | Prevention |
|
||||||
|
|---------|------------|
|
||||||
|
| `const x = ...` inside object literal | `tsc --noEmit` + `npm run build` |
|
||||||
|
| Server-only import in client component | `import 'server-only'` guard + ESLint |
|
||||||
|
| Missing `await` on async function call | `@typescript-eslint/no-floating-promises` |
|
||||||
|
| `useEffect` with missing dependency | `react-hooks/exhaustive-deps` error |
|
||||||
|
| `any` type hiding type errors | `@typescript-eslint/no-explicit-any` error |
|
||||||
|
| Unused variable left after refactor | `noUnusedLocals` in tsconfig |
|
||||||
|
| `process.env.SECRET` in client component | Next.js build error |
|
||||||
|
| Forgetting `export default` on page component | Next.js build error |
|
||||||
|
| Calling server action from server component | must use route handler instead |
|
||||||
|
| `jose` full import in Edge Runtime | Use specific subpath: `jose/jwt/verify` |
|
||||||
+166
-29
@@ -2,28 +2,72 @@
|
|||||||
|
|
||||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||||
|
|
||||||
### Zwei-Rechner-Setup
|
### Zwei-Rechner-Setup + Orca
|
||||||
|
|
||||||
| Geraet | Rolle | Aufgaben |
|
| Geraet | Rolle | Aufgaben |
|
||||||
|--------|-------|----------|
|
|--------|-------|----------|
|
||||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||||
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||||
|
| **Orca** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||||
|
|
||||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Production-Deployment laeuft automatisch ueber Orca.
|
||||||
|
|
||||||
### Entwicklungsworkflow
|
### Entwicklungsworkflow (CI/CD — Orca)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||||
# 2. Committen und pushen:
|
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||||
git push origin main && git push gitea main
|
git push origin main
|
||||||
|
|
||||||
# 3. Auf Mac Mini pullen und Container neu bauen:
|
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||||
|
# - Gitea Actions: Tests
|
||||||
|
# - Orca: Build → Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
|
||||||
|
**IMMER auf `main` pushen** — sowohl origin als auch gitea.
|
||||||
|
|
||||||
|
### TEMPORAER: Compliance-Repo Refactoring (Stand 2026-04-12)
|
||||||
|
|
||||||
|
**Das Compliance-Repo wird aktuell auf Production (gitea) refakturiert.**
|
||||||
|
|
||||||
|
- **Core + Lehrer:** Normal auf `main` pushen (origin + gitea) ✅
|
||||||
|
- **Compliance auf Mac Mini (origin):** Normal auf `main` pushen ✅
|
||||||
|
- **Compliance auf Production (gitea):** **NUR Feature Branches**, NICHT auf `main` pushen! ⚠️
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compliance-Repo — RICHTIG:
|
||||||
|
git push origin main # Mac Mini OK
|
||||||
|
git push gitea feature/mein-feature # Production: nur Feature Branch!
|
||||||
|
|
||||||
|
# Compliance-Repo — FALSCH (waehrend Refactoring):
|
||||||
|
# git push gitea main # NICHT MACHEN!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nach Abschluss des Refactorings:** Gesamten Compliance-Code einmalig von Production auf Mac Mini uebernehmen. User sagt Bescheid wann es soweit ist.
|
||||||
|
|
||||||
|
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||||
|
|
||||||
|
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
||||||
|
|
||||||
|
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
|
||||||
|
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
|
||||||
|
```bash
|
||||||
|
curl -sf https://api-dev.breakpilot.ai/health # Compliance Backend
|
||||||
|
curl -sf https://sdk-dev.breakpilot.ai/health # AI SDK
|
||||||
|
```
|
||||||
|
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||||
|
**"Deploy abgeschlossen! Du kannst jetzt testen."**
|
||||||
|
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
|
||||||
|
|
||||||
|
### Lokale Entwicklung (Mac Mini — optional, nur Dev/Tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git pull --no-rebase origin main"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git pull --no-rebase origin main"
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSH-Verbindung (fuer Docker/Tests)
|
### SSH-Verbindung (fuer lokale Docker/Tests)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
||||||
@@ -51,6 +95,14 @@ networks:
|
|||||||
name: breakpilot-network # Fixer Name, kein Auto-Prefix!
|
name: breakpilot-network # Fixer Name, kein Auto-Prefix!
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Deployment-Modell
|
||||||
|
|
||||||
|
| Repo | Deployment | Trigger |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| **breakpilot-core** | Orca (automatisch) | Push auf gitea main |
|
||||||
|
| **breakpilot-compliance** | Orca (automatisch) | Push auf gitea main |
|
||||||
|
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell docker compose |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Haupt-URLs (via Nginx Reverse Proxy)
|
## Haupt-URLs (via Nginx Reverse Proxy)
|
||||||
@@ -161,7 +213,7 @@ networks:
|
|||||||
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DB-Zugang
|
# DB-Zugang (lokal)
|
||||||
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -185,15 +237,45 @@ breakpilot-core/
|
|||||||
├── gitea/ # Gitea Config
|
├── gitea/ # Gitea Config
|
||||||
├── docs-src/ # MkDocs Quellen
|
├── docs-src/ # MkDocs Quellen
|
||||||
├── mkdocs.yml # MkDocs Config
|
├── mkdocs.yml # MkDocs Config
|
||||||
|
├── control-pipeline/ # RAG/Control Pipeline (Port 8098)
|
||||||
├── scripts/ # Helper Scripts
|
├── scripts/ # Helper Scripts
|
||||||
└── docker-compose.yml # Haupt-Compose (28+ Services)
|
└── docker-compose.yml # Haupt-Compose (28+ Services)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Control Pipeline (WICHTIG)
|
||||||
|
|
||||||
|
**Seit 2026-04-09 liegt die gesamte RAG/Control-Pipeline im Core-Repo** (`control-pipeline/`), NICHT mehr im Compliance-Repo. Alle Arbeiten an der Pipeline (Pass 0a/0b, BatchDedup, Control Generator, Enrichment) finden ausschliesslich hier statt.
|
||||||
|
|
||||||
|
- **Port:** 8098
|
||||||
|
- **Container:** bp-core-control-pipeline
|
||||||
|
- **DB:** Schreibt ins `compliance`-Schema der shared PostgreSQL
|
||||||
|
- **Das Compliance-Repo wird NICHT fuer Pipeline-Aenderungen benutzt**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container auf Mac Mini
|
||||||
|
ssh macmini "cd ~/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache control-pipeline && /usr/local/bin/docker compose up -d --no-deps control-pipeline"
|
||||||
|
|
||||||
|
# Health
|
||||||
|
ssh macmini "/usr/local/bin/docker exec bp-core-control-pipeline curl -sf http://127.0.0.1:8098/health"
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
ssh macmini "/usr/local/bin/docker logs -f bp-core-control-pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Haeufige Befehle
|
## Haeufige Befehle
|
||||||
|
|
||||||
### Docker
|
### Deployment (CI/CD — Standardweg)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Committen und pushen → Orca deployt automatisch:
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lokale Docker-Befehle (Mac Mini — nur Dev/Tests)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Alle Core-Services starten
|
# Alle Core-Services starten
|
||||||
@@ -211,35 +293,90 @@ ssh macmini "/usr/local/bin/docker ps --filter name=bp-core"
|
|||||||
|
|
||||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||||
|
|
||||||
### Alle 3 Projekte starten
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Core (MUSS zuerst!)
|
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose up -d"
|
|
||||||
# Warten auf Health:
|
|
||||||
ssh macmini "curl -sf http://127.0.0.1:8099/health"
|
|
||||||
|
|
||||||
# 2. Lehrer
|
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && /usr/local/bin/docker compose up -d"
|
|
||||||
|
|
||||||
# 3. Compliance
|
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && /usr/local/bin/docker compose up -d"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git push all main"
|
git push origin main
|
||||||
|
|
||||||
# Remotes:
|
# Remotes:
|
||||||
# origin: lokale Gitea (macmini:3003)
|
|
||||||
# gitea: gitea.meghsakha.com
|
|
||||||
# all: beide gleichzeitig
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pre-Push Checks (PFLICHT — VOR JEDEM PUSH)
|
||||||
|
|
||||||
|
> Full detail: `.claude/rules/pre-push-checks.md` | Stack rules: `AGENTS.python.md`, `AGENTS.go.md`, `AGENTS.typescript.md`
|
||||||
|
|
||||||
|
**NIEMALS pushen ohne diese Checks. CI-Failures blockieren das gesamte Deploy.**
|
||||||
|
|
||||||
|
### Python (backend-core, rag-service, embedding-service, control-pipeline)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service-dir>
|
||||||
|
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go (consent-service, billing-service)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service-dir>
|
||||||
|
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race ./... && go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript/Next.js (pitch-deck, admin-v2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pitch-deck # or admin-v2
|
||||||
|
npx tsc --noEmit && npm run lint && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
> `npm run build` ist PFLICHT — `tsc` allein reicht nicht. Syntax-Fehler wie `const` inside object literal werden nur vom Build gefangen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code-Qualitaet Guardrails (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
> Vollstaendige Details: `.claude/rules/architecture.md`
|
||||||
|
> Ausnahmen: `.claude/rules/loc-exceptions.txt`
|
||||||
|
|
||||||
|
### File Size Budget
|
||||||
|
|
||||||
|
- **Hard Cap: 500 LOC** pro Datei
|
||||||
|
- Wenn eine Aenderung eine Datei ueber 500 LOC bringen wuerde: **erst splitten, dann aendern**
|
||||||
|
- Ausnahmen nur mit Begruendung in `loc-exceptions.txt` + `[guardrail-change]` Commit-Marker
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
|
||||||
|
- **Go:** Handler ≤40 LOC → Service-Layer → Repository-Pattern
|
||||||
|
- **Python:** Routes duenn → Business Logic in Services → Persistenz in Repositories
|
||||||
|
- **TypeScript/Next.js:** page.tsx duenn → _components/, _hooks/ auslagern
|
||||||
|
|
||||||
|
### FINGER WEG (laufende RAG Pipeline)
|
||||||
|
|
||||||
|
Diese Verzeichnisse duerfen NICHT refaktoriert werden:
|
||||||
|
- `control-pipeline/` — RAG/Control-Extraction Pipeline
|
||||||
|
- `rag-service/` — Semantische Suche
|
||||||
|
- `embedding-service/` — Text-Embeddings
|
||||||
|
- `voice-service/bqas/` — RAG Quality Assessment
|
||||||
|
|
||||||
|
### LOC-Check ausfuehren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/check-loc.sh --changed # nur geaenderte Dateien
|
||||||
|
bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit-Marker
|
||||||
|
|
||||||
|
- `[split-required]` — Aenderung beginnt mit Datei-Split
|
||||||
|
- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh
|
||||||
|
- `[interface-change]` — Public API Contracts geaendert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Kernprinzipien
|
## Kernprinzipien
|
||||||
|
|
||||||
### 1. Open Source Policy
|
### 1. Open Source Policy
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Architecture Rule — BreakPilot Core
|
||||||
|
|
||||||
|
## File Size Budget
|
||||||
|
|
||||||
|
Hard default: **500 LOC max** per file.
|
||||||
|
Soft targets:
|
||||||
|
- Handler/Router/Service: 300-400 LOC
|
||||||
|
- Models/Schemas/Types: 200-300 LOC
|
||||||
|
- Utilities: 100-200 LOC
|
||||||
|
|
||||||
|
Ausnahmen nur in `.claude/rules/loc-exceptions.txt` mit Begruendung.
|
||||||
|
|
||||||
|
## Split-Trigger
|
||||||
|
|
||||||
|
Sofort splitten wenn:
|
||||||
|
- Datei ueberschreitet 500 LOC
|
||||||
|
- Datei wuerde nach Aenderung 500 LOC ueberschreiten
|
||||||
|
- Datei mischt Transport + Business Logic + Persistence
|
||||||
|
- Datei enthaelt mehrere unabhaengig testbare Verantwortlichkeiten
|
||||||
|
|
||||||
|
## Go (consent-service, billing-service)
|
||||||
|
|
||||||
|
- Handler duenn halten (≤40 LOC pro Handler-Funktion)
|
||||||
|
- Business Logic in Services/Use-Cases
|
||||||
|
- Transport/Request-Decoding getrennt von Domain-Logik
|
||||||
|
- Dateien im gleichen Package teilen Typen automatisch — kein Re-Export noetig
|
||||||
|
- Models nach Domain splitten (user, consent, school, document, etc.)
|
||||||
|
|
||||||
|
## Python (backend-core, night-scheduler)
|
||||||
|
|
||||||
|
- Routes duenn halten — Business Logic in Services
|
||||||
|
- Persistenz in Repositories/Data-Access-Module
|
||||||
|
- Pydantic Schemas nach Domain splitten
|
||||||
|
- Zirkulaere Imports vermeiden
|
||||||
|
|
||||||
|
## TypeScript / Next.js (admin-core, pitch-deck)
|
||||||
|
|
||||||
|
- page.tsx duenn halten — Server Actions, Queries, Components auslagern
|
||||||
|
- _components/ + _hooks/ Konvention fuer Route-lokale Extracts
|
||||||
|
- .ts Dateien mit JSX muessen .tsx heissen (Turbopack!)
|
||||||
|
- Monolithische types.ts frueh splitten
|
||||||
|
- types.ts + types/ Shadowing vermeiden
|
||||||
|
|
||||||
|
## Entscheidungsreihenfolge
|
||||||
|
|
||||||
|
1. Bestehendes kleines kohaeesives Modul wiederverwenden
|
||||||
|
2. Neues Modul in der Naehe erstellen
|
||||||
|
3. Ueberfuellte Datei splitten, neues Verhalten in richtiges Split-Modul
|
||||||
|
4. Nur als letzter Ausweg: Grosse bestehende Datei erweitern
|
||||||
|
|
||||||
|
## FINGER WEG (laufende RAG Pipeline)
|
||||||
|
|
||||||
|
Diese Verzeichnisse duerfen NICHT refaktoriert werden:
|
||||||
|
- `control-pipeline/` — RAG/Control-Extraction Pipeline
|
||||||
|
- `rag-service/` — Semantische Suche
|
||||||
|
- `embedding-service/` — Text-Embeddings
|
||||||
|
- `voice-service/bqas/` — RAG Quality Assessment
|
||||||
|
|
||||||
|
## Workflow (bei jeder Aenderung)
|
||||||
|
|
||||||
|
1. Datei lesen + LOC pruefen
|
||||||
|
2. Wenn nahe am Budget → erst splitten
|
||||||
|
3. Minimale kohaerente Aenderung
|
||||||
|
4. Verifikation (Tests + Lint)
|
||||||
|
5. Zusammenfassung: Was geaendert, was verifiziert, Restrisiko
|
||||||
|
|
||||||
|
## LOC-Check ausfuehren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/check-loc.sh --changed # nur geaenderte Dateien
|
||||||
|
bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit-Marker
|
||||||
|
|
||||||
|
- `[split-required]` — Aenderung beginnt mit Datei-Split
|
||||||
|
- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh
|
||||||
|
- `[interface-change]` — Public API Contracts geaendert
|
||||||
|
- `[migration-approved]` — Schema-/Migrations-Aenderungen
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# LOC Exceptions — BreakPilot Core
|
||||||
|
# Format: <glob> | owner=<person> | reason=<why> | review=<date>
|
||||||
|
#
|
||||||
|
# Jede Ausnahme braucht Begruendung und Review-Datum.
|
||||||
|
# Temporaere Ausnahmen muessen mit [guardrail-change] Commit-Marker versehen werden.
|
||||||
|
|
||||||
|
# Generated / Build Artifacts
|
||||||
|
**/node_modules/** | owner=infra | reason=npm packages | review=permanent
|
||||||
|
**/.next/** | owner=infra | reason=Next.js build output | review=permanent
|
||||||
|
**/__pycache__/** | owner=infra | reason=Python bytecode | review=permanent
|
||||||
|
**/venv/** | owner=infra | reason=Python virtualenv | review=permanent
|
||||||
|
|
||||||
|
# Test-Dateien (duerfen groesser sein fuer Table-Driven Tests)
|
||||||
|
**/*test*.py | owner=all | reason=Tests mit Table-Driven Patterns duerfen groesser sein | review=permanent
|
||||||
|
**/*test*.go | owner=all | reason=Go Tests mit Table-Driven Patterns | review=permanent
|
||||||
|
**/*test*.ts | owner=all | reason=TypeScript Tests | review=permanent
|
||||||
|
**/tests/** | owner=all | reason=Test-Verzeichnisse | review=permanent
|
||||||
|
|
||||||
|
# FINGER WEG — Laufende RAG Pipeline (NICHT anfassen!)
|
||||||
|
control-pipeline/** | owner=pipeline | reason=Laufende RAG Pipeline, parallele Jobs aktiv | review=permanent
|
||||||
|
rag-service/** | owner=pipeline | reason=Semantische Suche, produktiv | review=permanent
|
||||||
|
embedding-service/** | owner=pipeline | reason=Text-Embeddings, produktiv | review=permanent
|
||||||
|
voice-service/bqas/** | owner=pipeline | reason=RAG Quality Assessment, produktiv | review=permanent
|
||||||
|
|
||||||
|
# Seed/Helper Scripts (keine Service-Logik)
|
||||||
|
scripts/seed-demo-and-screenshot.py | owner=infra | reason=Einmaliges Seed-Script, kein Service-Code | review=permanent
|
||||||
|
pitch-deck/scripts/import-finanzplan.py | owner=pitch-deck | reason=583 LOC, einmaliges Excel-Import-Script (9 Sheet-Importer), hardcodierte Row/Col-Mappings fuer eine Finanzplan-.xlsm-Datei, keine wiederverwendbare Logik | review=2027-01
|
||||||
|
|
||||||
|
# PDF Templates (reine statische HTML/CSS Strings, keine Logik)
|
||||||
|
backend-core/services/pdf_templates.py | owner=all | reason=519 LOC, rein statische Jinja2-HTML-Templates + CSS, keine Logik | review=2026-07
|
||||||
|
|
||||||
|
# Pitch Deck — pure data files (static text, translations, no logic)
|
||||||
|
pitch-deck/lib/presenter/presenter-faq.ts | owner=pitch-deck | reason=973 LOC, pure static FAQ array (questions/answers/keywords), no logic | review=2027-01
|
||||||
|
pitch-deck/lib/presenter/presenter-script.ts | owner=pitch-deck | reason=608 LOC, pure static presenter script data + 3 trivial lookup functions | review=2027-01
|
||||||
|
pitch-deck/lib/i18n.ts | owner=pitch-deck | reason=620 LOC, pure DE/EN translation dictionaries + 3 small format helpers | review=2027-01
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Pre-Push Checks (MANDATORY)
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
|
||||||
|
**NEVER push to any remote without first running and confirming ALL checks pass for every changed language stack.**
|
||||||
|
|
||||||
|
This rule exists because CI failures break the deploy pipeline for everyone and waste ~5 minutes per failed build. A 60-second local check prevents that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference by Stack
|
||||||
|
|
||||||
|
### Python (backend-compliance, ai-compliance-sdk, compliance-tts-service)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service-dir>
|
||||||
|
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||||
|
```
|
||||||
|
|
||||||
|
Blocks on: syntax errors, type errors, failing tests.
|
||||||
|
|
||||||
|
### Go (ai-compliance-sdk Go path)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service-dir>
|
||||||
|
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race ./... && go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Blocks on: formatting, vet findings, lint violations, test failures, build errors.
|
||||||
|
|
||||||
|
### TypeScript/Next.js (admin-compliance, developer-portal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <nextjs-app-dir>
|
||||||
|
npx tsc --noEmit && npm run lint && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Blocks on: type errors, lint violations, **build failures**.
|
||||||
|
|
||||||
|
> `npm run build` is mandatory — `tsc` passes but `next build` fails more often than you'd expect (server/client boundary violations, env var issues, JSX syntax errors).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Claude Must Do Before Every Push
|
||||||
|
|
||||||
|
1. Identify which services/apps were changed in this task
|
||||||
|
2. Run the appropriate gate command(s) from the table above
|
||||||
|
3. If any check fails: fix it, re-run, confirm green
|
||||||
|
4. Only then run `git push origin main`
|
||||||
|
|
||||||
|
**No exceptions.** A push that skips pre-push checks and breaks CI is worse than a delayed push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI vs Local Checks
|
||||||
|
|
||||||
|
| Stage | Where | What |
|
||||||
|
|-------|-------|------|
|
||||||
|
| Pre-push (local) | Claude runs | Lint + type check + unit tests + build |
|
||||||
|
| CI (Gitea Actions) | Automatic on push | Same + integration tests + contract tests |
|
||||||
|
| Deploy (Orca) | Automatic after CI | Docker build + health check |
|
||||||
|
|
||||||
|
Local checks catch 90% of CI failures in seconds. CI is the safety net, not the first line of defense.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failures That Were Caused by Skipping Pre-Push Checks
|
||||||
|
|
||||||
|
- `ChatFAB.tsx`: `const textLang` inside fetch object literal — caught by `tsc --noEmit` and `npm run build`
|
||||||
|
- `nodemailer` webpack error: server-only import in client component — caught by `npm run build`
|
||||||
|
- `jose` Edge Runtime error: full package import — caught by `npm run build`
|
||||||
|
- `main.py` `<en>` tags spoken: missing `import re` — caught by `python -c "import main"`
|
||||||
|
|
||||||
|
These all caused a broken deploy. Each would have been caught in <60 seconds locally.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Build + push pitch-deck Docker image to registry.meghsakha.com
|
||||||
|
# and trigger orca redeploy on every push to main that touches pitch-deck/.
|
||||||
|
#
|
||||||
|
# Requires Gitea Actions secret: ORCA_WEBHOOK_SECRET
|
||||||
|
# (must match the `secret` field in ~/.orca/webhooks.json on the orca master)
|
||||||
|
|
||||||
|
name: Build pitch-deck
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'pitch-deck/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push-deploy:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git openssl curl
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
cd pitch-deck
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
--build-arg GIT_SHA=${SHORT_SHA} \
|
||||||
|
-t registry.meghsakha.com/breakpilot/pitch-deck:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push to registry
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker push registry.meghsakha.com/breakpilot/pitch-deck:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA}
|
||||||
|
echo "Pushed :latest + :${SHORT_SHA}"
|
||||||
|
|
||||||
|
- name: Trigger orca redeploy
|
||||||
|
env:
|
||||||
|
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
||||||
|
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
|
||||||
|
run: |
|
||||||
|
SHA=$(git rev-parse HEAD)
|
||||||
|
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"$SHA\",\"message\":\"ci: pitch-deck image build\"}}"
|
||||||
|
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
|
||||||
|
curl -sSf -k \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-GitHub-Event: push" \
|
||||||
|
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"$ORCA_WEBHOOK_URL" \
|
||||||
|
|| { echo "Orca redeploy failed"; exit 1; }
|
||||||
|
echo "Orca redeploy triggered"
|
||||||
@@ -140,20 +140,6 @@ jobs:
|
|||||||
python -m pytest tests/bqas/ -v --tb=short || true
|
python -m pytest tests/bqas/ -v --tb=short || true
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Deploy via Coolify (nur main, kein PR)
|
# Deploys now handled by per-service workflows (e.g. build-pitch-deck.yml)
|
||||||
|
# which trigger orca webhooks directly after building + pushing the image.
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
||||||
deploy-coolify:
|
|
||||||
name: Deploy
|
|
||||||
runs-on: docker
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
needs:
|
|
||||||
- test-go-consent
|
|
||||||
container:
|
|
||||||
image: alpine:latest
|
|
||||||
steps:
|
|
||||||
- name: Trigger Coolify deploy
|
|
||||||
run: |
|
|
||||||
apk add --no-cache curl
|
|
||||||
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Daily GDPR data cleanup for the pitch deck.
|
||||||
|
# Calls /api/admin/cleanup which runs runDataCleanup():
|
||||||
|
# - anonymizes investors inactive 30+ days
|
||||||
|
# - anonymizes never-activated invites after 90 days
|
||||||
|
# - deletes sessions + magic links older than 30 days
|
||||||
|
# - anonymizes IPs in audit logs older than 30 days
|
||||||
|
#
|
||||||
|
# Requires Gitea Actions secret: PITCH_ADMIN_SECRET
|
||||||
|
|
||||||
|
name: Pitch deck — GDPR cleanup
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: alpine:3.19
|
||||||
|
steps:
|
||||||
|
- name: Run data cleanup
|
||||||
|
env:
|
||||||
|
PITCH_ADMIN_SECRET: ${{ secrets.PITCH_ADMIN_SECRET }}
|
||||||
|
run: |
|
||||||
|
apk add --no-cache curl
|
||||||
|
RESPONSE=$(curl -sSf -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: Bearer $PITCH_ADMIN_SECRET" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
https://pitch.breakpilot.com/api/admin/cleanup) \
|
||||||
|
|| { echo "Cleanup request failed"; exit 1; }
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | head -n-1)
|
||||||
|
echo "Response: $BODY"
|
||||||
|
[ "$HTTP_CODE" = "200" ] || { echo "Unexpected status $HTTP_CODE"; exit 1; }
|
||||||
|
echo "GDPR cleanup completed successfully"
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
secrets/
|
secrets/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ContainerInfo, DockerStats } from '../types'
|
||||||
|
|
||||||
|
export function DeploymentsTab({
|
||||||
|
dockerStats,
|
||||||
|
containerFilter,
|
||||||
|
setContainerFilter,
|
||||||
|
filteredContainers,
|
||||||
|
onContainerAction,
|
||||||
|
actionLoading,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
dockerStats: DockerStats | null
|
||||||
|
containerFilter: 'all' | 'running' | 'stopped'
|
||||||
|
setContainerFilter: (filter: 'all' | 'running' | 'stopped') => void
|
||||||
|
filteredContainers: ContainerInfo[]
|
||||||
|
onContainerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => void
|
||||||
|
actionLoading: string | null
|
||||||
|
onRefresh: () => void
|
||||||
|
}) {
|
||||||
|
const getStateColor = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case 'running': return 'bg-green-100 text-green-800'
|
||||||
|
case 'exited':
|
||||||
|
case 'dead': return 'bg-red-100 text-red-800'
|
||||||
|
case 'paused': return 'bg-yellow-100 text-yellow-800'
|
||||||
|
case 'restarting': return 'bg-blue-100 text-blue-800'
|
||||||
|
default: return 'bg-slate-100 text-slate-600'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800">Docker Container</h3>
|
||||||
|
{dockerStats && (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{dockerStats.running_containers} laufend, {dockerStats.stopped_containers} gestoppt, {dockerStats.total_containers} gesamt
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={containerFilter}
|
||||||
|
onChange={(e) => setContainerFilter(e.target.value as typeof containerFilter)}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="running">Laufend</option>
|
||||||
|
<option value="stopped">Gestoppt</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Container List */}
|
||||||
|
{filteredContainers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">Keine Container gefunden</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredContainers.map((container) => (
|
||||||
|
<div
|
||||||
|
key={container.id}
|
||||||
|
className={`border rounded-xl p-4 transition-colors ${
|
||||||
|
container.state === 'running'
|
||||||
|
? 'border-green-200 bg-green-50/30'
|
||||||
|
: 'border-slate-200 bg-slate-50/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-slate-900 truncate">{container.name}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getStateColor(container.state)}`}>
|
||||||
|
{container.state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 mb-2">
|
||||||
|
<span className="font-mono">{container.image}</span>
|
||||||
|
{container.ports.length > 0 && (
|
||||||
|
<span className="ml-2 text-slate-400">
|
||||||
|
| {container.ports.slice(0, 2).join(', ')}
|
||||||
|
{container.ports.length > 2 && ` +${container.ports.length - 2}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{container.state === 'running' && (
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">CPU:</span>
|
||||||
|
<span className={`font-medium ${container.cpu_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
|
||||||
|
{container.cpu_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">RAM:</span>
|
||||||
|
<span className={`font-medium ${container.memory_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
|
||||||
|
{container.memory_usage}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">({container.memory_percent.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">Net:</span>
|
||||||
|
<span className="text-slate-700">{container.network_rx} / {container.network_tx}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{container.state === 'running' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onContainerAction(container.id, 'restart')}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === `${container.id}-restart` ? '...' : 'Restart'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onContainerAction(container.id, 'stop')}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === `${container.id}-stop` ? '...' : 'Stop'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onContainerAction(container.id, 'start')}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === `${container.id}-start` ? '...' : 'Start'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PipelineStatus, PipelineRun, SystemStats, DockerStats } from '../types'
|
||||||
|
|
||||||
|
function ProgressBar({ percent, color = 'blue' }: { percent: number; color?: string }) {
|
||||||
|
const getColor = () => {
|
||||||
|
if (percent > 90) return 'bg-red-500'
|
||||||
|
if (percent > 70) return 'bg-yellow-500'
|
||||||
|
if (color === 'green') return 'bg-green-500'
|
||||||
|
if (color === 'purple') return 'bg-purple-500'
|
||||||
|
return 'bg-blue-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${getColor()}`}
|
||||||
|
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab({
|
||||||
|
pipelineStatus,
|
||||||
|
pipelineHistory,
|
||||||
|
systemStats,
|
||||||
|
dockerStats,
|
||||||
|
}: {
|
||||||
|
pipelineStatus: PipelineStatus | null
|
||||||
|
pipelineHistory: PipelineRun[]
|
||||||
|
systemStats: SystemStats | null
|
||||||
|
dockerStats: DockerStats | null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className={`p-4 rounded-lg ${pipelineStatus?.gitea_connected ? 'bg-green-50' : 'bg-yellow-50'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className={`w-3 h-3 rounded-full ${pipelineStatus?.gitea_connected ? 'bg-green-500' : 'bg-yellow-500'}`}></span>
|
||||||
|
<span className="text-sm font-medium">Gitea Status</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-lg font-bold ${pipelineStatus?.gitea_connected ? 'text-green-700' : 'text-yellow-700'}`}>
|
||||||
|
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Nicht verbunden'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">http://macmini:3003</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">Pipeline Runs</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-blue-700">{pipelineStatus?.total_runs || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500">{pipelineStatus?.successful_runs || 0} erfolgreich</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-purple-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">Container</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-purple-700">{dockerStats?.running_containers || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500">von {dockerStats?.total_containers || 0} laufend</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">Letztes Update</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-slate-700">
|
||||||
|
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleDateString('de-DE') : 'Nie'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleTimeString('de-DE') : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Resources */}
|
||||||
|
{systemStats && (
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
Server Ressourcen ({systemStats.hostname})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white rounded-lg p-3">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-sm text-slate-600">CPU</span>
|
||||||
|
<span className={`font-bold ${systemStats.cpu.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||||
|
{systemStats.cpu.usage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar percent={systemStats.cpu.usage_percent} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-3">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-sm text-slate-600">RAM</span>
|
||||||
|
<span className={`font-bold ${systemStats.memory.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||||
|
{systemStats.memory.usage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar percent={systemStats.memory.usage_percent} color="purple" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-3">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-sm text-slate-600">Disk</span>
|
||||||
|
<span className={`font-bold ${systemStats.disk.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||||
|
{systemStats.disk.usage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar percent={systemStats.disk.usage_percent} color="green" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Pipeline Runs */}
|
||||||
|
{pipelineHistory.length > 0 && (
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-3">Letzte Pipeline Runs</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pipelineHistory.slice(0, 5).map((run) => (
|
||||||
|
<div key={run.id} className="flex items-center justify-between bg-white p-3 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
run.status === 'success' ? 'bg-green-500' :
|
||||||
|
run.status === 'failed' ? 'bg-red-500' :
|
||||||
|
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
|
||||||
|
}`}></span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-800">{run.workflow || 'SBOM Pipeline'}</p>
|
||||||
|
<p className="text-xs text-slate-500">{run.branch} - {run.commit_sha.substring(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-sm font-medium ${
|
||||||
|
run.status === 'success' ? 'text-green-600' :
|
||||||
|
run.status === 'failed' ? 'text-red-600' :
|
||||||
|
run.status === 'running' ? 'text-yellow-600' : 'text-slate-600'
|
||||||
|
}`}>
|
||||||
|
{run.status === 'success' ? 'Erfolgreich' :
|
||||||
|
run.status === 'failed' ? 'Fehlgeschlagen' :
|
||||||
|
run.status === 'running' ? 'Laeuft...' : run.status}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{new Date(run.started_at).toLocaleString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PipelineRun } from '../types'
|
||||||
|
|
||||||
|
export function PipelinesTab({
|
||||||
|
pipelineHistory,
|
||||||
|
triggeringPipeline,
|
||||||
|
onTriggerPipeline,
|
||||||
|
}: {
|
||||||
|
pipelineHistory: PipelineRun[]
|
||||||
|
triggeringPipeline: boolean
|
||||||
|
onTriggerPipeline: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Pipeline Controls */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800">Gitea Actions Pipelines</h3>
|
||||||
|
<p className="text-sm text-slate-600">Workflows werden bei Push auf main/develop automatisch ausgefuehrt</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onTriggerPipeline}
|
||||||
|
disabled={triggeringPipeline}
|
||||||
|
className="px-4 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{triggeringPipeline ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
Laeuft...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Pipeline starten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Pipelines */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||||
|
<span className="font-medium text-green-800">SBOM Pipeline</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-700 mb-2">Generiert Software Bill of Materials</p>
|
||||||
|
<p className="text-xs text-green-600">5 Jobs: generate, scan, license, upload, summary</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
|
||||||
|
<span className="font-medium text-slate-600">Test Pipeline</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-2">Unit & Integration Tests</p>
|
||||||
|
<p className="text-xs text-slate-400">Geplant</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
|
||||||
|
<span className="font-medium text-slate-600">Security Pipeline</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-2">SAST, SCA, Secrets Scan</p>
|
||||||
|
<p className="text-xs text-slate-400">Geplant</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline History */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-slate-800 mb-4">Pipeline Historie</h4>
|
||||||
|
{pipelineHistory.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
Keine Pipeline-Runs vorhanden. Starten Sie die erste Pipeline!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Workflow</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Branch</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Commit</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Gestartet</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Dauer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{pipelineHistory.map((run) => (
|
||||||
|
<tr key={run.id} className="hover:bg-white">
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
run.status === 'success' ? 'bg-green-100 text-green-800' :
|
||||||
|
run.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||||
|
run.status === 'running' ? 'bg-yellow-100 text-yellow-800' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
run.status === 'success' ? 'bg-green-500' :
|
||||||
|
run.status === 'failed' ? 'bg-red-500' :
|
||||||
|
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
|
||||||
|
}`}></span>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-slate-900">{run.workflow || 'SBOM Pipeline'}</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-slate-600">{run.branch}</td>
|
||||||
|
<td className="py-2 px-3 text-sm font-mono text-slate-500">{run.commit_sha.substring(0, 8)}</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-slate-500">{new Date(run.started_at).toLocaleString('de-DE')}</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-slate-500">
|
||||||
|
{run.duration_seconds ? `${run.duration_seconds}s` : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline Architecture */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-slate-800 mb-3">SBOM Pipeline Architektur</h4>
|
||||||
|
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||||
|
{`Gitea Actions Pipeline (.gitea/workflows/sbom.yaml)
|
||||||
|
│
|
||||||
|
├── 1. generate-sbom → Syft generiert CycloneDX SBOM
|
||||||
|
│
|
||||||
|
├── 2. vulnerability-scan → Grype scannt auf CVEs
|
||||||
|
│
|
||||||
|
├── 3. license-check → Prueft GPL/AGPL Lizenzen
|
||||||
|
│
|
||||||
|
├── 4. upload-dashboard → POST /api/v1/security/sbom/upload
|
||||||
|
│
|
||||||
|
└── 5. summary → Job Summary generieren`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export function SchedulerTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold">launchd Job</h4>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 opacity-80">Taeglich um 07:00 Uhr automatisch</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold">Git Hook</h4>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 opacity-80">Quick Tests bei voice-service Aenderungen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold">Benachrichtigungen</h4>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 opacity-80">Desktop-Alerts bei Fehlern aktiviert</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-4">Quick Actions (BQAS)</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<a href="/ai/test-quality" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Test Dashboard oeffnen
|
||||||
|
</a>
|
||||||
|
<span className="text-sm text-slate-500 self-center">Starte Tests direkt im BQAS Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub Actions vs Local - Comparison */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-4">GitHub Actions Alternative</h3>
|
||||||
|
<p className="text-slate-600 mb-4">
|
||||||
|
Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-white">
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-slate-700">Feature</th>
|
||||||
|
<th className="text-center py-3 px-4 font-medium text-slate-700">GitHub Actions</th>
|
||||||
|
<th className="text-center py-3 px-4 font-medium text-slate-700">Lokaler Scheduler</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
{ feature: 'Taegliche Tests (07:00)', gh: 'schedule: cron', local: 'macOS launchd', localColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
{ feature: 'Push-basierte Tests', gh: 'on: push', local: 'Git post-commit Hook', localColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
{ feature: 'PR-basierte Tests', gh: 'on: pull_request', ghColor: 'bg-emerald-100 text-emerald-700', local: 'Nicht moeglich', localColor: 'bg-amber-100 text-amber-700' },
|
||||||
|
{ feature: 'DSGVO-Konformitaet', gh: 'Daten bei GitHub (US)', ghColor: 'bg-amber-100 text-amber-700', local: '100% lokal', localColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
{ feature: 'Offline-Faehig', gh: 'Nein', ghColor: 'bg-red-100 text-red-700', local: 'Ja', localColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.feature} className="border-b border-slate-100">
|
||||||
|
<td className="py-3 px-4 text-slate-600">{row.feature}</td>
|
||||||
|
<td className="py-3 px-4 text-center">
|
||||||
|
<span className={`${row.ghColor ? `px-2 py-1 rounded text-xs font-medium ${row.ghColor}` : 'text-slate-600'}`}>{row.gh}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-center">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${row.localColor}`}>{row.local}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration Details */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-4">Konfiguration</h3>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-slate-700 mb-3">launchd Job</h4>
|
||||||
|
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-slate-100 overflow-x-auto">
|
||||||
|
<pre>{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
|
||||||
|
Label: com.breakpilot.bqas
|
||||||
|
Schedule: 07:00 taeglich
|
||||||
|
Script: /voice-service/scripts/run_bqas.sh
|
||||||
|
Logs: /var/log/bqas/`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-slate-700 mb-3">Umgebungsvariablen</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between p-2 bg-white rounded">
|
||||||
|
<span className="font-mono text-slate-600">BQAS_SERVICE_URL</span>
|
||||||
|
<span className="text-slate-900">http://localhost:8091</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-white rounded">
|
||||||
|
<span className="font-mono text-slate-600">BQAS_REGRESSION_THRESHOLD</span>
|
||||||
|
<span className="text-slate-900">0.1</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-white rounded">
|
||||||
|
<span className="font-mono text-slate-600">BQAS_NOTIFY_DESKTOP</span>
|
||||||
|
<span className="text-emerald-600 font-medium">true</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-white rounded">
|
||||||
|
<span className="font-mono text-slate-600">BQAS_NOTIFY_SLACK</span>
|
||||||
|
<span className="text-slate-400">false</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed Explanation */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Detaillierte Erklaerung
|
||||||
|
</h3>
|
||||||
|
<div className="prose prose-sm max-w-none text-slate-700">
|
||||||
|
<h4 className="text-base font-semibold mt-4 mb-2">Warum ein lokaler Scheduler?</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten,
|
||||||
|
aber mit dem entscheidenden Vorteil, dass <strong>alle Daten zu 100% auf dem lokalen Mac Mini verbleiben</strong>.
|
||||||
|
Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.
|
||||||
|
</p>
|
||||||
|
<h4 className="text-base font-semibold mt-4 mb-2">Komponenten</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-4">
|
||||||
|
<li><strong>run_bqas.sh</strong> - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet</li>
|
||||||
|
<li><strong>launchd Job</strong> - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet</li>
|
||||||
|
<li><strong>Git Hook</strong> - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet</li>
|
||||||
|
<li><strong>Notifier</strong> - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet</li>
|
||||||
|
</ul>
|
||||||
|
<h4 className="text-base font-semibold mt-4 mb-2">Installation</h4>
|
||||||
|
<div className="bg-slate-900 rounded-lg p-3 font-mono text-sm text-slate-100 mb-4">
|
||||||
|
<code>./voice-service/scripts/install_bqas_scheduler.sh install</code>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-base font-semibold mt-4 mb-2">Vorteile gegenueber GitHub Actions</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>100% DSGVO-konform - alle Daten bleiben lokal</li>
|
||||||
|
<li>Keine Internet-Abhaengigkeit - funktioniert auch offline</li>
|
||||||
|
<li>Keine GitHub-Kosten fuer private Repositories</li>
|
||||||
|
<li>Schnellere Ausfuehrung ohne Cloud-Overhead</li>
|
||||||
|
<li>Volle Kontrolle ueber Scheduling und Benachrichtigungen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PipelineStatus } from '../types'
|
||||||
|
|
||||||
|
export function SetupTab({ pipelineStatus }: { pipelineStatus: PipelineStatus | null }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-2">Erstkonfiguration - Gitea CI/CD</h3>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Anleitung zur Einrichtung der CI/CD Pipeline mit Gitea Actions auf dem Mac Mini Server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gitea Server Info */}
|
||||||
|
<div className="bg-blue-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-blue-800 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||||
|
</svg>
|
||||||
|
Gitea Server
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<p className="text-sm text-slate-500">Web-URL</p>
|
||||||
|
<p className="font-mono text-blue-700">http://macmini:3003</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<p className="text-sm text-slate-500">SSH</p>
|
||||||
|
<p className="font-mono text-blue-700">macmini:2222</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<p className="text-sm text-slate-500">Status</p>
|
||||||
|
<p className={`font-medium ${pipelineStatus?.gitea_connected ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||||
|
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Konfiguration erforderlich'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Implementierte Komponenten */}
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-slate-800 mb-3">Implementierte Komponenten</h4>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-slate-600">Komponente</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-slate-600">Pfad</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-slate-600">Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">Gitea Service</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">Gitea 1.22 mit Actions enabled</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">Gitea Runner</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">act_runner fuer Job-Ausfuehrung</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">SBOM Workflow</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">.gitea/workflows/sbom.yaml</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">5 Jobs: generate, scan, license, upload, summary</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">Backend API</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">backend/security_api.py</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">SBOM Upload, Pipeline Status, History</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">Runner Config</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">gitea/runner-config.yaml</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">Labels: ubuntu-latest, self-hosted</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setup Steps */}
|
||||||
|
<div className="bg-orange-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-orange-800 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||||
|
</svg>
|
||||||
|
Setup-Schritte
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">1. Gitea oeffnen</h5>
|
||||||
|
<code className="text-sm bg-slate-100 px-2 py-1 rounded">http://macmini:3003</code>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">2. Admin-Account erstellen</h5>
|
||||||
|
<p className="text-sm text-slate-600">Username: admin, Email: admin@breakpilot.de</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">3. Repository erstellen</h5>
|
||||||
|
<p className="text-sm text-slate-600">Name: breakpilot-pwa, Visibility: Private</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">4. Actions aktivieren</h5>
|
||||||
|
<p className="text-sm text-slate-600">Repository Settings → Actions → Enable Repository Actions</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">5. Runner Token erstellen & starten</h5>
|
||||||
|
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
|
||||||
|
{`export GITEA_RUNNER_TOKEN=<token>
|
||||||
|
docker compose up -d gitea-runner`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">6. Repository pushen</h5>
|
||||||
|
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
|
||||||
|
{`git remote add gitea http://macmini:3003/admin/breakpilot-pwa.git
|
||||||
|
git push gitea main`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="bg-purple-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-purple-800 mb-3">Quick Links</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<a href="http://macmini:3003" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-purple-800">Gitea</p>
|
||||||
|
<p className="text-xs text-slate-500">Git Server & CI/CD</p>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="http://macmini:3003/admin/breakpilot-pwa/actions" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-purple-800">Pipeline Actions</p>
|
||||||
|
<p className="text-xs text-slate-500">Workflow Runs</p>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Types for CI/CD Dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PipelineStatus {
|
||||||
|
gitea_connected: boolean
|
||||||
|
gitea_url: string
|
||||||
|
last_sbom_update: string | null
|
||||||
|
total_runs: number
|
||||||
|
successful_runs: number
|
||||||
|
failed_runs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineRun {
|
||||||
|
id: string
|
||||||
|
workflow: string
|
||||||
|
branch: string
|
||||||
|
commit_sha: string
|
||||||
|
status: 'success' | 'failed' | 'running' | 'pending'
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
duration_seconds: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
status: string
|
||||||
|
state: string
|
||||||
|
created: string
|
||||||
|
ports: string[]
|
||||||
|
cpu_percent: number
|
||||||
|
memory_usage: string
|
||||||
|
memory_limit: string
|
||||||
|
memory_percent: number
|
||||||
|
network_rx: string
|
||||||
|
network_tx: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStats {
|
||||||
|
hostname: string
|
||||||
|
platform: string
|
||||||
|
arch: string
|
||||||
|
uptime: number
|
||||||
|
cpu: {
|
||||||
|
model: string
|
||||||
|
cores: number
|
||||||
|
usage_percent: number
|
||||||
|
}
|
||||||
|
memory: {
|
||||||
|
total: string
|
||||||
|
used: string
|
||||||
|
free: string
|
||||||
|
usage_percent: number
|
||||||
|
}
|
||||||
|
disk: {
|
||||||
|
total: string
|
||||||
|
used: string
|
||||||
|
free: string
|
||||||
|
usage_percent: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerStats {
|
||||||
|
containers: ContainerInfo[]
|
||||||
|
total_containers: number
|
||||||
|
running_containers: number
|
||||||
|
stopped_containers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabType = 'overview' | 'pipelines' | 'deployments' | 'setup' | 'scheduler'
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { MiddlewareConfig } from '../types'
|
||||||
|
import { getMiddlewareDescription } from './helpers'
|
||||||
|
|
||||||
|
interface ConfigTabProps {
|
||||||
|
configs: MiddlewareConfig[]
|
||||||
|
actionLoading: string | null
|
||||||
|
onToggle: (name: string, enabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigTab({ configs, actionLoading, onToggle }: ConfigTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{configs.map(config => {
|
||||||
|
const info = getMiddlewareDescription(config.middleware_name)
|
||||||
|
return (
|
||||||
|
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<span>{info.icon}</span>
|
||||||
|
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||||
|
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(config.middleware_name, !config.enabled)}
|
||||||
|
disabled={actionLoading === config.middleware_name}
|
||||||
|
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === config.middleware_name
|
||||||
|
? '...'
|
||||||
|
: config.enabled
|
||||||
|
? 'Deaktivieren'
|
||||||
|
: 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Object.keys(config.config).length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-200">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
||||||
|
Konfiguration
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
|
||||||
|
{JSON.stringify(config.config, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { MiddlewareEvent } from '../types'
|
||||||
|
import { getEventTypeColor } from './helpers'
|
||||||
|
|
||||||
|
interface EventsTabProps {
|
||||||
|
events: MiddlewareEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsTab({ events }: EventsTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Zeit
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Middleware
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Event
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
IP
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Pfad
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||||
|
Keine Events vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
events.map(event => (
|
||||||
|
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{new Date(event.created_at).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm capitalize">
|
||||||
|
{event.middleware_name.replace('_', ' ')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
|
||||||
|
>
|
||||||
|
{event.event_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm font-mono text-slate-600">
|
||||||
|
{event.ip_address || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
|
||||||
|
{event.request_method && event.request_path
|
||||||
|
? `${event.request_method} ${event.request_path}`
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { RateLimitIP } from '../types'
|
||||||
|
|
||||||
|
interface IpListTabProps {
|
||||||
|
ipList: RateLimitIP[]
|
||||||
|
actionLoading: string | null
|
||||||
|
newIP: string
|
||||||
|
newIPType: 'whitelist' | 'blacklist'
|
||||||
|
newIPReason: string
|
||||||
|
onNewIPChange: (value: string) => void
|
||||||
|
onNewIPTypeChange: (value: 'whitelist' | 'blacklist') => void
|
||||||
|
onNewIPReasonChange: (value: string) => void
|
||||||
|
onAddIP: (e: React.FormEvent) => void
|
||||||
|
onRemoveIP: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IpListTab({
|
||||||
|
ipList,
|
||||||
|
actionLoading,
|
||||||
|
newIP,
|
||||||
|
newIPType,
|
||||||
|
newIPReason,
|
||||||
|
onNewIPChange,
|
||||||
|
onNewIPTypeChange,
|
||||||
|
onNewIPReasonChange,
|
||||||
|
onAddIP,
|
||||||
|
onRemoveIP,
|
||||||
|
}: IpListTabProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Add IP Form */}
|
||||||
|
<form onSubmit={onAddIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newIP}
|
||||||
|
onChange={e => onNewIPChange(e.target.value)}
|
||||||
|
placeholder="IP-Adresse (z.B. 192.168.1.1)"
|
||||||
|
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newIPType}
|
||||||
|
onChange={e => onNewIPTypeChange(e.target.value as 'whitelist' | 'blacklist')}
|
||||||
|
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
>
|
||||||
|
<option value="whitelist">Whitelist</option>
|
||||||
|
<option value="blacklist">Blacklist</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newIPReason}
|
||||||
|
onChange={e => onNewIPReasonChange(e.target.value)}
|
||||||
|
placeholder="Grund (optional)"
|
||||||
|
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newIP.trim() || actionLoading === 'add-ip'}
|
||||||
|
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* IP List Table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
IP-Adresse
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Typ
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Grund
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Hinzugefuegt
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Aktion
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ipList.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||||
|
Keine IP-Eintraege vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
ipList.map(ip => (
|
||||||
|
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||||
|
ip.list_type === 'whitelist'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{new Date(ip.created_at).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveIP(ip.id)}
|
||||||
|
disabled={actionLoading === `remove-${ip.id}`}
|
||||||
|
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { MiddlewareConfig } from '../types'
|
||||||
|
import { getMiddlewareDescription } from './helpers'
|
||||||
|
|
||||||
|
interface OverviewTabProps {
|
||||||
|
configs: MiddlewareConfig[]
|
||||||
|
actionLoading: string | null
|
||||||
|
onToggle: (name: string, enabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab({ configs, actionLoading, onToggle }: OverviewTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{configs.map(config => {
|
||||||
|
const info = getMiddlewareDescription(config.middleware_name)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className={`rounded-lg p-4 border ${
|
||||||
|
config.enabled
|
||||||
|
? 'bg-green-50 border-green-200'
|
||||||
|
: 'bg-slate-50 border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{info.icon}</span>
|
||||||
|
<span className="font-semibold text-slate-900 capitalize">
|
||||||
|
{config.middleware_name.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(config.middleware_name, !config.enabled)}
|
||||||
|
disabled={actionLoading === config.middleware_name}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
||||||
|
config.enabled
|
||||||
|
? 'bg-green-200 text-green-800 hover:bg-green-300'
|
||||||
|
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{actionLoading === config.middleware_name
|
||||||
|
? '...'
|
||||||
|
: config.enabled
|
||||||
|
? 'Aktiv'
|
||||||
|
: 'Inaktiv'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||||
|
{config.updated_at && (
|
||||||
|
<div className="mt-2 text-xs text-slate-400">
|
||||||
|
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { MiddlewareStats } from '../types'
|
||||||
|
import { getMiddlewareDescription, getEventTypeColor } from './helpers'
|
||||||
|
|
||||||
|
interface StatsTabProps {
|
||||||
|
stats: MiddlewareStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsTab({ stats }: StatsTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{stats.map(stat => {
|
||||||
|
const info = getMiddlewareDescription(stat.middleware_name)
|
||||||
|
return (
|
||||||
|
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
||||||
|
<span>{info.icon}</span>
|
||||||
|
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
|
||||||
|
<div className="text-xs text-slate-500">Gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
|
||||||
|
<div className="text-xs text-slate-500">Letzte Stunde</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
|
||||||
|
<div className="text-xs text-slate-500">24 Stunden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stat.top_event_types.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
||||||
|
Top Event-Typen
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stat.top_event_types.slice(0, 3).map(et => (
|
||||||
|
<span
|
||||||
|
key={et.event_type}
|
||||||
|
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
|
||||||
|
>
|
||||||
|
{et.event_type} ({et.count})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stat.top_ips.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
{stat.top_ips
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(ip => `${ip.ip_address} (${ip.count})`)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
interface StatusOverviewProps {
|
||||||
|
configCount: number
|
||||||
|
whitelistCount: number
|
||||||
|
blacklistCount: number
|
||||||
|
eventCount: number
|
||||||
|
loading: boolean
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusOverview({
|
||||||
|
configCount,
|
||||||
|
whitelistCount,
|
||||||
|
blacklistCount,
|
||||||
|
eventCount,
|
||||||
|
loading,
|
||||||
|
onRefresh,
|
||||||
|
}: StatusOverviewProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900">Middleware Status</h2>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Laden...' : 'Aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="text-2xl font-bold text-slate-900">{configCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Middleware</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{whitelistCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Whitelist IPs</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{blacklistCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Blacklist IPs</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{eventCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Recent Events</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function getMiddlewareDescription(name: string): { icon: string; desc: string } {
|
||||||
|
const descriptions: Record<string, { icon: string; desc: string }> = {
|
||||||
|
request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
|
||||||
|
security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
|
||||||
|
cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' },
|
||||||
|
rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
|
||||||
|
pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' },
|
||||||
|
input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' },
|
||||||
|
}
|
||||||
|
return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventTypeColor(eventType: string) {
|
||||||
|
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
|
||||||
|
return 'bg-red-100 text-red-800'
|
||||||
|
}
|
||||||
|
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
|
||||||
|
return 'bg-yellow-100 text-yellow-800'
|
||||||
|
}
|
||||||
|
if (eventType.includes('success') || eventType.includes('whitelist')) {
|
||||||
|
return 'bg-green-100 text-green-800'
|
||||||
|
}
|
||||||
|
return 'bg-slate-100 text-slate-800'
|
||||||
|
}
|
||||||
@@ -9,44 +9,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||||
|
import type { MiddlewareConfig, RateLimitIP, MiddlewareEvent, MiddlewareStats } from './types'
|
||||||
interface MiddlewareConfig {
|
import { StatusOverview } from './_components/StatusOverview'
|
||||||
id: string
|
import { OverviewTab } from './_components/OverviewTab'
|
||||||
middleware_name: string
|
import { ConfigTab } from './_components/ConfigTab'
|
||||||
enabled: boolean
|
import { IpListTab } from './_components/IpListTab'
|
||||||
config: Record<string, unknown>
|
import { EventsTab } from './_components/EventsTab'
|
||||||
updated_at: string | null
|
import { StatsTab } from './_components/StatsTab'
|
||||||
}
|
|
||||||
|
|
||||||
interface RateLimitIP {
|
|
||||||
id: string
|
|
||||||
ip_address: string
|
|
||||||
list_type: 'whitelist' | 'blacklist'
|
|
||||||
reason: string | null
|
|
||||||
expires_at: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MiddlewareEvent {
|
|
||||||
id: string
|
|
||||||
middleware_name: string
|
|
||||||
event_type: string
|
|
||||||
ip_address: string | null
|
|
||||||
user_id: string | null
|
|
||||||
request_path: string | null
|
|
||||||
request_method: string | null
|
|
||||||
details: Record<string, unknown> | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MiddlewareStats {
|
|
||||||
middleware_name: string
|
|
||||||
total_events: number
|
|
||||||
events_last_hour: number
|
|
||||||
events_last_24h: number
|
|
||||||
top_event_types: Array<{ event_type: string; count: number }>
|
|
||||||
top_ips: Array<{ ip_address: string; count: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MiddlewareAdminPage() {
|
export default function MiddlewareAdminPage() {
|
||||||
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
|
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
|
||||||
@@ -184,31 +153,6 @@ export default function MiddlewareAdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMiddlewareDescription = (name: string): { icon: string; desc: string } => {
|
|
||||||
const descriptions: Record<string, { icon: string; desc: string }> = {
|
|
||||||
request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
|
|
||||||
security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
|
|
||||||
cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' },
|
|
||||||
rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
|
|
||||||
pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' },
|
|
||||||
input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' },
|
|
||||||
}
|
|
||||||
return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const getEventTypeColor = (eventType: string) => {
|
|
||||||
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
|
|
||||||
return 'bg-red-100 text-red-800'
|
|
||||||
}
|
|
||||||
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
|
|
||||||
return 'bg-yellow-100 text-yellow-800'
|
|
||||||
}
|
|
||||||
if (eventType.includes('success') || eventType.includes('whitelist')) {
|
|
||||||
return 'bg-green-100 text-green-800'
|
|
||||||
}
|
|
||||||
return 'bg-slate-100 text-slate-800'
|
|
||||||
}
|
|
||||||
|
|
||||||
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
|
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
|
||||||
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
|
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
|
||||||
|
|
||||||
@@ -232,38 +176,14 @@ export default function MiddlewareAdminPage() {
|
|||||||
defaultCollapsed={true}
|
defaultCollapsed={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Overview */}
|
<StatusOverview
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
configCount={configs.length}
|
||||||
<div className="flex justify-between items-center mb-6">
|
whitelistCount={whitelistCount}
|
||||||
<h2 className="text-xl font-semibold text-slate-900">Middleware Status</h2>
|
blacklistCount={blacklistCount}
|
||||||
<button
|
eventCount={events.length}
|
||||||
onClick={fetchData}
|
loading={loading}
|
||||||
disabled={loading}
|
onRefresh={fetchData}
|
||||||
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
/>
|
||||||
>
|
|
||||||
{loading ? 'Laden...' : 'Aktualisieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{configs.length}</div>
|
|
||||||
<div className="text-sm text-slate-600">Middleware</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
|
||||||
<div className="text-2xl font-bold text-green-600">{whitelistCount}</div>
|
|
||||||
<div className="text-sm text-slate-600">Whitelist IPs</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
|
|
||||||
<div className="text-2xl font-bold text-red-600">{blacklistCount}</div>
|
|
||||||
<div className="text-sm text-slate-600">Blacklist IPs</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{events.length}</div>
|
|
||||||
<div className="text-sm text-slate-600">Recent Events</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mb-6">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mb-6">
|
||||||
@@ -298,332 +218,28 @@ export default function MiddlewareAdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Overview Tab */}
|
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<OverviewTab configs={configs} actionLoading={actionLoading} onToggle={toggleMiddleware} />
|
||||||
{configs.map(config => {
|
|
||||||
const info = getMiddlewareDescription(config.middleware_name)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={config.id}
|
|
||||||
className={`rounded-lg p-4 border ${
|
|
||||||
config.enabled
|
|
||||||
? 'bg-green-50 border-green-200'
|
|
||||||
: 'bg-slate-50 border-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">{info.icon}</span>
|
|
||||||
<span className="font-semibold text-slate-900 capitalize">
|
|
||||||
{config.middleware_name.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
|
|
||||||
disabled={actionLoading === config.middleware_name}
|
|
||||||
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
|
||||||
config.enabled
|
|
||||||
? 'bg-green-200 text-green-800 hover:bg-green-300'
|
|
||||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{actionLoading === config.middleware_name
|
|
||||||
? '...'
|
|
||||||
: config.enabled
|
|
||||||
? 'Aktiv'
|
|
||||||
: 'Inaktiv'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
|
||||||
{config.updated_at && (
|
|
||||||
<div className="mt-2 text-xs text-slate-400">
|
|
||||||
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Config Tab */}
|
|
||||||
{activeTab === 'config' && (
|
{activeTab === 'config' && (
|
||||||
<div className="space-y-4">
|
<ConfigTab configs={configs} actionLoading={actionLoading} onToggle={toggleMiddleware} />
|
||||||
{configs.map(config => {
|
|
||||||
const info = getMiddlewareDescription(config.middleware_name)
|
|
||||||
return (
|
|
||||||
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
|
||||||
<span>{info.icon}</span>
|
|
||||||
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
|
|
||||||
disabled={actionLoading === config.middleware_name}
|
|
||||||
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === config.middleware_name
|
|
||||||
? '...'
|
|
||||||
: config.enabled
|
|
||||||
? 'Deaktivieren'
|
|
||||||
: 'Aktivieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{Object.keys(config.config).length > 0 && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-slate-200">
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
|
||||||
Konfiguration
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
|
|
||||||
{JSON.stringify(config.config, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* IP List Tab */}
|
|
||||||
{activeTab === 'ip-list' && (
|
{activeTab === 'ip-list' && (
|
||||||
<div>
|
<IpListTab
|
||||||
{/* Add IP Form */}
|
ipList={ipList}
|
||||||
<form onSubmit={addIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
actionLoading={actionLoading}
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
|
newIP={newIP}
|
||||||
<div className="flex flex-wrap gap-3">
|
newIPType={newIPType}
|
||||||
<input
|
newIPReason={newIPReason}
|
||||||
type="text"
|
onNewIPChange={setNewIP}
|
||||||
value={newIP}
|
onNewIPTypeChange={setNewIPType}
|
||||||
onChange={e => setNewIP(e.target.value)}
|
onNewIPReasonChange={setNewIPReason}
|
||||||
placeholder="IP-Adresse (z.B. 192.168.1.1)"
|
onAddIP={addIP}
|
||||||
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
onRemoveIP={removeIP}
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
value={newIPType}
|
|
||||||
onChange={e => setNewIPType(e.target.value as 'whitelist' | 'blacklist')}
|
|
||||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
||||||
>
|
|
||||||
<option value="whitelist">Whitelist</option>
|
|
||||||
<option value="blacklist">Blacklist</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newIPReason}
|
|
||||||
onChange={e => setNewIPReason(e.target.value)}
|
|
||||||
placeholder="Grund (optional)"
|
|
||||||
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!newIP.trim() || actionLoading === 'add-ip'}
|
|
||||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* IP List Table */}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
IP-Adresse
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Typ
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Grund
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Hinzugefuegt
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Aktion
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ipList.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
|
||||||
Keine IP-Eintraege vorhanden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
ipList.map(ip => (
|
|
||||||
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
|
||||||
ip.list_type === 'whitelist'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">
|
|
||||||
{new Date(ip.created_at).toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => removeIP(ip.id)}
|
|
||||||
disabled={actionLoading === `remove-${ip.id}`}
|
|
||||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Events Tab */}
|
|
||||||
{activeTab === 'events' && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Zeit
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Middleware
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Event
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
IP
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Pfad
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
|
||||||
Keine Events vorhanden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
events.map(event => (
|
|
||||||
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">
|
|
||||||
{new Date(event.created_at).toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm capitalize">
|
|
||||||
{event.middleware_name.replace('_', ' ')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
|
|
||||||
>
|
|
||||||
{event.event_type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm font-mono text-slate-600">
|
|
||||||
{event.ip_address || '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
|
|
||||||
{event.request_method && event.request_path
|
|
||||||
? `${event.request_method} ${event.request_path}`
|
|
||||||
: '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats Tab */}
|
|
||||||
{activeTab === 'stats' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{stats.map(stat => {
|
|
||||||
const info = getMiddlewareDescription(stat.middleware_name)
|
|
||||||
return (
|
|
||||||
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
|
||||||
<span>{info.icon}</span>
|
|
||||||
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
|
|
||||||
<div className="text-xs text-slate-500">Gesamt</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
|
|
||||||
<div className="text-xs text-slate-500">Letzte Stunde</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
|
|
||||||
<div className="text-xs text-slate-500">24 Stunden</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{stat.top_event_types.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
|
||||||
Top Event-Typen
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{stat.top_event_types.slice(0, 3).map(et => (
|
|
||||||
<span
|
|
||||||
key={et.event_type}
|
|
||||||
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
|
|
||||||
>
|
|
||||||
{et.event_type} ({et.count})
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stat.top_ips.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
|
|
||||||
<div className="text-xs text-slate-600">
|
|
||||||
{stat.top_ips
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(ip => `${ip.ip_address} (${ip.count})`)
|
|
||||||
.join(', ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'events' && <EventsTab events={events} />}
|
||||||
|
{activeTab === 'stats' && <StatsTab stats={stats} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export interface MiddlewareConfig {
|
||||||
|
id: string
|
||||||
|
middleware_name: string
|
||||||
|
enabled: boolean
|
||||||
|
config: Record<string, unknown>
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimitIP {
|
||||||
|
id: string
|
||||||
|
ip_address: string
|
||||||
|
list_type: 'whitelist' | 'blacklist'
|
||||||
|
reason: string | null
|
||||||
|
expires_at: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiddlewareEvent {
|
||||||
|
id: string
|
||||||
|
middleware_name: string
|
||||||
|
event_type: string
|
||||||
|
ip_address: string | null
|
||||||
|
user_id: string | null
|
||||||
|
request_path: string | null
|
||||||
|
request_method: string | null
|
||||||
|
details: Record<string, unknown> | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiddlewareStats {
|
||||||
|
middleware_name: string
|
||||||
|
total_events: number
|
||||||
|
events_last_hour: number
|
||||||
|
events_last_24h: number
|
||||||
|
top_event_types: Array<{ event_type: string; count: number }>
|
||||||
|
top_ips: Array<{ ip_address: string; count: number }>
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Component } from '../types'
|
||||||
|
|
||||||
|
export const GO_MODULES: Component[] = [
|
||||||
|
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
|
||||||
|
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
|
||||||
|
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
|
||||||
|
{ type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' },
|
||||||
|
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
|
||||||
|
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
|
||||||
|
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_PACKAGES: Component[] = [
|
||||||
|
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
|
||||||
|
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
|
||||||
|
{ type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' },
|
||||||
|
{ type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' },
|
||||||
|
{ type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' },
|
||||||
|
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
|
||||||
|
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
|
||||||
|
{ type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' },
|
||||||
|
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
|
||||||
|
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
|
||||||
|
{ type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' },
|
||||||
|
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
|
||||||
|
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
|
||||||
|
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
|
||||||
|
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
|
||||||
|
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const UNITY_PACKAGES: Component[] = [
|
||||||
|
{ type: 'library', name: 'Unity Engine', version: '6000.0 (Unity 6)', category: 'unity', description: 'Game Engine', license: 'Unity EULA', sourceUrl: 'https://unity.com' },
|
||||||
|
{ type: 'library', name: 'Universal Render Pipeline (URP)', version: '17.x', category: 'unity', description: 'Render Pipeline', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0' },
|
||||||
|
{ type: 'library', name: 'TextMeshPro', version: '3.2', category: 'unity', description: 'Advanced Text Rendering', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2' },
|
||||||
|
{ type: 'library', name: 'Unity Mathematics', version: '1.3', category: 'unity', description: 'Math Library (SIMD)', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.mathematics@1.3' },
|
||||||
|
{ type: 'library', name: 'Newtonsoft.Json (Unity)', version: '3.2', category: 'unity', description: 'JSON Serialization', license: 'MIT', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2' },
|
||||||
|
{ type: 'library', name: 'Unity UI', version: '2.0', category: 'unity', description: 'UI System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.ugui@2.0' },
|
||||||
|
{ type: 'library', name: 'Unity Input System', version: '1.8', category: 'unity', description: 'New Input System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.inputsystem@1.8' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CSHARP_PACKAGES: Component[] = [
|
||||||
|
{ type: 'library', name: '.NET Standard', version: '2.1', category: 'csharp', description: 'Runtime', license: 'MIT', sourceUrl: 'https://github.com/dotnet/standard' },
|
||||||
|
{ type: 'library', name: 'UnityWebRequest', version: 'built-in', category: 'csharp', description: 'HTTP Client', license: 'Unity Companion', sourceUrl: '-' },
|
||||||
|
{ type: 'library', name: 'System.Text.Json', version: 'built-in', category: 'csharp', description: 'JSON Parsing', license: 'MIT', sourceUrl: 'https://github.com/dotnet/runtime' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Component } from '../types'
|
||||||
|
|
||||||
|
export const PYTHON_PACKAGES: Component[] = [
|
||||||
|
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
|
||||||
|
{ type: 'library', name: 'Uvicorn', version: '0.38+', category: 'python', description: 'ASGI Server', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/uvicorn' },
|
||||||
|
{ type: 'library', name: 'Starlette', version: '0.49+', category: 'python', description: 'ASGI Framework (FastAPI Basis)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/starlette' },
|
||||||
|
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
|
||||||
|
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
|
||||||
|
{ type: 'library', name: 'Alembic', version: '1.14+', category: 'python', description: 'DB Migrations (Classroom, Feedback Tables)', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/alembic' },
|
||||||
|
{ type: 'library', name: 'psycopg2-binary', version: '2.9+', category: 'python', description: 'PostgreSQL Driver', license: 'LGPL-3.0', sourceUrl: 'https://github.com/psycopg/psycopg2' },
|
||||||
|
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
|
||||||
|
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
|
||||||
|
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
|
||||||
|
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
|
||||||
|
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
|
||||||
|
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
|
||||||
|
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
|
||||||
|
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
|
||||||
|
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
|
||||||
|
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
|
||||||
|
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
|
||||||
|
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
|
||||||
|
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
|
||||||
|
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
|
||||||
|
{ type: 'library', name: 'faster-whisper', version: '1.0+', category: 'python', description: 'CTranslate2 Whisper (GPU-optimiert)', license: 'MIT', sourceUrl: 'https://github.com/SYSTRAN/faster-whisper' },
|
||||||
|
{ type: 'library', name: 'pyannote.audio', version: '3.x', category: 'python', description: 'Speaker Diarization', license: 'MIT', sourceUrl: 'https://github.com/pyannote/pyannote-audio' },
|
||||||
|
{ type: 'library', name: 'rq', version: '1.x', category: 'python', description: 'Redis Queue (Task Processing)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/rq/rq' },
|
||||||
|
{ type: 'library', name: 'ffmpeg-python', version: '0.2+', category: 'python', description: 'FFmpeg Python Bindings', license: 'Apache-2.0', sourceUrl: 'https://github.com/kkroening/ffmpeg-python' },
|
||||||
|
{ type: 'library', name: 'webvtt-py', version: '0.4+', category: 'python', description: 'WebVTT Subtitle Export', license: 'MIT', sourceUrl: 'https://github.com/glut23/webvtt-py' },
|
||||||
|
{ type: 'library', name: 'minio', version: '7.x', category: 'python', description: 'MinIO S3 Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/minio/minio-py' },
|
||||||
|
{ type: 'library', name: 'structlog', version: '24.x', category: 'python', description: 'Structured Logging', license: 'Apache-2.0', sourceUrl: 'https://github.com/hynek/structlog' },
|
||||||
|
{ type: 'library', name: 'feedparser', version: '6.x', category: 'python', description: 'RSS/Atom Feed Parser (Alerts Agent)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/kurtmckee/feedparser' },
|
||||||
|
{ type: 'library', name: 'APScheduler', version: '3.x', category: 'python', description: 'AsyncIO Job Scheduler (Alerts Agent)', license: 'MIT', sourceUrl: 'https://github.com/agronholm/apscheduler' },
|
||||||
|
{ type: 'library', name: 'beautifulsoup4', version: '4.x', category: 'python', description: 'HTML Parser (Email Parsing, Compliance Scraper)', license: 'MIT', sourceUrl: 'https://code.launchpad.net/beautifulsoup' },
|
||||||
|
{ type: 'library', name: 'lxml', version: '5.x', category: 'python', description: 'XML/HTML Parser (EUR-Lex Scraping)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/lxml/lxml' },
|
||||||
|
{ type: 'library', name: 'PyMuPDF', version: '1.24+', category: 'python', description: 'PDF Parser (BSI-TR Extraction)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/pymupdf/PyMuPDF' },
|
||||||
|
{ type: 'library', name: 'pdfplumber', version: '0.11+', category: 'python', description: 'PDF Table Extraction (Compliance Docs)', license: 'MIT', sourceUrl: 'https://github.com/jsvine/pdfplumber' },
|
||||||
|
{ type: 'library', name: 'websockets', version: '14.x', category: 'python', description: 'WebSocket Support (Voice Streaming)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/python-websockets/websockets' },
|
||||||
|
{ type: 'library', name: 'soundfile', version: '0.13+', category: 'python', description: 'Audio File Processing (Voice Service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/bastibe/python-soundfile' },
|
||||||
|
{ type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' },
|
||||||
|
{ type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' },
|
||||||
|
{ type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' },
|
||||||
|
{ type: 'library', name: 'pyspellchecker', version: '0.8.1+', category: 'python', description: 'Regel-basierte OCR-Korrektur (klausur-service Schritt 6)', license: 'MIT', sourceUrl: 'https://github.com/barrust/pyspellchecker' },
|
||||||
|
{ type: 'library', name: 'pytesseract', version: '0.3.10+', category: 'python', description: 'Tesseract OCR Engine Wrapper (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/madmaze/pytesseract' },
|
||||||
|
{ type: 'library', name: 'opencv-python-headless', version: '4.8+', category: 'python', description: 'Bildverarbeitung, Projektionsprofile, Inpainting (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opencv/opencv-python' },
|
||||||
|
{ type: 'library', name: 'rapidocr-onnxruntime', version: 'latest', category: 'python', description: 'Schnelles OCR ARM64 via ONNX (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/RapidAI/RapidOCR' },
|
||||||
|
{ type: 'library', name: 'onnxruntime', version: 'latest', category: 'python', description: 'ONNX-Inferenz fuer RapidOCR (klausur-service)', license: 'MIT', sourceUrl: 'https://github.com/microsoft/onnxruntime' },
|
||||||
|
{ type: 'library', name: 'eng-to-ipa', version: 'latest', category: 'python', description: 'IPA-Lautschrift-Lookup (klausur-service Vokabel-Pipeline)', license: 'MIT', sourceUrl: 'https://github.com/mphilli/English-to-IPA' },
|
||||||
|
{ type: 'library', name: 'sentence-transformers', version: '2.2+', category: 'python', description: 'Lokale Embeddings (klausur-service, rag-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/UKPLab/sentence-transformers' },
|
||||||
|
{ type: 'library', name: 'torch', version: '2.0+', category: 'python', description: 'ML-Framework CPU/MPS (TrOCR, klausur-service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pytorch/pytorch' },
|
||||||
|
{ type: 'library', name: 'transformers', version: '4.x', category: 'python', description: 'HuggingFace Transformers (TrOCR, Handschrift-HTR)', license: 'Apache-2.0', sourceUrl: 'https://github.com/huggingface/transformers' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Static SBOM component data
|
||||||
|
* Extracted from page.tsx to keep file sizes manageable
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Component } from '../types'
|
||||||
|
|
||||||
|
// Infrastructure components from docker-compose.yml and project analysis
|
||||||
|
export const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
||||||
|
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||||
|
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||||
|
{ type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' },
|
||||||
|
{ type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' },
|
||||||
|
{ type: 'service', name: 'Valkey', version: '8-alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions (Redis OSS Fork)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||||
|
{ type: 'service', name: 'ERPNext Valkey Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||||
|
{ type: 'service', name: 'ERPNext Valkey Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||||
|
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
|
||||||
|
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
|
||||||
|
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
|
||||||
|
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
|
||||||
|
{ type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' },
|
||||||
|
{ type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
|
||||||
|
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
|
||||||
|
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
|
||||||
|
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
|
||||||
|
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
|
||||||
|
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
|
||||||
|
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
|
||||||
|
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
|
||||||
|
{ type: 'service', name: 'Jibri', version: 'stable-9823', category: 'communication', port: '-', description: 'Recording & Streaming Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jibri' },
|
||||||
|
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API, Studio & Alerts Agent', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Compliance Module', version: '2.0', category: 'application', port: '8000', description: 'GRC Framework (19 Regulations, 558 Requirements, AI)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Transcription Worker', version: '1.0', category: 'application', port: '-', description: 'Whisper + pyannote Transkription', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' },
|
||||||
|
{ type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' },
|
||||||
|
{ type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' },
|
||||||
|
{ type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' },
|
||||||
|
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service with Actions CI/CD', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
|
||||||
|
{ type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' },
|
||||||
|
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
|
||||||
|
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS LLM Judge', version: '1.0', category: 'qa', port: '-', description: 'Qwen2.5-32B basierte Test-Bewertung', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS RAG Judge', version: '1.0', category: 'qa', port: '-', description: 'RAG/Korrektur Evaluierung', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS Notifier', version: '1.0', category: 'qa', port: '-', description: 'Desktop/Slack/Email Benachrichtigungen', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS Regression Tracker', version: '1.0', category: 'qa', port: '-', description: 'Score-Historie und Regression-Erkennung', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SECURITY_TOOLS: Component[] = [
|
||||||
|
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
|
||||||
|
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
|
||||||
|
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
|
||||||
|
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
|
||||||
|
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
|
||||||
|
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
|
||||||
|
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
|
||||||
|
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
|
||||||
|
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
|
||||||
|
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
|
||||||
|
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
|
||||||
|
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
|
||||||
|
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
|
||||||
|
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export { PYTHON_PACKAGES } from './sbom-data-python'
|
||||||
|
export { GO_MODULES, NODE_PACKAGES, UNITY_PACKAGES, CSHARP_PACKAGES } from './sbom-data-libs'
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Types for SBOM page
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Component {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
purl?: string
|
||||||
|
licenses?: { license: { id: string } }[]
|
||||||
|
category?: string
|
||||||
|
port?: string
|
||||||
|
description?: string
|
||||||
|
license?: string
|
||||||
|
sourceUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SBOMData {
|
||||||
|
bomFormat?: string
|
||||||
|
specVersion?: string
|
||||||
|
version?: number
|
||||||
|
metadata?: {
|
||||||
|
timestamp?: string
|
||||||
|
tools?: { vendor: string; name: string; version: string }[]
|
||||||
|
component?: { type: string; name: string; version: string }
|
||||||
|
}
|
||||||
|
components?: Component[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp'
|
||||||
|
export type InfoTabType = 'audit' | 'documentation'
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { Finding } from '../types'
|
||||||
|
|
||||||
|
export function FindingsTab({
|
||||||
|
findings,
|
||||||
|
severityFilter,
|
||||||
|
setSeverityFilter,
|
||||||
|
toolFilter,
|
||||||
|
setToolFilter,
|
||||||
|
getSeverityBadge,
|
||||||
|
}: {
|
||||||
|
findings: Finding[]
|
||||||
|
severityFilter: string | null
|
||||||
|
setSeverityFilter: (v: string | null) => void
|
||||||
|
toolFilter: string | null
|
||||||
|
setToolFilter: (v: string | null) => void
|
||||||
|
getSeverityBadge: (severity: string) => string
|
||||||
|
}) {
|
||||||
|
const filteredFindings = findings.filter(f => {
|
||||||
|
if (severityFilter && f.severity.toUpperCase() !== severityFilter.toUpperCase()) return false
|
||||||
|
if (toolFilter && f.tool.toLowerCase() !== toolFilter.toLowerCase()) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setSeverityFilter(null)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm ${!severityFilter ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
{['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].map(sev => (
|
||||||
|
<button
|
||||||
|
key={sev}
|
||||||
|
onClick={() => setSeverityFilter(sev)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm ${severityFilter === sev ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
{sev}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="mx-2 border-l border-slate-300" />
|
||||||
|
{['gitleaks', 'semgrep', 'bandit', 'trivy', 'grype'].map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setToolFilter(toolFilter === t ? null : t)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm capitalize ${toolFilter === t ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredFindings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
Keine Findings mit diesem Filter gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Severity</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Tool</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Finding</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Datei</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Zeile</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Gefunden</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredFindings.map((finding, idx) => (
|
||||||
|
<tr key={`${finding.id}-${idx}`} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className={getSeverityBadge(finding.severity)}>{finding.severity}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600">{finding.tool}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="text-sm text-slate-900">{finding.title}</div>
|
||||||
|
{finding.message && (
|
||||||
|
<div className="text-xs text-slate-500 mt-1">{finding.message}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500 font-mono max-w-xs truncate">
|
||||||
|
{finding.file || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">{finding.line || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{finding.found_at ? new Date(finding.found_at).toLocaleDateString('de-DE') : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { HistoryItem } from '../types'
|
||||||
|
|
||||||
|
export function HistoryTab({ history }: { history: HistoryItem[] }) {
|
||||||
|
const getHistoryStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return 'bg-green-500'
|
||||||
|
case 'warning': return 'bg-yellow-500'
|
||||||
|
case 'error': return 'bg-red-500'
|
||||||
|
default: return 'bg-slate-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
Keine Scan-Historie vorhanden.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200" />
|
||||||
|
{history.map((item, idx) => (
|
||||||
|
<div key={idx} className="relative pl-10 pb-6">
|
||||||
|
<div className={`absolute left-2.5 w-3 h-3 rounded-full ${getHistoryStatusColor(item.status)}`} />
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className="font-semibold text-slate-900">{item.title}</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{new Date(item.timestamp).toLocaleString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { MonitoringMetric, ActiveAlert } from '../types'
|
||||||
|
|
||||||
|
export function MonitoringTab({
|
||||||
|
monitoringMetrics,
|
||||||
|
activeAlerts,
|
||||||
|
}: {
|
||||||
|
monitoringMetrics: MonitoringMetric[]
|
||||||
|
activeAlerts: ActiveAlert[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Real-time Metrics */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
Security Metriken
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
{monitoringMetrics.map((metric, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`rounded-lg p-4 border ${
|
||||||
|
metric.status === 'critical' ? 'bg-red-50 border-red-200' :
|
||||||
|
metric.status === 'warning' ? 'bg-yellow-50 border-yellow-200' :
|
||||||
|
'bg-green-50 border-green-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<span className="text-xs text-slate-600">{metric.name}</span>
|
||||||
|
<span className={`text-xs ${
|
||||||
|
metric.trend === 'up' ? 'text-red-600' :
|
||||||
|
metric.trend === 'down' ? 'text-green-600' :
|
||||||
|
'text-slate-500'
|
||||||
|
}`}>
|
||||||
|
{metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-2xl font-bold ${
|
||||||
|
metric.status === 'critical' ? 'text-red-700' :
|
||||||
|
metric.status === 'warning' ? 'text-yellow-700' :
|
||||||
|
'text-green-700'
|
||||||
|
}`}>
|
||||||
|
{metric.value}
|
||||||
|
<span className="text-sm font-normal ml-1">{metric.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Alerts */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||||
|
</svg>
|
||||||
|
Aktive Alerts
|
||||||
|
{activeAlerts.filter(a => !a.acknowledged).length > 0 && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||||
|
{activeAlerts.filter(a => !a.acknowledged).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{activeAlerts.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-green-50 rounded-lg border border-green-200">
|
||||||
|
<span className="text-4xl block mb-2">✓</span>
|
||||||
|
<span className="text-green-700">Keine aktiven Security-Alerts</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeAlerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||||
|
alert.severity === 'critical' ? 'bg-red-50 border-red-200' :
|
||||||
|
alert.severity === 'high' ? 'bg-orange-50 border-orange-200' :
|
||||||
|
alert.severity === 'medium' ? 'bg-yellow-50 border-yellow-200' :
|
||||||
|
'bg-blue-50 border-blue-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`px-2 py-1 text-xs font-semibold rounded uppercase ${
|
||||||
|
alert.severity === 'critical' ? 'bg-red-100 text-red-800' :
|
||||||
|
alert.severity === 'high' ? 'bg-orange-100 text-orange-800' :
|
||||||
|
alert.severity === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-blue-100 text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{alert.severity}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-900">{alert.title}</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{alert.source} • {new Date(alert.timestamp).toLocaleString('de-DE')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!alert.acknowledged && (
|
||||||
|
<button className="px-3 py-1 text-xs bg-white border border-slate-300 rounded hover:bg-slate-50">
|
||||||
|
Bestaetigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Overview Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
Authentifizierung
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Aktive Sessions</span><span className="font-medium">24</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Fehlgeschlagene Logins (24h)</span><span className="font-medium text-green-600">0</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">2FA-Quote</span><span className="font-medium text-green-600">100%</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
SSL/TLS
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Zertifikate</span><span className="font-medium">5 aktiv</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Naechster Ablauf</span><span className="font-medium text-green-600">45 Tage</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">TLS Version</span><span className="font-medium">1.3</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
Firewall
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Blockierte IPs (24h)</span><span className="font-medium">12</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Rate Limit Hits</span><span className="font-medium text-yellow-600">7</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">WAF Status</span><span className="font-medium text-green-600">Aktiv</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link to CI/CD */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-blue-900">Pipeline Security</div>
|
||||||
|
<div className="text-sm text-blue-700">Security-Scans in CI/CD Pipelines und Container-Status</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/infrastructure/ci-cd" className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||||
|
CI/CD Dashboard →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function SecurityDocsSection() {
|
||||||
|
const [showFullDocs, setShowFullDocs] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Security Dokumentation
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFullDocs(!showFullDocs)}
|
||||||
|
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors flex items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<svg className={`w-4 h-4 transition-transform ${showFullDocs ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
{showFullDocs ? 'Weniger anzeigen' : 'Vollstaendige Dokumentation'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Short Description */}
|
||||||
|
<div className="prose prose-slate max-w-none">
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Das Security Dashboard bietet einen zentralen Ueberblick ueber alle DevSecOps-Aktivitaeten.
|
||||||
|
Es integriert 6 Security-Tools fuer umfassende Code- und Infrastruktur-Sicherheit:
|
||||||
|
Secrets Detection, Static Analysis (SAST), Dependency Scanning und SBOM-Generierung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Quick Reference */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mt-4">
|
||||||
|
{[
|
||||||
|
{ bg: 'bg-red-50', icon: '🔑', name: 'Gitleaks', label: 'Secrets', color: 'text-red-800', labelColor: 'text-red-600' },
|
||||||
|
{ bg: 'bg-blue-50', icon: '🔍', name: 'Semgrep', label: 'SAST', color: 'text-blue-800', labelColor: 'text-blue-600' },
|
||||||
|
{ bg: 'bg-yellow-50', icon: '🐍', name: 'Bandit', label: 'Python', color: 'text-yellow-800', labelColor: 'text-yellow-600' },
|
||||||
|
{ bg: 'bg-purple-50', icon: '🔒', name: 'Trivy', label: 'Container', color: 'text-purple-800', labelColor: 'text-purple-600' },
|
||||||
|
{ bg: 'bg-green-50', icon: '🐛', name: 'Grype', label: 'Dependencies', color: 'text-green-800', labelColor: 'text-green-600' },
|
||||||
|
{ bg: 'bg-orange-50', icon: '📦', name: 'Syft', label: 'SBOM', color: 'text-orange-800', labelColor: 'text-orange-600' },
|
||||||
|
].map((tool) => (
|
||||||
|
<div key={tool.name} className={`${tool.bg} p-3 rounded-lg text-center`}>
|
||||||
|
<span className="text-lg">{tool.icon}</span>
|
||||||
|
<p className={`text-xs font-medium ${tool.color} mt-1`}>{tool.name}</p>
|
||||||
|
<p className={`text-xs ${tool.labelColor}`}>{tool.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Documentation (Expandable) */}
|
||||||
|
{showFullDocs && (
|
||||||
|
<div className="mt-6 bg-slate-50 rounded-lg p-6 border border-slate-200">
|
||||||
|
<div className="prose prose-slate max-w-none prose-headings:text-slate-900 prose-p:text-slate-600 prose-li:text-slate-600">
|
||||||
|
<h3>1. Security Tools Uebersicht</h3>
|
||||||
|
<h4>🔑 Gitleaks - Secrets Detection</h4>
|
||||||
|
<p>Durchsucht die gesamte Git-Historie nach versehentlich eingecheckten Secrets wie API-Keys, Passwoertern und Tokens.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Scan-Bereich:</strong> Git-Historie, Commits, Branches</li>
|
||||||
|
<li><strong>Erkannte Secrets:</strong> AWS Keys, GitHub Tokens, Private Keys, Passwoerter</li>
|
||||||
|
<li><strong>Ausgabe:</strong> JSON-Report mit Fundstelle, Commit-Hash, Autor</li>
|
||||||
|
</ul>
|
||||||
|
<h4>🔍 Semgrep - Static Application Security Testing</h4>
|
||||||
|
<p>Fuehrt regelbasierte statische Code-Analyse durch, um Sicherheitsluecken und Anti-Patterns zu finden.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Unterstuetzte Sprachen:</strong> Python, JavaScript, TypeScript, Go, Java</li>
|
||||||
|
<li><strong>Regelsets:</strong> OWASP Top 10, CWE, Security Best Practices</li>
|
||||||
|
<li><strong>Findings:</strong> SQL Injection, XSS, Path Traversal, Insecure Deserialization</li>
|
||||||
|
</ul>
|
||||||
|
<h3>2. Severity-Klassifizierung</h3>
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead><tr className="border-b"><th className="text-left py-2">Severity</th><th className="text-left py-2">CVSS Score</th><th className="text-left py-2">Reaktionszeit</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-red-100 text-red-800 rounded text-xs font-semibold">CRITICAL</span></td><td>9.0 - 10.0</td><td>Sofort (24h)</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-orange-100 text-orange-800 rounded text-xs font-semibold">HIGH</span></td><td>7.0 - 8.9</td><td>1-3 Tage</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded text-xs font-semibold">MEDIUM</span></td><td>4.0 - 6.9</td><td>1-2 Wochen</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-green-100 text-green-800 rounded text-xs font-semibold">LOW</span></td><td>0.1 - 3.9</td><td>Naechster Sprint</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3>3. Scan-Workflow</h3>
|
||||||
|
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||||
|
{`1. Secrets Detection (Gitleaks)
|
||||||
|
2. Static Analysis (Semgrep + Bandit)
|
||||||
|
3. Dependency Scan (Trivy + Grype)
|
||||||
|
4. SBOM Generation (Syft)
|
||||||
|
5. Report & Dashboard`}
|
||||||
|
</pre>
|
||||||
|
<h3>4. API-Endpunkte</h3>
|
||||||
|
<table className="min-w-full text-sm font-mono">
|
||||||
|
<thead><tr className="border-b"><th className="text-left py-2">Methode</th><th className="text-left py-2">Endpoint</th><th className="text-left py-2 font-sans">Beschreibung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/tools</td><td className="font-sans">Tool-Status abrufen</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/findings</td><td className="font-sans">Alle Findings abrufen</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="bg-green-100 text-green-700 px-1 rounded">POST</span></td><td>/api/v1/security/scan/all</td><td className="font-sans">Full Scan starten</td></tr>
|
||||||
|
<tr><td className="py-2"><span className="bg-green-100 text-green-700 px-1 rounded">POST</span></td><td>/api/v1/security/scan/[tool]</td><td className="font-sans">Einzelnes Tool scannen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ToolStatus, Finding, ScanType } from '../types'
|
||||||
|
|
||||||
|
export function SecurityOverviewTab({
|
||||||
|
tools,
|
||||||
|
findings,
|
||||||
|
scanning,
|
||||||
|
onRunScan,
|
||||||
|
onShowAllFindings,
|
||||||
|
toolDescriptions,
|
||||||
|
toolToScanType,
|
||||||
|
getSeverityBadge,
|
||||||
|
getStatusBadge,
|
||||||
|
}: {
|
||||||
|
tools: ToolStatus[]
|
||||||
|
findings: Finding[]
|
||||||
|
scanning: string | null
|
||||||
|
onRunScan: (scanType: ScanType) => void
|
||||||
|
onShowAllFindings: () => void
|
||||||
|
toolDescriptions: Record<string, { icon: string; desc: string }>
|
||||||
|
toolToScanType: Record<string, ScanType>
|
||||||
|
getSeverityBadge: (severity: string) => string
|
||||||
|
getStatusBadge: (installed: boolean) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Tools Grid */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">DevSecOps Tools</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{tools.map(tool => {
|
||||||
|
const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' }
|
||||||
|
return (
|
||||||
|
<div key={tool.name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{info.icon}</span>
|
||||||
|
<span className="font-semibold text-slate-900">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className={getStatusBadge(tool.installed)}>
|
||||||
|
{tool.installed ? 'Installiert' : 'Nicht installiert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 mb-3">{info.desc}</p>
|
||||||
|
<div className="flex justify-between items-center text-xs text-slate-500">
|
||||||
|
<span>{tool.version || '-'}</span>
|
||||||
|
<span>Letzter Scan: {tool.last_run || 'Nie'}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onRunScan(toolToScanType[tool.name.toLowerCase()] || 'all')}
|
||||||
|
disabled={scanning !== null || !tool.installed}
|
||||||
|
className={`mt-3 w-full px-3 py-1.5 text-sm border rounded transition-colors flex items-center justify-center gap-2 ${
|
||||||
|
scanning === toolToScanType[tool.name.toLowerCase()]
|
||||||
|
? 'bg-orange-100 border-orange-300 text-orange-700'
|
||||||
|
: 'bg-white border-slate-300 hover:bg-slate-50 disabled:opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scanning === toolToScanType[tool.name.toLowerCase()] ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-600" />
|
||||||
|
<span>Scan laeuft...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Scan starten'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Findings */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Aktuelle Findings</h3>
|
||||||
|
{findings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
<span className="text-4xl block mb-2">🎉</span>
|
||||||
|
Keine Findings gefunden. Das ist gut!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Severity</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Tool</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Finding</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Datei</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Gefunden</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{findings.slice(0, 10).map((finding, idx) => (
|
||||||
|
<tr key={`${finding.id}-${idx}`} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className={getSeverityBadge(finding.severity)}>{finding.severity}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600">{finding.tool}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-900">{finding.title}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500 font-mono">{finding.file || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{finding.found_at ? new Date(finding.found_at).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||||
|
}) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{findings.length > 10 && (
|
||||||
|
<button onClick={onShowAllFindings} className="mt-4 text-sm text-orange-600 hover:text-orange-700">
|
||||||
|
Alle {findings.length} Findings anzeigen →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ToolStatus, ScanType } from '../types'
|
||||||
|
|
||||||
|
export function ToolsTab({
|
||||||
|
tools,
|
||||||
|
scanning,
|
||||||
|
onRunScan,
|
||||||
|
toolDescriptions,
|
||||||
|
toolToScanType,
|
||||||
|
getStatusBadge,
|
||||||
|
}: {
|
||||||
|
tools: ToolStatus[]
|
||||||
|
scanning: string | null
|
||||||
|
onRunScan: (scanType: ScanType) => void
|
||||||
|
toolDescriptions: Record<string, { icon: string; desc: string }>
|
||||||
|
toolToScanType: Record<string, ScanType>
|
||||||
|
getStatusBadge: (installed: boolean) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{tools.map(tool => {
|
||||||
|
const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' }
|
||||||
|
return (
|
||||||
|
<div key={tool.name} className="bg-white border border-slate-200 rounded-lg p-6">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="text-2xl">{info.icon}</span>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900">{tool.name}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||||
|
</div>
|
||||||
|
<span className={getStatusBadge(tool.installed)}>
|
||||||
|
{tool.installed ? 'Installiert' : 'Nicht installiert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Version:</span>
|
||||||
|
<span className="font-mono">{tool.version || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Letzter Scan:</span>
|
||||||
|
<span>{tool.last_run || 'Nie'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Findings:</span>
|
||||||
|
<span className="font-semibold">{tool.last_findings}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onRunScan(toolToScanType[tool.name.toLowerCase()] || 'all')}
|
||||||
|
disabled={scanning !== null || !tool.installed}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
|
||||||
|
scanning === toolToScanType[tool.name.toLowerCase()]
|
||||||
|
? 'bg-orange-200 text-orange-800'
|
||||||
|
: 'bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scanning === toolToScanType[tool.name.toLowerCase()] ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-700" />
|
||||||
|
<span>Scan laeuft...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Scan starten'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`/api/v1/security/reports/${tool.name.toLowerCase()}`, '_blank')}
|
||||||
|
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Types for Security Dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolStatus {
|
||||||
|
name: string
|
||||||
|
installed: boolean
|
||||||
|
version: string | null
|
||||||
|
last_run: string | null
|
||||||
|
last_findings: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
id: string
|
||||||
|
tool: string
|
||||||
|
severity: string
|
||||||
|
title: string
|
||||||
|
message: string | null
|
||||||
|
file: string | null
|
||||||
|
line: number | null
|
||||||
|
found_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeveritySummary {
|
||||||
|
critical: number
|
||||||
|
high: number
|
||||||
|
medium: number
|
||||||
|
low: number
|
||||||
|
info: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
timestamp: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanType = 'secrets' | 'sast' | 'deps' | 'containers' | 'sbom' | 'all'
|
||||||
|
|
||||||
|
export interface MonitoringMetric {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
status: 'ok' | 'warning' | 'critical'
|
||||||
|
trend: 'up' | 'down' | 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveAlert {
|
||||||
|
id: string
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low'
|
||||||
|
title: string
|
||||||
|
source: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { LLMRoutingOption } from '@/types/infrastructure-modules'
|
||||||
|
import type { FailedTest, BacklogItem, BacklogPriority } from '../types'
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// FailedTestCard
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
function FailedTestCard({
|
||||||
|
test,
|
||||||
|
onStatusChange,
|
||||||
|
onPriorityChange,
|
||||||
|
priority = 'medium',
|
||||||
|
failureCount = 1,
|
||||||
|
}: {
|
||||||
|
test: FailedTest
|
||||||
|
onStatusChange: (testId: string, status: string) => void
|
||||||
|
onPriorityChange?: (testId: string, priority: string) => void
|
||||||
|
priority?: BacklogPriority
|
||||||
|
failureCount?: number
|
||||||
|
}) {
|
||||||
|
const errorTypeColors: Record<string, string> = {
|
||||||
|
assertion: 'bg-amber-100 text-amber-700',
|
||||||
|
nil_pointer: 'bg-red-100 text-red-700',
|
||||||
|
type_error: 'bg-purple-100 text-purple-700',
|
||||||
|
network: 'bg-blue-100 text-blue-700',
|
||||||
|
timeout: 'bg-orange-100 text-orange-700',
|
||||||
|
logic_error: 'bg-slate-100 text-slate-700',
|
||||||
|
unknown: 'bg-slate-100 text-slate-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
open: 'bg-red-100 text-red-700',
|
||||||
|
in_progress: 'bg-blue-100 text-blue-700',
|
||||||
|
fixed: 'bg-emerald-100 text-emerald-700',
|
||||||
|
wont_fix: 'bg-slate-100 text-slate-700',
|
||||||
|
flaky: 'bg-purple-100 text-purple-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
critical: 'bg-red-500 text-white',
|
||||||
|
high: 'bg-orange-500 text-white',
|
||||||
|
medium: 'bg-yellow-500 text-white',
|
||||||
|
low: 'bg-slate-400 text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityLabels: Record<string, string> = {
|
||||||
|
critical: '!!! Kritisch',
|
||||||
|
high: '!! Hoch',
|
||||||
|
medium: '! Mittel',
|
||||||
|
low: 'Niedrig',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-4 hover:border-red-300 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityColors[priority]}`}>
|
||||||
|
{priorityLabels[priority]}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${errorTypeColors[test.error_type] || errorTypeColors.unknown}`}>
|
||||||
|
{test.error_type.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">{test.service}</span>
|
||||||
|
{failureCount > 1 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-red-100 text-red-600 text-xs font-medium">
|
||||||
|
{failureCount}x fehlgeschlagen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-mono text-sm font-medium text-slate-900 truncate" title={test.name}>
|
||||||
|
{test.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 truncate" title={test.file_path}>
|
||||||
|
{test.file_path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 ml-2">
|
||||||
|
<select
|
||||||
|
value={test.status}
|
||||||
|
onChange={(e) => onStatusChange(test.id, e.target.value)}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium cursor-pointer border-0 ${statusColors[test.status]}`}
|
||||||
|
>
|
||||||
|
<option value="open">Offen</option>
|
||||||
|
<option value="in_progress">In Arbeit</option>
|
||||||
|
<option value="fixed">Behoben</option>
|
||||||
|
<option value="wont_fix">Ignoriert</option>
|
||||||
|
<option value="flaky">Flaky</option>
|
||||||
|
</select>
|
||||||
|
{onPriorityChange && (
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => onPriorityChange(test.id, e.target.value)}
|
||||||
|
className="px-2 py-1 rounded text-xs font-medium cursor-pointer border border-slate-200"
|
||||||
|
>
|
||||||
|
<option value="critical">Kritisch</option>
|
||||||
|
<option value="high">Hoch</option>
|
||||||
|
<option value="medium">Mittel</option>
|
||||||
|
<option value="low">Niedrig</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-red-50 rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-sm text-red-800 font-medium mb-1">Fehlermeldung:</p>
|
||||||
|
<p className="text-xs text-red-700 font-mono break-words">
|
||||||
|
{test.error_message || 'Keine Details verfuegbar'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{test.suggestion && (
|
||||||
|
<div className="bg-emerald-50 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-emerald-800 font-medium mb-1">💡 Loesungsvorschlag:</p>
|
||||||
|
<p className="text-xs text-emerald-700">
|
||||||
|
{test.suggestion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-100 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Zuletzt fehlgeschlagen: {test.last_failed ? new Date(test.last_failed).toLocaleString('de-DE') : 'Unbekannt'}</span>
|
||||||
|
<button
|
||||||
|
className="text-orange-600 hover:text-orange-700 font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(test.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ID kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// BacklogTab
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
export function BacklogTab({
|
||||||
|
failedTests,
|
||||||
|
onStatusChange,
|
||||||
|
onPriorityChange,
|
||||||
|
isLoading,
|
||||||
|
backlogItems,
|
||||||
|
usePostgres = false,
|
||||||
|
}: {
|
||||||
|
failedTests: FailedTest[]
|
||||||
|
onStatusChange: (testId: string, status: string) => void
|
||||||
|
onPriorityChange?: (testId: string, priority: string) => void
|
||||||
|
isLoading: boolean
|
||||||
|
backlogItems?: BacklogItem[]
|
||||||
|
usePostgres?: boolean
|
||||||
|
}) {
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('open')
|
||||||
|
const [filterService, setFilterService] = useState<string>('all')
|
||||||
|
const [filterPriority, setFilterPriority] = useState<string>('all')
|
||||||
|
const [llmAutoAnalysis, setLlmAutoAnalysis] = useState<boolean>(true)
|
||||||
|
const [llmRouting, setLlmRouting] = useState<LLMRoutingOption>('smart_routing')
|
||||||
|
|
||||||
|
// Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy
|
||||||
|
const items = usePostgres && backlogItems ? backlogItems : failedTests
|
||||||
|
|
||||||
|
// Gruppiere nach Service
|
||||||
|
const services = [...new Set(items.map(t => 'service' in t ? t.service : (t as BacklogItem).service))]
|
||||||
|
|
||||||
|
// Filtere Items
|
||||||
|
const filteredItems = items.filter(item => {
|
||||||
|
const status = 'status' in item ? item.status : 'open'
|
||||||
|
const service = 'service' in item ? item.service : ''
|
||||||
|
const priority = 'priority' in item ? (item as BacklogItem).priority : 'medium'
|
||||||
|
|
||||||
|
if (filterStatus !== 'all' && status !== filterStatus) return false
|
||||||
|
if (filterService !== 'all' && service !== filterService) return false
|
||||||
|
if (filterPriority !== 'all' && priority !== filterPriority) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zaehle nach Status
|
||||||
|
const openCount = items.filter(t => t.status === 'open').length
|
||||||
|
const inProgressCount = items.filter(t => t.status === 'in_progress').length
|
||||||
|
const fixedCount = items.filter(t => t.status === 'fixed').length
|
||||||
|
const flakyCount = items.filter(t => t.status === 'flaky').length
|
||||||
|
|
||||||
|
// Zaehle nach Prioritaet (nur bei PostgreSQL)
|
||||||
|
const criticalCount = backlogItems?.filter(t => t.priority === 'critical').length || 0
|
||||||
|
const highCount = backlogItems?.filter(t => t.priority === 'high').length || 0
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-600"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konvertiere BacklogItem zu FailedTest fuer die Anzeige
|
||||||
|
const convertToFailedTest = (item: BacklogItem): FailedTest => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.test_name,
|
||||||
|
service: item.service,
|
||||||
|
file_path: item.test_file || '',
|
||||||
|
error_message: item.error_message || '',
|
||||||
|
error_type: item.error_type || 'unknown',
|
||||||
|
suggestion: item.fix_suggestion || '',
|
||||||
|
run_id: '',
|
||||||
|
last_failed: item.last_failed_at,
|
||||||
|
status: item.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-red-600">{openCount}</p>
|
||||||
|
<p className="text-sm text-red-700">Offene Fehler</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{inProgressCount}</p>
|
||||||
|
<p className="text-sm text-blue-700">In Arbeit</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-emerald-600">{fixedCount}</p>
|
||||||
|
<p className="text-sm text-emerald-700">Behoben</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-purple-600">{flakyCount}</p>
|
||||||
|
<p className="text-sm text-purple-700">Flaky</p>
|
||||||
|
</div>
|
||||||
|
{usePostgres && criticalCount + highCount > 0 && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-orange-600">{criticalCount + highCount}</p>
|
||||||
|
<p className="text-sm text-orange-700">Kritisch/Hoch</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PostgreSQL Badge */}
|
||||||
|
{usePostgres && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-50 border border-emerald-200 rounded-lg w-fit">
|
||||||
|
<svg className="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs text-emerald-700 font-medium">Persistente Speicherung aktiv (PostgreSQL)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LLM Analysis Toggle */}
|
||||||
|
<div className="bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-slate-800">Automatische LLM-Analyse</h4>
|
||||||
|
<p className="text-xs text-slate-500">KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={llmAutoAnalysis}
|
||||||
|
onChange={(e) => setLlmAutoAnalysis(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{llmAutoAnalysis && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-violet-200">
|
||||||
|
<p className="text-xs text-slate-600 mb-3">LLM-Routing Strategie:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{([
|
||||||
|
{ value: 'local_only' as const, label: 'Nur lokales 32B LLM', badge: 'DSGVO', badgeColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
{ value: 'claude_preferred' as const, label: 'Claude bevorzugt', badge: 'Qualitaet', badgeColor: 'bg-blue-100 text-blue-700' },
|
||||||
|
{ value: 'smart_routing' as const, label: 'Smart Routing', badge: 'Empfohlen', badgeColor: 'bg-amber-100 text-amber-700' },
|
||||||
|
]).map((option) => (
|
||||||
|
<label key={option.value} className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
llmRouting === option.value
|
||||||
|
? 'bg-violet-100 border-violet-300 text-violet-800'
|
||||||
|
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="llm-routing"
|
||||||
|
value={option.value}
|
||||||
|
checked={llmRouting === option.value}
|
||||||
|
onChange={() => setLlmRouting(option.value)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{option.label}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 ${option.badgeColor} rounded`}>{option.badge}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
{llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'}
|
||||||
|
{llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'}
|
||||||
|
{llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600 mr-2">Status:</label>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="open">Offen ({openCount})</option>
|
||||||
|
<option value="in_progress">In Arbeit ({inProgressCount})</option>
|
||||||
|
<option value="fixed">Behoben ({fixedCount})</option>
|
||||||
|
<option value="flaky">Flaky ({flakyCount})</option>
|
||||||
|
<option value="wont_fix">Ignoriert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600 mr-2">Service:</label>
|
||||||
|
<select
|
||||||
|
value={filterService}
|
||||||
|
onChange={(e) => setFilterService(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Services</option>
|
||||||
|
{services.map(s => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{usePostgres && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600 mr-2">Prioritaet:</label>
|
||||||
|
<select
|
||||||
|
value={filterPriority}
|
||||||
|
onChange={(e) => setFilterPriority(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="critical">Kritisch</option>
|
||||||
|
<option value="high">Hoch</option>
|
||||||
|
<option value="medium">Mittel</option>
|
||||||
|
<option value="low">Niedrig</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto text-sm text-slate-500">
|
||||||
|
{filteredItems.length} von {items.length} Tests angezeigt
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test-Liste */}
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-emerald-50 rounded-xl border border-emerald-200">
|
||||||
|
<svg className="w-12 h-12 mx-auto text-emerald-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-emerald-700 font-medium">
|
||||||
|
{filterStatus === 'open' ? 'Keine offenen Fehler! 🎉' : 'Keine Tests mit diesem Filter gefunden.'}
|
||||||
|
</p>
|
||||||
|
{filterStatus === 'open' && (
|
||||||
|
<p className="text-sm text-emerald-600 mt-2">
|
||||||
|
Alle Tests bestanden. Bereit fuer Go-Live!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const test = usePostgres && 'test_name' in item
|
||||||
|
? convertToFailedTest(item as BacklogItem)
|
||||||
|
: item as FailedTest
|
||||||
|
const itemPriority = usePostgres && 'priority' in item
|
||||||
|
? (item as BacklogItem).priority
|
||||||
|
: 'medium'
|
||||||
|
const failureCount = usePostgres && 'failure_count' in item
|
||||||
|
? (item as BacklogItem).failure_count
|
||||||
|
: 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FailedTestCard
|
||||||
|
key={test.id}
|
||||||
|
test={test}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
onPriorityChange={onPriorityChange}
|
||||||
|
priority={itemPriority}
|
||||||
|
failureCount={failureCount}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-blue-800 font-medium">Workflow fuer fehlgeschlagene Tests:</p>
|
||||||
|
<ol className="text-xs text-blue-700 mt-2 space-y-1 list-decimal list-inside">
|
||||||
|
<li>Markiere den Test als "In Arbeit" wenn du daran arbeitest</li>
|
||||||
|
<li>Analysiere die Fehlermeldung und den Loesungsvorschlag</li>
|
||||||
|
<li>Behebe den Fehler im Code</li>
|
||||||
|
<li>Fuehre den Test erneut aus (Button im Service-Tab)</li>
|
||||||
|
<li>Markiere als "Behoben" wenn der Test besteht</li>
|
||||||
|
{usePostgres && <li>Setze "Flaky" fuer sporadisch fehlschlagende Tests</li>}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { CoverageData } from '../types'
|
||||||
|
|
||||||
|
export function CoverageChart({ data }: { data: CoverageData[] }) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
Keine Coverage-Daten verfuegbar
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedData = [...data].sort((a, b) => b.coverage_percent - a.coverage_percent)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedData.map((item) => (
|
||||||
|
<div key={item.service}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-600 truncate max-w-[200px]">{item.display_name}</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
item.coverage_percent >= 80 ? 'text-emerald-600' : item.coverage_percent >= 60 ? 'text-amber-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.coverage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
item.coverage_percent >= 80 ? 'bg-emerald-500' : item.coverage_percent >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${item.coverage_percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export function FrameworkDistribution({ data }: { data: Record<string, number> }) {
|
||||||
|
const total = Object.values(data).reduce((a, b) => a + b, 0)
|
||||||
|
if (total === 0) return null
|
||||||
|
|
||||||
|
const frameworkLabels: Record<string, string> = {
|
||||||
|
go_test: 'Go Tests',
|
||||||
|
pytest: 'Python (pytest)',
|
||||||
|
jest: 'Jest (TS)',
|
||||||
|
vitest: 'Vitest (SDK)',
|
||||||
|
playwright: 'Playwright (E2E)',
|
||||||
|
bqas_golden: 'BQAS Golden',
|
||||||
|
bqas_rag: 'BQAS RAG',
|
||||||
|
bqas_synthetic: 'BQAS Synthetic',
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameworkColors: Record<string, string> = {
|
||||||
|
go_test: 'bg-cyan-500',
|
||||||
|
pytest: 'bg-yellow-500',
|
||||||
|
jest: 'bg-blue-500',
|
||||||
|
vitest: 'bg-orange-500',
|
||||||
|
playwright: 'bg-purple-500',
|
||||||
|
bqas_golden: 'bg-emerald-500',
|
||||||
|
bqas_rag: 'bg-teal-500',
|
||||||
|
bqas_synthetic: 'bg-amber-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(data)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([framework, count]) => (
|
||||||
|
<div key={framework} className="flex items-center gap-3">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${frameworkColors[framework] || 'bg-slate-400'}`} />
|
||||||
|
<span className="text-sm text-slate-600 flex-1">{frameworkLabels[framework] || framework}</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900">{count}</span>
|
||||||
|
<span className="text-xs text-slate-400">({((count / total) * 100).toFixed(0)}%)</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export function GuideTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl border border-orange-200 p-6">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
Was ist das Test Dashboard?
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-700 leading-relaxed">
|
||||||
|
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System.
|
||||||
|
Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren.
|
||||||
|
Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt.
|
||||||
|
Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-cyan-50 rounded-lg border border-cyan-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🐹</span>
|
||||||
|
<h4 className="font-medium text-cyan-800">Go Unit Tests (~57)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-cyan-700">
|
||||||
|
consent-service, billing-service, school-service, edu-search-service, ai-compliance-sdk
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🐍</span>
|
||||||
|
<h4 className="font-medium text-yellow-800">Python Tests (~50)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-yellow-700">
|
||||||
|
backend, voice-service, klausur-service, geo-service
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🎯</span>
|
||||||
|
<h4 className="font-medium text-emerald-800">BQAS Golden (97)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-emerald-700">
|
||||||
|
Validierte Referenz-Tests mit LLM-Judge fuer Intent-Erkennung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-teal-50 rounded-lg border border-teal-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">📚</span>
|
||||||
|
<h4 className="font-medium text-teal-800">BQAS RAG (~20)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-teal-700">
|
||||||
|
RAG-Judge Tests fuer Retrieval, Citations, Hallucination-Control
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">📘</span>
|
||||||
|
<h4 className="font-medium text-blue-800">TypeScript Jest (~8)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
Website Unit Tests fuer React-Komponenten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">⚡</span>
|
||||||
|
<h4 className="font-medium text-orange-800">SDK Vitest (~43)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-orange-700">
|
||||||
|
AI Compliance SDK Unit Tests: Types, Export, Components, Reducer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🎭</span>
|
||||||
|
<h4 className="font-medium text-purple-800">SDK Playwright (~25)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-purple-700">
|
||||||
|
SDK E2E Tests: Navigation, Workflow, Command Bar, Export
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🌐</span>
|
||||||
|
<h4 className="font-medium text-slate-800">Website E2E (~5)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
End-to-End Tests fuer kritische User Flows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🔗</span>
|
||||||
|
<h4 className="font-medium text-indigo-800">Integration Tests (~15)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-indigo-700">
|
||||||
|
Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Architektur</h3>
|
||||||
|
<pre className="bg-slate-50 p-4 rounded-lg text-xs overflow-x-auto">
|
||||||
|
{`┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Admin-v2 Test Dashboard │
|
||||||
|
│ /infrastructure/tests │
|
||||||
|
├────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Unit Tests │ │ SDK Tests │ │ BQAS │ │ E2E Tests │ │
|
||||||
|
│ │ (Go, Py) │ │ (Vitest) │ │ (LLM/RAG) │ │ (Playwright)│ │
|
||||||
|
│ └────────────┘ └────────────┘ └────────────┘ └─────────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Test Registry API │ │
|
||||||
|
│ │ /backend/api/tests/registry.py │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Tests bleiben wo sie sind:
|
||||||
|
- /consent-service/internal/**/*_test.go
|
||||||
|
- /backend/tests/test_*.py
|
||||||
|
- /voice-service/tests/bqas/
|
||||||
|
- /admin-v2/components/sdk/__tests__/*.test.ts (Vitest)
|
||||||
|
- /admin-v2/e2e/specs/*.spec.ts (Playwright)`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CI/CD Workflow Anleitung */}
|
||||||
|
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
CI/CD Integration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-800 mb-2">🤖 Automatisch (bei jedem Push/PR)</h4>
|
||||||
|
<ul className="space-y-2 text-sm text-blue-700">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">✓</span>
|
||||||
|
<span><strong>Unit Tests</strong> - Go & Python Tests laufen automatisch</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">✓</span>
|
||||||
|
<span><strong>Test-Ergebnisse</strong> - Werden ans Dashboard gesendet</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">✓</span>
|
||||||
|
<span><strong>Backlog</strong> - Fehlgeschlagene Tests erscheinen hier</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">✓</span>
|
||||||
|
<span><strong>Linting</strong> - Code-Qualitaet bei PRs pruefen</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-800 mb-2">👆 Manuell (Button oder Tag)</h4>
|
||||||
|
<ul className="space-y-2 text-sm text-blue-700">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-1">▶</span>
|
||||||
|
<span><strong>Docker Builds</strong> - Container erstellen</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-1">▶</span>
|
||||||
|
<span><strong>SBOM/Scans</strong> - Sicherheitsanalyse ausfuehren</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-1">▶</span>
|
||||||
|
<span><strong>Deployment</strong> - In Produktion deployen</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-1">▶</span>
|
||||||
|
<span><strong>Pipeline starten</strong> - Im CI/CD Dashboard</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-blue-200">
|
||||||
|
<p className="text-sm text-blue-600">
|
||||||
|
<strong>Daten-Fluss:</strong> Gitea Actions → POST /api/tests/ci-result → PostgreSQL → Test Dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Link
|
||||||
|
href="/ai/test-quality"
|
||||||
|
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">BQAS Dashboard</p>
|
||||||
|
<p className="text-xs text-slate-500">Detaillierte BQAS-Metriken und Trend-Analyse</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/infrastructure/ci-cd"
|
||||||
|
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">CI/CD Pipelines</p>
|
||||||
|
<p className="text-xs text-slate-500">Gitea Actions und automatische Test-Planung</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
trend,
|
||||||
|
color = 'blue',
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
subtitle?: string
|
||||||
|
trend?: 'up' | 'down' | 'stable'
|
||||||
|
color?: 'blue' | 'green' | 'red' | 'yellow' | 'orange' | 'purple'
|
||||||
|
}) {
|
||||||
|
const colorClasses = {
|
||||||
|
blue: 'bg-blue-50 border-blue-200',
|
||||||
|
green: 'bg-emerald-50 border-emerald-200',
|
||||||
|
red: 'bg-red-50 border-red-200',
|
||||||
|
yellow: 'bg-amber-50 border-amber-200',
|
||||||
|
orange: 'bg-orange-50 border-orange-200',
|
||||||
|
purple: 'bg-purple-50 border-purple-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendIcons = {
|
||||||
|
up: (
|
||||||
|
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
down: (
|
||||||
|
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
stable: (
|
||||||
|
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-5 ${colorClasses[color]}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600">{title}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
||||||
|
{subtitle && <p className="mt-1 text-xs text-slate-500">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{trend && <div className="mt-1">{trendIcons[trend]}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ServiceTestInfo, ServiceProgress } from '../types'
|
||||||
|
|
||||||
|
export function ServiceTestCard({
|
||||||
|
service,
|
||||||
|
onRun,
|
||||||
|
isRunning,
|
||||||
|
progress,
|
||||||
|
}: {
|
||||||
|
service: ServiceTestInfo
|
||||||
|
onRun: (service: string) => void
|
||||||
|
isRunning: boolean
|
||||||
|
progress?: ServiceProgress
|
||||||
|
}) {
|
||||||
|
const passRate = service.total_tests > 0 ? (service.passed_tests / service.total_tests) * 100 : 0
|
||||||
|
|
||||||
|
const getLanguageIcon = (lang: string) => {
|
||||||
|
switch (lang) {
|
||||||
|
case 'go':
|
||||||
|
return '🐹'
|
||||||
|
case 'python':
|
||||||
|
return '🐍'
|
||||||
|
case 'typescript':
|
||||||
|
return '📘'
|
||||||
|
case 'mixed':
|
||||||
|
return '🔀'
|
||||||
|
default:
|
||||||
|
return '📦'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'passed':
|
||||||
|
return 'bg-emerald-100 text-emerald-700'
|
||||||
|
case 'failed':
|
||||||
|
return 'bg-red-100 text-red-700'
|
||||||
|
case 'running':
|
||||||
|
return 'bg-blue-100 text-blue-700'
|
||||||
|
default:
|
||||||
|
return 'bg-slate-100 text-slate-700'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:border-orange-300 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{getLanguageIcon(service.language)}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">{service.display_name}</h3>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{service.port ? `Port ${service.port}` : 'Library'} • {service.language}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(service.status)}`}>
|
||||||
|
{service.status === 'passed' ? 'Bestanden' : service.status === 'failed' ? 'Fehler' : 'Ausstehend'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-600">Pass Rate</span>
|
||||||
|
<span className="font-medium text-slate-900">{passRate.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
passRate >= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${passRate}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="p-2 bg-slate-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-slate-900">{service.total_tests}</p>
|
||||||
|
<p className="text-xs text-slate-500">Tests</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-emerald-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-emerald-600">{service.passed_tests}</p>
|
||||||
|
<p className="text-xs text-slate-500">Bestanden</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-red-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-red-600">{service.failed_tests}</p>
|
||||||
|
<p className="text-xs text-slate-500">Fehler</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{service.coverage_percent && (
|
||||||
|
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-100">
|
||||||
|
<span className="text-slate-600">Coverage</span>
|
||||||
|
<span className={`font-medium ${service.coverage_percent >= 70 ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||||
|
{service.coverage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress-Anzeige wenn Tests laufen */}
|
||||||
|
{isRunning && progress && progress.status === 'running' && (
|
||||||
|
<div className="mb-3 p-3 bg-orange-50 rounded-lg border border-orange-200">
|
||||||
|
<div className="flex items-center justify-between text-xs text-orange-700 mb-2">
|
||||||
|
<span className="font-mono truncate max-w-[180px]">{progress.current_file || 'Starte...'}</span>
|
||||||
|
<span>{progress.files_done}/{progress.files_total} Dateien</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-orange-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-orange-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${progress.files_total > 0 ? (progress.files_done / progress.files_total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 text-xs">
|
||||||
|
<span className="text-emerald-600 font-medium">{progress.passed} bestanden</span>
|
||||||
|
<span className="text-red-600 font-medium">{progress.failed} fehler</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onRun(service.service)}
|
||||||
|
disabled={isRunning}
|
||||||
|
className={`w-full py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
isRunning
|
||||||
|
? 'bg-orange-100 text-orange-600 cursor-wait'
|
||||||
|
: 'bg-orange-600 text-white hover:bg-orange-700 active:scale-98'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
{progress && progress.status === 'running' ? `${progress.passed + progress.failed} Tests...` : 'Laeuft...'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Tests starten'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { TestRun } from '../types'
|
||||||
|
|
||||||
|
export function TestRunsTable({ runs }: { runs: TestRun[] }) {
|
||||||
|
if (runs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
Keine Test-Laeufe vorhanden
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-slate-600">ID</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-slate-600">Service</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-slate-600">Zeitpunkt</th>
|
||||||
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Tests</th>
|
||||||
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Bestanden</th>
|
||||||
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Dauer</th>
|
||||||
|
<th className="text-center py-3 px-4 font-medium text-slate-600">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runs.map((run) => (
|
||||||
|
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4 font-mono text-xs text-slate-500">{run.id.slice(-8)}</td>
|
||||||
|
<td className="py-3 px-4 text-slate-900">{run.service}</td>
|
||||||
|
<td className="py-3 px-4 text-slate-600">
|
||||||
|
{new Date(run.started_at).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-600">{run.total_tests}</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<span className="text-emerald-600">{run.passed_tests}</span>
|
||||||
|
<span className="text-slate-400"> / </span>
|
||||||
|
<span className="text-red-600">{run.failed_tests}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-500">
|
||||||
|
{run.duration_seconds.toFixed(1)}s
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-center">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
run.status === 'completed'
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: run.status === 'failed'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: run.status === 'running'
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: 'bg-slate-100 text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { Toast } from '../types'
|
||||||
|
|
||||||
|
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border animate-slide-in ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
|
||||||
|
: toast.type === 'error'
|
||||||
|
? 'bg-red-50 border-red-200 text-red-800'
|
||||||
|
: toast.type === 'loading'
|
||||||
|
? 'bg-blue-50 border-blue-200 text-blue-800'
|
||||||
|
: 'bg-slate-50 border-slate-200 text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.type === 'loading' ? (
|
||||||
|
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : toast.type === 'success' ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : toast.type === 'error' ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{toast.message}</span>
|
||||||
|
{toast.type !== 'loading' && (
|
||||||
|
<button onClick={() => onDismiss(toast.id)} className="ml-2 opacity-60 hover:opacity-100">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import type {
|
||||||
|
ServiceTestInfo,
|
||||||
|
TestRegistryStats,
|
||||||
|
TestRun,
|
||||||
|
CoverageData,
|
||||||
|
TabType,
|
||||||
|
Toast,
|
||||||
|
FailedTest,
|
||||||
|
BacklogItem,
|
||||||
|
ServiceProgress,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const API_BASE = '/api/tests'
|
||||||
|
|
||||||
|
// Demo data for when API is not available
|
||||||
|
const DEMO_SERVICES: ServiceTestInfo[] = [
|
||||||
|
{ service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' },
|
||||||
|
{ service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' },
|
||||||
|
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEMO_STATS: TestRegistryStats = {
|
||||||
|
total_tests: 278,
|
||||||
|
total_passed: 263,
|
||||||
|
total_failed: 15,
|
||||||
|
total_skipped: 0,
|
||||||
|
overall_pass_rate: 94.6,
|
||||||
|
average_coverage: 78.5,
|
||||||
|
services_count: 11,
|
||||||
|
by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 },
|
||||||
|
by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestDashboard() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Toast state
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
const toastIdRef = useRef(0)
|
||||||
|
|
||||||
|
const addToast = useCallback((type: Toast['type'], message: string) => {
|
||||||
|
const id = ++toastIdRef.current
|
||||||
|
setToasts((prev) => [...prev, { id, type, message }])
|
||||||
|
if (type !== 'loading') {
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateToast = useCallback((id: number, type: Toast['type'], message: string) => {
|
||||||
|
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, type, message } : t)))
|
||||||
|
if (type !== 'loading') {
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Data states
|
||||||
|
const [services, setServices] = useState<ServiceTestInfo[]>([])
|
||||||
|
const [stats, setStats] = useState<TestRegistryStats | null>(null)
|
||||||
|
const [coverage, setCoverage] = useState<CoverageData[]>([])
|
||||||
|
const [testRuns, setTestRuns] = useState<TestRun[]>([])
|
||||||
|
const [failedTests, setFailedTests] = useState<FailedTest[]>([])
|
||||||
|
const [backlogItems, setBacklogItems] = useState<BacklogItem[]>([])
|
||||||
|
const [usePostgres, setUsePostgres] = useState(false)
|
||||||
|
|
||||||
|
// Running states
|
||||||
|
const [runningServices, setRunningServices] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Progress states fuer laufende Tests
|
||||||
|
const [serviceProgress, setServiceProgress] = useState<Record<string, ServiceProgress>>({})
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registryResponse = await fetch(`${API_BASE}/registry`)
|
||||||
|
if (registryResponse.ok) {
|
||||||
|
const data = await registryResponse.json()
|
||||||
|
setServices(data.services || DEMO_SERVICES)
|
||||||
|
setStats(data.stats || DEMO_STATS)
|
||||||
|
} else {
|
||||||
|
setServices(DEMO_SERVICES)
|
||||||
|
setStats(DEMO_STATS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverageResponse = await fetch(`${API_BASE}/coverage`)
|
||||||
|
if (coverageResponse.ok) {
|
||||||
|
const data = await coverageResponse.json()
|
||||||
|
setCoverage(data.services || [])
|
||||||
|
} else {
|
||||||
|
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
|
||||||
|
service: s.service,
|
||||||
|
display_name: s.display_name,
|
||||||
|
coverage_percent: s.coverage_percent!,
|
||||||
|
language: s.language,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
const runsResponse = await fetch(`${API_BASE}/runs`)
|
||||||
|
if (runsResponse.ok) {
|
||||||
|
const data = await runsResponse.json()
|
||||||
|
setTestRuns(data.runs || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade fehlgeschlagene Tests fuer Backlog
|
||||||
|
const failedResponse = await fetch(`${API_BASE}/failed`)
|
||||||
|
if (failedResponse.ok) {
|
||||||
|
const data = await failedResponse.json()
|
||||||
|
setFailedTests(data.tests || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versuche PostgreSQL-Backlog zu laden (neue API)
|
||||||
|
try {
|
||||||
|
const backlogResponse = await fetch(`${API_BASE}/backlog`)
|
||||||
|
if (backlogResponse.ok) {
|
||||||
|
const data = await backlogResponse.json()
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
setBacklogItems(data.items)
|
||||||
|
setUsePostgres(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// PostgreSQL nicht verfuegbar, nutze Legacy
|
||||||
|
setUsePostgres(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch test registry data:', err)
|
||||||
|
setServices(DEMO_SERVICES)
|
||||||
|
setStats(DEMO_STATS)
|
||||||
|
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
|
||||||
|
service: s.service,
|
||||||
|
display_name: s.display_name,
|
||||||
|
coverage_percent: s.coverage_percent!,
|
||||||
|
language: s.language,
|
||||||
|
})))
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
// Update failed test status
|
||||||
|
const updateTestStatus = async (testId: string, status: string) => {
|
||||||
|
try {
|
||||||
|
const endpoint = usePostgres
|
||||||
|
? `${API_BASE}/backlog/${testId}/status`
|
||||||
|
: `${API_BASE}/failed/${encodeURIComponent(testId)}/status?status=${status}`
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: usePostgres ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: usePostgres ? JSON.stringify({ status }) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (usePostgres) {
|
||||||
|
setBacklogItems(prev =>
|
||||||
|
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setFailedTests(prev =>
|
||||||
|
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
|
||||||
|
)
|
||||||
|
addToast('success', `Test-Status auf "${status}" gesetzt`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update test status:', err)
|
||||||
|
setFailedTests(prev =>
|
||||||
|
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
|
||||||
|
)
|
||||||
|
if (usePostgres) {
|
||||||
|
setBacklogItems(prev =>
|
||||||
|
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update failed test priority (nur PostgreSQL)
|
||||||
|
const updateTestPriority = async (testId: string, priority: string) => {
|
||||||
|
if (!usePostgres) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/backlog/${testId}/priority`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ priority }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setBacklogItems(prev =>
|
||||||
|
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
|
||||||
|
)
|
||||||
|
addToast('success', `Prioritaet auf "${priority}" gesetzt`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update test priority:', err)
|
||||||
|
setBacklogItems(prev =>
|
||||||
|
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests mit Progress-Polling
|
||||||
|
const runTests = async (service: string) => {
|
||||||
|
setRunningServices((prev) => new Set(prev).add(service))
|
||||||
|
const loadingToast = addToast('loading', `Tests fuer ${service} werden gestartet...`)
|
||||||
|
|
||||||
|
// Progress-Polling starten
|
||||||
|
let pollInterval: NodeJS.Timeout | null = null
|
||||||
|
const pollProgress = async () => {
|
||||||
|
try {
|
||||||
|
const progressResponse = await fetch(`${API_BASE}/progress/${service}`)
|
||||||
|
if (progressResponse.ok) {
|
||||||
|
const progress = await progressResponse.json()
|
||||||
|
setServiceProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[service]: progress,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (progress.status === 'running' && progress.files_total > 0) {
|
||||||
|
const toastMsg = `${service}: ${progress.current_file} (${progress.passed} bestanden, ${progress.failed} fehler)`
|
||||||
|
updateToast(loadingToast, 'loading', toastMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore polling errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollInterval = setInterval(pollProgress, 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/run/${service}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
await pollProgress()
|
||||||
|
const finalProgress = serviceProgress[service]
|
||||||
|
const passedMsg = finalProgress ? `${finalProgress.passed} bestanden, ${finalProgress.failed} fehler` : 'abgeschlossen'
|
||||||
|
updateToast(loadingToast, 'success', `${service}: Tests ${passedMsg}`)
|
||||||
|
await fetchData()
|
||||||
|
} else {
|
||||||
|
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to run tests:', err)
|
||||||
|
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
|
||||||
|
} finally {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
}
|
||||||
|
setRunningServices((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(service)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setServiceProgress((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[service]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
toasts,
|
||||||
|
removeToast,
|
||||||
|
services,
|
||||||
|
stats,
|
||||||
|
coverage,
|
||||||
|
testRuns,
|
||||||
|
failedTests,
|
||||||
|
backlogItems,
|
||||||
|
usePostgres,
|
||||||
|
runningServices,
|
||||||
|
serviceProgress,
|
||||||
|
fetchData,
|
||||||
|
updateTestStatus,
|
||||||
|
updateTestPriority,
|
||||||
|
runTests,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Types for Test Dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ServiceTestInfo {
|
||||||
|
service: string
|
||||||
|
display_name: string
|
||||||
|
port?: number
|
||||||
|
language: string
|
||||||
|
total_tests: number
|
||||||
|
passed_tests: number
|
||||||
|
failed_tests: number
|
||||||
|
skipped_tests: number
|
||||||
|
pass_rate: number
|
||||||
|
coverage_percent?: number
|
||||||
|
last_run: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRegistryStats {
|
||||||
|
total_tests: number
|
||||||
|
total_passed: number
|
||||||
|
total_failed: number
|
||||||
|
total_skipped: number
|
||||||
|
overall_pass_rate: number
|
||||||
|
average_coverage: number
|
||||||
|
services_count: number
|
||||||
|
by_category: Record<string, number>
|
||||||
|
by_framework: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRun {
|
||||||
|
id: string
|
||||||
|
service: string
|
||||||
|
started_at: string
|
||||||
|
total_tests: number
|
||||||
|
passed_tests: number
|
||||||
|
failed_tests: number
|
||||||
|
duration_seconds: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverageData {
|
||||||
|
service: string
|
||||||
|
display_name: string
|
||||||
|
coverage_percent: number
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabType = 'overview' | 'unit' | 'bqas' | 'history' | 'backlog' | 'guide'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
type: 'success' | 'error' | 'loading' | 'info'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedTest {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
service: string
|
||||||
|
file_path: string
|
||||||
|
error_message: string
|
||||||
|
error_type: string
|
||||||
|
suggestion: string
|
||||||
|
run_id: string
|
||||||
|
last_failed: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BacklogPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||||
|
export type BacklogStatus = 'open' | 'in_progress' | 'fixed' | 'wont_fix' | 'flaky'
|
||||||
|
|
||||||
|
export interface BacklogItem {
|
||||||
|
id: number
|
||||||
|
test_name: string
|
||||||
|
service: string
|
||||||
|
test_file?: string
|
||||||
|
error_message?: string
|
||||||
|
error_type?: string
|
||||||
|
fix_suggestion?: string
|
||||||
|
priority: BacklogPriority
|
||||||
|
status: BacklogStatus
|
||||||
|
failure_count: number
|
||||||
|
last_failed_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendDataPoint {
|
||||||
|
date: string
|
||||||
|
passed: number
|
||||||
|
failed: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceProgress {
|
||||||
|
current_file: string
|
||||||
|
files_done: number
|
||||||
|
files_total: number
|
||||||
|
passed: number
|
||||||
|
failed: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
@@ -20,108 +20,17 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type {
|
import type {
|
||||||
DevOpsToolId,
|
|
||||||
DevOpsPipelineSidebarProps,
|
DevOpsPipelineSidebarProps,
|
||||||
DevOpsPipelineSidebarResponsiveProps,
|
DevOpsPipelineSidebarResponsiveProps,
|
||||||
PipelineLiveStatus,
|
|
||||||
} from '@/types/infrastructure-modules'
|
} from '@/types/infrastructure-modules'
|
||||||
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
||||||
|
import {
|
||||||
// =============================================================================
|
ToolIcon,
|
||||||
// Icons
|
ServerIcon,
|
||||||
// =============================================================================
|
PlayIcon,
|
||||||
|
StatusBadge,
|
||||||
const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
|
usePipelineLiveStatus,
|
||||||
switch (id) {
|
} from './DevOpsPipelineSidebarParts'
|
||||||
case 'ci-cd':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'tests':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'sbom':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'security':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server/Pipeline Icon fuer Header
|
|
||||||
const ServerIcon = () => (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Play Icon fuer Quick Action
|
|
||||||
const PlayIcon = () => (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Live Status Hook (optional - fetches status from API)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
|
||||||
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Live status fetching not yet implemented
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Status Badge Component
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
count: number
|
|
||||||
type: 'backlog' | 'security' | 'running'
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ count, type }: StatusBadgeProps) {
|
|
||||||
if (count === 0) return null
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
backlog: 'bg-amber-500',
|
|
||||||
security: 'bg-red-500',
|
|
||||||
running: 'bg-green-500 animate-pulse',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main Sidebar Component
|
// Main Sidebar Component
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DevOps Pipeline Sidebar — shared icons, badge, and live-status hook.
|
||||||
|
*
|
||||||
|
* Extracted from DevOpsPipelineSidebar.tsx to stay within the 500 LOC budget.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import type { DevOpsToolId, PipelineLiveStatus } from '@/types/infrastructure-modules'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Icons
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
|
||||||
|
switch (id) {
|
||||||
|
case 'ci-cd':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'tests':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'sbom':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'security':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server/Pipeline Icon fuer Header
|
||||||
|
export const ServerIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Play Icon fuer Quick Action
|
||||||
|
export const PlayIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Live Status Hook (optional - fetches status from API)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
||||||
|
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Live status fetching not yet implemented
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Status Badge Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
count: number
|
||||||
|
type: 'backlog' | 'security' | 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ count, type }: StatusBadgeProps) {
|
||||||
|
if (count === 0) return null
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
backlog: 'bg-amber-500',
|
||||||
|
security: 'bg-red-500',
|
||||||
|
running: 'bg-green-500 animate-pulse',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ Hybrid authentication supporting both Keycloak and local JWT tokens.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .keycloak_auth import (
|
from .keycloak_auth import (
|
||||||
# Config
|
# Config & Models
|
||||||
KeycloakConfig,
|
KeycloakConfig,
|
||||||
KeycloakUser,
|
KeycloakUser,
|
||||||
|
|
||||||
@@ -18,7 +18,9 @@ from .keycloak_auth import (
|
|||||||
TokenExpiredError,
|
TokenExpiredError,
|
||||||
TokenInvalidError,
|
TokenInvalidError,
|
||||||
KeycloakConfigError,
|
KeycloakConfigError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .dependencies import (
|
||||||
# Factory functions
|
# Factory functions
|
||||||
get_keycloak_config_from_env,
|
get_keycloak_config_from_env,
|
||||||
get_authenticator,
|
get_authenticator,
|
||||||
@@ -30,7 +32,7 @@ from .keycloak_auth import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Config
|
# Config & Models
|
||||||
"KeycloakConfig",
|
"KeycloakConfig",
|
||||||
"KeycloakUser",
|
"KeycloakUser",
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
FastAPI Authentication Dependencies and Factory Functions.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- get_keycloak_config_from_env(): Create config from env vars
|
||||||
|
- get_authenticator(): Create HybridAuthenticator instance
|
||||||
|
- get_auth(): Global authenticator singleton
|
||||||
|
- get_current_user(): FastAPI dependency for authentication
|
||||||
|
- require_role(): FastAPI dependency factory for role-based access
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from fastapi import Request, HTTPException, Depends
|
||||||
|
|
||||||
|
from .keycloak_auth import (
|
||||||
|
KeycloakConfig,
|
||||||
|
KeycloakConfigError,
|
||||||
|
HybridAuthenticator,
|
||||||
|
TokenExpiredError,
|
||||||
|
TokenInvalidError,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================
|
||||||
|
# FACTORY FUNCTIONS
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
|
||||||
|
"""
|
||||||
|
Create KeycloakConfig from environment variables.
|
||||||
|
|
||||||
|
Required env vars:
|
||||||
|
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
|
||||||
|
- KEYCLOAK_REALM: e.g., breakpilot
|
||||||
|
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- KEYCLOAK_CLIENT_SECRET: For confidential clients
|
||||||
|
- KEYCLOAK_VERIFY_SSL: Default true
|
||||||
|
"""
|
||||||
|
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
|
||||||
|
realm = os.environ.get("KEYCLOAK_REALM")
|
||||||
|
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
|
||||||
|
|
||||||
|
if not all([server_url, realm, client_id]):
|
||||||
|
logger.info("Keycloak not configured, using local JWT only")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return KeycloakConfig(
|
||||||
|
server_url=server_url,
|
||||||
|
realm=realm,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
|
||||||
|
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_authenticator() -> HybridAuthenticator:
|
||||||
|
"""
|
||||||
|
Get configured authenticator instance.
|
||||||
|
|
||||||
|
Uses environment variables to determine configuration.
|
||||||
|
"""
|
||||||
|
keycloak_config = get_keycloak_config_from_env()
|
||||||
|
|
||||||
|
# JWT_SECRET is required - no default fallback in production
|
||||||
|
jwt_secret = os.environ.get("JWT_SECRET")
|
||||||
|
environment = os.environ.get("ENVIRONMENT", "development")
|
||||||
|
|
||||||
|
if not jwt_secret and environment == "production":
|
||||||
|
raise KeycloakConfigError(
|
||||||
|
"JWT_SECRET environment variable is required in production"
|
||||||
|
)
|
||||||
|
|
||||||
|
return HybridAuthenticator(
|
||||||
|
keycloak_config=keycloak_config,
|
||||||
|
local_jwt_secret=jwt_secret,
|
||||||
|
environment=environment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================
|
||||||
|
# FASTAPI DEPENDENCY
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
# Global authenticator instance (lazy-initialized)
|
||||||
|
_authenticator: Optional[HybridAuthenticator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth() -> HybridAuthenticator:
|
||||||
|
"""Get or create global authenticator."""
|
||||||
|
global _authenticator
|
||||||
|
if _authenticator is None:
|
||||||
|
_authenticator = get_authenticator()
|
||||||
|
return _authenticator
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(request: Request) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
FastAPI dependency to get current authenticated user.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/api/protected")
|
||||||
|
async def protected_endpoint(user: dict = Depends(get_current_user)):
|
||||||
|
return {"user_id": user["user_id"]}
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
# Check for development mode
|
||||||
|
environment = os.environ.get("ENVIRONMENT", "development")
|
||||||
|
if environment == "development":
|
||||||
|
# Return demo user in development without token
|
||||||
|
return {
|
||||||
|
"user_id": "10000000-0000-0000-0000-000000000024",
|
||||||
|
"email": "demo@breakpilot.app",
|
||||||
|
"role": "admin",
|
||||||
|
"realm_roles": ["admin"],
|
||||||
|
"tenant_id": "a0000000-0000-0000-0000-000000000001",
|
||||||
|
"auth_method": "development_bypass"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization header")
|
||||||
|
|
||||||
|
token = auth_header.split(" ")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth = get_auth()
|
||||||
|
return await auth.validate_token(token)
|
||||||
|
except TokenExpiredError:
|
||||||
|
raise HTTPException(status_code=401, detail="Token expired")
|
||||||
|
except TokenInvalidError as e:
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Authentication failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def require_role(required_role: str):
|
||||||
|
"""
|
||||||
|
FastAPI dependency factory for role-based access.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/api/admin-only")
|
||||||
|
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
|
||||||
|
return {"message": "Admin access granted"}
|
||||||
|
"""
|
||||||
|
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
user_role = user.get("role", "user")
|
||||||
|
realm_roles = user.get("realm_roles", [])
|
||||||
|
|
||||||
|
if user_role == required_role or required_role in realm_roles:
|
||||||
|
return user
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Role '{required_role}' required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return role_checker
|
||||||
@@ -374,142 +374,3 @@ class HybridAuthenticator:
|
|||||||
if self.keycloak_auth:
|
if self.keycloak_auth:
|
||||||
await self.keycloak_auth.close()
|
await self.keycloak_auth.close()
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
|
||||||
# FACTORY FUNCTIONS
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
|
|
||||||
"""
|
|
||||||
Create KeycloakConfig from environment variables.
|
|
||||||
|
|
||||||
Required env vars:
|
|
||||||
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
|
|
||||||
- KEYCLOAK_REALM: e.g., breakpilot
|
|
||||||
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
- KEYCLOAK_CLIENT_SECRET: For confidential clients
|
|
||||||
- KEYCLOAK_VERIFY_SSL: Default true
|
|
||||||
"""
|
|
||||||
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
|
|
||||||
realm = os.environ.get("KEYCLOAK_REALM")
|
|
||||||
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
|
|
||||||
|
|
||||||
if not all([server_url, realm, client_id]):
|
|
||||||
logger.info("Keycloak not configured, using local JWT only")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return KeycloakConfig(
|
|
||||||
server_url=server_url,
|
|
||||||
realm=realm,
|
|
||||||
client_id=client_id,
|
|
||||||
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
|
|
||||||
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_authenticator() -> HybridAuthenticator:
|
|
||||||
"""
|
|
||||||
Get configured authenticator instance.
|
|
||||||
|
|
||||||
Uses environment variables to determine configuration.
|
|
||||||
"""
|
|
||||||
keycloak_config = get_keycloak_config_from_env()
|
|
||||||
|
|
||||||
# JWT_SECRET is required - no default fallback in production
|
|
||||||
jwt_secret = os.environ.get("JWT_SECRET")
|
|
||||||
environment = os.environ.get("ENVIRONMENT", "development")
|
|
||||||
|
|
||||||
if not jwt_secret and environment == "production":
|
|
||||||
raise KeycloakConfigError(
|
|
||||||
"JWT_SECRET environment variable is required in production"
|
|
||||||
)
|
|
||||||
|
|
||||||
return HybridAuthenticator(
|
|
||||||
keycloak_config=keycloak_config,
|
|
||||||
local_jwt_secret=jwt_secret,
|
|
||||||
environment=environment
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
|
||||||
# FASTAPI DEPENDENCY
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
from fastapi import Request, HTTPException, Depends
|
|
||||||
|
|
||||||
# Global authenticator instance (lazy-initialized)
|
|
||||||
_authenticator: Optional[HybridAuthenticator] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth() -> HybridAuthenticator:
|
|
||||||
"""Get or create global authenticator."""
|
|
||||||
global _authenticator
|
|
||||||
if _authenticator is None:
|
|
||||||
_authenticator = get_authenticator()
|
|
||||||
return _authenticator
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(request: Request) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
FastAPI dependency to get current authenticated user.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@app.get("/api/protected")
|
|
||||||
async def protected_endpoint(user: dict = Depends(get_current_user)):
|
|
||||||
return {"user_id": user["user_id"]}
|
|
||||||
"""
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
|
||||||
|
|
||||||
if not auth_header.startswith("Bearer "):
|
|
||||||
# Check for development mode
|
|
||||||
environment = os.environ.get("ENVIRONMENT", "development")
|
|
||||||
if environment == "development":
|
|
||||||
# Return demo user in development without token
|
|
||||||
return {
|
|
||||||
"user_id": "10000000-0000-0000-0000-000000000024",
|
|
||||||
"email": "demo@breakpilot.app",
|
|
||||||
"role": "admin",
|
|
||||||
"realm_roles": ["admin"],
|
|
||||||
"tenant_id": "a0000000-0000-0000-0000-000000000001",
|
|
||||||
"auth_method": "development_bypass"
|
|
||||||
}
|
|
||||||
raise HTTPException(status_code=401, detail="Missing authorization header")
|
|
||||||
|
|
||||||
token = auth_header.split(" ")[1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
auth = get_auth()
|
|
||||||
return await auth.validate_token(token)
|
|
||||||
except TokenExpiredError:
|
|
||||||
raise HTTPException(status_code=401, detail="Token expired")
|
|
||||||
except TokenInvalidError as e:
|
|
||||||
raise HTTPException(status_code=401, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Authentication failed: {e}")
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
|
||||||
|
|
||||||
|
|
||||||
async def require_role(required_role: str):
|
|
||||||
"""
|
|
||||||
FastAPI dependency factory for role-based access.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@app.get("/api/admin-only")
|
|
||||||
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
|
|
||||||
return {"message": "Admin access granted"}
|
|
||||||
"""
|
|
||||||
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
|
|
||||||
user_role = user.get("role", "user")
|
|
||||||
realm_roles = user.get("realm_roles", [])
|
|
||||||
|
|
||||||
if user_role == required_role or required_role in realm_roles:
|
|
||||||
return user
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Role '{required_role}' required"
|
|
||||||
)
|
|
||||||
|
|
||||||
return role_checker
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
from auth_api import router as auth_router
|
from auth_api import router as auth_router
|
||||||
from rbac_api import router as rbac_router
|
from rbac_api import router as rbac_router
|
||||||
|
from rbac_teachers_api import router as rbac_teachers_router
|
||||||
from notification_api import router as notification_router
|
from notification_api import router as notification_router
|
||||||
from email_template_api import (
|
from email_template_api import (
|
||||||
router as email_template_router,
|
router as email_template_router,
|
||||||
@@ -89,9 +90,12 @@ app.add_middleware(RateLimiterMiddleware, valkey_url=VALKEY_URL)
|
|||||||
# Auth (proxy to consent-service)
|
# Auth (proxy to consent-service)
|
||||||
app.include_router(auth_router, prefix="/api")
|
app.include_router(auth_router, prefix="/api")
|
||||||
|
|
||||||
# RBAC (teacher / role management)
|
# RBAC (role / assignment / custom-role management)
|
||||||
app.include_router(rbac_router, prefix="/api")
|
app.include_router(rbac_router, prefix="/api")
|
||||||
|
|
||||||
|
# RBAC Teachers (teacher CRUD, listing, roles per teacher)
|
||||||
|
app.include_router(rbac_teachers_router, prefix="/api")
|
||||||
|
|
||||||
# Notifications (proxy to consent-service)
|
# Notifications (proxy to consent-service)
|
||||||
app.include_router(notification_router, prefix="/api")
|
app.include_router(notification_router, prefix="/api")
|
||||||
|
|
||||||
|
|||||||
+10
-335
@@ -1,11 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
RBAC API - Teacher and Role Management Endpoints
|
RBAC API - Role and Assignment Management Endpoints
|
||||||
|
|
||||||
Provides API endpoints for:
|
Provides API endpoints for:
|
||||||
- Listing all teachers
|
- Listing all available roles (built-in + custom)
|
||||||
- Listing all available roles
|
- Assigning/revoking roles to users
|
||||||
- Assigning/revoking roles to teachers
|
- Role summary with assignment counts
|
||||||
- Viewing role assignments per teacher
|
- Custom role CRUD
|
||||||
|
|
||||||
|
Shared infrastructure (DB pool, Pydantic models, role definitions)
|
||||||
|
used by rbac_teachers_api.py as well.
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
- Authentication: Keycloak (when configured) or local JWT
|
- Authentication: Keycloak (when configured) or local JWT
|
||||||
@@ -24,7 +27,8 @@ try:
|
|||||||
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback for standalone testing
|
# Fallback for standalone testing
|
||||||
from auth.keycloak_auth import get_current_user, TokenExpiredError, TokenInvalidError
|
from auth.keycloak_auth import TokenExpiredError, TokenInvalidError
|
||||||
|
from auth.dependencies import get_current_user
|
||||||
|
|
||||||
# Configuration from environment - NO DEFAULT SECRETS
|
# Configuration from environment - NO DEFAULT SECRETS
|
||||||
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
||||||
@@ -230,163 +234,6 @@ async def list_available_roles() -> List[RoleInfo]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/teachers")
|
|
||||||
async def list_teachers(user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
|
||||||
"""List all teachers with their current roles"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Get all teachers with their user info
|
|
||||||
teachers = await conn.fetch("""
|
|
||||||
SELECT
|
|
||||||
t.id, t.user_id, t.teacher_code, t.title,
|
|
||||||
t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
ORDER BY t.last_name, t.first_name
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Get role assignments for all teachers
|
|
||||||
role_assignments = await conn.fetch("""
|
|
||||||
SELECT user_id, role
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Build role lookup
|
|
||||||
role_lookup: Dict[str, List[str]] = {}
|
|
||||||
for ra in role_assignments:
|
|
||||||
uid = str(ra["user_id"])
|
|
||||||
if uid not in role_lookup:
|
|
||||||
role_lookup[uid] = []
|
|
||||||
role_lookup[uid].append(ra["role"])
|
|
||||||
|
|
||||||
# Build response
|
|
||||||
result = []
|
|
||||||
for t in teachers:
|
|
||||||
uid = str(t["user_id"])
|
|
||||||
result.append(TeacherResponse(
|
|
||||||
id=str(t["id"]),
|
|
||||||
user_id=uid,
|
|
||||||
email=t["email"],
|
|
||||||
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
|
||||||
teacher_code=t["teacher_code"],
|
|
||||||
title=t["title"],
|
|
||||||
first_name=t["first_name"],
|
|
||||||
last_name=t["last_name"],
|
|
||||||
is_active=t["is_active"],
|
|
||||||
roles=role_lookup.get(uid, [])
|
|
||||||
))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/teachers/{teacher_id}/roles")
|
|
||||||
async def get_teacher_roles(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleAssignmentResponse]:
|
|
||||||
"""Get all role assignments for a specific teacher"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Get teacher's user_id
|
|
||||||
teacher = await conn.fetchrow(
|
|
||||||
"SELECT user_id FROM teachers WHERE id = $1",
|
|
||||||
teacher_id
|
|
||||||
)
|
|
||||||
if not teacher:
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
# Get role assignments
|
|
||||||
assignments = await conn.fetch("""
|
|
||||||
SELECT id, user_id, role, resource_type, resource_id,
|
|
||||||
valid_from, valid_to, granted_at, revoked_at
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE user_id = $1
|
|
||||||
ORDER BY granted_at DESC
|
|
||||||
""", teacher["user_id"])
|
|
||||||
|
|
||||||
return [
|
|
||||||
RoleAssignmentResponse(
|
|
||||||
id=str(a["id"]),
|
|
||||||
user_id=str(a["user_id"]),
|
|
||||||
role=a["role"],
|
|
||||||
resource_type=a["resource_type"],
|
|
||||||
resource_id=str(a["resource_id"]),
|
|
||||||
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
|
||||||
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
|
||||||
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
|
||||||
is_active=a["revoked_at"] is None and (
|
|
||||||
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for a in assignments
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/roles/{role}/teachers")
|
|
||||||
async def get_teachers_by_role(role: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
|
||||||
"""Get all teachers with a specific role"""
|
|
||||||
if role not in AVAILABLE_ROLES:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
|
||||||
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
teachers = await conn.fetch("""
|
|
||||||
SELECT DISTINCT
|
|
||||||
t.id, t.user_id, t.teacher_code, t.title,
|
|
||||||
t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
JOIN role_assignments ra ON t.user_id = ra.user_id
|
|
||||||
WHERE ra.role = $1
|
|
||||||
AND ra.revoked_at IS NULL
|
|
||||||
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
|
||||||
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
ORDER BY t.last_name, t.first_name
|
|
||||||
""", role)
|
|
||||||
|
|
||||||
# Get all roles for these teachers
|
|
||||||
if teachers:
|
|
||||||
user_ids = [t["user_id"] for t in teachers]
|
|
||||||
role_assignments = await conn.fetch("""
|
|
||||||
SELECT user_id, role
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE user_id = ANY($1)
|
|
||||||
AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""", user_ids)
|
|
||||||
|
|
||||||
role_lookup: Dict[str, List[str]] = {}
|
|
||||||
for ra in role_assignments:
|
|
||||||
uid = str(ra["user_id"])
|
|
||||||
if uid not in role_lookup:
|
|
||||||
role_lookup[uid] = []
|
|
||||||
role_lookup[uid].append(ra["role"])
|
|
||||||
else:
|
|
||||||
role_lookup = {}
|
|
||||||
|
|
||||||
return [
|
|
||||||
TeacherResponse(
|
|
||||||
id=str(t["id"]),
|
|
||||||
user_id=str(t["user_id"]),
|
|
||||||
email=t["email"],
|
|
||||||
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
|
||||||
teacher_code=t["teacher_code"],
|
|
||||||
title=t["title"],
|
|
||||||
first_name=t["first_name"],
|
|
||||||
last_name=t["last_name"],
|
|
||||||
is_active=t["is_active"],
|
|
||||||
roles=role_lookup.get(str(t["user_id"]), [])
|
|
||||||
)
|
|
||||||
for t in teachers
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/assignments")
|
@router.post("/assignments")
|
||||||
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
||||||
"""Assign a role to a user"""
|
"""Assign a role to a user"""
|
||||||
@@ -519,178 +366,6 @@ async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# TEACHER MANAGEMENT ENDPOINTS
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
@router.post("/teachers")
|
|
||||||
async def create_teacher(teacher: TeacherCreate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
|
||||||
"""Create a new teacher with optional initial roles"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Check if email already exists
|
|
||||||
existing = await conn.fetchrow(
|
|
||||||
"SELECT id FROM users WHERE email = $1",
|
|
||||||
teacher.email
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=409, detail="Email already exists")
|
|
||||||
|
|
||||||
# Generate UUIDs
|
|
||||||
user_id = str(uuid.uuid4())
|
|
||||||
teacher_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Create user first
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
|
||||||
VALUES ($1, $2, $3, '', 'teacher', true)
|
|
||||||
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
|
||||||
|
|
||||||
# Create teacher record
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
|
||||||
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
|
||||||
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
|
||||||
teacher.teacher_code, teacher.title)
|
|
||||||
|
|
||||||
# Assign initial roles if provided
|
|
||||||
assigned_roles = []
|
|
||||||
for role in teacher.roles:
|
|
||||||
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
|
||||||
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
|
||||||
):
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
|
||||||
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
|
||||||
'a0000000-0000-0000-0000-000000000001', $3)
|
|
||||||
""", user_id, role, user.get("user_id"))
|
|
||||||
assigned_roles.append(role)
|
|
||||||
|
|
||||||
return TeacherResponse(
|
|
||||||
id=teacher_id,
|
|
||||||
user_id=user_id,
|
|
||||||
email=teacher.email,
|
|
||||||
name=f"{teacher.first_name} {teacher.last_name}",
|
|
||||||
teacher_code=teacher.teacher_code,
|
|
||||||
title=teacher.title,
|
|
||||||
first_name=teacher.first_name,
|
|
||||||
last_name=teacher.last_name,
|
|
||||||
is_active=True,
|
|
||||||
roles=assigned_roles
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/teachers/{teacher_id}")
|
|
||||||
async def update_teacher(teacher_id: str, updates: TeacherUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
|
||||||
"""Update teacher information"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Get current teacher data
|
|
||||||
teacher = await conn.fetchrow("""
|
|
||||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
if not teacher:
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
# Build update queries
|
|
||||||
if updates.email:
|
|
||||||
await conn.execute("UPDATE users SET email = $1 WHERE id = $2",
|
|
||||||
updates.email, teacher["user_id"])
|
|
||||||
|
|
||||||
teacher_updates = []
|
|
||||||
teacher_values = []
|
|
||||||
idx = 1
|
|
||||||
|
|
||||||
if updates.first_name:
|
|
||||||
teacher_updates.append(f"first_name = ${idx}")
|
|
||||||
teacher_values.append(updates.first_name)
|
|
||||||
idx += 1
|
|
||||||
if updates.last_name:
|
|
||||||
teacher_updates.append(f"last_name = ${idx}")
|
|
||||||
teacher_values.append(updates.last_name)
|
|
||||||
idx += 1
|
|
||||||
if updates.teacher_code is not None:
|
|
||||||
teacher_updates.append(f"teacher_code = ${idx}")
|
|
||||||
teacher_values.append(updates.teacher_code)
|
|
||||||
idx += 1
|
|
||||||
if updates.title is not None:
|
|
||||||
teacher_updates.append(f"title = ${idx}")
|
|
||||||
teacher_values.append(updates.title)
|
|
||||||
idx += 1
|
|
||||||
if updates.is_active is not None:
|
|
||||||
teacher_updates.append(f"is_active = ${idx}")
|
|
||||||
teacher_values.append(updates.is_active)
|
|
||||||
idx += 1
|
|
||||||
|
|
||||||
if teacher_updates:
|
|
||||||
teacher_values.append(teacher_id)
|
|
||||||
await conn.execute(
|
|
||||||
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
|
||||||
*teacher_values
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update user name if first/last name changed
|
|
||||||
if updates.first_name or updates.last_name:
|
|
||||||
new_first = updates.first_name or teacher["first_name"]
|
|
||||||
new_last = updates.last_name or teacher["last_name"]
|
|
||||||
await conn.execute("UPDATE users SET name = $1 WHERE id = $2",
|
|
||||||
f"{new_first} {new_last}", teacher["user_id"])
|
|
||||||
|
|
||||||
# Fetch updated data
|
|
||||||
updated = await conn.fetchrow("""
|
|
||||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
# Get roles
|
|
||||||
roles = await conn.fetch("""
|
|
||||||
SELECT role FROM role_assignments
|
|
||||||
WHERE user_id = $1 AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""", updated["user_id"])
|
|
||||||
|
|
||||||
return TeacherResponse(
|
|
||||||
id=str(updated["id"]),
|
|
||||||
user_id=str(updated["user_id"]),
|
|
||||||
email=updated["email"],
|
|
||||||
name=updated["name"],
|
|
||||||
teacher_code=updated["teacher_code"],
|
|
||||||
title=updated["title"],
|
|
||||||
first_name=updated["first_name"],
|
|
||||||
last_name=updated["last_name"],
|
|
||||||
is_active=updated["is_active"],
|
|
||||||
roles=[r["role"] for r in roles]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/teachers/{teacher_id}")
|
|
||||||
async def deactivate_teacher(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
"""Deactivate a teacher (soft delete)"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
result = await conn.execute("""
|
|
||||||
UPDATE teachers SET is_active = false WHERE id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
if result == "UPDATE 0":
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
return {"status": "deactivated", "teacher_id": teacher_id}
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
RBAC Teachers API - Teacher Management Endpoints
|
||||||
|
|
||||||
|
Provides API endpoints for:
|
||||||
|
- Listing all teachers with roles
|
||||||
|
- Getting teacher roles
|
||||||
|
- Getting teachers by role
|
||||||
|
- Creating, updating, deactivating teachers
|
||||||
|
|
||||||
|
Split from rbac_api.py for file-size compliance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
|
from rbac_api import (
|
||||||
|
get_pool,
|
||||||
|
get_current_user,
|
||||||
|
TeacherCreate,
|
||||||
|
TeacherUpdate,
|
||||||
|
TeacherResponse,
|
||||||
|
RoleAssignmentResponse,
|
||||||
|
AVAILABLE_ROLES,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rbac", tags=["rbac"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_teacher_response(teacher_row, roles: List[str]) -> TeacherResponse:
|
||||||
|
"""Build a TeacherResponse from a DB row and a list of role strings."""
|
||||||
|
return TeacherResponse(
|
||||||
|
id=str(teacher_row["id"]),
|
||||||
|
user_id=str(teacher_row["user_id"]),
|
||||||
|
email=teacher_row["email"],
|
||||||
|
name=teacher_row["name"] or f"{teacher_row['first_name']} {teacher_row['last_name']}",
|
||||||
|
teacher_code=teacher_row["teacher_code"],
|
||||||
|
title=teacher_row["title"],
|
||||||
|
first_name=teacher_row["first_name"],
|
||||||
|
last_name=teacher_row["last_name"],
|
||||||
|
is_active=teacher_row["is_active"],
|
||||||
|
roles=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_role_lookup(role_assignments) -> Dict[str, List[str]]:
|
||||||
|
"""Build a user_id -> [roles] lookup from role assignment rows."""
|
||||||
|
role_lookup: Dict[str, List[str]] = {}
|
||||||
|
for ra in role_assignments:
|
||||||
|
uid = str(ra["user_id"])
|
||||||
|
if uid not in role_lookup:
|
||||||
|
role_lookup[uid] = []
|
||||||
|
role_lookup[uid].append(ra["role"])
|
||||||
|
return role_lookup
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# TEACHER LISTING / QUERY ENDPOINTS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/teachers")
|
||||||
|
async def list_teachers(
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> List[TeacherResponse]:
|
||||||
|
"""List all teachers with their current roles"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teachers = await conn.fetch("""
|
||||||
|
SELECT
|
||||||
|
t.id, t.user_id, t.teacher_code, t.title,
|
||||||
|
t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
ORDER BY t.last_name, t.first_name
|
||||||
|
""")
|
||||||
|
|
||||||
|
role_assignments = await conn.fetch("""
|
||||||
|
SELECT user_id, role
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""")
|
||||||
|
|
||||||
|
role_lookup = _build_role_lookup(role_assignments)
|
||||||
|
|
||||||
|
return [
|
||||||
|
_build_teacher_response(t, role_lookup.get(str(t["user_id"]), []))
|
||||||
|
for t in teachers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/teachers/{teacher_id}/roles")
|
||||||
|
async def get_teacher_roles(
|
||||||
|
teacher_id: str,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> List[RoleAssignmentResponse]:
|
||||||
|
"""Get all role assignments for a specific teacher"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teacher = await conn.fetchrow(
|
||||||
|
"SELECT user_id FROM teachers WHERE id = $1",
|
||||||
|
teacher_id,
|
||||||
|
)
|
||||||
|
if not teacher:
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
assignments = await conn.fetch("""
|
||||||
|
SELECT id, user_id, role, resource_type, resource_id,
|
||||||
|
valid_from, valid_to, granted_at, revoked_at
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY granted_at DESC
|
||||||
|
""", teacher["user_id"])
|
||||||
|
|
||||||
|
return [
|
||||||
|
RoleAssignmentResponse(
|
||||||
|
id=str(a["id"]),
|
||||||
|
user_id=str(a["user_id"]),
|
||||||
|
role=a["role"],
|
||||||
|
resource_type=a["resource_type"],
|
||||||
|
resource_id=str(a["resource_id"]),
|
||||||
|
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
||||||
|
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
||||||
|
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
||||||
|
is_active=a["revoked_at"] is None and (
|
||||||
|
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for a in assignments
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles/{role}/teachers")
|
||||||
|
async def get_teachers_by_role(
|
||||||
|
role: str,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> List[TeacherResponse]:
|
||||||
|
"""Get all teachers with a specific role"""
|
||||||
|
if role not in AVAILABLE_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teachers = await conn.fetch("""
|
||||||
|
SELECT DISTINCT
|
||||||
|
t.id, t.user_id, t.teacher_code, t.title,
|
||||||
|
t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
JOIN role_assignments ra ON t.user_id = ra.user_id
|
||||||
|
WHERE ra.role = $1
|
||||||
|
AND ra.revoked_at IS NULL
|
||||||
|
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
||||||
|
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
ORDER BY t.last_name, t.first_name
|
||||||
|
""", role)
|
||||||
|
|
||||||
|
if teachers:
|
||||||
|
user_ids = [t["user_id"] for t in teachers]
|
||||||
|
role_assignments = await conn.fetch("""
|
||||||
|
SELECT user_id, role
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE user_id = ANY($1)
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""", user_ids)
|
||||||
|
role_lookup = _build_role_lookup(role_assignments)
|
||||||
|
else:
|
||||||
|
role_lookup = {}
|
||||||
|
|
||||||
|
return [
|
||||||
|
_build_teacher_response(t, role_lookup.get(str(t["user_id"]), []))
|
||||||
|
for t in teachers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# TEACHER CRUD ENDPOINTS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/teachers")
|
||||||
|
async def create_teacher(
|
||||||
|
teacher: TeacherCreate,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> TeacherResponse:
|
||||||
|
"""Create a new teacher with optional initial roles"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT id FROM users WHERE email = $1",
|
||||||
|
teacher.email,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Email already exists")
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
teacher_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
||||||
|
VALUES ($1, $2, $3, '', 'teacher', true)
|
||||||
|
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
||||||
|
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
||||||
|
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
||||||
|
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
||||||
|
teacher.teacher_code, teacher.title)
|
||||||
|
|
||||||
|
assigned_roles = []
|
||||||
|
for role in teacher.roles:
|
||||||
|
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
||||||
|
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
||||||
|
):
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
||||||
|
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'a0000000-0000-0000-0000-000000000001', $3)
|
||||||
|
""", user_id, role, user.get("user_id"))
|
||||||
|
assigned_roles.append(role)
|
||||||
|
|
||||||
|
return TeacherResponse(
|
||||||
|
id=teacher_id,
|
||||||
|
user_id=user_id,
|
||||||
|
email=teacher.email,
|
||||||
|
name=f"{teacher.first_name} {teacher.last_name}",
|
||||||
|
teacher_code=teacher.teacher_code,
|
||||||
|
title=teacher.title,
|
||||||
|
first_name=teacher.first_name,
|
||||||
|
last_name=teacher.last_name,
|
||||||
|
is_active=True,
|
||||||
|
roles=assigned_roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/teachers/{teacher_id}")
|
||||||
|
async def update_teacher(
|
||||||
|
teacher_id: str,
|
||||||
|
updates: TeacherUpdate,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> TeacherResponse:
|
||||||
|
"""Update teacher information"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teacher = await conn.fetchrow("""
|
||||||
|
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
if not teacher:
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
if updates.email:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET email = $1 WHERE id = $2",
|
||||||
|
updates.email, teacher["user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
teacher_updates = []
|
||||||
|
teacher_values = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if updates.first_name:
|
||||||
|
teacher_updates.append(f"first_name = ${idx}")
|
||||||
|
teacher_values.append(updates.first_name)
|
||||||
|
idx += 1
|
||||||
|
if updates.last_name:
|
||||||
|
teacher_updates.append(f"last_name = ${idx}")
|
||||||
|
teacher_values.append(updates.last_name)
|
||||||
|
idx += 1
|
||||||
|
if updates.teacher_code is not None:
|
||||||
|
teacher_updates.append(f"teacher_code = ${idx}")
|
||||||
|
teacher_values.append(updates.teacher_code)
|
||||||
|
idx += 1
|
||||||
|
if updates.title is not None:
|
||||||
|
teacher_updates.append(f"title = ${idx}")
|
||||||
|
teacher_values.append(updates.title)
|
||||||
|
idx += 1
|
||||||
|
if updates.is_active is not None:
|
||||||
|
teacher_updates.append(f"is_active = ${idx}")
|
||||||
|
teacher_values.append(updates.is_active)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if teacher_updates:
|
||||||
|
teacher_values.append(teacher_id)
|
||||||
|
await conn.execute(
|
||||||
|
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
||||||
|
*teacher_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
if updates.first_name or updates.last_name:
|
||||||
|
new_first = updates.first_name or teacher["first_name"]
|
||||||
|
new_last = updates.last_name or teacher["last_name"]
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET name = $1 WHERE id = $2",
|
||||||
|
f"{new_first} {new_last}", teacher["user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await conn.fetchrow("""
|
||||||
|
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
roles = await conn.fetch("""
|
||||||
|
SELECT role FROM role_assignments
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""", updated["user_id"])
|
||||||
|
|
||||||
|
return TeacherResponse(
|
||||||
|
id=str(updated["id"]),
|
||||||
|
user_id=str(updated["user_id"]),
|
||||||
|
email=updated["email"],
|
||||||
|
name=updated["name"],
|
||||||
|
teacher_code=updated["teacher_code"],
|
||||||
|
title=updated["title"],
|
||||||
|
first_name=updated["first_name"],
|
||||||
|
last_name=updated["last_name"],
|
||||||
|
is_active=updated["is_active"],
|
||||||
|
roles=[r["role"] for r in roles],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/teachers/{teacher_id}")
|
||||||
|
async def deactivate_teacher(
|
||||||
|
teacher_id: str,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Deactivate a teacher (soft delete)"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute("""
|
||||||
|
UPDATE teachers SET is_active = false WHERE id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
if result == "UPDATE 0":
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
return {"status": "deactivated", "teacher_id": teacher_id}
|
||||||
+126
-750
@@ -13,312 +13,47 @@ Features:
|
|||||||
- Fuehrt Security-Scans via subprocess aus
|
- Fuehrt Security-Scans via subprocess aus
|
||||||
- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports
|
- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports
|
||||||
- Generiert SBOM mit Syft
|
- Generiert SBOM mit Syft
|
||||||
|
|
||||||
|
Split structure:
|
||||||
|
- security_models.py — Pydantic models
|
||||||
|
- security_report_parsers.py — Report parsing, tool detection, aggregation
|
||||||
|
- security_mock_data.py — Mock data generators + /demo/* endpoints
|
||||||
|
- security_monitoring.py — /monitoring/* endpoints (logs, metrics, containers)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import asyncio
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from typing import List, Optional
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
from pydantic import BaseModel
|
|
||||||
|
from security_models import (
|
||||||
|
ToolStatus,
|
||||||
|
Finding,
|
||||||
|
SeveritySummary,
|
||||||
|
HistoryItem,
|
||||||
|
)
|
||||||
|
from security_report_parsers import (
|
||||||
|
REPORTS_DIR,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
check_tool_installed,
|
||||||
|
get_latest_report,
|
||||||
|
get_all_findings,
|
||||||
|
calculate_summary,
|
||||||
|
)
|
||||||
|
from security_mock_data import (
|
||||||
|
get_mock_findings,
|
||||||
|
get_mock_sbom_data,
|
||||||
|
get_mock_history,
|
||||||
|
router as mock_data_router,
|
||||||
|
)
|
||||||
|
from security_monitoring import router as monitoring_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/security", tags=["Security"])
|
router = APIRouter(prefix="/v1/security", tags=["Security"])
|
||||||
|
|
||||||
# Pfade - innerhalb des Backend-Verzeichnisses
|
# Include sub-routers (they share the same prefix/tags)
|
||||||
# In Docker: /app/security-reports, /app/scripts
|
router.include_router(mock_data_router, prefix="", tags=["Security"])
|
||||||
# Lokal: backend/security-reports, backend/scripts
|
router.include_router(monitoring_router, prefix="", tags=["Security"])
|
||||||
BACKEND_DIR = Path(__file__).parent
|
|
||||||
REPORTS_DIR = BACKEND_DIR / "security-reports"
|
|
||||||
SCRIPTS_DIR = BACKEND_DIR / "scripts"
|
|
||||||
|
|
||||||
# Projekt-Root fuer Security-Scans
|
|
||||||
PROJECT_ROOT = BACKEND_DIR
|
|
||||||
|
|
||||||
# Sicherstellen, dass das Reports-Verzeichnis existiert
|
|
||||||
try:
|
|
||||||
REPORTS_DIR.mkdir(exist_ok=True)
|
|
||||||
except PermissionError:
|
|
||||||
# Falls keine Schreibrechte, verwende tmp-Verzeichnis
|
|
||||||
REPORTS_DIR = Path("/tmp/security-reports")
|
|
||||||
REPORTS_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Pydantic Models
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
class ToolStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
installed: bool
|
|
||||||
version: Optional[str] = None
|
|
||||||
last_run: Optional[str] = None
|
|
||||||
last_findings: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Finding(BaseModel):
|
|
||||||
id: str
|
|
||||||
tool: str
|
|
||||||
severity: str
|
|
||||||
title: str
|
|
||||||
message: Optional[str] = None
|
|
||||||
file: Optional[str] = None
|
|
||||||
line: Optional[int] = None
|
|
||||||
found_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class SeveritySummary(BaseModel):
|
|
||||||
critical: int = 0
|
|
||||||
high: int = 0
|
|
||||||
medium: int = 0
|
|
||||||
low: int = 0
|
|
||||||
info: int = 0
|
|
||||||
total: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ScanResult(BaseModel):
|
|
||||||
tool: str
|
|
||||||
status: str
|
|
||||||
started_at: str
|
|
||||||
completed_at: Optional[str] = None
|
|
||||||
findings_count: int = 0
|
|
||||||
report_path: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HistoryItem(BaseModel):
|
|
||||||
timestamp: str
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
status: str # success, warning, error
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Utility Functions
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]:
|
|
||||||
"""Prueft, ob ein Tool installiert ist und gibt die Version zurueck."""
|
|
||||||
try:
|
|
||||||
if tool_name == "gitleaks":
|
|
||||||
result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip()
|
|
||||||
elif tool_name == "semgrep":
|
|
||||||
result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "bandit":
|
|
||||||
result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip()
|
|
||||||
elif tool_name == "trivy":
|
|
||||||
result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
# Parse "Version: 0.48.x"
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if line.startswith('Version:'):
|
|
||||||
return True, line.split(':')[1].strip()
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "grype":
|
|
||||||
result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "syft":
|
|
||||||
result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_report(tool_prefix: str) -> Optional[Path]:
|
|
||||||
"""Findet den neuesten Report fuer ein Tool."""
|
|
||||||
if not REPORTS_DIR.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json"))
|
|
||||||
if not reports:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return max(reports, key=lambda p: p.stat().st_mtime)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_gitleaks_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Gitleaks JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if isinstance(data, list):
|
|
||||||
for item in data:
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("Fingerprint", "unknown"),
|
|
||||||
tool="gitleaks",
|
|
||||||
severity="HIGH", # Secrets sind immer kritisch
|
|
||||||
title=item.get("Description", "Secret detected"),
|
|
||||||
message=f"Rule: {item.get('RuleID', 'unknown')}",
|
|
||||||
file=item.get("File", ""),
|
|
||||||
line=item.get("StartLine", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_semgrep_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Semgrep JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("results", [])
|
|
||||||
for item in results:
|
|
||||||
severity = item.get("extra", {}).get("severity", "INFO").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("check_id", "unknown"),
|
|
||||||
tool="semgrep",
|
|
||||||
severity=severity,
|
|
||||||
title=item.get("extra", {}).get("message", "Finding"),
|
|
||||||
message=item.get("check_id", ""),
|
|
||||||
file=item.get("path", ""),
|
|
||||||
line=item.get("start", {}).get("line", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_bandit_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Bandit JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("results", [])
|
|
||||||
for item in results:
|
|
||||||
severity = item.get("issue_severity", "LOW").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("test_id", "unknown"),
|
|
||||||
tool="bandit",
|
|
||||||
severity=severity,
|
|
||||||
title=item.get("issue_text", "Finding"),
|
|
||||||
message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}",
|
|
||||||
file=item.get("filename", ""),
|
|
||||||
line=item.get("line_number", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_trivy_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Trivy JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("Results", [])
|
|
||||||
for result in results:
|
|
||||||
vulnerabilities = result.get("Vulnerabilities", []) or []
|
|
||||||
target = result.get("Target", "")
|
|
||||||
for vuln in vulnerabilities:
|
|
||||||
severity = vuln.get("Severity", "UNKNOWN").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=vuln.get("VulnerabilityID", "unknown"),
|
|
||||||
tool="trivy",
|
|
||||||
severity=severity,
|
|
||||||
title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")),
|
|
||||||
message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}",
|
|
||||||
file=target,
|
|
||||||
line=None,
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_grype_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Grype JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
matches = data.get("matches", [])
|
|
||||||
for match in matches:
|
|
||||||
vuln = match.get("vulnerability", {})
|
|
||||||
artifact = match.get("artifact", {})
|
|
||||||
severity = vuln.get("severity", "Unknown").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=vuln.get("id", "unknown"),
|
|
||||||
tool="grype",
|
|
||||||
severity=severity,
|
|
||||||
title=vuln.get("description", vuln.get("id", "CVE"))[:100],
|
|
||||||
message=f"{artifact.get('name', '')} {artifact.get('version', '')}",
|
|
||||||
file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "",
|
|
||||||
line=None,
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_findings() -> List[Finding]:
|
|
||||||
"""Sammelt alle Findings aus allen Reports."""
|
|
||||||
findings = []
|
|
||||||
|
|
||||||
# Gitleaks
|
|
||||||
gitleaks_report = get_latest_report("gitleaks")
|
|
||||||
if gitleaks_report:
|
|
||||||
findings.extend(parse_gitleaks_report(gitleaks_report))
|
|
||||||
|
|
||||||
# Semgrep
|
|
||||||
semgrep_report = get_latest_report("semgrep")
|
|
||||||
if semgrep_report:
|
|
||||||
findings.extend(parse_semgrep_report(semgrep_report))
|
|
||||||
|
|
||||||
# Bandit
|
|
||||||
bandit_report = get_latest_report("bandit")
|
|
||||||
if bandit_report:
|
|
||||||
findings.extend(parse_bandit_report(bandit_report))
|
|
||||||
|
|
||||||
# Trivy (filesystem)
|
|
||||||
trivy_fs_report = get_latest_report("trivy-fs")
|
|
||||||
if trivy_fs_report:
|
|
||||||
findings.extend(parse_trivy_report(trivy_fs_report))
|
|
||||||
|
|
||||||
# Grype
|
|
||||||
grype_report = get_latest_report("grype")
|
|
||||||
if grype_report:
|
|
||||||
findings.extend(parse_grype_report(grype_report))
|
|
||||||
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_summary(findings: List[Finding]) -> SeveritySummary:
|
|
||||||
"""Berechnet die Severity-Zusammenfassung."""
|
|
||||||
summary = SeveritySummary()
|
|
||||||
for finding in findings:
|
|
||||||
severity = finding.severity.upper()
|
|
||||||
if severity == "CRITICAL":
|
|
||||||
summary.critical += 1
|
|
||||||
elif severity == "HIGH":
|
|
||||||
summary.high += 1
|
|
||||||
elif severity == "MEDIUM":
|
|
||||||
summary.medium += 1
|
|
||||||
elif severity == "LOW":
|
|
||||||
summary.low += 1
|
|
||||||
else:
|
|
||||||
summary.info += 1
|
|
||||||
summary.total = len(findings)
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
@@ -435,11 +170,15 @@ async def get_history(limit: int = 20):
|
|||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
findings_count = len(data)
|
findings_count = len(data)
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
findings_count = len(data.get("results", [])) or len(data.get("matches", [])) or len(data.get("Results", []))
|
findings_count = (
|
||||||
|
len(data.get("results", []))
|
||||||
|
or len(data.get("matches", []))
|
||||||
|
or len(data.get("Results", []))
|
||||||
|
)
|
||||||
|
|
||||||
if findings_count > 0:
|
if findings_count > 0:
|
||||||
status = "warning"
|
status = "warning"
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
history.append(HistoryItem(
|
history.append(HistoryItem(
|
||||||
@@ -493,97 +232,19 @@ async def run_scan(scan_type: str, background_tasks: BackgroundTasks):
|
|||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
async def run_scan_async(scan_type: str):
|
async def run_scan_async(st: str):
|
||||||
"""Fuehrt den Scan asynchron aus."""
|
"""Fuehrt den Scan asynchron aus."""
|
||||||
try:
|
try:
|
||||||
if scan_type == "secrets" or scan_type == "all":
|
if st in ("secrets", "all"):
|
||||||
# Gitleaks
|
_run_secrets_scan(timestamp)
|
||||||
installed, _ = check_tool_installed("gitleaks")
|
if st in ("sast", "all"):
|
||||||
if installed:
|
_run_sast_scan(timestamp)
|
||||||
subprocess.run(
|
if st in ("deps", "all"):
|
||||||
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
_run_deps_scan(timestamp)
|
||||||
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
if st in ("sbom", "all"):
|
||||||
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
_run_sbom_scan(timestamp)
|
||||||
"--report-format", "json"],
|
if st in ("containers", "all"):
|
||||||
capture_output=True,
|
_run_container_scan(timestamp)
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
if scan_type == "sast" or scan_type == "all":
|
|
||||||
# Semgrep
|
|
||||||
installed, _ = check_tool_installed("semgrep")
|
|
||||||
if installed:
|
|
||||||
subprocess.run(
|
|
||||||
["semgrep", "scan", "--config", "auto",
|
|
||||||
"--config", str(PROJECT_ROOT / ".semgrep.yml"),
|
|
||||||
"--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=600,
|
|
||||||
cwd=str(PROJECT_ROOT)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Bandit
|
|
||||||
installed, _ = check_tool_installed("bandit")
|
|
||||||
if installed:
|
|
||||||
subprocess.run(
|
|
||||||
["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll",
|
|
||||||
"-x", str(PROJECT_ROOT / "backend" / "tests"),
|
|
||||||
"-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
if scan_type == "deps" or scan_type == "all":
|
|
||||||
# Trivy filesystem scan
|
|
||||||
installed, _ = check_tool_installed("trivy")
|
|
||||||
if installed:
|
|
||||||
subprocess.run(
|
|
||||||
["trivy", "fs", str(PROJECT_ROOT),
|
|
||||||
"--config", str(PROJECT_ROOT / ".trivy.yaml"),
|
|
||||||
"--format", "json",
|
|
||||||
"--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=600
|
|
||||||
)
|
|
||||||
|
|
||||||
# Grype
|
|
||||||
installed, _ = check_tool_installed("grype")
|
|
||||||
if installed:
|
|
||||||
result = subprocess.run(
|
|
||||||
["grype", f"dir:{PROJECT_ROOT}", "-o", "json"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=600
|
|
||||||
)
|
|
||||||
if result.stdout:
|
|
||||||
with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f:
|
|
||||||
f.write(result.stdout)
|
|
||||||
|
|
||||||
if scan_type == "sbom" or scan_type == "all":
|
|
||||||
# Syft SBOM generation
|
|
||||||
installed, _ = check_tool_installed("syft")
|
|
||||||
if installed:
|
|
||||||
subprocess.run(
|
|
||||||
["syft", f"dir:{PROJECT_ROOT}",
|
|
||||||
"-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
if scan_type == "containers" or scan_type == "all":
|
|
||||||
# Trivy image scan
|
|
||||||
installed, _ = check_tool_installed("trivy")
|
|
||||||
if installed:
|
|
||||||
images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"]
|
|
||||||
for image in images:
|
|
||||||
subprocess.run(
|
|
||||||
["trivy", "image", image,
|
|
||||||
"--format", "json",
|
|
||||||
"--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=600
|
|
||||||
)
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -619,380 +280,95 @@ async def health_check():
|
|||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# Mock Data for Demo/Development
|
# Scan Helper Functions
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
||||||
def get_mock_sbom_data() -> Dict[str, Any]:
|
def _run_secrets_scan(timestamp: str):
|
||||||
"""Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt."""
|
"""Gitleaks scan."""
|
||||||
return {
|
installed, _ = check_tool_installed("gitleaks")
|
||||||
"bomFormat": "CycloneDX",
|
if installed:
|
||||||
"specVersion": "1.4",
|
subprocess.run(
|
||||||
"version": 1,
|
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
||||||
"metadata": {
|
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
||||||
"timestamp": datetime.now().isoformat(),
|
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
||||||
"tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}],
|
"--report-format", "json"],
|
||||||
"component": {
|
capture_output=True,
|
||||||
"type": "application",
|
timeout=300
|
||||||
"name": "breakpilot-pwa",
|
)
|
||||||
"version": "2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]},
|
|
||||||
{"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_mock_findings() -> List[Finding]:
|
def _run_sast_scan(timestamp: str):
|
||||||
"""Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden."""
|
"""Semgrep + Bandit scan."""
|
||||||
# Alle kritischen Findings wurden behoben:
|
installed, _ = check_tool_installed("semgrep")
|
||||||
# - idna >= 3.7 gepinnt (CVE-2024-3651)
|
if installed:
|
||||||
# - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27)
|
subprocess.run(
|
||||||
# - jinja2 3.1.6 installiert (CVE-2024-34064)
|
["semgrep", "scan", "--config", "auto",
|
||||||
# - .env.example Placeholders verbessert
|
"--config", str(PROJECT_ROOT / ".semgrep.yml"),
|
||||||
# - Keine shell=True Verwendung im Code
|
"--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")],
|
||||||
return [
|
capture_output=True,
|
||||||
Finding(
|
timeout=600,
|
||||||
id="info-scan-complete",
|
cwd=str(PROJECT_ROOT)
|
||||||
tool="system",
|
)
|
||||||
severity="INFO",
|
|
||||||
title="Letzte Sicherheitspruefung erfolgreich",
|
installed, _ = check_tool_installed("bandit")
|
||||||
message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.",
|
if installed:
|
||||||
file="",
|
subprocess.run(
|
||||||
line=None,
|
["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll",
|
||||||
found_at=datetime.now().isoformat()
|
"-x", str(PROJECT_ROOT / "backend" / "tests"),
|
||||||
),
|
"-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")],
|
||||||
]
|
capture_output=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_mock_history() -> List[HistoryItem]:
|
def _run_deps_scan(timestamp: str):
|
||||||
"""Generiert Mock-Scan-Historie."""
|
"""Trivy filesystem + Grype scan."""
|
||||||
base_time = datetime.now()
|
installed, _ = check_tool_installed("trivy")
|
||||||
return [
|
if installed:
|
||||||
HistoryItem(
|
subprocess.run(
|
||||||
timestamp=(base_time).isoformat(),
|
["trivy", "fs", str(PROJECT_ROOT),
|
||||||
title="Full Security Scan",
|
"--config", str(PROJECT_ROOT / ".trivy.yaml"),
|
||||||
description="7 Findings (1 High, 3 Medium, 3 Low)",
|
"--format", "json",
|
||||||
status="warning"
|
"--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")],
|
||||||
),
|
capture_output=True,
|
||||||
HistoryItem(
|
timeout=600
|
||||||
timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(),
|
)
|
||||||
title="SBOM Generation",
|
|
||||||
description="20 Components analysiert",
|
|
||||||
status="success"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(),
|
|
||||||
title="Container Scan",
|
|
||||||
description="Keine kritischen CVEs",
|
|
||||||
status="success"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-1)).isoformat(),
|
|
||||||
title="Secrets Scan",
|
|
||||||
description="1 Finding (API Key in .env.example)",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(),
|
|
||||||
title="SAST Scan",
|
|
||||||
description="3 Findings (Bandit, Semgrep)",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-2)).isoformat(),
|
|
||||||
title="Dependency Scan",
|
|
||||||
description="3 vulnerable packages",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
installed, _ = check_tool_installed("grype")
|
||||||
# ===========================
|
if installed:
|
||||||
# Demo-Mode Endpoints (with Mock Data)
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
@router.get("/demo/sbom")
|
|
||||||
async def get_demo_sbom():
|
|
||||||
"""Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar."""
|
|
||||||
# Erst echte Daten versuchen
|
|
||||||
sbom_report = get_latest_report("sbom")
|
|
||||||
if sbom_report and sbom_report.exists():
|
|
||||||
try:
|
|
||||||
with open(sbom_report) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
# Fallback zu Mock-Daten
|
|
||||||
return get_mock_sbom_data()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/findings")
|
|
||||||
async def get_demo_findings():
|
|
||||||
"""Gibt Demo-Findings zurueck wenn keine echten verfuegbar."""
|
|
||||||
# Erst echte Daten versuchen
|
|
||||||
real_findings = get_all_findings()
|
|
||||||
if real_findings:
|
|
||||||
return real_findings
|
|
||||||
# Fallback zu Mock-Daten
|
|
||||||
return get_mock_findings()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/summary")
|
|
||||||
async def get_demo_summary():
|
|
||||||
"""Gibt Demo-Summary zurueck."""
|
|
||||||
real_findings = get_all_findings()
|
|
||||||
if real_findings:
|
|
||||||
return calculate_summary(real_findings)
|
|
||||||
# Mock summary
|
|
||||||
mock_findings = get_mock_findings()
|
|
||||||
return calculate_summary(mock_findings)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/history")
|
|
||||||
async def get_demo_history():
|
|
||||||
"""Gibt Demo-Historie zurueck wenn keine echten verfuegbar."""
|
|
||||||
real_history = await get_history()
|
|
||||||
if real_history:
|
|
||||||
return real_history
|
|
||||||
return get_mock_history()
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Monitoring Endpoints
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
class LogEntry(BaseModel):
|
|
||||||
timestamp: str
|
|
||||||
level: str
|
|
||||||
service: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class MetricValue(BaseModel):
|
|
||||||
name: str
|
|
||||||
value: float
|
|
||||||
unit: str
|
|
||||||
trend: Optional[str] = None # up, down, stable
|
|
||||||
|
|
||||||
|
|
||||||
class ContainerStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
health: str
|
|
||||||
cpu_percent: float
|
|
||||||
memory_mb: float
|
|
||||||
uptime: str
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
url: str
|
|
||||||
status: str
|
|
||||||
response_time_ms: int
|
|
||||||
last_check: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/logs", response_model=List[LogEntry])
|
|
||||||
async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50):
|
|
||||||
"""Gibt Log-Eintraege zurueck (Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
services = ["backend", "consent-service", "postgres", "mailpit"]
|
|
||||||
levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"]
|
|
||||||
messages = {
|
|
||||||
"backend": [
|
|
||||||
"Request completed: GET /api/consent/health 200",
|
|
||||||
"Request completed: POST /api/auth/login 200",
|
|
||||||
"Database connection established",
|
|
||||||
"JWT token validated successfully",
|
|
||||||
"Starting background task: email_notification",
|
|
||||||
"Cache miss for key: user_session_abc123",
|
|
||||||
"Request completed: GET /api/v1/security/demo/sbom 200",
|
|
||||||
],
|
|
||||||
"consent-service": [
|
|
||||||
"Health check passed",
|
|
||||||
"Document version created: v1.2.0",
|
|
||||||
"Consent recorded for user: user-12345",
|
|
||||||
"GDPR export job started",
|
|
||||||
"Database query executed in 12ms",
|
|
||||||
],
|
|
||||||
"postgres": [
|
|
||||||
"checkpoint starting: time",
|
|
||||||
"automatic analyze of table completed",
|
|
||||||
"connection authorized: user=breakpilot",
|
|
||||||
"statement: SELECT * FROM documents WHERE...",
|
|
||||||
],
|
|
||||||
"mailpit": [
|
|
||||||
"SMTP connection from 172.18.0.3",
|
|
||||||
"Email received: Consent Confirmation",
|
|
||||||
"Message stored: id=msg-001",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
base_time = datetime.now()
|
|
||||||
|
|
||||||
for i in range(limit):
|
|
||||||
svc = random.choice(services) if not service else service
|
|
||||||
lvl = random.choice(levels) if not level else level
|
|
||||||
msg_list = messages.get(svc, messages["backend"])
|
|
||||||
msg = random.choice(msg_list)
|
|
||||||
|
|
||||||
# Add some variety to error messages
|
|
||||||
if lvl == "ERROR":
|
|
||||||
msg = random.choice([
|
|
||||||
"Connection timeout after 30s",
|
|
||||||
"Failed to parse JSON response",
|
|
||||||
"Database query failed: connection reset",
|
|
||||||
"Rate limit exceeded for IP 192.168.1.1",
|
|
||||||
])
|
|
||||||
elif lvl == "WARNING":
|
|
||||||
msg = random.choice([
|
|
||||||
"Slow query detected: 523ms",
|
|
||||||
"Memory usage above 80%",
|
|
||||||
"Retry attempt 2/3 for external API",
|
|
||||||
"Deprecated API endpoint called",
|
|
||||||
])
|
|
||||||
|
|
||||||
logs.append(LogEntry(
|
|
||||||
timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(),
|
|
||||||
level=lvl,
|
|
||||||
service=svc,
|
|
||||||
message=msg
|
|
||||||
))
|
|
||||||
|
|
||||||
# Filter
|
|
||||||
if service:
|
|
||||||
logs = [l for l in logs if l.service == service]
|
|
||||||
if level:
|
|
||||||
logs = [l for l in logs if l.level.upper() == level.upper()]
|
|
||||||
|
|
||||||
return logs[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/metrics", response_model=List[MetricValue])
|
|
||||||
async def get_metrics():
|
|
||||||
"""Gibt System-Metriken zurueck (Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
|
|
||||||
return [
|
|
||||||
MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"),
|
|
||||||
MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"),
|
|
||||||
MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"),
|
|
||||||
MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"),
|
|
||||||
MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"),
|
|
||||||
MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"),
|
|
||||||
MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/containers", response_model=List[ContainerStatus])
|
|
||||||
async def get_container_status():
|
|
||||||
"""Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
|
|
||||||
# Versuche echte Docker-Daten
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"],
|
["grype", f"dir:{PROJECT_ROOT}", "-o", "json"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5
|
timeout=600
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.stdout:
|
||||||
containers = []
|
with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f:
|
||||||
for line in result.stdout.strip().split('\n'):
|
f.write(result.stdout)
|
||||||
parts = line.split('\t')
|
|
||||||
if len(parts) >= 3:
|
|
||||||
name, status, state = parts[0], parts[1], parts[2]
|
|
||||||
# Parse uptime from status like "Up 2 hours"
|
|
||||||
uptime = status if "Up" in status else "N/A"
|
|
||||||
|
|
||||||
containers.append(ContainerStatus(
|
|
||||||
name=name,
|
|
||||||
status=state,
|
|
||||||
health="healthy" if state == "running" else "unhealthy",
|
|
||||||
cpu_percent=round(random.uniform(0.5, 15), 1),
|
|
||||||
memory_mb=round(random.uniform(50, 500), 0),
|
|
||||||
uptime=uptime
|
|
||||||
))
|
|
||||||
if containers:
|
|
||||||
return containers
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: Demo-Daten
|
|
||||||
return [
|
|
||||||
ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/services", response_model=List[ServiceStatus])
|
def _run_sbom_scan(timestamp: str):
|
||||||
async def get_service_status():
|
"""Syft SBOM generation."""
|
||||||
"""Prueft den Status aller Services (Health-Checks)."""
|
installed, _ = check_tool_installed("syft")
|
||||||
import random
|
if installed:
|
||||||
|
subprocess.run(
|
||||||
|
["syft", f"dir:{PROJECT_ROOT}",
|
||||||
|
"-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
services_to_check = [
|
|
||||||
("Backend API", "http://localhost:8000/api/consent/health"),
|
|
||||||
("Consent Service", "http://consent-service:8081/health"),
|
|
||||||
("School Service", "http://school-service:8084/health"),
|
|
||||||
("Klausur Service", "http://klausur-service:8086/health"),
|
|
||||||
]
|
|
||||||
|
|
||||||
results = []
|
def _run_container_scan(timestamp: str):
|
||||||
for name, url in services_to_check:
|
"""Trivy image scan."""
|
||||||
status = "healthy"
|
installed, _ = check_tool_installed("trivy")
|
||||||
response_time = random.randint(15, 150)
|
if installed:
|
||||||
|
images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"]
|
||||||
# Versuche echten Health-Check fuer Backend
|
for image in images:
|
||||||
if "localhost:8000" in url:
|
subprocess.run(
|
||||||
try:
|
["trivy", "image", image,
|
||||||
import httpx
|
"--format", "json",
|
||||||
async with httpx.AsyncClient() as client:
|
"--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")],
|
||||||
start = datetime.now()
|
capture_output=True,
|
||||||
response = await client.get(url, timeout=5)
|
timeout=600
|
||||||
response_time = int((datetime.now() - start).total_seconds() * 1000)
|
)
|
||||||
status = "healthy" if response.status_code == 200 else "unhealthy"
|
|
||||||
except:
|
|
||||||
status = "healthy" # Assume healthy if we're running
|
|
||||||
|
|
||||||
results.append(ServiceStatus(
|
|
||||||
name=name,
|
|
||||||
url=url,
|
|
||||||
status=status,
|
|
||||||
response_time_ms=response_time,
|
|
||||||
last_check=datetime.now().isoformat()
|
|
||||||
))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Security Mock Data & Demo Endpoints
|
||||||
|
|
||||||
|
Mock/demo data generators for the Security Dashboard.
|
||||||
|
Used as fallback when no real scan reports are available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from security_models import (
|
||||||
|
Finding,
|
||||||
|
SeveritySummary,
|
||||||
|
HistoryItem,
|
||||||
|
)
|
||||||
|
from security_report_parsers import get_all_findings, get_latest_report, calculate_summary
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Security"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Mock Data Generators
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def get_mock_sbom_data() -> Dict[str, Any]:
|
||||||
|
"""Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt."""
|
||||||
|
return {
|
||||||
|
"bomFormat": "CycloneDX",
|
||||||
|
"specVersion": "1.4",
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}],
|
||||||
|
"component": {
|
||||||
|
"type": "application",
|
||||||
|
"name": "breakpilot-pwa",
|
||||||
|
"version": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]},
|
||||||
|
{"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_mock_findings() -> List[Finding]:
|
||||||
|
"""Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden."""
|
||||||
|
# Alle kritischen Findings wurden behoben:
|
||||||
|
# - idna >= 3.7 gepinnt (CVE-2024-3651)
|
||||||
|
# - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27)
|
||||||
|
# - jinja2 3.1.6 installiert (CVE-2024-34064)
|
||||||
|
# - .env.example Placeholders verbessert
|
||||||
|
# - Keine shell=True Verwendung im Code
|
||||||
|
return [
|
||||||
|
Finding(
|
||||||
|
id="info-scan-complete",
|
||||||
|
tool="system",
|
||||||
|
severity="INFO",
|
||||||
|
title="Letzte Sicherheitspruefung erfolgreich",
|
||||||
|
message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.",
|
||||||
|
file="",
|
||||||
|
line=None,
|
||||||
|
found_at=datetime.now().isoformat()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_mock_history() -> List[HistoryItem]:
|
||||||
|
"""Generiert Mock-Scan-Historie."""
|
||||||
|
base_time = datetime.now()
|
||||||
|
return [
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time).isoformat(),
|
||||||
|
title="Full Security Scan",
|
||||||
|
description="7 Findings (1 High, 3 Medium, 3 Low)",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(),
|
||||||
|
title="SBOM Generation",
|
||||||
|
description="20 Components analysiert",
|
||||||
|
status="success"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(),
|
||||||
|
title="Container Scan",
|
||||||
|
description="Keine kritischen CVEs",
|
||||||
|
status="success"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-1)).isoformat(),
|
||||||
|
title="Secrets Scan",
|
||||||
|
description="1 Finding (API Key in .env.example)",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(),
|
||||||
|
title="SAST Scan",
|
||||||
|
description="3 Findings (Bandit, Semgrep)",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-2)).isoformat(),
|
||||||
|
title="Dependency Scan",
|
||||||
|
description="3 vulnerable packages",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Demo-Mode Endpoints (with Mock Data)
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
@router.get("/demo/sbom")
|
||||||
|
async def get_demo_sbom():
|
||||||
|
"""Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar."""
|
||||||
|
# Erst echte Daten versuchen
|
||||||
|
sbom_report = get_latest_report("sbom")
|
||||||
|
if sbom_report and sbom_report.exists():
|
||||||
|
try:
|
||||||
|
with open(sbom_report) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback zu Mock-Daten
|
||||||
|
return get_mock_sbom_data()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/findings")
|
||||||
|
async def get_demo_findings():
|
||||||
|
"""Gibt Demo-Findings zurueck wenn keine echten verfuegbar."""
|
||||||
|
# Erst echte Daten versuchen
|
||||||
|
real_findings = get_all_findings()
|
||||||
|
if real_findings:
|
||||||
|
return real_findings
|
||||||
|
# Fallback zu Mock-Daten
|
||||||
|
return get_mock_findings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/summary")
|
||||||
|
async def get_demo_summary():
|
||||||
|
"""Gibt Demo-Summary zurueck."""
|
||||||
|
real_findings = get_all_findings()
|
||||||
|
if real_findings:
|
||||||
|
return calculate_summary(real_findings)
|
||||||
|
# Mock summary
|
||||||
|
mock_findings = get_mock_findings()
|
||||||
|
return calculate_summary(mock_findings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/history")
|
||||||
|
async def get_demo_history():
|
||||||
|
"""Gibt Demo-Historie zurueck wenn keine echten verfuegbar."""
|
||||||
|
# Note: uses mock data directly instead of calling the main history endpoint
|
||||||
|
return get_mock_history()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
Security API - Shared Pydantic Models
|
||||||
|
|
||||||
|
Data models used across security_api, security_mock_data, and security_monitoring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ToolStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
installed: bool
|
||||||
|
version: Optional[str] = None
|
||||||
|
last_run: Optional[str] = None
|
||||||
|
last_findings: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Finding(BaseModel):
|
||||||
|
id: str
|
||||||
|
tool: str
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
message: Optional[str] = None
|
||||||
|
file: Optional[str] = None
|
||||||
|
line: Optional[int] = None
|
||||||
|
found_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class SeveritySummary(BaseModel):
|
||||||
|
critical: int = 0
|
||||||
|
high: int = 0
|
||||||
|
medium: int = 0
|
||||||
|
low: int = 0
|
||||||
|
info: int = 0
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResult(BaseModel):
|
||||||
|
tool: str
|
||||||
|
status: str
|
||||||
|
started_at: str
|
||||||
|
completed_at: Optional[str] = None
|
||||||
|
findings_count: int = 0
|
||||||
|
report_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryItem(BaseModel):
|
||||||
|
timestamp: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str # success, warning, error
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
Security Monitoring Endpoints
|
||||||
|
|
||||||
|
System monitoring endpoints for the Security Dashboard:
|
||||||
|
- Log viewing (demo data)
|
||||||
|
- System metrics (demo data)
|
||||||
|
- Container status (real Docker data with demo fallback)
|
||||||
|
- Service health checks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Security"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Pydantic Models
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
class LogEntry(BaseModel):
|
||||||
|
timestamp: str
|
||||||
|
level: str
|
||||||
|
service: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MetricValue(BaseModel):
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
trend: Optional[str] = None # up, down, stable
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
health: str
|
||||||
|
cpu_percent: float
|
||||||
|
memory_mb: float
|
||||||
|
uptime: str
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
status: str
|
||||||
|
response_time_ms: int
|
||||||
|
last_check: str
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Monitoring Endpoints
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
@router.get("/monitoring/logs", response_model=List[LogEntry])
|
||||||
|
async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50):
|
||||||
|
"""Gibt Log-Eintraege zurueck (Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
services = ["backend", "consent-service", "postgres", "mailpit"]
|
||||||
|
levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"]
|
||||||
|
messages = {
|
||||||
|
"backend": [
|
||||||
|
"Request completed: GET /api/consent/health 200",
|
||||||
|
"Request completed: POST /api/auth/login 200",
|
||||||
|
"Database connection established",
|
||||||
|
"JWT token validated successfully",
|
||||||
|
"Starting background task: email_notification",
|
||||||
|
"Cache miss for key: user_session_abc123",
|
||||||
|
"Request completed: GET /api/v1/security/demo/sbom 200",
|
||||||
|
],
|
||||||
|
"consent-service": [
|
||||||
|
"Health check passed",
|
||||||
|
"Document version created: v1.2.0",
|
||||||
|
"Consent recorded for user: user-12345",
|
||||||
|
"GDPR export job started",
|
||||||
|
"Database query executed in 12ms",
|
||||||
|
],
|
||||||
|
"postgres": [
|
||||||
|
"checkpoint starting: time",
|
||||||
|
"automatic analyze of table completed",
|
||||||
|
"connection authorized: user=breakpilot",
|
||||||
|
"statement: SELECT * FROM documents WHERE...",
|
||||||
|
],
|
||||||
|
"mailpit": [
|
||||||
|
"SMTP connection from 172.18.0.3",
|
||||||
|
"Email received: Consent Confirmation",
|
||||||
|
"Message stored: id=msg-001",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
base_time = datetime.now()
|
||||||
|
|
||||||
|
for i in range(limit):
|
||||||
|
svc = random.choice(services) if not service else service
|
||||||
|
lvl = random.choice(levels) if not level else level
|
||||||
|
msg_list = messages.get(svc, messages["backend"])
|
||||||
|
msg = random.choice(msg_list)
|
||||||
|
|
||||||
|
# Add some variety to error messages
|
||||||
|
if lvl == "ERROR":
|
||||||
|
msg = random.choice([
|
||||||
|
"Connection timeout after 30s",
|
||||||
|
"Failed to parse JSON response",
|
||||||
|
"Database query failed: connection reset",
|
||||||
|
"Rate limit exceeded for IP 192.168.1.1",
|
||||||
|
])
|
||||||
|
elif lvl == "WARNING":
|
||||||
|
msg = random.choice([
|
||||||
|
"Slow query detected: 523ms",
|
||||||
|
"Memory usage above 80%",
|
||||||
|
"Retry attempt 2/3 for external API",
|
||||||
|
"Deprecated API endpoint called",
|
||||||
|
])
|
||||||
|
|
||||||
|
logs.append(LogEntry(
|
||||||
|
timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(),
|
||||||
|
level=lvl,
|
||||||
|
service=svc,
|
||||||
|
message=msg
|
||||||
|
))
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
if service:
|
||||||
|
logs = [log for log in logs if log.service == service]
|
||||||
|
if level:
|
||||||
|
logs = [log for log in logs if log.level.upper() == level.upper()]
|
||||||
|
|
||||||
|
return logs[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/metrics", response_model=List[MetricValue])
|
||||||
|
async def get_metrics():
|
||||||
|
"""Gibt System-Metriken zurueck (Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
return [
|
||||||
|
MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"),
|
||||||
|
MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"),
|
||||||
|
MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"),
|
||||||
|
MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"),
|
||||||
|
MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"),
|
||||||
|
MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"),
|
||||||
|
MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/containers", response_model=List[ContainerStatus])
|
||||||
|
async def get_container_status():
|
||||||
|
"""Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
# Versuche echte Docker-Daten
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
containers = []
|
||||||
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
parts = line.split('\t')
|
||||||
|
if len(parts) >= 3:
|
||||||
|
name, status, state = parts[0], parts[1], parts[2]
|
||||||
|
# Parse uptime from status like "Up 2 hours"
|
||||||
|
uptime = status if "Up" in status else "N/A"
|
||||||
|
|
||||||
|
containers.append(ContainerStatus(
|
||||||
|
name=name,
|
||||||
|
status=state,
|
||||||
|
health="healthy" if state == "running" else "unhealthy",
|
||||||
|
cpu_percent=round(random.uniform(0.5, 15), 1),
|
||||||
|
memory_mb=round(random.uniform(50, 500), 0),
|
||||||
|
uptime=uptime
|
||||||
|
))
|
||||||
|
if containers:
|
||||||
|
return containers
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: Demo-Daten
|
||||||
|
return [
|
||||||
|
ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/services", response_model=List[ServiceStatus])
|
||||||
|
async def get_service_status():
|
||||||
|
"""Prueft den Status aller Services (Health-Checks)."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
services_to_check = [
|
||||||
|
("Backend API", "http://localhost:8000/api/consent/health"),
|
||||||
|
("Consent Service", "http://consent-service:8081/health"),
|
||||||
|
("School Service", "http://school-service:8084/health"),
|
||||||
|
("Klausur Service", "http://klausur-service:8086/health"),
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name, url in services_to_check:
|
||||||
|
status = "healthy"
|
||||||
|
response_time = random.randint(15, 150)
|
||||||
|
|
||||||
|
# Versuche echten Health-Check fuer Backend
|
||||||
|
if "localhost:8000" in url:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
start = datetime.now()
|
||||||
|
response = await client.get(url, timeout=5)
|
||||||
|
response_time = int((datetime.now() - start).total_seconds() * 1000)
|
||||||
|
status = "healthy" if response.status_code == 200 else "unhealthy"
|
||||||
|
except Exception:
|
||||||
|
status = "healthy" # Assume healthy if we're running
|
||||||
|
|
||||||
|
results.append(ServiceStatus(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
status=status,
|
||||||
|
response_time_ms=response_time,
|
||||||
|
last_check=datetime.now().isoformat()
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Security Report Parsers & Utility Functions
|
||||||
|
|
||||||
|
Parsing logic for security tool reports (Gitleaks, Semgrep, Bandit, Trivy, Grype).
|
||||||
|
Also contains shared utility functions: tool detection, report lookup, summary calculation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from security_models import Finding, SeveritySummary
|
||||||
|
|
||||||
|
|
||||||
|
# Pfade - innerhalb des Backend-Verzeichnisses
|
||||||
|
# In Docker: /app/security-reports, /app/scripts
|
||||||
|
# Lokal: backend/security-reports, backend/scripts
|
||||||
|
BACKEND_DIR = Path(__file__).parent
|
||||||
|
REPORTS_DIR = BACKEND_DIR / "security-reports"
|
||||||
|
SCRIPTS_DIR = BACKEND_DIR / "scripts"
|
||||||
|
|
||||||
|
# Projekt-Root fuer Security-Scans
|
||||||
|
PROJECT_ROOT = BACKEND_DIR
|
||||||
|
|
||||||
|
# Sicherstellen, dass das Reports-Verzeichnis existiert
|
||||||
|
try:
|
||||||
|
REPORTS_DIR.mkdir(exist_ok=True)
|
||||||
|
except PermissionError:
|
||||||
|
# Falls keine Schreibrechte, verwende tmp-Verzeichnis
|
||||||
|
REPORTS_DIR = Path("/tmp/security-reports")
|
||||||
|
REPORTS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Utility Functions
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Prueft, ob ein Tool installiert ist und gibt die Version zurueck."""
|
||||||
|
try:
|
||||||
|
if tool_name == "gitleaks":
|
||||||
|
result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
elif tool_name == "semgrep":
|
||||||
|
result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "bandit":
|
||||||
|
result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
elif tool_name == "trivy":
|
||||||
|
result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Parse "Version: 0.48.x"
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if line.startswith('Version:'):
|
||||||
|
return True, line.split(':')[1].strip()
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "grype":
|
||||||
|
result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "syft":
|
||||||
|
result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_report(tool_prefix: str) -> Optional[Path]:
|
||||||
|
"""Findet den neuesten Report fuer ein Tool."""
|
||||||
|
if not REPORTS_DIR.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json"))
|
||||||
|
if not reports:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return max(reports, key=lambda p: p.stat().st_mtime)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Report Parsers
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def parse_gitleaks_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Gitleaks JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("Fingerprint", "unknown"),
|
||||||
|
tool="gitleaks",
|
||||||
|
severity="HIGH", # Secrets sind immer kritisch
|
||||||
|
title=item.get("Description", "Secret detected"),
|
||||||
|
message=f"Rule: {item.get('RuleID', 'unknown')}",
|
||||||
|
file=item.get("File", ""),
|
||||||
|
line=item.get("StartLine", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_semgrep_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Semgrep JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("results", [])
|
||||||
|
for item in results:
|
||||||
|
severity = item.get("extra", {}).get("severity", "INFO").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("check_id", "unknown"),
|
||||||
|
tool="semgrep",
|
||||||
|
severity=severity,
|
||||||
|
title=item.get("extra", {}).get("message", "Finding"),
|
||||||
|
message=item.get("check_id", ""),
|
||||||
|
file=item.get("path", ""),
|
||||||
|
line=item.get("start", {}).get("line", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bandit_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Bandit JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("results", [])
|
||||||
|
for item in results:
|
||||||
|
severity = item.get("issue_severity", "LOW").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("test_id", "unknown"),
|
||||||
|
tool="bandit",
|
||||||
|
severity=severity,
|
||||||
|
title=item.get("issue_text", "Finding"),
|
||||||
|
message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}",
|
||||||
|
file=item.get("filename", ""),
|
||||||
|
line=item.get("line_number", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_trivy_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Trivy JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("Results", [])
|
||||||
|
for result in results:
|
||||||
|
vulnerabilities = result.get("Vulnerabilities", []) or []
|
||||||
|
target = result.get("Target", "")
|
||||||
|
for vuln in vulnerabilities:
|
||||||
|
severity = vuln.get("Severity", "UNKNOWN").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=vuln.get("VulnerabilityID", "unknown"),
|
||||||
|
tool="trivy",
|
||||||
|
severity=severity,
|
||||||
|
title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")),
|
||||||
|
message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}",
|
||||||
|
file=target,
|
||||||
|
line=None,
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_grype_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Grype JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
matches = data.get("matches", [])
|
||||||
|
for match in matches:
|
||||||
|
vuln = match.get("vulnerability", {})
|
||||||
|
artifact = match.get("artifact", {})
|
||||||
|
severity = vuln.get("severity", "Unknown").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=vuln.get("id", "unknown"),
|
||||||
|
tool="grype",
|
||||||
|
severity=severity,
|
||||||
|
title=vuln.get("description", vuln.get("id", "CVE"))[:100],
|
||||||
|
message=f"{artifact.get('name', '')} {artifact.get('version', '')}",
|
||||||
|
file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "",
|
||||||
|
line=None,
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Aggregation Functions
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def get_all_findings() -> List[Finding]:
|
||||||
|
"""Sammelt alle Findings aus allen Reports."""
|
||||||
|
findings = []
|
||||||
|
|
||||||
|
# Gitleaks
|
||||||
|
gitleaks_report = get_latest_report("gitleaks")
|
||||||
|
if gitleaks_report:
|
||||||
|
findings.extend(parse_gitleaks_report(gitleaks_report))
|
||||||
|
|
||||||
|
# Semgrep
|
||||||
|
semgrep_report = get_latest_report("semgrep")
|
||||||
|
if semgrep_report:
|
||||||
|
findings.extend(parse_semgrep_report(semgrep_report))
|
||||||
|
|
||||||
|
# Bandit
|
||||||
|
bandit_report = get_latest_report("bandit")
|
||||||
|
if bandit_report:
|
||||||
|
findings.extend(parse_bandit_report(bandit_report))
|
||||||
|
|
||||||
|
# Trivy (filesystem)
|
||||||
|
trivy_fs_report = get_latest_report("trivy-fs")
|
||||||
|
if trivy_fs_report:
|
||||||
|
findings.extend(parse_trivy_report(trivy_fs_report))
|
||||||
|
|
||||||
|
# Grype
|
||||||
|
grype_report = get_latest_report("grype")
|
||||||
|
if grype_report:
|
||||||
|
findings.extend(parse_grype_report(grype_report))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_summary(findings: List[Finding]) -> SeveritySummary:
|
||||||
|
"""Berechnet die Severity-Zusammenfassung."""
|
||||||
|
summary = SeveritySummary()
|
||||||
|
for finding in findings:
|
||||||
|
severity = finding.severity.upper()
|
||||||
|
if severity == "CRITICAL":
|
||||||
|
summary.critical += 1
|
||||||
|
elif severity == "HIGH":
|
||||||
|
summary.high += 1
|
||||||
|
elif severity == "MEDIUM":
|
||||||
|
summary.medium += 1
|
||||||
|
elif severity == "LOW":
|
||||||
|
summary.low += 1
|
||||||
|
else:
|
||||||
|
summary.info += 1
|
||||||
|
summary.total = len(findings)
|
||||||
|
return summary
|
||||||
@@ -1,83 +1,59 @@
|
|||||||
"""
|
"""
|
||||||
File Processor Service - Dokumentenverarbeitung für BreakPilot.
|
File Processor Service - Dokumentenverarbeitung fuer BreakPilot.
|
||||||
|
|
||||||
Shared Service für:
|
Shared Service fuer:
|
||||||
- OCR (Optical Character Recognition) für Handschrift und gedruckten Text
|
- OCR (Optical Character Recognition) fuer Handschrift und gedruckten Text
|
||||||
- PDF-Parsing und Textextraktion
|
- PDF-Parsing und Textextraktion
|
||||||
- Bildverarbeitung und -optimierung
|
- Bildverarbeitung und -optimierung
|
||||||
- DOCX/DOC Textextraktion
|
- DOCX/DOC Textextraktion
|
||||||
|
|
||||||
Verwendet:
|
Verwendet:
|
||||||
- PaddleOCR für deutsche Handschrift
|
- PaddleOCR fuer deutsche Handschrift (via ImageProcessor)
|
||||||
- PyMuPDF für PDF-Verarbeitung
|
- PyMuPDF fuer PDF-Verarbeitung
|
||||||
- python-docx für DOCX-Dateien
|
- python-docx fuer DOCX-Dateien
|
||||||
- OpenCV für Bildvorverarbeitung
|
- OpenCV fuer Bildvorverarbeitung (via ImageProcessor)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import io
|
import io
|
||||||
import base64
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict, Any, Tuple, Union
|
from typing import Optional, List, Dict, Any
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from .file_processor_types import (
|
||||||
|
FileType,
|
||||||
|
ProcessingMode,
|
||||||
|
ProcessedRegion,
|
||||||
|
ProcessingResult,
|
||||||
|
)
|
||||||
|
from .image_processing import ImageProcessor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
class FileType(str, Enum):
|
"FileType",
|
||||||
"""Unterstützte Dateitypen."""
|
"ProcessingMode",
|
||||||
PDF = "pdf"
|
"ProcessedRegion",
|
||||||
IMAGE = "image"
|
"ProcessingResult",
|
||||||
DOCX = "docx"
|
"FileProcessor",
|
||||||
DOC = "doc"
|
"get_file_processor",
|
||||||
TXT = "txt"
|
"process_file",
|
||||||
UNKNOWN = "unknown"
|
"extract_text_from_pdf",
|
||||||
|
"ocr_image",
|
||||||
|
"ocr_handwriting",
|
||||||
class ProcessingMode(str, Enum):
|
]
|
||||||
"""Verarbeitungsmodi."""
|
|
||||||
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
|
|
||||||
OCR_PRINTED = "ocr_printed" # Gedruckter Text
|
|
||||||
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
|
|
||||||
MIXED = "mixed" # Kombiniert OCR + Textextraktion
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessedRegion:
|
|
||||||
"""Ein erkannter Textbereich."""
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
|
|
||||||
page: int = 1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessingResult:
|
|
||||||
"""Ergebnis der Dokumentenverarbeitung."""
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
regions: List[ProcessedRegion]
|
|
||||||
page_count: int
|
|
||||||
file_type: FileType
|
|
||||||
processing_mode: ProcessingMode
|
|
||||||
metadata: Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
class FileProcessor:
|
class FileProcessor:
|
||||||
"""
|
"""
|
||||||
Zentrale Dokumentenverarbeitung für BreakPilot.
|
Zentrale Dokumentenverarbeitung fuer BreakPilot.
|
||||||
|
|
||||||
Unterstützt:
|
Unterstuetzt:
|
||||||
- Handschrifterkennung (OCR) für Klausuren
|
- Handschrifterkennung (OCR) fuer Klausuren
|
||||||
- Textextraktion aus PDFs
|
- Textextraktion aus PDFs
|
||||||
- DOCX/DOC Verarbeitung
|
- DOCX/DOC Verarbeitung
|
||||||
- Bildvorverarbeitung für bessere OCR-Ergebnisse
|
- Bildvorverarbeitung fuer bessere OCR-Ergebnisse
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
||||||
@@ -85,37 +61,18 @@ class FileProcessor:
|
|||||||
Initialisiert den File Processor.
|
Initialisiert den File Processor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ocr_lang: Sprache für OCR (default: "de" für Deutsch)
|
ocr_lang: Sprache fuer OCR (default: "de" fuer Deutsch)
|
||||||
use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung)
|
use_gpu: GPU fuer OCR nutzen (beschleunigt Verarbeitung)
|
||||||
"""
|
"""
|
||||||
self.ocr_lang = ocr_lang
|
self.ocr_lang = ocr_lang
|
||||||
self.use_gpu = use_gpu
|
self.use_gpu = use_gpu
|
||||||
self._ocr_engine = None
|
self._image_processor = ImageProcessor(ocr_lang=ocr_lang, use_gpu=use_gpu)
|
||||||
|
|
||||||
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
|
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
|
||||||
|
|
||||||
@property
|
def detect_file_type(
|
||||||
def ocr_engine(self):
|
self, file_path: str = None, file_bytes: bytes = None
|
||||||
"""Lazy-Loading des OCR-Engines."""
|
) -> FileType:
|
||||||
if self._ocr_engine is None:
|
|
||||||
self._ocr_engine = self._init_ocr_engine()
|
|
||||||
return self._ocr_engine
|
|
||||||
|
|
||||||
def _init_ocr_engine(self):
|
|
||||||
"""Initialisiert PaddleOCR oder Fallback."""
|
|
||||||
try:
|
|
||||||
from paddleocr import PaddleOCR
|
|
||||||
return PaddleOCR(
|
|
||||||
use_angle_cls=True,
|
|
||||||
lang='german', # Deutsch
|
|
||||||
use_gpu=self.use_gpu,
|
|
||||||
show_log=False
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def detect_file_type(self, file_path: str = None, file_bytes: bytes = None) -> FileType:
|
|
||||||
"""
|
"""
|
||||||
Erkennt den Dateityp.
|
Erkennt den Dateityp.
|
||||||
|
|
||||||
@@ -170,7 +127,9 @@ class FileProcessor:
|
|||||||
ProcessingResult mit extrahiertem Text und Metadaten
|
ProcessingResult mit extrahiertem Text und Metadaten
|
||||||
"""
|
"""
|
||||||
if not file_path and not file_bytes:
|
if not file_path and not file_bytes:
|
||||||
raise ValueError("Entweder file_path oder file_bytes muss angegeben werden")
|
raise ValueError(
|
||||||
|
"Entweder file_path oder file_bytes muss angegeben werden"
|
||||||
|
)
|
||||||
|
|
||||||
file_type = self.detect_file_type(file_path, file_bytes)
|
file_type = self.detect_file_type(file_path, file_bytes)
|
||||||
logger.info(f"Processing file of type: {file_type}")
|
logger.info(f"Processing file of type: {file_type}")
|
||||||
@@ -184,7 +143,7 @@ class FileProcessor:
|
|||||||
elif file_type == FileType.TXT:
|
elif file_type == FileType.TXT:
|
||||||
return self._process_txt(file_path, file_bytes)
|
return self._process_txt(file_path, file_bytes)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Nicht unterstützter Dateityp: {file_type}")
|
raise ValueError(f"Nicht unterstuetzter Dateityp: {file_type}")
|
||||||
|
|
||||||
def _process_pdf(
|
def _process_pdf(
|
||||||
self,
|
self,
|
||||||
@@ -197,7 +156,6 @@ class FileProcessor:
|
|||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
|
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
|
||||||
# Fallback: PDF als Bild behandeln
|
|
||||||
return self._process_image(file_path, file_bytes, mode)
|
return self._process_image(file_path, file_bytes, mode)
|
||||||
|
|
||||||
if file_bytes:
|
if file_bytes:
|
||||||
@@ -211,11 +169,9 @@ class FileProcessor:
|
|||||||
region_count = 0
|
region_count = 0
|
||||||
|
|
||||||
for page_num, page in enumerate(doc, start=1):
|
for page_num, page in enumerate(doc, start=1):
|
||||||
# Erst versuchen Text direkt zu extrahieren
|
|
||||||
page_text = page.get_text()
|
page_text = page.get_text()
|
||||||
|
|
||||||
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
|
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
|
||||||
# PDF enthält Text (nicht nur Bilder)
|
|
||||||
all_text.append(page_text)
|
all_text.append(page_text)
|
||||||
all_regions.append(ProcessedRegion(
|
all_regions.append(ProcessedRegion(
|
||||||
text=page_text,
|
text=page_text,
|
||||||
@@ -227,11 +183,11 @@ class FileProcessor:
|
|||||||
region_count += 1
|
region_count += 1
|
||||||
else:
|
else:
|
||||||
# Seite als Bild rendern und OCR anwenden
|
# Seite als Bild rendern und OCR anwenden
|
||||||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x Auflösung
|
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
|
||||||
img_bytes = pix.tobytes("png")
|
img_bytes = pix.tobytes("png")
|
||||||
img = Image.open(io.BytesIO(img_bytes))
|
img = Image.open(io.BytesIO(img_bytes))
|
||||||
|
|
||||||
ocr_result = self._ocr_image(img)
|
ocr_result = self._image_processor.ocr_image(img)
|
||||||
all_text.append(ocr_result["text"])
|
all_text.append(ocr_result["text"])
|
||||||
|
|
||||||
for region in ocr_result["regions"]:
|
for region in ocr_result["regions"]:
|
||||||
@@ -242,7 +198,9 @@ class FileProcessor:
|
|||||||
|
|
||||||
doc.close()
|
doc.close()
|
||||||
|
|
||||||
avg_confidence = total_confidence / region_count if region_count > 0 else 0.0
|
avg_confidence = (
|
||||||
|
total_confidence / region_count if region_count > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text="\n\n".join(all_text),
|
text="\n\n".join(all_text),
|
||||||
@@ -266,11 +224,8 @@ class FileProcessor:
|
|||||||
else:
|
else:
|
||||||
img = Image.open(file_path)
|
img = Image.open(file_path)
|
||||||
|
|
||||||
# Bildvorverarbeitung
|
processed_img = self._image_processor.preprocess_image(img)
|
||||||
processed_img = self._preprocess_image(img)
|
ocr_result = self._image_processor.ocr_image(processed_img)
|
||||||
|
|
||||||
# OCR
|
|
||||||
ocr_result = self._ocr_image(processed_img)
|
|
||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text=ocr_result["text"],
|
text=ocr_result["text"],
|
||||||
@@ -306,7 +261,6 @@ class FileProcessor:
|
|||||||
if para.text.strip():
|
if para.text.strip():
|
||||||
paragraphs.append(para.text)
|
paragraphs.append(para.text)
|
||||||
|
|
||||||
# Auch Tabellen extrahieren
|
|
||||||
for table in doc.tables:
|
for table in doc.tables:
|
||||||
for row in table.rows:
|
for row in table.rows:
|
||||||
row_text = " | ".join(cell.text for cell in row.cells)
|
row_text = " | ".join(cell.text for cell in row.cells)
|
||||||
@@ -317,12 +271,9 @@ class FileProcessor:
|
|||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text=text,
|
text=text,
|
||||||
confidence=1.0, # Direkte Textextraktion
|
confidence=1.0,
|
||||||
regions=[ProcessedRegion(
|
regions=[ProcessedRegion(
|
||||||
text=text,
|
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
|
||||||
confidence=1.0,
|
|
||||||
bbox=(0, 0, 0, 0),
|
|
||||||
page=1
|
|
||||||
)],
|
)],
|
||||||
page_count=1,
|
page_count=1,
|
||||||
file_type=FileType.DOCX,
|
file_type=FileType.DOCX,
|
||||||
@@ -346,10 +297,7 @@ class FileProcessor:
|
|||||||
text=text,
|
text=text,
|
||||||
confidence=1.0,
|
confidence=1.0,
|
||||||
regions=[ProcessedRegion(
|
regions=[ProcessedRegion(
|
||||||
text=text,
|
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
|
||||||
confidence=1.0,
|
|
||||||
bbox=(0, 0, 0, 0),
|
|
||||||
page=1
|
|
||||||
)],
|
)],
|
||||||
page_count=1,
|
page_count=1,
|
||||||
file_type=FileType.TXT,
|
file_type=FileType.TXT,
|
||||||
@@ -357,159 +305,13 @@ class FileProcessor:
|
|||||||
metadata={"source": file_path or "bytes"}
|
metadata={"source": file_path or "bytes"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _preprocess_image(self, img: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Vorverarbeitung des Bildes für bessere OCR-Ergebnisse.
|
|
||||||
|
|
||||||
- Konvertierung zu Graustufen
|
|
||||||
- Kontrastverstärkung
|
|
||||||
- Rauschunterdrückung
|
|
||||||
- Binarisierung
|
|
||||||
"""
|
|
||||||
# PIL zu OpenCV
|
|
||||||
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
|
||||||
|
|
||||||
# Zu Graustufen konvertieren
|
|
||||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
|
||||||
|
|
||||||
# Rauschunterdrückung
|
|
||||||
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
|
||||||
|
|
||||||
# Kontrastverstärkung (CLAHE)
|
|
||||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
|
||||||
enhanced = clahe.apply(denoised)
|
|
||||||
|
|
||||||
# Adaptive Binarisierung
|
|
||||||
binary = cv2.adaptiveThreshold(
|
|
||||||
enhanced,
|
|
||||||
255,
|
|
||||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
||||||
cv2.THRESH_BINARY,
|
|
||||||
11,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
|
|
||||||
# Zurück zu PIL
|
|
||||||
return Image.fromarray(binary)
|
|
||||||
|
|
||||||
def _ocr_image(self, img: Image.Image) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Führt OCR auf einem Bild aus.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mit text, confidence und regions
|
|
||||||
"""
|
|
||||||
if self.ocr_engine is None:
|
|
||||||
# Fallback wenn kein OCR-Engine verfügbar
|
|
||||||
return {
|
|
||||||
"text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"regions": []
|
|
||||||
}
|
|
||||||
|
|
||||||
# PIL zu numpy array
|
|
||||||
img_array = np.array(img)
|
|
||||||
|
|
||||||
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
|
|
||||||
if len(img_array.shape) == 2:
|
|
||||||
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
|
||||||
|
|
||||||
# OCR ausführen
|
|
||||||
result = self.ocr_engine.ocr(img_array, cls=True)
|
|
||||||
|
|
||||||
if not result or not result[0]:
|
|
||||||
return {"text": "", "confidence": 0.0, "regions": []}
|
|
||||||
|
|
||||||
all_text = []
|
|
||||||
all_regions = []
|
|
||||||
total_confidence = 0.0
|
|
||||||
|
|
||||||
for line in result[0]:
|
|
||||||
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
|
||||||
text, confidence = line[1]
|
|
||||||
|
|
||||||
# Bounding Box zu x1, y1, x2, y2 konvertieren
|
|
||||||
x_coords = [p[0] for p in bbox_points]
|
|
||||||
y_coords = [p[1] for p in bbox_points]
|
|
||||||
bbox = (
|
|
||||||
int(min(x_coords)),
|
|
||||||
int(min(y_coords)),
|
|
||||||
int(max(x_coords)),
|
|
||||||
int(max(y_coords))
|
|
||||||
)
|
|
||||||
|
|
||||||
all_text.append(text)
|
|
||||||
all_regions.append(ProcessedRegion(
|
|
||||||
text=text,
|
|
||||||
confidence=confidence,
|
|
||||||
bbox=bbox
|
|
||||||
))
|
|
||||||
total_confidence += confidence
|
|
||||||
|
|
||||||
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"text": "\n".join(all_text),
|
|
||||||
"confidence": avg_confidence,
|
|
||||||
"regions": all_regions
|
|
||||||
}
|
|
||||||
|
|
||||||
def extract_handwriting_regions(
|
def extract_handwriting_regions(
|
||||||
self,
|
self,
|
||||||
img: Image.Image,
|
img: Image.Image,
|
||||||
min_area: int = 500
|
min_area: int = 500
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""Delegate to ImageProcessor."""
|
||||||
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
|
return self._image_processor.extract_handwriting_regions(img, min_area)
|
||||||
|
|
||||||
Nützlich für Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Eingabebild
|
|
||||||
min_area: Minimale Fläche für erkannte Regionen
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Liste von Regionen mit Koordinaten und erkanntem Text
|
|
||||||
"""
|
|
||||||
# Bildvorverarbeitung
|
|
||||||
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
|
||||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
|
||||||
|
|
||||||
# Kanten erkennen
|
|
||||||
edges = cv2.Canny(gray, 50, 150)
|
|
||||||
|
|
||||||
# Morphologische Operationen zum Verbinden
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
|
|
||||||
dilated = cv2.dilate(edges, kernel, iterations=2)
|
|
||||||
|
|
||||||
# Konturen finden
|
|
||||||
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
regions = []
|
|
||||||
for contour in contours:
|
|
||||||
area = cv2.contourArea(contour)
|
|
||||||
if area < min_area:
|
|
||||||
continue
|
|
||||||
|
|
||||||
x, y, w, h = cv2.boundingRect(contour)
|
|
||||||
|
|
||||||
# Region ausschneiden
|
|
||||||
region_img = img.crop((x, y, x + w, y + h))
|
|
||||||
|
|
||||||
# OCR auf Region anwenden
|
|
||||||
ocr_result = self._ocr_image(region_img)
|
|
||||||
|
|
||||||
regions.append({
|
|
||||||
"bbox": (x, y, x + w, y + h),
|
|
||||||
"area": area,
|
|
||||||
"text": ocr_result["text"],
|
|
||||||
"confidence": ocr_result["confidence"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Nach Y-Position sortieren (oben nach unten)
|
|
||||||
regions.sort(key=lambda r: r["bbox"][1])
|
|
||||||
|
|
||||||
return regions
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton-Instanz
|
# Singleton-Instanz
|
||||||
@@ -517,7 +319,7 @@ _file_processor: Optional[FileProcessor] = None
|
|||||||
|
|
||||||
|
|
||||||
def get_file_processor() -> FileProcessor:
|
def get_file_processor() -> FileProcessor:
|
||||||
"""Gibt Singleton-Instanz des File Processors zurück."""
|
"""Gibt Singleton-Instanz des File Processors zurueck."""
|
||||||
global _file_processor
|
global _file_processor
|
||||||
if _file_processor is None:
|
if _file_processor is None:
|
||||||
_file_processor = FileProcessor()
|
_file_processor = FileProcessor()
|
||||||
@@ -530,34 +332,26 @@ def process_file(
|
|||||||
file_bytes: bytes = None,
|
file_bytes: bytes = None,
|
||||||
mode: ProcessingMode = ProcessingMode.MIXED
|
mode: ProcessingMode = ProcessingMode.MIXED
|
||||||
) -> ProcessingResult:
|
) -> ProcessingResult:
|
||||||
"""
|
"""Convenience function zum Verarbeiten einer Datei."""
|
||||||
Convenience function zum Verarbeiten einer Datei.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Pfad zur Datei
|
|
||||||
file_bytes: Dateiinhalt als Bytes
|
|
||||||
mode: Verarbeitungsmodus
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ProcessingResult
|
|
||||||
"""
|
|
||||||
processor = get_file_processor()
|
processor = get_file_processor()
|
||||||
return processor.process(file_path, file_bytes, mode)
|
return processor.process(file_path, file_bytes, mode)
|
||||||
|
|
||||||
|
|
||||||
def extract_text_from_pdf(file_path: str = None, file_bytes: bytes = None) -> str:
|
def extract_text_from_pdf(
|
||||||
|
file_path: str = None, file_bytes: bytes = None
|
||||||
|
) -> str:
|
||||||
"""Extrahiert Text aus einer PDF-Datei."""
|
"""Extrahiert Text aus einer PDF-Datei."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
|
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
|
||||||
return result.text
|
return result.text
|
||||||
|
|
||||||
|
|
||||||
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
|
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||||
"""Führt OCR auf einem Bild aus."""
|
"""Fuehrt OCR auf einem Bild aus."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
|
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
|
||||||
return result.text
|
return result.text
|
||||||
|
|
||||||
|
|
||||||
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
|
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||||
"""Führt Handschrift-OCR auf einem Bild aus."""
|
"""Fuehrt Handschrift-OCR auf einem Bild aus."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
|
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
|
||||||
return result.text
|
return result.text
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Shared types for file processing and image processing modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class FileType(str, Enum):
|
||||||
|
"""Unterstuetzte Dateitypen."""
|
||||||
|
PDF = "pdf"
|
||||||
|
IMAGE = "image"
|
||||||
|
DOCX = "docx"
|
||||||
|
DOC = "doc"
|
||||||
|
TXT = "txt"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingMode(str, Enum):
|
||||||
|
"""Verarbeitungsmodi."""
|
||||||
|
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
|
||||||
|
OCR_PRINTED = "ocr_printed" # Gedruckter Text
|
||||||
|
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
|
||||||
|
MIXED = "mixed" # Kombiniert OCR + Textextraktion
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessedRegion:
|
||||||
|
"""Ein erkannter Textbereich."""
|
||||||
|
text: str
|
||||||
|
confidence: float
|
||||||
|
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
|
||||||
|
page: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessingResult:
|
||||||
|
"""Ergebnis der Dokumentenverarbeitung."""
|
||||||
|
text: str
|
||||||
|
confidence: float
|
||||||
|
regions: List[ProcessedRegion]
|
||||||
|
page_count: int
|
||||||
|
file_type: FileType
|
||||||
|
processing_mode: ProcessingMode
|
||||||
|
metadata: Dict[str, Any]
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Image Processing and OCR Service.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Image preprocessing for better OCR results (grayscale, denoising, binarization)
|
||||||
|
- PaddleOCR integration for text recognition
|
||||||
|
- Handwriting region extraction from scanned documents
|
||||||
|
|
||||||
|
Used by FileProcessor for image and PDF-to-image OCR workflows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .file_processor_types import ProcessedRegion
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageProcessor:
|
||||||
|
"""
|
||||||
|
Image preprocessing and OCR for BreakPilot.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- PaddleOCR for German handwriting and printed text
|
||||||
|
- OpenCV-based preprocessing (denoising, CLAHE, adaptive binarization)
|
||||||
|
- Handwriting region extraction for exam correction
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
||||||
|
self.ocr_lang = ocr_lang
|
||||||
|
self.use_gpu = use_gpu
|
||||||
|
self._ocr_engine = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ocr_engine(self):
|
||||||
|
"""Lazy-Loading des OCR-Engines."""
|
||||||
|
if self._ocr_engine is None:
|
||||||
|
self._ocr_engine = self._init_ocr_engine()
|
||||||
|
return self._ocr_engine
|
||||||
|
|
||||||
|
def _init_ocr_engine(self):
|
||||||
|
"""Initialisiert PaddleOCR oder Fallback."""
|
||||||
|
try:
|
||||||
|
from paddleocr import PaddleOCR
|
||||||
|
return PaddleOCR(
|
||||||
|
use_angle_cls=True,
|
||||||
|
lang='german',
|
||||||
|
use_gpu=self.use_gpu,
|
||||||
|
show_log=False
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def preprocess_image(self, img: Image.Image) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Vorverarbeitung des Bildes fuer bessere OCR-Ergebnisse.
|
||||||
|
|
||||||
|
- Konvertierung zu Graustufen
|
||||||
|
- Kontrastverstaerkung
|
||||||
|
- Rauschunterdrueckung
|
||||||
|
- Binarisierung
|
||||||
|
"""
|
||||||
|
# PIL zu OpenCV
|
||||||
|
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
# Zu Graustufen konvertieren
|
||||||
|
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Rauschunterdrueckung
|
||||||
|
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
||||||
|
|
||||||
|
# Kontrastverstaerkung (CLAHE)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||||
|
enhanced = clahe.apply(denoised)
|
||||||
|
|
||||||
|
# Adaptive Binarisierung
|
||||||
|
binary = cv2.adaptiveThreshold(
|
||||||
|
enhanced,
|
||||||
|
255,
|
||||||
|
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||||
|
cv2.THRESH_BINARY,
|
||||||
|
11,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zurueck zu PIL
|
||||||
|
return Image.fromarray(binary)
|
||||||
|
|
||||||
|
def ocr_image(self, img: Image.Image) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fuehrt OCR auf einem Bild aus.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mit text, confidence und regions
|
||||||
|
"""
|
||||||
|
if self.ocr_engine is None:
|
||||||
|
return {
|
||||||
|
"text": "[OCR nicht verfuegbar - bitte PaddleOCR installieren]",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"regions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# PIL zu numpy array
|
||||||
|
img_array = np.array(img)
|
||||||
|
|
||||||
|
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
|
||||||
|
if len(img_array.shape) == 2:
|
||||||
|
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
||||||
|
|
||||||
|
# OCR ausfuehren
|
||||||
|
result = self.ocr_engine.ocr(img_array, cls=True)
|
||||||
|
|
||||||
|
if not result or not result[0]:
|
||||||
|
return {"text": "", "confidence": 0.0, "regions": []}
|
||||||
|
|
||||||
|
all_text = []
|
||||||
|
all_regions = []
|
||||||
|
total_confidence = 0.0
|
||||||
|
|
||||||
|
for line in result[0]:
|
||||||
|
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
||||||
|
text, confidence = line[1]
|
||||||
|
|
||||||
|
# Bounding Box zu x1, y1, x2, y2 konvertieren
|
||||||
|
x_coords = [p[0] for p in bbox_points]
|
||||||
|
y_coords = [p[1] for p in bbox_points]
|
||||||
|
bbox = (
|
||||||
|
int(min(x_coords)),
|
||||||
|
int(min(y_coords)),
|
||||||
|
int(max(x_coords)),
|
||||||
|
int(max(y_coords))
|
||||||
|
)
|
||||||
|
|
||||||
|
all_text.append(text)
|
||||||
|
all_regions.append(ProcessedRegion(
|
||||||
|
text=text,
|
||||||
|
confidence=confidence,
|
||||||
|
bbox=bbox
|
||||||
|
))
|
||||||
|
total_confidence += confidence
|
||||||
|
|
||||||
|
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": "\n".join(all_text),
|
||||||
|
"confidence": avg_confidence,
|
||||||
|
"regions": all_regions
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract_handwriting_regions(
|
||||||
|
self,
|
||||||
|
img: Image.Image,
|
||||||
|
min_area: int = 500
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
|
||||||
|
|
||||||
|
Nuetzlich fuer Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: Eingabebild
|
||||||
|
min_area: Minimale Flaeche fuer erkannte Regionen
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste von Regionen mit Koordinaten und erkanntem Text
|
||||||
|
"""
|
||||||
|
# Bildvorverarbeitung
|
||||||
|
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||||
|
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Kanten erkennen
|
||||||
|
edges = cv2.Canny(gray, 50, 150)
|
||||||
|
|
||||||
|
# Morphologische Operationen zum Verbinden
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
|
||||||
|
dilated = cv2.dilate(edges, kernel, iterations=2)
|
||||||
|
|
||||||
|
# Konturen finden
|
||||||
|
contours, _ = cv2.findContours(
|
||||||
|
dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
regions = []
|
||||||
|
for contour in contours:
|
||||||
|
area = cv2.contourArea(contour)
|
||||||
|
if area < min_area:
|
||||||
|
continue
|
||||||
|
|
||||||
|
x, y, w, h = cv2.boundingRect(contour)
|
||||||
|
|
||||||
|
# Region ausschneiden
|
||||||
|
region_img = img.crop((x, y, x + w, y + h))
|
||||||
|
|
||||||
|
# OCR auf Region anwenden
|
||||||
|
ocr_result = self.ocr_image(region_img)
|
||||||
|
|
||||||
|
regions.append({
|
||||||
|
"bbox": (x, y, x + w, y + h),
|
||||||
|
"area": area,
|
||||||
|
"text": ocr_result["text"],
|
||||||
|
"confidence": ocr_result["confidence"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Nach Y-Position sortieren (oben nach unten)
|
||||||
|
regions.sort(key=lambda r: r["bbox"][1])
|
||||||
|
|
||||||
|
return regions
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
PDF Models - Dataclasses fuer PDF-Generierung.
|
||||||
|
|
||||||
|
Enthaelt alle Datenmodelle die von PDFService und den Convenience-Funktionen
|
||||||
|
in pdf_service.py verwendet werden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SchoolInfo:
|
||||||
|
"""Schulinformationen fuer Header."""
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
phone: str
|
||||||
|
email: str
|
||||||
|
logo_path: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
principal: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LetterData:
|
||||||
|
"""Daten fuer Elternbrief-PDF."""
|
||||||
|
recipient_name: str
|
||||||
|
recipient_address: str
|
||||||
|
student_name: str
|
||||||
|
student_class: str
|
||||||
|
subject: str
|
||||||
|
content: str
|
||||||
|
date: str
|
||||||
|
teacher_name: str
|
||||||
|
teacher_title: Optional[str] = None
|
||||||
|
school_info: Optional[SchoolInfo] = None
|
||||||
|
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
||||||
|
tone: str = "professional"
|
||||||
|
legal_references: Optional[List[Dict[str, str]]] = None
|
||||||
|
gfk_principles_applied: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CertificateData:
|
||||||
|
"""Daten fuer Zeugnis-PDF."""
|
||||||
|
student_name: str
|
||||||
|
student_birthdate: str
|
||||||
|
student_class: str
|
||||||
|
school_year: str
|
||||||
|
certificate_type: str # halbjahr, jahres, abschluss
|
||||||
|
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
||||||
|
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
||||||
|
remarks: Optional[str] = None
|
||||||
|
class_teacher: str = ""
|
||||||
|
principal: str = ""
|
||||||
|
school_info: Optional[SchoolInfo] = None
|
||||||
|
issue_date: str = ""
|
||||||
|
social_behavior: Optional[str] = None # A, B, C, D
|
||||||
|
work_behavior: Optional[str] = None # A, B, C, D
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StudentInfo:
|
||||||
|
"""Schuelerinformationen fuer Korrektur-PDFs."""
|
||||||
|
student_id: str
|
||||||
|
name: str
|
||||||
|
class_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CorrectionData:
|
||||||
|
"""Daten fuer Korrektur-Uebersicht PDF."""
|
||||||
|
student: StudentInfo
|
||||||
|
exam_title: str
|
||||||
|
subject: str
|
||||||
|
date: str
|
||||||
|
max_points: int
|
||||||
|
achieved_points: int
|
||||||
|
grade: str
|
||||||
|
percentage: float
|
||||||
|
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
||||||
|
teacher_notes: str = ""
|
||||||
|
ai_feedback: str = ""
|
||||||
|
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
||||||
|
class_average: Optional[float] = None
|
||||||
@@ -1,115 +1,54 @@
|
|||||||
"""
|
"""
|
||||||
PDF Service - Zentrale PDF-Generierung für BreakPilot.
|
PDF Service - Zentrale PDF-Generierung fuer BreakPilot.
|
||||||
|
|
||||||
Shared Service für:
|
Shared Service fuer:
|
||||||
- Letters (Elternbriefe)
|
- Letters (Elternbriefe)
|
||||||
- Zeugnisse (Schulzeugnisse)
|
- Zeugnisse (Schulzeugnisse)
|
||||||
- Correction (Korrektur-Übersichten)
|
- Correction (Korrektur-Uebersichten)
|
||||||
|
|
||||||
Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates.
|
Verwendet WeasyPrint fuer PDF-Rendering und Jinja2 fuer Templates.
|
||||||
|
|
||||||
|
Datenmodelle: services/pdf_models.py
|
||||||
|
HTML-Templates: services/pdf_templates.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, List
|
from typing import Any, Dict, Optional
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from weasyprint import HTML, CSS
|
from weasyprint import HTML, CSS
|
||||||
from weasyprint.text.fonts import FontConfiguration
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
|
||||||
|
from .pdf_models import (
|
||||||
|
SchoolInfo,
|
||||||
|
LetterData,
|
||||||
|
CertificateData,
|
||||||
|
StudentInfo,
|
||||||
|
CorrectionData,
|
||||||
|
)
|
||||||
|
from .pdf_templates import (
|
||||||
|
get_base_css,
|
||||||
|
get_letter_template_html,
|
||||||
|
get_certificate_template_html,
|
||||||
|
get_correction_template_html,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Template directory
|
# Template directory
|
||||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
|
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SchoolInfo:
|
|
||||||
"""Schulinformationen für Header."""
|
|
||||||
name: str
|
|
||||||
address: str
|
|
||||||
phone: str
|
|
||||||
email: str
|
|
||||||
logo_path: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
principal: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LetterData:
|
|
||||||
"""Daten für Elternbrief-PDF."""
|
|
||||||
recipient_name: str
|
|
||||||
recipient_address: str
|
|
||||||
student_name: str
|
|
||||||
student_class: str
|
|
||||||
subject: str
|
|
||||||
content: str
|
|
||||||
date: str
|
|
||||||
teacher_name: str
|
|
||||||
teacher_title: Optional[str] = None
|
|
||||||
school_info: Optional[SchoolInfo] = None
|
|
||||||
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
|
||||||
tone: str = "professional"
|
|
||||||
legal_references: Optional[List[Dict[str, str]]] = None
|
|
||||||
gfk_principles_applied: Optional[List[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CertificateData:
|
|
||||||
"""Daten für Zeugnis-PDF."""
|
|
||||||
student_name: str
|
|
||||||
student_birthdate: str
|
|
||||||
student_class: str
|
|
||||||
school_year: str
|
|
||||||
certificate_type: str # halbjahr, jahres, abschluss
|
|
||||||
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
|
||||||
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
|
||||||
remarks: Optional[str] = None
|
|
||||||
class_teacher: str = ""
|
|
||||||
principal: str = ""
|
|
||||||
school_info: Optional[SchoolInfo] = None
|
|
||||||
issue_date: str = ""
|
|
||||||
social_behavior: Optional[str] = None # A, B, C, D
|
|
||||||
work_behavior: Optional[str] = None # A, B, C, D
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StudentInfo:
|
|
||||||
"""Schülerinformationen für Korrektur-PDFs."""
|
|
||||||
student_id: str
|
|
||||||
name: str
|
|
||||||
class_name: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CorrectionData:
|
|
||||||
"""Daten für Korrektur-Übersicht PDF."""
|
|
||||||
student: StudentInfo
|
|
||||||
exam_title: str
|
|
||||||
subject: str
|
|
||||||
date: str
|
|
||||||
max_points: int
|
|
||||||
achieved_points: int
|
|
||||||
grade: str
|
|
||||||
percentage: float
|
|
||||||
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
|
||||||
teacher_notes: str = ""
|
|
||||||
ai_feedback: str = ""
|
|
||||||
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
|
||||||
class_average: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PDFService:
|
class PDFService:
|
||||||
"""
|
"""
|
||||||
Zentrale PDF-Generierung für BreakPilot.
|
Zentrale PDF-Generierung fuer BreakPilot.
|
||||||
|
|
||||||
Unterstützt:
|
Unterstuetzt:
|
||||||
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
|
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
|
||||||
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
|
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
|
||||||
- Korrektur-Übersichten für Klausuren
|
- Korrektur-Uebersichten fuer Klausuren
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, templates_dir: Optional[Path] = None):
|
def __init__(self, templates_dir: Optional[Path] = None):
|
||||||
@@ -143,7 +82,7 @@ class PDFService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
|
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
|
||||||
"""Formatiert Datum für deutsche Darstellung."""
|
"""Formatiert Datum fuer deutsche Darstellung."""
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
@@ -154,10 +93,10 @@ class PDFService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _grade_color(grade: str) -> str:
|
def _grade_color(grade: str) -> str:
|
||||||
"""Gibt Farbe basierend auf Note zurück."""
|
"""Gibt Farbe basierend auf Note zurueck."""
|
||||||
grade_colors = {
|
grade_colors = {
|
||||||
"1": "#27ae60", # Grün
|
"1": "#27ae60", # Gruen
|
||||||
"2": "#2ecc71", # Hellgrün
|
"2": "#2ecc71", # Hellgruen
|
||||||
"3": "#f1c40f", # Gelb
|
"3": "#f1c40f", # Gelb
|
||||||
"4": "#e67e22", # Orange
|
"4": "#e67e22", # Orange
|
||||||
"5": "#e74c3c", # Rot
|
"5": "#e74c3c", # Rot
|
||||||
@@ -170,227 +109,12 @@ class PDFService:
|
|||||||
return grade_colors.get(str(grade), "#333333")
|
return grade_colors.get(str(grade), "#333333")
|
||||||
|
|
||||||
def _get_base_css(self) -> str:
|
def _get_base_css(self) -> str:
|
||||||
"""Gibt Basis-CSS für alle PDFs zurück."""
|
"""Gibt Basis-CSS fuer alle PDFs zurueck (delegiert an pdf_templates)."""
|
||||||
return """
|
return get_base_css()
|
||||||
@page {
|
|
||||||
size: A4;
|
|
||||||
margin: 2cm 2.5cm;
|
|
||||||
@top-right {
|
|
||||||
content: counter(page) " / " counter(pages);
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
|
|
||||||
font-size: 11pt;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: 16pt; }
|
|
||||||
h2 { font-size: 14pt; }
|
|
||||||
h3 { font-size: 12pt; }
|
|
||||||
|
|
||||||
.header {
|
|
||||||
border-bottom: 2px solid #2c3e50;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-name {
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-info {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter-date {
|
|
||||||
text-align: right;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipient {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subject {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
text-align: justify;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legal-references {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gfk-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #27ae60;
|
|
||||||
font-size: 8pt;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zeugnis-Styles */
|
|
||||||
.certificate-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.certificate-title {
|
|
||||||
font-size: 20pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-info {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 15px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table th,
|
|
||||||
.grades-table td {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table th {
|
|
||||||
background: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table tr:nth-child(even) {
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grade-cell {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 12pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-box {
|
|
||||||
background: #fff3cd;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signatures-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-block {
|
|
||||||
text-align: center;
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-line {
|
|
||||||
border-top: 1px solid #333;
|
|
||||||
margin-top: 40px;
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Korrektur-Styles */
|
|
||||||
.exam-header {
|
|
||||||
background: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-box {
|
|
||||||
background: #e8f5e9;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-grade {
|
|
||||||
font-size: 36pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-points {
|
|
||||||
font-size: 14pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-list {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-item {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-question {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-feedback {
|
|
||||||
background: #fff8e1;
|
|
||||||
padding: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
border-left: 3px solid #ffc107;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-table {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-table td {
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF für Elternbrief.
|
Generiert PDF fuer Elternbrief.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: LetterData mit allen Briefinformationen
|
data: LetterData mit allen Briefinformationen
|
||||||
@@ -417,7 +141,7 @@ class PDFService:
|
|||||||
|
|
||||||
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF für Schulzeugnis.
|
Generiert PDF fuer Schulzeugnis.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: CertificateData mit allen Zeugnisinformationen
|
data: CertificateData mit allen Zeugnisinformationen
|
||||||
@@ -444,7 +168,7 @@ class PDFService:
|
|||||||
|
|
||||||
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF für Korrektur-Übersicht.
|
Generiert PDF fuer Korrektur-Uebersicht.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: CorrectionData mit allen Korrekturinformationen
|
data: CorrectionData mit allen Korrekturinformationen
|
||||||
@@ -470,322 +194,29 @@ class PDFService:
|
|||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|
||||||
def _get_letter_template(self):
|
def _get_letter_template(self):
|
||||||
"""Gibt Letter-Template zurück (inline falls Datei nicht existiert)."""
|
"""Gibt Letter-Template zurueck (inline falls Datei nicht existiert)."""
|
||||||
template_path = self.templates_dir / "letter.html"
|
template_path = self.templates_dir / "letter.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("letter.html")
|
return self.jinja_env.get_template("letter.html")
|
||||||
|
|
||||||
# Inline-Template als Fallback
|
# Inline-Template als Fallback
|
||||||
return self.jinja_env.from_string(self._get_letter_template_html())
|
return self.jinja_env.from_string(get_letter_template_html())
|
||||||
|
|
||||||
def _get_certificate_template(self):
|
def _get_certificate_template(self):
|
||||||
"""Gibt Certificate-Template zurück."""
|
"""Gibt Certificate-Template zurueck."""
|
||||||
template_path = self.templates_dir / "certificate.html"
|
template_path = self.templates_dir / "certificate.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("certificate.html")
|
return self.jinja_env.get_template("certificate.html")
|
||||||
|
|
||||||
return self.jinja_env.from_string(self._get_certificate_template_html())
|
return self.jinja_env.from_string(get_certificate_template_html())
|
||||||
|
|
||||||
def _get_correction_template(self):
|
def _get_correction_template(self):
|
||||||
"""Gibt Correction-Template zurück."""
|
"""Gibt Correction-Template zurueck."""
|
||||||
template_path = self.templates_dir / "correction.html"
|
template_path = self.templates_dir / "correction.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("correction.html")
|
return self.jinja_env.get_template("correction.html")
|
||||||
|
|
||||||
return self.jinja_env.from_string(self._get_correction_template_html())
|
return self.jinja_env.from_string(get_correction_template_html())
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_letter_template_html() -> str:
|
|
||||||
"""Inline HTML-Template für Elternbriefe."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{{ data.subject }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
{% if data.school_info %}
|
|
||||||
<div class="school-name">{{ data.school_info.name }}</div>
|
|
||||||
<div class="school-info">
|
|
||||||
{{ data.school_info.address }}<br>
|
|
||||||
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
|
||||||
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="school-name">Schule</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="letter-date">
|
|
||||||
{{ data.date }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="recipient">
|
|
||||||
{{ data.recipient_name }}<br>
|
|
||||||
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subject">
|
|
||||||
Betreff: {{ data.subject }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
|
||||||
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
{{ data.content | replace('\\n', '<br>') | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.gfk_principles_applied %}
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
{% for principle in data.gfk_principles_applied %}
|
|
||||||
<span class="gfk-badge">✓ {{ principle }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="signature">
|
|
||||||
<p>Mit freundlichen Grüßen</p>
|
|
||||||
<p style="margin-top: 30px;">
|
|
||||||
{{ data.teacher_name }}
|
|
||||||
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.legal_references %}
|
|
||||||
<div class="legal-references">
|
|
||||||
<strong>Rechtliche Grundlagen:</strong><br>
|
|
||||||
{% for ref in data.legal_references %}
|
|
||||||
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
|
||||||
Erstellt mit BreakPilot | {{ generated_at }}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_certificate_template_html() -> str:
|
|
||||||
"""Inline HTML-Template für Zeugnisse."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Zeugnis - {{ data.student_name }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="certificate-header">
|
|
||||||
{% if data.school_info %}
|
|
||||||
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="certificate-title">
|
|
||||||
{% if data.certificate_type == 'halbjahr' %}
|
|
||||||
Halbjahreszeugnis
|
|
||||||
{% elif data.certificate_type == 'jahres' %}
|
|
||||||
Jahreszeugnis
|
|
||||||
{% else %}
|
|
||||||
Abschlusszeugnis
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div>Schuljahr {{ data.school_year }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="student-info">
|
|
||||||
<table style="width: 100%;">
|
|
||||||
<tr>
|
|
||||||
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
|
||||||
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
|
||||||
<td> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Leistungen</h3>
|
|
||||||
<table class="grades-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 70%;">Fach</th>
|
|
||||||
<th style="width: 15%;">Note</th>
|
|
||||||
<th style="width: 15%;">Punkte</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for subject in data.subjects %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ subject.name }}</td>
|
|
||||||
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
|
||||||
{{ subject.grade }}
|
|
||||||
</td>
|
|
||||||
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% if data.social_behavior or data.work_behavior %}
|
|
||||||
<h3>Verhalten</h3>
|
|
||||||
<table class="grades-table" style="width: 50%;">
|
|
||||||
{% if data.social_behavior %}
|
|
||||||
<tr>
|
|
||||||
<td>Sozialverhalten</td>
|
|
||||||
<td class="grade-cell">{{ data.social_behavior }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if data.work_behavior %}
|
|
||||||
<tr>
|
|
||||||
<td>Arbeitsverhalten</td>
|
|
||||||
<td class="grade-cell">{{ data.work_behavior }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="attendance-box">
|
|
||||||
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
|
||||||
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
|
||||||
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.remarks %}
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
<strong>Bemerkungen:</strong><br>
|
|
||||||
{{ data.remarks }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="margin-top: 30px;">
|
|
||||||
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="signatures-row">
|
|
||||||
<div class="signature-block">
|
|
||||||
<div class="signature-line">{{ data.class_teacher }}</div>
|
|
||||||
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
|
||||||
</div>
|
|
||||||
<div class="signature-block">
|
|
||||||
<div class="signature-line">{{ data.principal }}</div>
|
|
||||||
<div style="font-size: 9pt;">Schulleiter/in</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 40px;">
|
|
||||||
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_correction_template_html() -> str:
|
|
||||||
"""Inline HTML-Template für Korrektur-Übersichten."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Korrektur - {{ data.exam_title }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="exam-header">
|
|
||||||
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
|
||||||
<div>{{ data.subject }} | {{ data.date }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="student-info">
|
|
||||||
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-box">
|
|
||||||
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
|
||||||
Note: {{ data.grade }}
|
|
||||||
</div>
|
|
||||||
<div class="result-points">
|
|
||||||
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
|
||||||
({{ data.percentage | round(1) }}%)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Detaillierte Auswertung</h3>
|
|
||||||
<div class="corrections-list">
|
|
||||||
{% for item in data.corrections %}
|
|
||||||
<div class="correction-item">
|
|
||||||
<div class="correction-question">
|
|
||||||
{{ item.question }}
|
|
||||||
</div>
|
|
||||||
{% if item.answer %}
|
|
||||||
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
|
||||||
<strong>Antwort:</strong> {{ item.answer }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
<strong>Punkte:</strong> {{ item.points }}
|
|
||||||
</div>
|
|
||||||
{% if item.feedback %}
|
|
||||||
<div class="correction-feedback">
|
|
||||||
{{ item.feedback }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.teacher_notes %}
|
|
||||||
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
|
||||||
<strong>Lehrerkommentar:</strong><br>
|
|
||||||
{{ data.teacher_notes }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if data.ai_feedback %}
|
|
||||||
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
|
||||||
<strong>KI-Feedback:</strong><br>
|
|
||||||
{{ data.ai_feedback }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if data.class_average or data.grade_distribution %}
|
|
||||||
<h3>Klassenstatistik</h3>
|
|
||||||
<table class="stats-table">
|
|
||||||
{% if data.class_average %}
|
|
||||||
<tr>
|
|
||||||
<td><strong>Klassendurchschnitt:</strong></td>
|
|
||||||
<td>{{ data.class_average }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if data.grade_distribution %}
|
|
||||||
<tr>
|
|
||||||
<td><strong>Notenverteilung:</strong></td>
|
|
||||||
<td>
|
|
||||||
{% for grade, count in data.grade_distribution.items() %}
|
|
||||||
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="signature" style="margin-top: 40px;">
|
|
||||||
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
|
||||||
Erstellt mit BreakPilot | {{ generated_at }}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# Convenience functions for direct usage
|
# Convenience functions for direct usage
|
||||||
@@ -793,7 +224,7 @@ _pdf_service: Optional[PDFService] = None
|
|||||||
|
|
||||||
|
|
||||||
def get_pdf_service() -> PDFService:
|
def get_pdf_service() -> PDFService:
|
||||||
"""Gibt Singleton-Instanz des PDF-Service zurück."""
|
"""Gibt Singleton-Instanz des PDF-Service zurueck."""
|
||||||
global _pdf_service
|
global _pdf_service
|
||||||
if _pdf_service is None:
|
if _pdf_service is None:
|
||||||
_pdf_service = PDFService()
|
_pdf_service = PDFService()
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
"""
|
||||||
|
PDF Templates - Inline HTML-Templates und CSS fuer PDF-Generierung.
|
||||||
|
|
||||||
|
Fallback-Templates die verwendet werden wenn keine externen HTML-Dateien
|
||||||
|
im templates/pdf/ Verzeichnis vorhanden sind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_css() -> str:
|
||||||
|
"""Basis-CSS fuer alle PDFs (A4, Typografie, Komponenten-Styles)."""
|
||||||
|
return """
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 2cm 2.5cm;
|
||||||
|
@top-right {
|
||||||
|
content: counter(page) " / " counter(pages);
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 16pt; }
|
||||||
|
h2 { font-size: 14pt; }
|
||||||
|
h3 { font-size: 12pt; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
border-bottom: 2px solid #2c3e50;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-name {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-info {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-date {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
text-align: justify;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-references {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gfk-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #27ae60;
|
||||||
|
font-size: 8pt;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeugnis-Styles */
|
||||||
|
.certificate-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-title {
|
||||||
|
font-size: 20pt;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table th,
|
||||||
|
.grades-table td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table th {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table tr:nth-child(even) {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-cell {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signatures-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block {
|
||||||
|
text-align: center;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-line {
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Korrektur-Styles */
|
||||||
|
.exam-header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box {
|
||||||
|
background: #e8f5e9;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-grade {
|
||||||
|
font-size: 36pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-points {
|
||||||
|
font-size: 14pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-list {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-question {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-feedback {
|
||||||
|
background: #fff8e1;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-left: 3px solid #ffc107;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_letter_template_html() -> str:
|
||||||
|
"""Inline HTML-Template fuer Elternbriefe."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ data.subject }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
{% if data.school_info %}
|
||||||
|
<div class="school-name">{{ data.school_info.name }}</div>
|
||||||
|
<div class="school-info">
|
||||||
|
{{ data.school_info.address }}<br>
|
||||||
|
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
||||||
|
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="school-name">Schule</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="letter-date">
|
||||||
|
{{ data.date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recipient">
|
||||||
|
{{ data.recipient_name }}<br>
|
||||||
|
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subject">
|
||||||
|
Betreff: {{ data.subject }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
||||||
|
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{{ data.content | replace('\\n', '<br>') | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.gfk_principles_applied %}
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
{% for principle in data.gfk_principles_applied %}
|
||||||
|
<span class="gfk-badge">✓ {{ principle }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="signature">
|
||||||
|
<p>Mit freundlichen Grüßen</p>
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
{{ data.teacher_name }}
|
||||||
|
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.legal_references %}
|
||||||
|
<div class="legal-references">
|
||||||
|
<strong>Rechtliche Grundlagen:</strong><br>
|
||||||
|
{% for ref in data.legal_references %}
|
||||||
|
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||||
|
Erstellt mit BreakPilot | {{ generated_at }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_certificate_template_html() -> str:
|
||||||
|
"""Inline HTML-Template fuer Zeugnisse."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Zeugnis - {{ data.student_name }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate-header">
|
||||||
|
{% if data.school_info %}
|
||||||
|
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="certificate-title">
|
||||||
|
{% if data.certificate_type == 'halbjahr' %}
|
||||||
|
Halbjahreszeugnis
|
||||||
|
{% elif data.certificate_type == 'jahres' %}
|
||||||
|
Jahreszeugnis
|
||||||
|
{% else %}
|
||||||
|
Abschlusszeugnis
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>Schuljahr {{ data.school_year }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="student-info">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
||||||
|
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Leistungen</h3>
|
||||||
|
<table class="grades-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 70%;">Fach</th>
|
||||||
|
<th style="width: 15%;">Note</th>
|
||||||
|
<th style="width: 15%;">Punkte</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for subject in data.subjects %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ subject.name }}</td>
|
||||||
|
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
||||||
|
{{ subject.grade }}
|
||||||
|
</td>
|
||||||
|
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if data.social_behavior or data.work_behavior %}
|
||||||
|
<h3>Verhalten</h3>
|
||||||
|
<table class="grades-table" style="width: 50%;">
|
||||||
|
{% if data.social_behavior %}
|
||||||
|
<tr>
|
||||||
|
<td>Sozialverhalten</td>
|
||||||
|
<td class="grade-cell">{{ data.social_behavior }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.work_behavior %}
|
||||||
|
<tr>
|
||||||
|
<td>Arbeitsverhalten</td>
|
||||||
|
<td class="grade-cell">{{ data.work_behavior }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="attendance-box">
|
||||||
|
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
||||||
|
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
||||||
|
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.remarks %}
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<strong>Bemerkungen:</strong><br>
|
||||||
|
{{ data.remarks }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top: 30px;">
|
||||||
|
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signatures-row">
|
||||||
|
<div class="signature-block">
|
||||||
|
<div class="signature-line">{{ data.class_teacher }}</div>
|
||||||
|
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
||||||
|
</div>
|
||||||
|
<div class="signature-block">
|
||||||
|
<div class="signature-line">{{ data.principal }}</div>
|
||||||
|
<div style="font-size: 9pt;">Schulleiter/in</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 40px;">
|
||||||
|
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_correction_template_html() -> str:
|
||||||
|
"""Inline HTML-Template fuer Korrektur-Uebersichten."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Korrektur - {{ data.exam_title }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="exam-header">
|
||||||
|
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
||||||
|
<div>{{ data.subject }} | {{ data.date }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="student-info">
|
||||||
|
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-box">
|
||||||
|
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
||||||
|
Note: {{ data.grade }}
|
||||||
|
</div>
|
||||||
|
<div class="result-points">
|
||||||
|
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
||||||
|
({{ data.percentage | round(1) }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Detaillierte Auswertung</h3>
|
||||||
|
<div class="corrections-list">
|
||||||
|
{% for item in data.corrections %}
|
||||||
|
<div class="correction-item">
|
||||||
|
<div class="correction-question">
|
||||||
|
{{ item.question }}
|
||||||
|
</div>
|
||||||
|
{% if item.answer %}
|
||||||
|
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
||||||
|
<strong>Antwort:</strong> {{ item.answer }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<strong>Punkte:</strong> {{ item.points }}
|
||||||
|
</div>
|
||||||
|
{% if item.feedback %}
|
||||||
|
<div class="correction-feedback">
|
||||||
|
{{ item.feedback }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.teacher_notes %}
|
||||||
|
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<strong>Lehrerkommentar:</strong><br>
|
||||||
|
{{ data.teacher_notes }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.ai_feedback %}
|
||||||
|
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<strong>KI-Feedback:</strong><br>
|
||||||
|
{{ data.ai_feedback }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.class_average or data.grade_distribution %}
|
||||||
|
<h3>Klassenstatistik</h3>
|
||||||
|
<table class="stats-table">
|
||||||
|
{% if data.class_average %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Klassendurchschnitt:</strong></td>
|
||||||
|
<td>{{ data.class_average }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.grade_distribution %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Notenverteilung:</strong></td>
|
||||||
|
<td>
|
||||||
|
{% for grade, count in data.grade_distribution.items() %}
|
||||||
|
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="signature" style="margin-top: 40px;">
|
||||||
|
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||||
|
Erstellt mit BreakPilot | {{ generated_at }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateCore creates the core tables: users, auth tokens, sessions,
|
||||||
|
// documents, versions, consents, cookies, audit, notifications,
|
||||||
|
// deadlines, suspensions, and their indexes (Phases 1-5).
|
||||||
|
func migrateCore(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// Users table (extended for full auth)
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id VARCHAR(255) UNIQUE,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255),
|
||||||
|
name VARCHAR(255),
|
||||||
|
role VARCHAR(50) DEFAULT 'user',
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
email_verified_at TIMESTAMPTZ,
|
||||||
|
account_status VARCHAR(20) DEFAULT 'active',
|
||||||
|
last_login_at TIMESTAMPTZ,
|
||||||
|
failed_login_attempts INT DEFAULT 0,
|
||||||
|
locked_until TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Legal documents table
|
||||||
|
`CREATE TABLE IF NOT EXISTS legal_documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_mandatory BOOLEAN DEFAULT true,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Document versions table
|
||||||
|
`CREATE TABLE IF NOT EXISTS document_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE,
|
||||||
|
version VARCHAR(20) NOT NULL,
|
||||||
|
language VARCHAR(5) DEFAULT 'de',
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'draft',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
scheduled_publish_at TIMESTAMPTZ,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
approved_by UUID REFERENCES users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(document_id, version, language)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Add scheduled_publish_at column if not exists (migration)
|
||||||
|
`ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS scheduled_publish_at TIMESTAMPTZ`,
|
||||||
|
`ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ`,
|
||||||
|
|
||||||
|
// User consents table
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_consents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
document_version_id UUID REFERENCES document_versions(id),
|
||||||
|
consented BOOLEAN NOT NULL,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
consented_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
withdrawn_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(user_id, document_version_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Cookie categories table
|
||||||
|
`CREATE TABLE IF NOT EXISTS cookie_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
display_name_de VARCHAR(255) NOT NULL,
|
||||||
|
display_name_en VARCHAR(255),
|
||||||
|
description_de TEXT,
|
||||||
|
description_en TEXT,
|
||||||
|
is_mandatory BOOLEAN DEFAULT false,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Cookie consents table
|
||||||
|
`CREATE TABLE IF NOT EXISTS cookie_consents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
category_id UUID REFERENCES cookie_categories(id) ON DELETE CASCADE,
|
||||||
|
consented BOOLEAN NOT NULL,
|
||||||
|
consented_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, category_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Audit log table
|
||||||
|
`CREATE TABLE IF NOT EXISTS consent_audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50),
|
||||||
|
entity_id UUID,
|
||||||
|
details JSONB,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Data export requests table
|
||||||
|
`CREATE TABLE IF NOT EXISTS data_export_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
download_url TEXT,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Data deletion requests table
|
||||||
|
`CREATE TABLE IF NOT EXISTS data_deletion_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
processed_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 1: User Management Tables
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Email verification tokens
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Password reset tokens
|
||||||
|
`CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
ip_address INET,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// User sessions (for JWT revocation and session management)
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
device_info TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_activity_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 3: Version Approvals (DSB Workflow)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Version approval tracking
|
||||||
|
`CREATE TABLE IF NOT EXISTS version_approvals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||||
|
approver_id UUID REFERENCES users(id),
|
||||||
|
action VARCHAR(30) NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 4: Notification System
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
`CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
data JSONB,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Push subscriptions for Web Push
|
||||||
|
`CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, endpoint)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Notification preferences per user
|
||||||
|
`CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
email_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
push_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
in_app_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
reminder_frequency VARCHAR(20) DEFAULT 'weekly',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 5: Consent Deadlines & Account Suspension
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Consent deadlines per user per version
|
||||||
|
`CREATE TABLE IF NOT EXISTS consent_deadlines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
document_version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||||
|
deadline_at TIMESTAMPTZ NOT NULL,
|
||||||
|
reminder_count INT DEFAULT 0,
|
||||||
|
last_reminder_at TIMESTAMPTZ,
|
||||||
|
consent_given_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, document_version_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Account suspensions tracking
|
||||||
|
`CREATE TABLE IF NOT EXISTS account_suspensions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reason VARCHAR(50) NOT NULL,
|
||||||
|
details JSONB,
|
||||||
|
suspended_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
lifted_at TIMESTAMPTZ,
|
||||||
|
lifted_reason TEXT
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Indexes for performance
|
||||||
|
// =============================================
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_consents_user ON user_consents(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_consents_version ON user_consents(document_version_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_cookie_consents_user ON cookie_consents(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON consent_audit_log(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON consent_audit_log(created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_document_versions_document ON document_versions(document_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_document_versions_status ON document_versions(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_legal_documents_type ON legal_documents(type)`,
|
||||||
|
|
||||||
|
// Phase 1: Auth indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_token ON email_verification_tokens(token)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token_hash)`,
|
||||||
|
|
||||||
|
// Phase 3: Approval indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_version_approvals_version ON version_approvals(version_id)`,
|
||||||
|
|
||||||
|
// Phase 4: Notification indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`,
|
||||||
|
|
||||||
|
// Phase 5: Deadline indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_consent_deadlines_user ON consent_deadlines(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_consent_deadlines_deadline ON consent_deadlines(deadline_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_account_suspensions_user ON account_suspensions(user_id)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateCore: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateDSR creates DSGVO Data Subject Request tables (Phase 10)
|
||||||
|
// and EduSearch seed management tables (Phase 11).
|
||||||
|
func migrateDSR(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Phase 10: DSGVO Betroffenenanfragen (DSR)
|
||||||
|
// Data Subject Request Management
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Sequence for request numbers
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS dsr_request_number_seq START 1`,
|
||||||
|
|
||||||
|
// Main table: Data Subject Requests
|
||||||
|
`CREATE TABLE IF NOT EXISTS data_subject_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
request_number VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
request_type VARCHAR(30) NOT NULL,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'intake',
|
||||||
|
priority VARCHAR(20) DEFAULT 'normal',
|
||||||
|
source VARCHAR(30) NOT NULL DEFAULT 'api',
|
||||||
|
requester_email VARCHAR(255) NOT NULL,
|
||||||
|
requester_name VARCHAR(255),
|
||||||
|
requester_phone VARCHAR(50),
|
||||||
|
identity_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
identity_verified_at TIMESTAMPTZ,
|
||||||
|
identity_verified_by UUID REFERENCES users(id),
|
||||||
|
identity_verification_method VARCHAR(50),
|
||||||
|
request_details JSONB DEFAULT '{}',
|
||||||
|
deadline_at TIMESTAMPTZ NOT NULL,
|
||||||
|
legal_deadline_days INT NOT NULL,
|
||||||
|
extended_deadline_at TIMESTAMPTZ,
|
||||||
|
extension_reason TEXT,
|
||||||
|
assigned_to UUID REFERENCES users(id),
|
||||||
|
processing_notes TEXT,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
completed_by UUID REFERENCES users(id),
|
||||||
|
result_summary TEXT,
|
||||||
|
result_data JSONB,
|
||||||
|
rejected_at TIMESTAMPTZ,
|
||||||
|
rejected_by UUID REFERENCES users(id),
|
||||||
|
rejection_reason TEXT,
|
||||||
|
rejection_legal_basis TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Status History for audit trail
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_status_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
||||||
|
from_status VARCHAR(30),
|
||||||
|
to_status VARCHAR(30) NOT NULL,
|
||||||
|
changed_by UUID REFERENCES users(id),
|
||||||
|
comment TEXT,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Communications log
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_communications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
||||||
|
direction VARCHAR(10) NOT NULL,
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
communication_type VARCHAR(50) NOT NULL,
|
||||||
|
template_version_id UUID,
|
||||||
|
subject VARCHAR(500),
|
||||||
|
body_html TEXT,
|
||||||
|
body_text TEXT,
|
||||||
|
recipient_email VARCHAR(255),
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
error_message TEXT,
|
||||||
|
attachments JSONB DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Templates
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
template_type VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
request_types JSONB DEFAULT '["access","rectification","erasure","restriction","portability"]',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Template Versions
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_template_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
template_id UUID REFERENCES dsr_templates(id) ON DELETE CASCADE,
|
||||||
|
version VARCHAR(20) NOT NULL,
|
||||||
|
language VARCHAR(5) DEFAULT 'de',
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
body_text TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'draft',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
approved_by UUID REFERENCES users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(template_id, version, language)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Exception Checks (for Art. 17(3) erasure exceptions)
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_exception_checks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
||||||
|
exception_type VARCHAR(50) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
applies BOOLEAN,
|
||||||
|
checked_by UUID REFERENCES users(id),
|
||||||
|
checked_at TIMESTAMPTZ,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Phase 10 Indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_user ON data_subject_requests(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_status ON data_subject_requests(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_type ON data_subject_requests(request_type)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_deadline ON data_subject_requests(deadline_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_assigned ON data_subject_requests(assigned_to)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_request_number ON data_subject_requests(request_number)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_created ON data_subject_requests(created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_status_history_request ON dsr_status_history(request_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_communications_request ON dsr_communications(request_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_exception_checks_request ON dsr_exception_checks(request_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_templates_type ON dsr_templates(template_type)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_template ON dsr_template_versions(template_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_status ON dsr_template_versions(status)`,
|
||||||
|
|
||||||
|
// Insert default DSR templates
|
||||||
|
`INSERT INTO dsr_templates (template_type, name, description, request_types, sort_order)
|
||||||
|
VALUES
|
||||||
|
('dsr_receipt_access', 'Eingangsbestätigung Auskunft', 'Bestätigung des Eingangs einer Auskunftsanfrage nach Art. 15 DSGVO', '["access"]', 1),
|
||||||
|
('dsr_receipt_rectification', 'Eingangsbestätigung Berichtigung', 'Bestätigung des Eingangs einer Berichtigungsanfrage nach Art. 16 DSGVO', '["rectification"]', 2),
|
||||||
|
('dsr_receipt_erasure', 'Eingangsbestätigung Löschung', 'Bestätigung des Eingangs einer Löschanfrage nach Art. 17 DSGVO', '["erasure"]', 3),
|
||||||
|
('dsr_receipt_restriction', 'Eingangsbestätigung Einschränkung', 'Bestätigung des Eingangs einer Einschränkungsanfrage nach Art. 18 DSGVO', '["restriction"]', 4),
|
||||||
|
('dsr_receipt_portability', 'Eingangsbestätigung Datenübertragung', 'Bestätigung des Eingangs einer Datenübertragungsanfrage nach Art. 20 DSGVO', '["portability"]', 5),
|
||||||
|
('dsr_identity_request', 'Anfrage Identitätsnachweis', 'Aufforderung zur Identitätsverifizierung', '["access","rectification","erasure","restriction","portability"]', 6),
|
||||||
|
('dsr_processing_started', 'Bearbeitungsbestätigung', 'Bestätigung, dass die Bearbeitung begonnen hat', '["access","rectification","erasure","restriction","portability"]', 7),
|
||||||
|
('dsr_processing_update', 'Zwischenbericht', 'Zwischenstand zur Bearbeitung', '["access","rectification","erasure","restriction","portability"]', 8),
|
||||||
|
('dsr_clarification_request', 'Rückfragen', 'Anfrage zur Klärung des Begehrens', '["access","rectification","erasure","restriction","portability"]', 9),
|
||||||
|
('dsr_completed_access', 'Auskunft erteilt', 'Abschließende Mitteilung mit Datenauskunft', '["access"]', 10),
|
||||||
|
('dsr_completed_access_negative', 'Negativauskunft', 'Mitteilung dass keine Daten vorhanden sind', '["access"]', 11),
|
||||||
|
('dsr_completed_rectification', 'Berichtigung durchgeführt', 'Bestätigung der Datenberichtigung', '["rectification"]', 12),
|
||||||
|
('dsr_completed_erasure', 'Löschung durchgeführt', 'Bestätigung der Datenlöschung', '["erasure"]', 13),
|
||||||
|
('dsr_completed_restriction', 'Einschränkung aktiviert', 'Bestätigung der Verarbeitungseinschränkung', '["restriction"]', 14),
|
||||||
|
('dsr_completed_portability', 'Daten bereitgestellt', 'Mitteilung zur Datenübermittlung', '["portability"]', 15),
|
||||||
|
('dsr_restriction_lifted', 'Einschränkung aufgehoben', 'Vorabbenachrichtigung vor Aufhebung der Einschränkung', '["restriction"]', 16),
|
||||||
|
('dsr_rejected_identity', 'Ablehnung - Identität nicht verifizierbar', 'Ablehnung mangels Identitätsnachweis', '["access","rectification","erasure","restriction","portability"]', 17),
|
||||||
|
('dsr_rejected_exception', 'Ablehnung - Ausnahme', 'Ablehnung aufgrund gesetzlicher Ausnahmen (z.B. Art. 17 Abs. 3)', '["erasure","restriction"]', 18),
|
||||||
|
('dsr_rejected_unfounded', 'Ablehnung - Offensichtlich unbegründet', 'Ablehnung nach Art. 12 Abs. 5 DSGVO', '["access","rectification","erasure","restriction","portability"]', 19)
|
||||||
|
ON CONFLICT (template_type) DO NOTHING`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 11: EduSearch Seeds Management
|
||||||
|
// Seed URLs for the education search crawler
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// EduSearch Seed Categories
|
||||||
|
`CREATE TABLE IF NOT EXISTS edu_search_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
display_name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon VARCHAR(10),
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// EduSearch Seeds (crawler seed URLs)
|
||||||
|
`CREATE TABLE IF NOT EXISTS edu_search_seeds (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
url VARCHAR(500) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category_id UUID REFERENCES edu_search_categories(id) ON DELETE SET NULL,
|
||||||
|
source_type VARCHAR(20) DEFAULT 'GOV',
|
||||||
|
scope VARCHAR(20) DEFAULT 'FEDERAL',
|
||||||
|
state VARCHAR(5),
|
||||||
|
trust_boost DECIMAL(3,2) DEFAULT 0.50,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
crawl_depth INT DEFAULT 2,
|
||||||
|
crawl_frequency VARCHAR(20) DEFAULT 'weekly',
|
||||||
|
last_crawled_at TIMESTAMPTZ,
|
||||||
|
last_crawl_status VARCHAR(20),
|
||||||
|
last_crawl_docs INT DEFAULT 0,
|
||||||
|
total_documents INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// EduSearch Crawl Runs (history of crawl executions)
|
||||||
|
`CREATE TABLE IF NOT EXISTS edu_search_crawl_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
seed_id UUID REFERENCES edu_search_seeds(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) DEFAULT 'running',
|
||||||
|
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
pages_crawled INT DEFAULT 0,
|
||||||
|
documents_indexed INT DEFAULT 0,
|
||||||
|
errors_count INT DEFAULT 0,
|
||||||
|
error_details JSONB,
|
||||||
|
triggered_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// EduSearch Denylist (URLs/domains to never crawl)
|
||||||
|
`CREATE TABLE IF NOT EXISTS edu_search_denylist (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pattern VARCHAR(500) UNIQUE NOT NULL,
|
||||||
|
pattern_type VARCHAR(20) DEFAULT 'domain',
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Phase 11 Indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_category ON edu_search_seeds(category_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_enabled ON edu_search_seeds(enabled)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_state ON edu_search_seeds(state)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_scope ON edu_search_seeds(scope)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_seed ON edu_search_crawl_runs(seed_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_status ON edu_search_crawl_runs(status)`,
|
||||||
|
|
||||||
|
// Insert default EduSearch categories
|
||||||
|
`INSERT INTO edu_search_categories (name, display_name, description, icon, sort_order)
|
||||||
|
VALUES
|
||||||
|
('federal', 'Bundesebene', 'KMK, BMBF, Bildungsserver', '🏛️', 1),
|
||||||
|
('states', 'Bundesländer', 'Ministerien, Landesbildungsserver', '🗺️', 2),
|
||||||
|
('science', 'Wissenschaft', 'Bertelsmann, PISA, IGLU, TIMSS', '🔬', 3),
|
||||||
|
('universities', 'Universitäten', 'Deutsche Hochschulen', '🎓', 4),
|
||||||
|
('schools', 'Schulen', 'Schulwebsites', '🏫', 5),
|
||||||
|
('portals', 'Bildungsportale', 'Lehrer-Online, 4teachers, ZUM', '📚', 6),
|
||||||
|
('eu', 'EU/International', 'Europäische Bildungsberichte', '🇪🇺', 7),
|
||||||
|
('authorities', 'Schulbehörden', 'Regierungspräsidien, Schulämter', '📋', 8)
|
||||||
|
ON CONFLICT (name) DO NOTHING`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateDSR: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateEmail creates email template tables, settings, and indexes (Phase 8).
|
||||||
|
func migrateEmail(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Phase 8: E-Mail Templates (Transactional)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Email templates (like legal_documents)
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Email template versions (like document_versions)
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_template_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
template_id UUID REFERENCES email_templates(id) ON DELETE CASCADE,
|
||||||
|
version VARCHAR(20) NOT NULL,
|
||||||
|
language VARCHAR(5) DEFAULT 'de',
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
body_text TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'draft',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
scheduled_publish_at TIMESTAMPTZ,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
approved_by UUID REFERENCES users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(template_id, version, language)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Email template approvals (like version_approvals)
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_template_approvals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
version_id UUID REFERENCES email_template_versions(id) ON DELETE CASCADE,
|
||||||
|
approver_id UUID REFERENCES users(id),
|
||||||
|
action VARCHAR(30) NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Email send logs for audit
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_send_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
version_id UUID REFERENCES email_template_versions(id) ON DELETE SET NULL,
|
||||||
|
recipient VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'queued',
|
||||||
|
error_msg TEXT,
|
||||||
|
variables JSONB,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Global email settings (logo, colors, signature)
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_template_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
logo_url TEXT,
|
||||||
|
logo_base64 TEXT,
|
||||||
|
company_name VARCHAR(255) DEFAULT 'BreakPilot',
|
||||||
|
sender_name VARCHAR(255) DEFAULT 'BreakPilot',
|
||||||
|
sender_email VARCHAR(255) DEFAULT 'noreply@breakpilot.app',
|
||||||
|
reply_to_email VARCHAR(255),
|
||||||
|
footer_html TEXT,
|
||||||
|
footer_text TEXT,
|
||||||
|
primary_color VARCHAR(7) DEFAULT '#2563eb',
|
||||||
|
secondary_color VARCHAR(7) DEFAULT '#64748b',
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Insert default email settings
|
||||||
|
`INSERT INTO email_template_settings (id, company_name, sender_name, sender_email, primary_color, secondary_color)
|
||||||
|
VALUES (gen_random_uuid(), 'BreakPilot', 'BreakPilot', 'noreply@breakpilot.app', '#2563eb', '#64748b')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
|
||||||
|
// Phase 8 Indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_templates_type ON email_templates(type)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_template_versions_template ON email_template_versions(template_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_template_versions_status ON email_template_versions(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_template_approvals_version ON email_template_approvals(version_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_user ON email_send_logs(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_created ON email_send_logs(created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_status ON email_send_logs(status)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateEmail: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateOAuth creates OAuth 2.0 and 2FA tables (Phases 6-7),
|
||||||
|
// plus default seed data for OAuth clients, cookie categories,
|
||||||
|
// and legal documents.
|
||||||
|
func migrateOAuth(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Phase 6: OAuth 2.0 Authorization Code Flow
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// OAuth 2.0 Clients
|
||||||
|
`CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
client_secret VARCHAR(255),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
redirect_uris JSONB NOT NULL DEFAULT '[]',
|
||||||
|
scopes JSONB NOT NULL DEFAULT '["openid", "profile", "email"]',
|
||||||
|
grant_types JSONB NOT NULL DEFAULT '["authorization_code", "refresh_token"]',
|
||||||
|
is_public BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// OAuth 2.0 Authorization Codes
|
||||||
|
`CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '[]',
|
||||||
|
code_challenge VARCHAR(255),
|
||||||
|
code_challenge_method VARCHAR(10),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// OAuth 2.0 Access Tokens
|
||||||
|
`CREATE TABLE IF NOT EXISTS oauth_access_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '[]',
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// OAuth 2.0 Refresh Tokens
|
||||||
|
`CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
access_token_id UUID REFERENCES oauth_access_tokens(id) ON DELETE CASCADE,
|
||||||
|
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '[]',
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 7: Two-Factor Authentication (2FA/TOTP)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// User TOTP secrets and recovery codes
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_totp (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
secret VARCHAR(255) NOT NULL,
|
||||||
|
verified BOOLEAN DEFAULT FALSE,
|
||||||
|
recovery_codes JSONB DEFAULT '[]',
|
||||||
|
enabled_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// 2FA challenges during login
|
||||||
|
`CREATE TABLE IF NOT EXISTS two_factor_challenges (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
challenge_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Add 2FA required flag to users
|
||||||
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT FALSE`,
|
||||||
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_verified_at TIMESTAMPTZ`,
|
||||||
|
|
||||||
|
// Phase 6 & 7 Indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code ON oauth_authorization_codes(code)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_user ON oauth_authorization_codes(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_hash ON oauth_access_tokens(token_hash)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_user ON oauth_access_tokens(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_hash ON oauth_refresh_tokens(token_hash)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_user ON oauth_refresh_tokens(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_totp_user ON user_totp(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_id ON two_factor_challenges(challenge_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_user ON two_factor_challenges(user_id)`,
|
||||||
|
|
||||||
|
// Insert default OAuth client for BreakPilot PWA (public client with PKCE)
|
||||||
|
`INSERT INTO oauth_clients (client_id, name, description, redirect_uris, scopes, grant_types, is_public)
|
||||||
|
VALUES (
|
||||||
|
'breakpilot-pwa',
|
||||||
|
'BreakPilot PWA',
|
||||||
|
'Official BreakPilot Progressive Web Application',
|
||||||
|
'["http://localhost:8000/oauth/callback", "http://localhost:8000/app/oauth/callback"]',
|
||||||
|
'["openid", "profile", "email", "consent:read", "consent:write"]',
|
||||||
|
'["authorization_code", "refresh_token"]',
|
||||||
|
true
|
||||||
|
) ON CONFLICT (client_id) DO NOTHING`,
|
||||||
|
|
||||||
|
// Insert default cookie categories
|
||||||
|
`INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
|
||||||
|
VALUES
|
||||||
|
('necessary', 'Notwendige Cookies', 'Necessary Cookies',
|
||||||
|
'Diese Cookies sind für die Grundfunktionen der Website unbedingt erforderlich.',
|
||||||
|
'These cookies are essential for the basic functions of the website.',
|
||||||
|
true, 1),
|
||||||
|
('functional', 'Funktionale Cookies', 'Functional Cookies',
|
||||||
|
'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.',
|
||||||
|
'These cookies enable enhanced functionality and personalization.',
|
||||||
|
false, 2),
|
||||||
|
('analytics', 'Analyse Cookies', 'Analytics Cookies',
|
||||||
|
'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.',
|
||||||
|
'These cookies help us understand how visitors interact with the website.',
|
||||||
|
false, 3),
|
||||||
|
('marketing', 'Marketing Cookies', 'Marketing Cookies',
|
||||||
|
'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.',
|
||||||
|
'These cookies are used to make advertising more relevant to you.',
|
||||||
|
false, 4)
|
||||||
|
ON CONFLICT (name) DO NOTHING`,
|
||||||
|
|
||||||
|
// Insert default legal documents
|
||||||
|
`INSERT INTO legal_documents (type, name, description, is_mandatory, sort_order)
|
||||||
|
VALUES
|
||||||
|
('terms', 'Allgemeine Geschäftsbedingungen', 'Die allgemeinen Geschäftsbedingungen für die Nutzung von BreakPilot.', true, 1),
|
||||||
|
('privacy', 'Datenschutzerklärung', 'Informationen über die Verarbeitung Ihrer personenbezogenen Daten.', true, 2),
|
||||||
|
('cookies', 'Cookie-Richtlinie', 'Informationen über die Verwendung von Cookies auf unserer Website.', false, 3),
|
||||||
|
('community', 'Community Guidelines', 'Regeln für das Verhalten in der BreakPilot Community.', true, 4)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateOAuth: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateSchool creates school management tables: schools, classes,
|
||||||
|
// students, teachers, parents, timetable, attendance, grades,
|
||||||
|
// class diary, parent meetings, Matrix integration (Phase 9).
|
||||||
|
func migrateSchool(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Phase 9: Schulverwaltung / School Management
|
||||||
|
// Matrix-basierte Kommunikation für Schulen
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Schools table
|
||||||
|
`CREATE TABLE IF NOT EXISTS schools (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
short_name VARCHAR(50),
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
city VARCHAR(100),
|
||||||
|
postal_code VARCHAR(20),
|
||||||
|
state VARCHAR(50),
|
||||||
|
country VARCHAR(2) DEFAULT 'DE',
|
||||||
|
phone VARCHAR(50),
|
||||||
|
email VARCHAR(255),
|
||||||
|
website VARCHAR(255),
|
||||||
|
matrix_server_name VARCHAR(255),
|
||||||
|
logo_url TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// School years
|
||||||
|
`CREATE TABLE IF NOT EXISTS school_years (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(20) NOT NULL,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
is_current BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(school_id, name)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Subjects
|
||||||
|
`CREATE TABLE IF NOT EXISTS subjects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
short_name VARCHAR(10) NOT NULL,
|
||||||
|
color VARCHAR(7),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(school_id, short_name)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Classes
|
||||||
|
`CREATE TABLE IF NOT EXISTS classes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(20) NOT NULL,
|
||||||
|
grade INT NOT NULL,
|
||||||
|
section VARCHAR(5),
|
||||||
|
room VARCHAR(50),
|
||||||
|
matrix_info_room VARCHAR(255),
|
||||||
|
matrix_rep_room VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(school_id, school_year_id, name)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Students
|
||||||
|
`CREATE TABLE IF NOT EXISTS students (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
student_number VARCHAR(50),
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
date_of_birth DATE,
|
||||||
|
gender VARCHAR(1),
|
||||||
|
matrix_user_id VARCHAR(255),
|
||||||
|
matrix_dm_room VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Teachers
|
||||||
|
`CREATE TABLE IF NOT EXISTS teachers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
teacher_code VARCHAR(10),
|
||||||
|
title VARCHAR(20),
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
matrix_user_id VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(school_id, user_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Class teachers assignment
|
||||||
|
`CREATE TABLE IF NOT EXISTS class_teachers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(class_id, teacher_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Teacher subjects assignment
|
||||||
|
`CREATE TABLE IF NOT EXISTS teacher_subjects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(teacher_id, subject_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Parents
|
||||||
|
`CREATE TABLE IF NOT EXISTS parents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
matrix_user_id VARCHAR(255),
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
emergency_contact BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Student-parent relationships
|
||||||
|
`CREATE TABLE IF NOT EXISTS student_parents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||||
|
relationship VARCHAR(20) NOT NULL,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
has_custody BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(student_id, parent_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Parent representatives
|
||||||
|
`CREATE TABLE IF NOT EXISTS parent_representatives (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
elected_at TIMESTAMPTZ NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateSchool: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the second batch (timetable, attendance, grades, etc.)
|
||||||
|
return migrateSchoolPart2(db)
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateSchoolPart2 creates timetable, attendance, grades, diary,
|
||||||
|
// meetings, Matrix, and Phase 9 indexes/seed data.
|
||||||
|
func migrateSchoolPart2(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Stundenplan / Timetable
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Timetable slots (Stundenraster)
|
||||||
|
`CREATE TABLE IF NOT EXISTS timetable_slots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
slot_number INT NOT NULL,
|
||||||
|
start_time TIME NOT NULL,
|
||||||
|
end_time TIME NOT NULL,
|
||||||
|
is_break BOOLEAN DEFAULT FALSE,
|
||||||
|
name VARCHAR(50),
|
||||||
|
UNIQUE(school_id, slot_number)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Timetable entries (Stundenplan)
|
||||||
|
`CREATE TABLE IF NOT EXISTS timetable_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
||||||
|
day_of_week INT NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7),
|
||||||
|
room VARCHAR(50),
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_until DATE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Timetable substitutions (Vertretungsplan)
|
||||||
|
`CREATE TABLE IF NOT EXISTS timetable_substitutions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
original_entry_id UUID NOT NULL REFERENCES timetable_entries(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
substitute_teacher_id UUID REFERENCES teachers(id) ON DELETE SET NULL,
|
||||||
|
substitute_subject_id UUID REFERENCES subjects(id) ON DELETE SET NULL,
|
||||||
|
room VARCHAR(50),
|
||||||
|
type VARCHAR(20) NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Abwesenheit / Attendance
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Attendance records per lesson
|
||||||
|
`CREATE TABLE IF NOT EXISTS attendance_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
timetable_entry_id UUID REFERENCES timetable_entries(id) ON DELETE SET NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(30) NOT NULL,
|
||||||
|
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(student_id, date, slot_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Absence reports (Krankmeldungen)
|
||||||
|
`CREATE TABLE IF NOT EXISTS absence_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
reason_category VARCHAR(30) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'reported',
|
||||||
|
reported_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
confirmed_by UUID REFERENCES users(id),
|
||||||
|
confirmed_at TIMESTAMPTZ,
|
||||||
|
medical_certificate BOOLEAN DEFAULT FALSE,
|
||||||
|
certificate_uploaded BOOLEAN DEFAULT FALSE,
|
||||||
|
matrix_notification_sent BOOLEAN DEFAULT FALSE,
|
||||||
|
email_notification_sent BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Absence notifications to parents
|
||||||
|
`CREATE TABLE IF NOT EXISTS absence_notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
attendance_record_id UUID NOT NULL REFERENCES attendance_records(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
message_content TEXT NOT NULL,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
response_received BOOLEAN DEFAULT FALSE,
|
||||||
|
response_content TEXT,
|
||||||
|
response_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Notenspiegel / Grades
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Grade scales
|
||||||
|
`CREATE TABLE IF NOT EXISTS grade_scales (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
min_value DECIMAL(5,2) NOT NULL,
|
||||||
|
max_value DECIMAL(5,2) NOT NULL,
|
||||||
|
passing_value DECIMAL(5,2) NOT NULL,
|
||||||
|
is_ascending BOOLEAN DEFAULT FALSE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Grades
|
||||||
|
`CREATE TABLE IF NOT EXISTS grades (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
||||||
|
grade_scale_id UUID NOT NULL REFERENCES grade_scales(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
value DECIMAL(5,2) NOT NULL,
|
||||||
|
weight DECIMAL(3,2) DEFAULT 1.0,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
title VARCHAR(100),
|
||||||
|
description TEXT,
|
||||||
|
is_visible BOOLEAN DEFAULT TRUE,
|
||||||
|
semester INT NOT NULL CHECK (semester IN (1, 2)),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Grade comments
|
||||||
|
`CREATE TABLE IF NOT EXISTS grade_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grade_id UUID NOT NULL REFERENCES grades(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
is_private BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Klassenbuch / Class Diary
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Class diary entries
|
||||||
|
`CREATE TABLE IF NOT EXISTS class_diary_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
||||||
|
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
topic TEXT,
|
||||||
|
homework TEXT,
|
||||||
|
homework_due_date DATE,
|
||||||
|
materials TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
is_cancelled BOOLEAN DEFAULT FALSE,
|
||||||
|
cancellation_reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(class_id, date, slot_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Elterngespräche / Parent Meetings
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Parent meeting slots
|
||||||
|
`CREATE TABLE IF NOT EXISTS parent_meeting_slots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
start_time TIME NOT NULL,
|
||||||
|
end_time TIME NOT NULL,
|
||||||
|
location VARCHAR(100),
|
||||||
|
is_online BOOLEAN DEFAULT FALSE,
|
||||||
|
meeting_link TEXT,
|
||||||
|
is_booked BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Parent meetings
|
||||||
|
`CREATE TABLE IF NOT EXISTS parent_meetings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slot_id UUID NOT NULL REFERENCES parent_meeting_slots(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
topic TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
|
||||||
|
cancelled_at TIMESTAMPTZ,
|
||||||
|
cancelled_by UUID REFERENCES users(id),
|
||||||
|
cancel_reason TEXT,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Matrix / Communication Integration
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Matrix rooms
|
||||||
|
`CREATE TABLE IF NOT EXISTS matrix_rooms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
matrix_room_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
class_id UUID REFERENCES classes(id) ON DELETE SET NULL,
|
||||||
|
student_id UUID REFERENCES students(id) ON DELETE SET NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
is_encrypted BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Matrix room members
|
||||||
|
`CREATE TABLE IF NOT EXISTS matrix_room_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
matrix_room_id UUID NOT NULL REFERENCES matrix_rooms(id) ON DELETE CASCADE,
|
||||||
|
matrix_user_id VARCHAR(255) NOT NULL,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
power_level INT DEFAULT 0,
|
||||||
|
can_write BOOLEAN DEFAULT TRUE,
|
||||||
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
left_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(matrix_room_id, matrix_user_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Parent onboarding tokens (QR codes)
|
||||||
|
`CREATE TABLE IF NOT EXISTS parent_onboarding_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
role VARCHAR(30) NOT NULL DEFAULT 'parent',
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
used_by_user_id UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 9 Indexes
|
||||||
|
// =============================================
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_schools_active ON schools(is_active)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_school_years_school ON school_years(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_school_years_current ON school_years(is_current)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_classes_school ON classes(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_classes_school_year ON classes(school_year_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_students_school ON students(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_students_class ON students(class_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_students_user ON students(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_teachers_school ON teachers(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_teachers_user ON teachers(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_class_teachers_teacher ON class_teachers(teacher_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parents_user ON parents(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_student_parents_student ON student_parents(student_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_student_parents_parent ON student_parents(parent_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_class ON timetable_entries(class_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_teacher ON timetable_entries(teacher_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_day ON timetable_entries(day_of_week)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_date ON timetable_substitutions(date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_entry ON timetable_substitutions(original_entry_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_attendance_records_student ON attendance_records(student_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_attendance_records_date ON attendance_records(date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_absence_reports_student ON absence_reports(student_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_absence_reports_dates ON absence_reports(start_date, end_date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_grades_student ON grades(student_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_grades_subject ON grades(subject_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_grades_teacher ON grades(teacher_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_grades_school_year ON grades(school_year_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_class_diary_class_date ON class_diary_entries(class_id, date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_teacher ON parent_meeting_slots(teacher_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_date ON parent_meeting_slots(date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_meetings_slot ON parent_meetings(slot_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_meetings_parent ON parent_meetings(parent_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_matrix_rooms_school ON matrix_rooms(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_matrix_rooms_class ON matrix_rooms(class_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_matrix_room_members_room ON matrix_room_members(matrix_room_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_token ON parent_onboarding_tokens(token)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_student ON parent_onboarding_tokens(student_id)`,
|
||||||
|
|
||||||
|
// Insert default grade scales
|
||||||
|
`INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default)
|
||||||
|
SELECT gen_random_uuid(), s.id, '1-6 (Noten)', 1, 6, 4, false, true
|
||||||
|
FROM schools s
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM grade_scales gs WHERE gs.school_id = s.id AND gs.name = '1-6 (Noten)')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
|
||||||
|
// Insert default timetable slots for schools
|
||||||
|
`DO $$
|
||||||
|
DECLARE
|
||||||
|
school_rec RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR school_rec IN SELECT id FROM schools LOOP
|
||||||
|
INSERT INTO timetable_slots (school_id, slot_number, start_time, end_time, is_break, name)
|
||||||
|
VALUES
|
||||||
|
(school_rec.id, 1, '08:00', '08:45', false, '1. Stunde'),
|
||||||
|
(school_rec.id, 2, '08:45', '09:30', false, '2. Stunde'),
|
||||||
|
(school_rec.id, 3, '09:30', '09:50', true, 'Erste Pause'),
|
||||||
|
(school_rec.id, 4, '09:50', '10:35', false, '3. Stunde'),
|
||||||
|
(school_rec.id, 5, '10:35', '11:20', false, '4. Stunde'),
|
||||||
|
(school_rec.id, 6, '11:20', '11:40', true, 'Zweite Pause'),
|
||||||
|
(school_rec.id, 7, '11:40', '12:25', false, '5. Stunde'),
|
||||||
|
(school_rec.id, 8, '12:25', '13:10', false, '6. Stunde'),
|
||||||
|
(school_rec.id, 9, '13:10', '14:00', true, 'Mittagspause'),
|
||||||
|
(school_rec.id, 10, '14:00', '14:45', false, '7. Stunde'),
|
||||||
|
(school_rec.id, 11, '14:45', '15:30', false, '8. Stunde')
|
||||||
|
ON CONFLICT (school_id, slot_number) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
END $$`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateSchool: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ENDPOINTS - Version Approval Workflow (DSB)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminSubmitForReview submits a version for DSB review
|
||||||
|
func (h *Handler) AdminSubmitForReview(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Check current status
|
||||||
|
var status string
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "draft" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status to review
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'review', updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log approval action
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||||
|
VALUES ($1, $2, 'submitted', 'Submitted for DSB review')
|
||||||
|
`, versionID, userID)
|
||||||
|
|
||||||
|
h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminApproveVersion approves a version with scheduled publish date (DSB only)
|
||||||
|
func (h *Handler) AdminApproveVersion(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is DSB or Admin (for dev purposes)
|
||||||
|
if !middleware.IsDSB(c) && !middleware.IsAdmin(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
// Validate scheduled publish date
|
||||||
|
var scheduledAt *time.Time
|
||||||
|
if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parsed.Before(time.Now()) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduledAt = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Check current status
|
||||||
|
var status string
|
||||||
|
var createdBy *uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "review" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four-eyes principle: DSB cannot approve their own version
|
||||||
|
// Exception: Admins can approve their own versions for development/testing purposes
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
roleStr, _ := role.(string)
|
||||||
|
if createdBy != nil && *createdBy == userID && roleStr != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine new status: 'scheduled' if date set, otherwise 'approved'
|
||||||
|
newStatus := "approved"
|
||||||
|
if scheduledAt != nil {
|
||||||
|
newStatus = "scheduled"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status to approved/scheduled
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID, newStatus, userID, scheduledAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log approval action
|
||||||
|
comment := req.Comment
|
||||||
|
if comment == "" {
|
||||||
|
if scheduledAt != nil {
|
||||||
|
comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04")
|
||||||
|
} else {
|
||||||
|
comment = "Approved by DSB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||||
|
VALUES ($1, $2, 'approved', $3)
|
||||||
|
`, versionID, userID, comment)
|
||||||
|
|
||||||
|
h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent)
|
||||||
|
|
||||||
|
response := gin.H{"message": "Version approved", "status": newStatus}
|
||||||
|
if scheduledAt != nil {
|
||||||
|
response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRejectVersion rejects a version (DSB only)
|
||||||
|
func (h *Handler) AdminRejectVersion(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is DSB
|
||||||
|
if !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Comment string `json:"comment" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Comment is required when rejecting"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Check current status
|
||||||
|
var status string
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "review" && status != "approved" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status back to draft
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'draft', approved_by = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log rejection
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||||
|
VALUES ($1, $2, 'rejected', $3)
|
||||||
|
`, versionID, userID, req.Comment)
|
||||||
|
|
||||||
|
h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCompareVersions returns two versions for side-by-side comparison
|
||||||
|
func (h *Handler) AdminCompareVersions(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Get the current version and its document
|
||||||
|
var currentVersion models.DocumentVersion
|
||||||
|
var documentID uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at
|
||||||
|
FROM document_versions
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID).Scan(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language,
|
||||||
|
¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status,
|
||||||
|
¤tVersion.CreatedAt, ¤tVersion.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the currently published version (if any)
|
||||||
|
var publishedVersion *models.DocumentVersion
|
||||||
|
var pv models.DocumentVersion
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at
|
||||||
|
FROM document_versions
|
||||||
|
WHERE document_id = $1 AND language = $2 AND status = 'published'
|
||||||
|
ORDER BY published_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language,
|
||||||
|
&pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt)
|
||||||
|
|
||||||
|
if err == nil && pv.ID != currentVersion.ID {
|
||||||
|
publishedVersion = &pv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get approval history
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT va.action, va.comment, va.created_at, u.email
|
||||||
|
FROM version_approvals va
|
||||||
|
LEFT JOIN users u ON va.approver_id = u.id
|
||||||
|
WHERE va.version_id = $1
|
||||||
|
ORDER BY va.created_at DESC
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
var approvalHistory []map[string]interface{}
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var action, email string
|
||||||
|
var comment *string
|
||||||
|
var createdAt time.Time
|
||||||
|
if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil {
|
||||||
|
approvalHistory = append(approvalHistory, map[string]interface{}{
|
||||||
|
"action": action,
|
||||||
|
"comment": comment,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"approver": email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"current_version": currentVersion,
|
||||||
|
"published_version": publishedVersion,
|
||||||
|
"approval_history": approvalHistory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetApprovalHistory returns the approval history for a version
|
||||||
|
func (h *Handler) AdminGetApprovalHistory(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name
|
||||||
|
FROM version_approvals va
|
||||||
|
LEFT JOIN users u ON va.approver_id = u.id
|
||||||
|
WHERE va.version_id = $1
|
||||||
|
ORDER BY va.created_at DESC
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var history []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var action string
|
||||||
|
var comment *string
|
||||||
|
var createdAt time.Time
|
||||||
|
var email, name *string
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
history = append(history, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"action": action,
|
||||||
|
"comment": comment,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"approver": email,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"approval_history": history})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SCHEDULED PUBLISHING
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ProcessScheduledPublishing publishes all versions that are due
|
||||||
|
// This should be called by a cron job or scheduler
|
||||||
|
func (h *Handler) ProcessScheduledPublishing(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Find all scheduled versions that are due
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, document_id, version
|
||||||
|
FROM document_versions
|
||||||
|
WHERE status = 'scheduled'
|
||||||
|
AND scheduled_publish_at IS NOT NULL
|
||||||
|
AND scheduled_publish_at <= NOW()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var published []string
|
||||||
|
for rows.Next() {
|
||||||
|
var versionID, docID uuid.UUID
|
||||||
|
var version string
|
||||||
|
if err := rows.Scan(&versionID, &docID, &version); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish this version
|
||||||
|
_, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'published', published_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// Archive previous published versions for this document
|
||||||
|
h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'archived', updated_at = NOW()
|
||||||
|
WHERE document_id = $1 AND id != $2 AND status = 'published'
|
||||||
|
`, docID, versionID)
|
||||||
|
|
||||||
|
// Log the publishing
|
||||||
|
details := fmt.Sprintf("Version %s automatically published by scheduler", version)
|
||||||
|
h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler")
|
||||||
|
|
||||||
|
published = append(published, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Scheduled publishing processed",
|
||||||
|
"published_count": len(published),
|
||||||
|
"published_versions": published,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScheduledVersions returns all versions scheduled for publishing
|
||||||
|
func (h *Handler) GetScheduledVersions(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name
|
||||||
|
FROM document_versions dv
|
||||||
|
JOIN legal_documents ld ON ld.id = dv.document_id
|
||||||
|
WHERE dv.status = 'scheduled'
|
||||||
|
AND dv.scheduled_publish_at IS NOT NULL
|
||||||
|
ORDER BY dv.scheduled_publish_at ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type ScheduledVersion struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
DocumentID uuid.UUID `json:"document_id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ScheduledPublishAt *time.Time `json:"scheduled_publish_at"`
|
||||||
|
DocumentName string `json:"document_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions []ScheduledVersion
|
||||||
|
for rows.Next() {
|
||||||
|
var v ScheduledVersion
|
||||||
|
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions})
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ENDPOINTS - Document Management
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminGetDocuments returns all documents (including inactive) for admin
|
||||||
|
func (h *Handler) AdminGetDocuments(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
||||||
|
FROM legal_documents
|
||||||
|
ORDER BY sort_order ASC, created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var documents []models.LegalDocument
|
||||||
|
for rows.Next() {
|
||||||
|
var doc models.LegalDocument
|
||||||
|
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
||||||
|
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
documents = append(documents, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"documents": documents})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateDocument creates a new legal document
|
||||||
|
func (h *Handler) AdminCreateDocument(c *gin.Context) {
|
||||||
|
var req models.CreateDocumentRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var docID uuid.UUID
|
||||||
|
err := h.db.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO legal_documents (type, name, description, is_mandatory)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Document created successfully",
|
||||||
|
"id": docID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateDocument updates a legal document
|
||||||
|
func (h *Handler) AdminUpdateDocument(c *gin.Context) {
|
||||||
|
docID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
IsMandatory *bool `json:"is_mandatory"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE legal_documents
|
||||||
|
SET name = COALESCE($2, name),
|
||||||
|
description = COALESCE($3, description),
|
||||||
|
is_mandatory = COALESCE($4, is_mandatory),
|
||||||
|
is_active = COALESCE($5, is_active),
|
||||||
|
sort_order = COALESCE($6, sort_order),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteDocument soft-deletes a document (sets is_active to false)
|
||||||
|
func (h *Handler) AdminDeleteDocument(c *gin.Context) {
|
||||||
|
docID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE legal_documents
|
||||||
|
SET is_active = false, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, docID)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ENDPOINTS - Version Management
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminGetVersions returns all versions for a document
|
||||||
|
func (h *Handler) AdminGetVersions(c *gin.Context) {
|
||||||
|
docID, err := uuid.Parse(c.Param("docId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, document_id, version, language, title, content, summary, status,
|
||||||
|
published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at
|
||||||
|
FROM document_versions
|
||||||
|
WHERE document_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, docID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var versions []models.DocumentVersion
|
||||||
|
for rows.Next() {
|
||||||
|
var v models.DocumentVersion
|
||||||
|
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content,
|
||||||
|
&v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateVersion creates a new document version
|
||||||
|
func (h *Handler) AdminCreateVersion(c *gin.Context) {
|
||||||
|
var req models.CreateVersionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
docID, err := uuid.Parse(req.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var versionID uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7)
|
||||||
|
RETURNING id
|
||||||
|
`, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Check for unique constraint violation
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Log the actual error for debugging
|
||||||
|
fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Version created successfully",
|
||||||
|
"id": versionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateVersion updates a document version
|
||||||
|
func (h *Handler) AdminUpdateVersion(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.UpdateVersionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Check if version is in draft or review status (only these can be edited)
|
||||||
|
var status string
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "draft" && status != "review" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET title = COALESCE($2, title),
|
||||||
|
content = COALESCE($3, content),
|
||||||
|
summary = COALESCE($4, summary),
|
||||||
|
status = COALESCE($5, status),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID, req.Title, req.Content, req.Summary, req.Status)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPublishVersion publishes a document version
|
||||||
|
func (h *Handler) AdminPublishVersion(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Check current status
|
||||||
|
var status string
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "approved" && status != "review" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'published',
|
||||||
|
published_at = NOW(),
|
||||||
|
approved_by = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID, userID)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminArchiveVersion archives a document version
|
||||||
|
func (h *Handler) AdminArchiveVersion(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'archived', updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteVersion permanently deletes a draft/rejected version
|
||||||
|
// Only draft and rejected versions can be deleted. Published versions must be archived.
|
||||||
|
func (h *Handler) AdminDeleteVersion(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First check the version status - only draft/rejected can be deleted
|
||||||
|
var status string
|
||||||
|
var version string
|
||||||
|
var docID uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT status, version, document_id FROM document_versions WHERE id = $1
|
||||||
|
`, versionID).Scan(&status, &version, &docID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow deletion of draft and rejected versions
|
||||||
|
if status != "draft" && status != "rejected" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "Cannot delete version",
|
||||||
|
"message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.",
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the version
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM document_versions WHERE id = $1
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the deletion
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent)
|
||||||
|
VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5)
|
||||||
|
`, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent())
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Version deleted successfully",
|
||||||
|
"deleted_version": version,
|
||||||
|
"version_id": versionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ENDPOINTS - Cookie Categories
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminGetCookieCategories returns all cookie categories
|
||||||
|
func (h *Handler) AdminGetCookieCategories(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
|
||||||
|
is_mandatory, sort_order, is_active, created_at, updated_at
|
||||||
|
FROM cookie_categories
|
||||||
|
ORDER BY sort_order ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var categories []models.CookieCategory
|
||||||
|
for rows.Next() {
|
||||||
|
var cat models.CookieCategory
|
||||||
|
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
|
||||||
|
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder,
|
||||||
|
&cat.IsActive, &cat.CreatedAt, &cat.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
categories = append(categories, cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateCookieCategory creates a new cookie category
|
||||||
|
func (h *Handler) AdminCreateCookieCategory(c *gin.Context) {
|
||||||
|
var req models.CreateCookieCategoryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var catID uuid.UUID
|
||||||
|
err := h.db.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, req.IsMandatory, req.SortOrder).Scan(&catID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Cookie category created successfully",
|
||||||
|
"id": catID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateCookieCategory updates a cookie category
|
||||||
|
func (h *Handler) AdminUpdateCookieCategory(c *gin.Context) {
|
||||||
|
catID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
DisplayNameDE *string `json:"display_name_de"`
|
||||||
|
DisplayNameEN *string `json:"display_name_en"`
|
||||||
|
DescriptionDE *string `json:"description_de"`
|
||||||
|
DescriptionEN *string `json:"description_en"`
|
||||||
|
IsMandatory *bool `json:"is_mandatory"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE cookie_categories
|
||||||
|
SET display_name_de = COALESCE($2, display_name_de),
|
||||||
|
display_name_en = COALESCE($3, display_name_en),
|
||||||
|
description_de = COALESCE($4, description_de),
|
||||||
|
description_en = COALESCE($5, description_en),
|
||||||
|
is_mandatory = COALESCE($6, is_mandatory),
|
||||||
|
sort_order = COALESCE($7, sort_order),
|
||||||
|
is_active = COALESCE($8, is_active),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, catID, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN,
|
||||||
|
req.IsMandatory, req.SortOrder, req.IsActive)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Cookie category updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteCookieCategory soft-deletes a cookie category
|
||||||
|
func (h *Handler) AdminDeleteCookieCategory(c *gin.Context) {
|
||||||
|
catID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE cookie_categories
|
||||||
|
SET is_active = false, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, catID)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Cookie category deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ENDPOINTS - Statistics & Audit
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// GetConsentStats returns consent statistics
|
||||||
|
func (h *Handler) GetConsentStats(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
docType := c.Query("document_type")
|
||||||
|
|
||||||
|
var stats models.ConsentStats
|
||||||
|
|
||||||
|
// Total users
|
||||||
|
h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
||||||
|
|
||||||
|
// Consented users (with active consent)
|
||||||
|
query := `
|
||||||
|
SELECT COUNT(DISTINCT uc.user_id)
|
||||||
|
FROM user_consents uc
|
||||||
|
JOIN document_versions dv ON uc.document_version_id = dv.id
|
||||||
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||||
|
WHERE uc.consented = true AND uc.withdrawn_at IS NULL
|
||||||
|
`
|
||||||
|
if docType != "" {
|
||||||
|
query += ` AND ld.type = $1`
|
||||||
|
h.db.Pool.QueryRow(ctx, query, docType).Scan(&stats.ConsentedUsers)
|
||||||
|
} else {
|
||||||
|
h.db.Pool.QueryRow(ctx, query).Scan(&stats.ConsentedUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate consent rate
|
||||||
|
if stats.TotalUsers > 0 {
|
||||||
|
stats.ConsentRate = float64(stats.ConsentedUsers) / float64(stats.TotalUsers) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent consents (last 7 days)
|
||||||
|
h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM user_consents
|
||||||
|
WHERE consented = true AND consented_at > NOW() - INTERVAL '7 days'
|
||||||
|
`).Scan(&stats.RecentConsents)
|
||||||
|
|
||||||
|
// Recent withdrawals
|
||||||
|
h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM user_consents
|
||||||
|
WHERE withdrawn_at IS NOT NULL AND withdrawn_at > NOW() - INTERVAL '7 days'
|
||||||
|
`).Scan(&stats.RecentWithdrawals)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCookieStats returns cookie consent statistics
|
||||||
|
func (h *Handler) GetCookieStats(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT cat.name,
|
||||||
|
COUNT(DISTINCT u.id) as total_users,
|
||||||
|
COUNT(DISTINCT CASE WHEN cc.consented = true THEN cc.user_id END) as consented_users
|
||||||
|
FROM cookie_categories cat
|
||||||
|
CROSS JOIN users u
|
||||||
|
LEFT JOIN cookie_consents cc ON cat.id = cc.category_id AND u.id = cc.user_id
|
||||||
|
WHERE cat.is_active = true
|
||||||
|
GROUP BY cat.id, cat.name
|
||||||
|
ORDER BY cat.sort_order
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch stats"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var stats []models.CookieStats
|
||||||
|
for rows.Next() {
|
||||||
|
var s models.CookieStats
|
||||||
|
if err := rows.Scan(&s.Category, &s.TotalUsers, &s.ConsentedUsers); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.TotalUsers > 0 {
|
||||||
|
s.ConsentRate = float64(s.ConsentedUsers) / float64(s.TotalUsers) * 100
|
||||||
|
}
|
||||||
|
stats = append(stats, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"cookie_stats": stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLog returns audit log entries
|
||||||
|
func (h *Handler) GetAuditLog(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
limit := 50
|
||||||
|
offset := 0
|
||||||
|
if l := c.Query("limit"); l != "" {
|
||||||
|
if parsed, err := parseIntFromQuery(l); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o := c.Query("offset"); o != "" {
|
||||||
|
if parsed, err := parseIntFromQuery(o); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
userIDFilter := c.Query("user_id")
|
||||||
|
actionFilter := c.Query("action")
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT al.id, al.user_id, al.action, al.entity_type, al.entity_id, al.details,
|
||||||
|
al.ip_address, al.user_agent, al.created_at, u.email
|
||||||
|
FROM consent_audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE 1=1
|
||||||
|
`
|
||||||
|
args := []interface{}{}
|
||||||
|
argCount := 0
|
||||||
|
|
||||||
|
if userIDFilter != "" {
|
||||||
|
argCount++
|
||||||
|
query += fmt.Sprintf(" AND al.user_id = $%d", argCount)
|
||||||
|
args = append(args, userIDFilter)
|
||||||
|
}
|
||||||
|
if actionFilter != "" {
|
||||||
|
argCount++
|
||||||
|
query += fmt.Sprintf(" AND al.action = $%d", argCount)
|
||||||
|
args = append(args, actionFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += fmt.Sprintf(" ORDER BY al.created_at DESC LIMIT $%d OFFSET $%d", argCount+1, argCount+2)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit log"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id uuid.UUID
|
||||||
|
userIDPtr *uuid.UUID
|
||||||
|
action string
|
||||||
|
entityType *string
|
||||||
|
entityID *uuid.UUID
|
||||||
|
details *string
|
||||||
|
ipAddress *string
|
||||||
|
userAgent *string
|
||||||
|
createdAt time.Time
|
||||||
|
email *string
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &userIDPtr, &action, &entityType, &entityID, &details,
|
||||||
|
&ipAddress, &userAgent, &createdAt, &email); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logs = append(logs, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"user_id": userIDPtr,
|
||||||
|
"user_email": email,
|
||||||
|
"action": action,
|
||||||
|
"entity_type": entityType,
|
||||||
|
"entity_id": entityID,
|
||||||
|
"details": details,
|
||||||
|
"ip_address": ipAddress,
|
||||||
|
"user_agent": userAgent,
|
||||||
|
"created_at": createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"audit_log": logs})
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSiteConfig gibt die Konfiguration für eine Site zurück
|
||||||
|
// GET /api/v1/banner/config/:siteId
|
||||||
|
func (h *Handler) GetSiteConfig(c *gin.Context) {
|
||||||
|
siteID := c.Param("siteId")
|
||||||
|
|
||||||
|
// Standard-Kategorien (aus Datenbank oder Default)
|
||||||
|
categories := []CategoryConfig{
|
||||||
|
{
|
||||||
|
ID: "essential",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Essentiell",
|
||||||
|
"en": "Essential",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Notwendig für die Grundfunktionen der Website.",
|
||||||
|
"en": "Required for basic website functionality.",
|
||||||
|
},
|
||||||
|
Required: true,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "functional",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Funktional",
|
||||||
|
"en": "Functional",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Ermöglicht Personalisierung und Komfortfunktionen.",
|
||||||
|
"en": "Enables personalization and comfort features.",
|
||||||
|
},
|
||||||
|
Required: false,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "analytics",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Statistik",
|
||||||
|
"en": "Analytics",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Hilft uns, die Website zu verbessern.",
|
||||||
|
"en": "Helps us improve the website.",
|
||||||
|
},
|
||||||
|
Required: false,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "marketing",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Marketing",
|
||||||
|
"en": "Marketing",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Ermöglicht personalisierte Werbung.",
|
||||||
|
"en": "Enables personalized advertising.",
|
||||||
|
},
|
||||||
|
Required: false,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "social",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Soziale Medien",
|
||||||
|
"en": "Social Media",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Ermöglicht Inhalte von sozialen Netzwerken.",
|
||||||
|
"en": "Enables content from social networks.",
|
||||||
|
},
|
||||||
|
Required: false,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
config := SiteConfig{
|
||||||
|
SiteID: siteID,
|
||||||
|
SiteName: "BreakPilot",
|
||||||
|
Categories: categories,
|
||||||
|
UI: UIConfig{
|
||||||
|
Theme: "auto",
|
||||||
|
Position: "bottom",
|
||||||
|
},
|
||||||
|
Legal: LegalConfig{
|
||||||
|
PrivacyPolicyURL: "/datenschutz",
|
||||||
|
ImprintURL: "/impressum",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20)
|
||||||
|
// GET /api/v1/banner/consent/export?userId=xxx
|
||||||
|
func (h *Handler) ExportBannerConsent(c *gin.Context) {
|
||||||
|
userID := c.Query("userId")
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "missing_user_id",
|
||||||
|
"message": "userId parameter is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, site_id, device_fingerprint, categories, vendors,
|
||||||
|
version, created_at, updated_at, revoked_at
|
||||||
|
FROM banner_consents
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "export_failed",
|
||||||
|
"message": "Failed to export consent data",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var consents []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, siteID, deviceFingerprint, version string
|
||||||
|
var categoriesJSON, vendorsJSON []byte
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
var revokedAt *time.Time
|
||||||
|
|
||||||
|
rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON,
|
||||||
|
&version, &createdAt, &updatedAt, &revokedAt)
|
||||||
|
|
||||||
|
var categories, vendors map[string]bool
|
||||||
|
json.Unmarshal(categoriesJSON, &categories)
|
||||||
|
json.Unmarshal(vendorsJSON, &vendors)
|
||||||
|
|
||||||
|
consent := map[string]interface{}{
|
||||||
|
"consentId": id,
|
||||||
|
"siteId": siteID,
|
||||||
|
"consent": map[string]interface{}{
|
||||||
|
"categories": categories,
|
||||||
|
"vendors": vendors,
|
||||||
|
},
|
||||||
|
"createdAt": createdAt.UTC().Format(time.RFC3339),
|
||||||
|
"revokedAt": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if revokedAt != nil {
|
||||||
|
consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
consents = append(consents, consent)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"userId": userID,
|
||||||
|
"exportedAt": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"consents": consents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBannerStats gibt anonymisierte Statistiken zurück (Admin)
|
||||||
|
// GET /api/v1/banner/admin/stats/:siteId
|
||||||
|
func (h *Handler) GetBannerStats(c *gin.Context) {
|
||||||
|
siteID := c.Param("siteId")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Gesamtanzahl Consents
|
||||||
|
var totalConsents int
|
||||||
|
h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM banner_consents
|
||||||
|
WHERE site_id = $1 AND revoked_at IS NULL
|
||||||
|
`, siteID).Scan(&totalConsents)
|
||||||
|
|
||||||
|
// Consent-Rate pro Kategorie
|
||||||
|
categoryStats := make(map[string]map[string]interface{})
|
||||||
|
|
||||||
|
rows, _ := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
key as category,
|
||||||
|
COUNT(*) FILTER (WHERE value::text = 'true') as accepted,
|
||||||
|
COUNT(*) as total
|
||||||
|
FROM banner_consents,
|
||||||
|
jsonb_each(categories::jsonb)
|
||||||
|
WHERE site_id = $1 AND revoked_at IS NULL
|
||||||
|
GROUP BY key
|
||||||
|
`, siteID)
|
||||||
|
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var category string
|
||||||
|
var accepted, total int
|
||||||
|
rows.Scan(&category, &accepted, &total)
|
||||||
|
|
||||||
|
rate := float64(0)
|
||||||
|
if total > 0 {
|
||||||
|
rate = float64(accepted) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryStats[category] = map[string]interface{}{
|
||||||
|
"accepted": accepted,
|
||||||
|
"rate": rate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"siteId": siteID,
|
||||||
|
"period": gin.H{
|
||||||
|
"from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"),
|
||||||
|
"to": time.Now().Format("2006-01-02"),
|
||||||
|
},
|
||||||
|
"totalConsents": totalConsents,
|
||||||
|
"consentByCategory": categoryStats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Helper Functions
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform)
|
||||||
|
func anonymizeIP(ip string) string {
|
||||||
|
// IPv4: Letztes Oktett auf 0
|
||||||
|
parts := strings.Split(ip, ".")
|
||||||
|
if len(parts) == 4 {
|
||||||
|
parts[3] = "0"
|
||||||
|
anonymized := strings.Join(parts, ".")
|
||||||
|
hash := sha256.Sum256([]byte(anonymized))
|
||||||
|
return hex.EncodeToString(hash[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6: Hash
|
||||||
|
hash := sha256.Sum256([]byte(ip))
|
||||||
|
return hex.EncodeToString(hash[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// logBannerConsentAudit schreibt einen Audit-Log-Eintrag
|
||||||
|
func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) {
|
||||||
|
details, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO banner_consent_audit_log (
|
||||||
|
id, consent_id, action, details, ip_hash, created_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
`, uuid.New().String(), consentID, action, string(details), ipHash)
|
||||||
|
}
|
||||||
@@ -2,11 +2,8 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -308,254 +305,3 @@ func (h *Handler) RevokeBannerConsent(c *gin.Context) {
|
|||||||
"revokedAt": time.Now().UTC().Format(time.RFC3339),
|
"revokedAt": time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSiteConfig gibt die Konfiguration für eine Site zurück
|
|
||||||
// GET /api/v1/banner/config/:siteId
|
|
||||||
func (h *Handler) GetSiteConfig(c *gin.Context) {
|
|
||||||
siteID := c.Param("siteId")
|
|
||||||
|
|
||||||
// Standard-Kategorien (aus Datenbank oder Default)
|
|
||||||
categories := []CategoryConfig{
|
|
||||||
{
|
|
||||||
ID: "essential",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Essentiell",
|
|
||||||
"en": "Essential",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Notwendig für die Grundfunktionen der Website.",
|
|
||||||
"en": "Required for basic website functionality.",
|
|
||||||
},
|
|
||||||
Required: true,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "functional",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Funktional",
|
|
||||||
"en": "Functional",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Ermöglicht Personalisierung und Komfortfunktionen.",
|
|
||||||
"en": "Enables personalization and comfort features.",
|
|
||||||
},
|
|
||||||
Required: false,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "analytics",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Statistik",
|
|
||||||
"en": "Analytics",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Hilft uns, die Website zu verbessern.",
|
|
||||||
"en": "Helps us improve the website.",
|
|
||||||
},
|
|
||||||
Required: false,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "marketing",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Marketing",
|
|
||||||
"en": "Marketing",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Ermöglicht personalisierte Werbung.",
|
|
||||||
"en": "Enables personalized advertising.",
|
|
||||||
},
|
|
||||||
Required: false,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "social",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Soziale Medien",
|
|
||||||
"en": "Social Media",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Ermöglicht Inhalte von sozialen Netzwerken.",
|
|
||||||
"en": "Enables content from social networks.",
|
|
||||||
},
|
|
||||||
Required: false,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
config := SiteConfig{
|
|
||||||
SiteID: siteID,
|
|
||||||
SiteName: "BreakPilot",
|
|
||||||
Categories: categories,
|
|
||||||
UI: UIConfig{
|
|
||||||
Theme: "auto",
|
|
||||||
Position: "bottom",
|
|
||||||
},
|
|
||||||
Legal: LegalConfig{
|
|
||||||
PrivacyPolicyURL: "/datenschutz",
|
|
||||||
ImprintURL: "/impressum",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20)
|
|
||||||
// GET /api/v1/banner/consent/export?userId=xxx
|
|
||||||
func (h *Handler) ExportBannerConsent(c *gin.Context) {
|
|
||||||
userID := c.Query("userId")
|
|
||||||
|
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "missing_user_id",
|
|
||||||
"message": "userId parameter is required",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT id, site_id, device_fingerprint, categories, vendors,
|
|
||||||
version, created_at, updated_at, revoked_at
|
|
||||||
FROM banner_consents
|
|
||||||
WHERE user_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`, userID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "export_failed",
|
|
||||||
"message": "Failed to export consent data",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var consents []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var id, siteID, deviceFingerprint, version string
|
|
||||||
var categoriesJSON, vendorsJSON []byte
|
|
||||||
var createdAt, updatedAt time.Time
|
|
||||||
var revokedAt *time.Time
|
|
||||||
|
|
||||||
rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON,
|
|
||||||
&version, &createdAt, &updatedAt, &revokedAt)
|
|
||||||
|
|
||||||
var categories, vendors map[string]bool
|
|
||||||
json.Unmarshal(categoriesJSON, &categories)
|
|
||||||
json.Unmarshal(vendorsJSON, &vendors)
|
|
||||||
|
|
||||||
consent := map[string]interface{}{
|
|
||||||
"consentId": id,
|
|
||||||
"siteId": siteID,
|
|
||||||
"consent": map[string]interface{}{
|
|
||||||
"categories": categories,
|
|
||||||
"vendors": vendors,
|
|
||||||
},
|
|
||||||
"createdAt": createdAt.UTC().Format(time.RFC3339),
|
|
||||||
"revokedAt": nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
if revokedAt != nil {
|
|
||||||
consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
consents = append(consents, consent)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"userId": userID,
|
|
||||||
"exportedAt": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
"consents": consents,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBannerStats gibt anonymisierte Statistiken zurück (Admin)
|
|
||||||
// GET /api/v1/banner/admin/stats/:siteId
|
|
||||||
func (h *Handler) GetBannerStats(c *gin.Context) {
|
|
||||||
siteID := c.Param("siteId")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Gesamtanzahl Consents
|
|
||||||
var totalConsents int
|
|
||||||
h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM banner_consents
|
|
||||||
WHERE site_id = $1 AND revoked_at IS NULL
|
|
||||||
`, siteID).Scan(&totalConsents)
|
|
||||||
|
|
||||||
// Consent-Rate pro Kategorie
|
|
||||||
categoryStats := make(map[string]map[string]interface{})
|
|
||||||
|
|
||||||
rows, _ := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT
|
|
||||||
key as category,
|
|
||||||
COUNT(*) FILTER (WHERE value::text = 'true') as accepted,
|
|
||||||
COUNT(*) as total
|
|
||||||
FROM banner_consents,
|
|
||||||
jsonb_each(categories::jsonb)
|
|
||||||
WHERE site_id = $1 AND revoked_at IS NULL
|
|
||||||
GROUP BY key
|
|
||||||
`, siteID)
|
|
||||||
|
|
||||||
if rows != nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var category string
|
|
||||||
var accepted, total int
|
|
||||||
rows.Scan(&category, &accepted, &total)
|
|
||||||
|
|
||||||
rate := float64(0)
|
|
||||||
if total > 0 {
|
|
||||||
rate = float64(accepted) / float64(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryStats[category] = map[string]interface{}{
|
|
||||||
"accepted": accepted,
|
|
||||||
"rate": rate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"siteId": siteID,
|
|
||||||
"period": gin.H{
|
|
||||||
"from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"),
|
|
||||||
"to": time.Now().Format("2006-01-02"),
|
|
||||||
},
|
|
||||||
"totalConsents": totalConsents,
|
|
||||||
"consentByCategory": categoryStats,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Helper Functions
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform)
|
|
||||||
func anonymizeIP(ip string) string {
|
|
||||||
// IPv4: Letztes Oktett auf 0
|
|
||||||
parts := strings.Split(ip, ".")
|
|
||||||
if len(parts) == 4 {
|
|
||||||
parts[3] = "0"
|
|
||||||
anonymized := strings.Join(parts, ".")
|
|
||||||
hash := sha256.Sum256([]byte(anonymized))
|
|
||||||
return hex.EncodeToString(hash[:])[:16]
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv6: Hash
|
|
||||||
hash := sha256.Sum256([]byte(ip))
|
|
||||||
return hex.EncodeToString(hash[:])[:16]
|
|
||||||
}
|
|
||||||
|
|
||||||
// logBannerConsentAudit schreibt einen Audit-Log-Eintrag
|
|
||||||
func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) {
|
|
||||||
details, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
h.db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO banner_consent_audit_log (
|
|
||||||
id, consent_id, action, details, ip_hash, created_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, NOW())
|
|
||||||
`, uuid.New().String(), consentID, action, string(details), ipHash)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -273,239 +273,3 @@ func (h *CommunicationHandlers) RegisterMatrixUser(c *gin.Context) {
|
|||||||
"user_id": resp.UserID,
|
"user_id": resp.UserID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Jitsi Video Conference Endpoints
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CreateMeetingRequest for creating Jitsi meetings
|
|
||||||
type CreateMeetingRequest struct {
|
|
||||||
Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class"
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
Duration int `json:"duration,omitempty"` // minutes
|
|
||||||
ClassName string `json:"class_name,omitempty"`
|
|
||||||
ParentName string `json:"parent_name,omitempty"`
|
|
||||||
StudentName string `json:"student_name,omitempty"`
|
|
||||||
Subject string `json:"subject,omitempty"`
|
|
||||||
StartTime time.Time `json:"start_time,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateMeeting creates a new Jitsi meeting
|
|
||||||
func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) {
|
|
||||||
if h.jitsiService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CreateMeetingRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
var link *jitsi.MeetingLink
|
|
||||||
var err error
|
|
||||||
|
|
||||||
switch req.Type {
|
|
||||||
case "quick":
|
|
||||||
link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName)
|
|
||||||
case "training":
|
|
||||||
link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration)
|
|
||||||
case "parent_teacher":
|
|
||||||
link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime)
|
|
||||||
case "class":
|
|
||||||
link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject)
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"room_name": link.RoomName,
|
|
||||||
"url": link.URL,
|
|
||||||
"join_url": link.JoinURL,
|
|
||||||
"moderator_url": link.ModeratorURL,
|
|
||||||
"password": link.Password,
|
|
||||||
"expires_at": link.ExpiresAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmbedURLRequest for embedding Jitsi
|
|
||||||
type GetEmbedURLRequest struct {
|
|
||||||
RoomName string `json:"room_name" binding:"required"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
AudioMuted bool `json:"audio_muted"`
|
|
||||||
VideoMuted bool `json:"video_muted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmbedURL returns an embeddable Jitsi URL
|
|
||||||
func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) {
|
|
||||||
if h.jitsiService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req GetEmbedURLRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &jitsi.MeetingConfig{
|
|
||||||
StartWithAudioMuted: req.AudioMuted,
|
|
||||||
StartWithVideoMuted: req.VideoMuted,
|
|
||||||
DisableDeepLinking: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config)
|
|
||||||
iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"embed_url": embedURL,
|
|
||||||
"iframe_code": iframeCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJitsiInfo returns Jitsi server information
|
|
||||||
func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) {
|
|
||||||
if h.jitsiService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
info := h.jitsiService.GetServerInfo()
|
|
||||||
c.JSON(http.StatusOK, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Admin Statistics Endpoints (for Admin Panel)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CommunicationStats holds communication service statistics
|
|
||||||
type CommunicationStats struct {
|
|
||||||
Matrix MatrixStats `json:"matrix"`
|
|
||||||
Jitsi JitsiStats `json:"jitsi"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MatrixStats holds Matrix-specific statistics
|
|
||||||
type MatrixStats struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Healthy bool `json:"healthy"`
|
|
||||||
ServerName string `json:"server_name"`
|
|
||||||
// TODO: Add real stats from Matrix Synapse Admin API
|
|
||||||
TotalUsers int `json:"total_users"`
|
|
||||||
TotalRooms int `json:"total_rooms"`
|
|
||||||
ActiveToday int `json:"active_today"`
|
|
||||||
MessagesToday int `json:"messages_today"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// JitsiStats holds Jitsi-specific statistics
|
|
||||||
type JitsiStats struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Healthy bool `json:"healthy"`
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
AuthEnabled bool `json:"auth_enabled"`
|
|
||||||
// TODO: Add real stats from Jitsi SRTP API or Jicofo
|
|
||||||
ActiveMeetings int `json:"active_meetings"`
|
|
||||||
TotalParticipants int `json:"total_participants"`
|
|
||||||
MeetingsToday int `json:"meetings_today"`
|
|
||||||
AvgDurationMin int `json:"avg_duration_min"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAdminStats returns admin statistics for Matrix and Jitsi
|
|
||||||
func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
stats := CommunicationStats{}
|
|
||||||
|
|
||||||
// Matrix Stats
|
|
||||||
if h.matrixService != nil {
|
|
||||||
matrixErr := h.matrixService.HealthCheck(ctx)
|
|
||||||
stats.Matrix = MatrixStats{
|
|
||||||
Enabled: true,
|
|
||||||
Healthy: matrixErr == nil,
|
|
||||||
ServerName: h.matrixService.GetServerName(),
|
|
||||||
// Placeholder stats - in production these would come from Synapse Admin API
|
|
||||||
TotalUsers: 0,
|
|
||||||
TotalRooms: 0,
|
|
||||||
ActiveToday: 0,
|
|
||||||
MessagesToday: 0,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stats.Matrix = MatrixStats{Enabled: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jitsi Stats
|
|
||||||
if h.jitsiService != nil {
|
|
||||||
jitsiErr := h.jitsiService.HealthCheck(ctx)
|
|
||||||
serverInfo := h.jitsiService.GetServerInfo()
|
|
||||||
stats.Jitsi = JitsiStats{
|
|
||||||
Enabled: true,
|
|
||||||
Healthy: jitsiErr == nil,
|
|
||||||
BaseURL: serverInfo["base_url"],
|
|
||||||
AuthEnabled: serverInfo["auth_enabled"] == "true",
|
|
||||||
// Placeholder stats - in production these would come from Jicofo/JVB stats
|
|
||||||
ActiveMeetings: 0,
|
|
||||||
TotalParticipants: 0,
|
|
||||||
MeetingsToday: 0,
|
|
||||||
AvgDurationMin: 0,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stats.Jitsi = JitsiStats{Enabled: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Helper Functions
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
func errToString(err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes registers all communication routes
|
|
||||||
func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) {
|
|
||||||
comm := router.Group("/communication")
|
|
||||||
{
|
|
||||||
// Public health check
|
|
||||||
comm.GET("/status", h.GetCommunicationStatus)
|
|
||||||
|
|
||||||
// Protected routes
|
|
||||||
protected := comm.Group("")
|
|
||||||
protected.Use(authMiddleware)
|
|
||||||
{
|
|
||||||
// Matrix
|
|
||||||
protected.POST("/rooms", h.CreateRoom)
|
|
||||||
protected.POST("/rooms/invite", h.InviteUser)
|
|
||||||
protected.POST("/messages", h.SendMessage)
|
|
||||||
protected.POST("/notifications", h.SendNotification)
|
|
||||||
|
|
||||||
// Jitsi
|
|
||||||
protected.POST("/meetings", h.CreateMeeting)
|
|
||||||
protected.POST("/meetings/embed", h.GetEmbedURL)
|
|
||||||
protected.GET("/jitsi/info", h.GetJitsiInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin routes (for Matrix user registration and stats)
|
|
||||||
admin := comm.Group("/admin")
|
|
||||||
admin.Use(authMiddleware)
|
|
||||||
// TODO: Add AdminOnly middleware
|
|
||||||
{
|
|
||||||
admin.POST("/matrix/users", h.RegisterMatrixUser)
|
|
||||||
admin.GET("/stats", h.GetAdminStats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/services/jitsi"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Jitsi Video Conference Endpoints
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CreateMeetingRequest for creating Jitsi meetings
|
||||||
|
type CreateMeetingRequest struct {
|
||||||
|
Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class"
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Duration int `json:"duration,omitempty"` // minutes
|
||||||
|
ClassName string `json:"class_name,omitempty"`
|
||||||
|
ParentName string `json:"parent_name,omitempty"`
|
||||||
|
StudentName string `json:"student_name,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
StartTime time.Time `json:"start_time,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMeeting creates a new Jitsi meeting
|
||||||
|
func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) {
|
||||||
|
if h.jitsiService == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateMeetingRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var link *jitsi.MeetingLink
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "quick":
|
||||||
|
link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName)
|
||||||
|
case "training":
|
||||||
|
link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration)
|
||||||
|
case "parent_teacher":
|
||||||
|
link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime)
|
||||||
|
case "class":
|
||||||
|
link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject)
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"room_name": link.RoomName,
|
||||||
|
"url": link.URL,
|
||||||
|
"join_url": link.JoinURL,
|
||||||
|
"moderator_url": link.ModeratorURL,
|
||||||
|
"password": link.Password,
|
||||||
|
"expires_at": link.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmbedURLRequest for embedding Jitsi
|
||||||
|
type GetEmbedURLRequest struct {
|
||||||
|
RoomName string `json:"room_name" binding:"required"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
AudioMuted bool `json:"audio_muted"`
|
||||||
|
VideoMuted bool `json:"video_muted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmbedURL returns an embeddable Jitsi URL
|
||||||
|
func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) {
|
||||||
|
if h.jitsiService == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req GetEmbedURLRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &jitsi.MeetingConfig{
|
||||||
|
StartWithAudioMuted: req.AudioMuted,
|
||||||
|
StartWithVideoMuted: req.VideoMuted,
|
||||||
|
DisableDeepLinking: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config)
|
||||||
|
iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"embed_url": embedURL,
|
||||||
|
"iframe_code": iframeCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJitsiInfo returns Jitsi server information
|
||||||
|
func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) {
|
||||||
|
if h.jitsiService == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := h.jitsiService.GetServerInfo()
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Admin Statistics Endpoints (for Admin Panel)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CommunicationStats holds communication service statistics
|
||||||
|
type CommunicationStats struct {
|
||||||
|
Matrix MatrixStats `json:"matrix"`
|
||||||
|
Jitsi JitsiStats `json:"jitsi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatrixStats holds Matrix-specific statistics
|
||||||
|
type MatrixStats struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
ServerName string `json:"server_name"`
|
||||||
|
// TODO: Add real stats from Matrix Synapse Admin API
|
||||||
|
TotalUsers int `json:"total_users"`
|
||||||
|
TotalRooms int `json:"total_rooms"`
|
||||||
|
ActiveToday int `json:"active_today"`
|
||||||
|
MessagesToday int `json:"messages_today"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JitsiStats holds Jitsi-specific statistics
|
||||||
|
type JitsiStats struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
AuthEnabled bool `json:"auth_enabled"`
|
||||||
|
// TODO: Add real stats from Jitsi SRTP API or Jicofo
|
||||||
|
ActiveMeetings int `json:"active_meetings"`
|
||||||
|
TotalParticipants int `json:"total_participants"`
|
||||||
|
MeetingsToday int `json:"meetings_today"`
|
||||||
|
AvgDurationMin int `json:"avg_duration_min"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminStats returns admin statistics for Matrix and Jitsi
|
||||||
|
func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
stats := CommunicationStats{}
|
||||||
|
|
||||||
|
// Matrix Stats
|
||||||
|
if h.matrixService != nil {
|
||||||
|
matrixErr := h.matrixService.HealthCheck(ctx)
|
||||||
|
stats.Matrix = MatrixStats{
|
||||||
|
Enabled: true,
|
||||||
|
Healthy: matrixErr == nil,
|
||||||
|
ServerName: h.matrixService.GetServerName(),
|
||||||
|
// Placeholder stats - in production these would come from Synapse Admin API
|
||||||
|
TotalUsers: 0,
|
||||||
|
TotalRooms: 0,
|
||||||
|
ActiveToday: 0,
|
||||||
|
MessagesToday: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats.Matrix = MatrixStats{Enabled: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jitsi Stats
|
||||||
|
if h.jitsiService != nil {
|
||||||
|
jitsiErr := h.jitsiService.HealthCheck(ctx)
|
||||||
|
serverInfo := h.jitsiService.GetServerInfo()
|
||||||
|
stats.Jitsi = JitsiStats{
|
||||||
|
Enabled: true,
|
||||||
|
Healthy: jitsiErr == nil,
|
||||||
|
BaseURL: serverInfo["base_url"],
|
||||||
|
AuthEnabled: serverInfo["auth_enabled"] == "true",
|
||||||
|
// Placeholder stats - in production these would come from Jicofo/JVB stats
|
||||||
|
ActiveMeetings: 0,
|
||||||
|
TotalParticipants: 0,
|
||||||
|
MeetingsToday: 0,
|
||||||
|
AvgDurationMin: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats.Jitsi = JitsiStats{Enabled: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Helper Functions
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
func errToString(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers all communication routes
|
||||||
|
func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) {
|
||||||
|
comm := router.Group("/communication")
|
||||||
|
{
|
||||||
|
// Public health check
|
||||||
|
comm.GET("/status", h.GetCommunicationStatus)
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
protected := comm.Group("")
|
||||||
|
protected.Use(authMiddleware)
|
||||||
|
{
|
||||||
|
// Matrix
|
||||||
|
protected.POST("/rooms", h.CreateRoom)
|
||||||
|
protected.POST("/rooms/invite", h.InviteUser)
|
||||||
|
protected.POST("/messages", h.SendMessage)
|
||||||
|
protected.POST("/notifications", h.SendNotification)
|
||||||
|
|
||||||
|
// Jitsi
|
||||||
|
protected.POST("/meetings", h.CreateMeeting)
|
||||||
|
protected.POST("/meetings/embed", h.GetEmbedURL)
|
||||||
|
protected.GET("/jitsi/info", h.GetJitsiInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin routes (for Matrix user registration and stats)
|
||||||
|
admin := comm.Group("/admin")
|
||||||
|
admin.Use(authMiddleware)
|
||||||
|
// TODO: Add AdminOnly middleware
|
||||||
|
{
|
||||||
|
admin.POST("/matrix/users", h.RegisterMatrixUser)
|
||||||
|
admin.GET("/stats", h.GetAdminStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PUBLIC ENDPOINTS - Consent
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CreateConsent creates a new user consent
|
||||||
|
func (h *Handler) CreateConsent(c *gin.Context) {
|
||||||
|
var req models.CreateConsentRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID, err := uuid.Parse(req.VersionID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Upsert consent
|
||||||
|
var consentID uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (user_id, document_version_id)
|
||||||
|
DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL
|
||||||
|
RETURNING id
|
||||||
|
`, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to audit trail
|
||||||
|
h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Consent saved successfully",
|
||||||
|
"consent_id": consentID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyConsents returns all consents for the current user
|
||||||
|
func (h *Handler) GetMyConsents(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at,
|
||||||
|
ld.id, ld.type, ld.name, ld.is_mandatory,
|
||||||
|
dv.id, dv.version, dv.language, dv.title
|
||||||
|
FROM user_consents uc
|
||||||
|
JOIN document_versions dv ON uc.document_version_id = dv.id
|
||||||
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||||
|
WHERE uc.user_id = $1
|
||||||
|
ORDER BY uc.consented_at DESC
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var consents []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
consentID uuid.UUID
|
||||||
|
consented bool
|
||||||
|
consentedAt time.Time
|
||||||
|
withdrawnAt *time.Time
|
||||||
|
docID uuid.UUID
|
||||||
|
docType string
|
||||||
|
docName string
|
||||||
|
isMandatory bool
|
||||||
|
versionID uuid.UUID
|
||||||
|
version string
|
||||||
|
language string
|
||||||
|
title string
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt,
|
||||||
|
&docID, &docType, &docName, &isMandatory,
|
||||||
|
&versionID, &version, &language, &title); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
consents = append(consents, map[string]interface{}{
|
||||||
|
"consent_id": consentID,
|
||||||
|
"consented": consented,
|
||||||
|
"consented_at": consentedAt,
|
||||||
|
"withdrawn_at": withdrawnAt,
|
||||||
|
"document": map[string]interface{}{
|
||||||
|
"id": docID,
|
||||||
|
"type": docType,
|
||||||
|
"name": docName,
|
||||||
|
"is_mandatory": isMandatory,
|
||||||
|
},
|
||||||
|
"version": map[string]interface{}{
|
||||||
|
"id": versionID,
|
||||||
|
"version": version,
|
||||||
|
"language": language,
|
||||||
|
"title": title,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"consents": consents})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckConsent checks if the user has consented to a document
|
||||||
|
func (h *Handler) CheckConsent(c *gin.Context) {
|
||||||
|
docType := c.Param("documentType")
|
||||||
|
language := c.DefaultQuery("language", "de")
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Get latest published version
|
||||||
|
var latestVersionID uuid.UUID
|
||||||
|
var latestVersion string
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT dv.id, dv.version
|
||||||
|
FROM document_versions dv
|
||||||
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||||
|
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
|
||||||
|
ORDER BY dv.published_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, docType, language).Scan(&latestVersionID, &latestVersion)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
||||||
|
HasConsent: false,
|
||||||
|
NeedsUpdate: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has consented to this version
|
||||||
|
var consentedVersionID uuid.UUID
|
||||||
|
var consentedVersion string
|
||||||
|
var consentedAt time.Time
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT dv.id, dv.version, uc.consented_at
|
||||||
|
FROM user_consents uc
|
||||||
|
JOIN document_versions dv ON uc.document_version_id = dv.id
|
||||||
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||||
|
WHERE uc.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL
|
||||||
|
ORDER BY uc.consented_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// No consent found
|
||||||
|
latestIDStr := latestVersionID.String()
|
||||||
|
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
||||||
|
HasConsent: false,
|
||||||
|
CurrentVersionID: &latestIDStr,
|
||||||
|
NeedsUpdate: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if consent is for latest version
|
||||||
|
needsUpdate := consentedVersionID != latestVersionID
|
||||||
|
latestIDStr := latestVersionID.String()
|
||||||
|
consentedVerStr := consentedVersion
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
||||||
|
HasConsent: true,
|
||||||
|
CurrentVersionID: &latestIDStr,
|
||||||
|
ConsentedVersion: &consentedVerStr,
|
||||||
|
NeedsUpdate: needsUpdate,
|
||||||
|
ConsentedAt: &consentedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithdrawConsent withdraws a consent
|
||||||
|
func (h *Handler) WithdrawConsent(c *gin.Context) {
|
||||||
|
consentID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Update consent
|
||||||
|
result, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE user_consents
|
||||||
|
SET withdrawn_at = NOW(), consented = false
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`, consentID, userID)
|
||||||
|
|
||||||
|
if err != nil || result.RowsAffected() == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to audit trail
|
||||||
|
h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PUBLIC ENDPOINTS - Cookie Consent
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// GetCookieCategories returns all active cookie categories
|
||||||
|
func (h *Handler) GetCookieCategories(c *gin.Context) {
|
||||||
|
language := c.DefaultQuery("language", "de")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
|
||||||
|
is_mandatory, sort_order
|
||||||
|
FROM cookie_categories
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY sort_order ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var categories []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var cat models.CookieCategory
|
||||||
|
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
|
||||||
|
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return localized data
|
||||||
|
displayName := cat.DisplayNameDE
|
||||||
|
description := cat.DescriptionDE
|
||||||
|
if language == "en" && cat.DisplayNameEN != nil {
|
||||||
|
displayName = *cat.DisplayNameEN
|
||||||
|
if cat.DescriptionEN != nil {
|
||||||
|
description = cat.DescriptionEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = append(categories, map[string]interface{}{
|
||||||
|
"id": cat.ID,
|
||||||
|
"name": cat.Name,
|
||||||
|
"display_name": displayName,
|
||||||
|
"description": description,
|
||||||
|
"is_mandatory": cat.IsMandatory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCookieConsent sets cookie preferences for a user
|
||||||
|
func (h *Handler) SetCookieConsent(c *gin.Context) {
|
||||||
|
var req models.CookieConsentRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Process each category
|
||||||
|
for _, cat := range req.Categories {
|
||||||
|
categoryID, err := uuid.Parse(cat.CategoryID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO cookie_consents (user_id, category_id, consented)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, category_id)
|
||||||
|
DO UPDATE SET consented = $3, updated_at = NOW()
|
||||||
|
`, userID, categoryID, cat.Consented)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to audit trail
|
||||||
|
h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyCookieConsent returns cookie preferences for the current user
|
||||||
|
func (h *Handler) GetMyCookieConsent(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT cc.category_id, cc.consented, cc.updated_at,
|
||||||
|
cat.name, cat.display_name_de, cat.is_mandatory
|
||||||
|
FROM cookie_consents cc
|
||||||
|
JOIN cookie_categories cat ON cc.category_id = cat.id
|
||||||
|
WHERE cc.user_id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var consents []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
categoryID uuid.UUID
|
||||||
|
consented bool
|
||||||
|
updatedAt time.Time
|
||||||
|
name string
|
||||||
|
displayName string
|
||||||
|
isMandatory bool
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
consents = append(consents, map[string]interface{}{
|
||||||
|
"category_id": categoryID,
|
||||||
|
"name": name,
|
||||||
|
"display_name": displayName,
|
||||||
|
"consented": consented,
|
||||||
|
"is_mandatory": isMandatory,
|
||||||
|
"updated_at": updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"cookie_consents": consents})
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PUBLIC ENDPOINTS - Documents
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// GetDocuments returns all active legal documents
|
||||||
|
func (h *Handler) GetDocuments(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
||||||
|
FROM legal_documents
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY sort_order ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var documents []models.LegalDocument
|
||||||
|
for rows.Next() {
|
||||||
|
var doc models.LegalDocument
|
||||||
|
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
||||||
|
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
documents = append(documents, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"documents": documents})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDocumentByType returns a document by its type
|
||||||
|
func (h *Handler) GetDocumentByType(c *gin.Context) {
|
||||||
|
docType := c.Param("type")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var doc models.LegalDocument
|
||||||
|
err := h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
||||||
|
FROM legal_documents
|
||||||
|
WHERE type = $1 AND is_active = true
|
||||||
|
`, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
||||||
|
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestDocumentVersion returns the latest published version of a document
|
||||||
|
func (h *Handler) GetLatestDocumentVersion(c *gin.Context) {
|
||||||
|
docType := c.Param("type")
|
||||||
|
language := c.DefaultQuery("language", "de")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var version models.DocumentVersion
|
||||||
|
err := h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content,
|
||||||
|
dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at
|
||||||
|
FROM document_versions dv
|
||||||
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||||
|
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
|
||||||
|
ORDER BY dv.published_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language,
|
||||||
|
&version.Title, &version.Content, &version.Summary, &version.Status,
|
||||||
|
&version.PublishedAt, &version.CreatedAt, &version.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, version)
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
@@ -135,814 +133,3 @@ func (h *DSRHandler) CancelMyDSR(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde storniert"})
|
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde storniert"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ADMIN ENDPOINTS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminListDSR returns all DSRs with filters (admin only)
|
|
||||||
func (h *DSRHandler) AdminListDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse pagination
|
|
||||||
limit := 20
|
|
||||||
offset := 0
|
|
||||||
if l := c.Query("limit"); l != "" {
|
|
||||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
||||||
limit = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if o := c.Query("offset"); o != "" {
|
|
||||||
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
|
||||||
offset = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse filters
|
|
||||||
filters := models.DSRListFilters{}
|
|
||||||
if status := c.Query("status"); status != "" {
|
|
||||||
filters.Status = &status
|
|
||||||
}
|
|
||||||
if reqType := c.Query("request_type"); reqType != "" {
|
|
||||||
filters.RequestType = &reqType
|
|
||||||
}
|
|
||||||
if assignedTo := c.Query("assigned_to"); assignedTo != "" {
|
|
||||||
filters.AssignedTo = &assignedTo
|
|
||||||
}
|
|
||||||
if priority := c.Query("priority"); priority != "" {
|
|
||||||
filters.Priority = &priority
|
|
||||||
}
|
|
||||||
if c.Query("overdue_only") == "true" {
|
|
||||||
filters.OverdueOnly = true
|
|
||||||
}
|
|
||||||
if search := c.Query("search"); search != "" {
|
|
||||||
filters.Search = &search
|
|
||||||
}
|
|
||||||
if from := c.Query("from_date"); from != "" {
|
|
||||||
if t, err := time.Parse("2006-01-02", from); err == nil {
|
|
||||||
filters.FromDate = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if to := c.Query("to_date"); to != "" {
|
|
||||||
if t, err := time.Parse("2006-01-02", to); err == nil {
|
|
||||||
filters.ToDate = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"requests": dsrs,
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSR returns a specific DSR (admin only)
|
|
||||||
func (h *DSRHandler) AdminGetDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, dsr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCreateDSR creates a DSR manually (admin only)
|
|
||||||
func (h *DSRHandler) AdminCreateDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.CreateDSRRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set source as admin_panel
|
|
||||||
if req.Source == "" {
|
|
||||||
req.Source = "admin_panel"
|
|
||||||
}
|
|
||||||
|
|
||||||
dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Anfrage wurde erstellt",
|
|
||||||
"request_number": dsr.RequestNumber,
|
|
||||||
"dsr": dsr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateDSR updates a DSR (admin only)
|
|
||||||
func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.UpdateDSRRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Update status if provided
|
|
||||||
if req.Status != nil {
|
|
||||||
err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update processing notes
|
|
||||||
if req.ProcessingNotes != nil {
|
|
||||||
h.dsrService.GetPool().Exec(ctx, `
|
|
||||||
UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2
|
|
||||||
`, *req.ProcessingNotes, dsrID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update priority
|
|
||||||
if req.Priority != nil {
|
|
||||||
h.dsrService.GetPool().Exec(ctx, `
|
|
||||||
UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2
|
|
||||||
`, *req.Priority, dsrID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get updated DSR
|
|
||||||
dsr, _ := h.dsrService.GetByID(ctx, dsrID)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Anfrage wurde aktualisiert",
|
|
||||||
"dsr": dsr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSRStats returns dashboard statistics
|
|
||||||
func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := h.dsrService.GetDashboardStats(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminVerifyIdentity verifies the identity of a requester
|
|
||||||
func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.VerifyDSRIdentityRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminAssignDSR assigns a DSR to a user
|
|
||||||
func (h *DSRHandler) AdminAssignDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
AssigneeID string `json:"assignee_id" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assigneeID, err := uuid.Parse(req.AssigneeID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminExtendDSRDeadline extends the deadline for a DSR
|
|
||||||
func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.ExtendDSRDeadlineRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCompleteDSR marks a DSR as completed
|
|
||||||
func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.CompleteDSRRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminRejectDSR rejects a DSR
|
|
||||||
func (h *DSRHandler) AdminRejectDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.RejectDSRRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSRHistory returns the status history for a DSR
|
|
||||||
func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"history": history})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSRCommunications returns communications for a DSR
|
|
||||||
func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"communications": comms})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSendDSRCommunication sends a communication for a DSR
|
|
||||||
func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.SendDSRCommunicationRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateDSRStatus updates the status of a DSR
|
|
||||||
func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Status string `json:"status" binding:"required"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// EXCEPTION CHECKS (Art. 17)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminGetExceptionChecks returns exception checks for an erasure DSR
|
|
||||||
func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"exception_checks": checks})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminInitExceptionChecks initializes exception checks for an erasure DSR
|
|
||||||
func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateExceptionCheck updates a single exception check
|
|
||||||
func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
checkID, err := uuid.Parse(c.Param("checkId"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Applies bool `json:"applies"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// TEMPLATE ENDPOINTS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminGetDSRTemplates returns all DSR templates
|
|
||||||
func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
rows, err := h.dsrService.GetPool().Query(ctx, `
|
|
||||||
SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at
|
|
||||||
FROM dsr_templates ORDER BY sort_order, name
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var templates []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var id uuid.UUID
|
|
||||||
var templateType, name string
|
|
||||||
var description *string
|
|
||||||
var requestTypes []byte
|
|
||||||
var isActive bool
|
|
||||||
var sortOrder int
|
|
||||||
var createdAt, updatedAt time.Time
|
|
||||||
|
|
||||||
err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
templates = append(templates, map[string]interface{}{
|
|
||||||
"id": id,
|
|
||||||
"template_type": templateType,
|
|
||||||
"name": name,
|
|
||||||
"description": description,
|
|
||||||
"request_types": string(requestTypes),
|
|
||||||
"is_active": isActive,
|
|
||||||
"sort_order": sortOrder,
|
|
||||||
"created_at": createdAt,
|
|
||||||
"updated_at": updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSRTemplateVersions returns versions for a template
|
|
||||||
func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
rows, err := h.dsrService.GetPool().Query(ctx, `
|
|
||||||
SELECT id, template_id, version, language, subject, body_html, body_text,
|
|
||||||
status, published_at, created_by, approved_by, approved_at, created_at, updated_at
|
|
||||||
FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC
|
|
||||||
`, templateID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var versions []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var id, tempID uuid.UUID
|
|
||||||
var version, language, subject, bodyHTML, bodyText, status string
|
|
||||||
var publishedAt, approvedAt *time.Time
|
|
||||||
var createdBy, approvedBy *uuid.UUID
|
|
||||||
var createdAt, updatedAt time.Time
|
|
||||||
|
|
||||||
err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText,
|
|
||||||
&status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
versions = append(versions, map[string]interface{}{
|
|
||||||
"id": id,
|
|
||||||
"template_id": tempID,
|
|
||||||
"version": version,
|
|
||||||
"language": language,
|
|
||||||
"subject": subject,
|
|
||||||
"body_html": bodyHTML,
|
|
||||||
"body_text": bodyText,
|
|
||||||
"status": status,
|
|
||||||
"published_at": publishedAt,
|
|
||||||
"created_by": createdBy,
|
|
||||||
"approved_by": approvedBy,
|
|
||||||
"approved_at": approvedAt,
|
|
||||||
"created_at": createdAt,
|
|
||||||
"updated_at": updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCreateDSRTemplateVersion creates a new template version
|
|
||||||
func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Version string `json:"version" binding:"required"`
|
|
||||||
Language string `json:"language"`
|
|
||||||
Subject string `json:"subject" binding:"required"`
|
|
||||||
BodyHTML string `json:"body_html" binding:"required"`
|
|
||||||
BodyText string `json:"body_text"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Language == "" {
|
|
||||||
req.Language = "de"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
var versionID uuid.UUID
|
|
||||||
err = h.dsrService.GetPool().QueryRow(ctx, `
|
|
||||||
INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id
|
|
||||||
`, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Version wurde erstellt",
|
|
||||||
"id": versionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminPublishDSRTemplateVersion publishes a template version
|
|
||||||
func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
versionID, err := uuid.Parse(c.Param("versionId"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
_, err = h.dsrService.GetPool().Exec(ctx, `
|
|
||||||
UPDATE dsr_template_versions
|
|
||||||
SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW()
|
|
||||||
WHERE id = $2
|
|
||||||
`, userID, versionID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetPublishedDSRTemplates returns all published templates for selection
|
|
||||||
func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requestType := c.Query("request_type")
|
|
||||||
language := c.DefaultQuery("language", "de")
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
query := `
|
|
||||||
SELECT t.id, t.template_type, t.name, t.description,
|
|
||||||
v.id as version_id, v.version, v.subject, v.body_html, v.body_text
|
|
||||||
FROM dsr_templates t
|
|
||||||
JOIN dsr_template_versions v ON t.id = v.template_id
|
|
||||||
WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1
|
|
||||||
`
|
|
||||||
args := []interface{}{language}
|
|
||||||
|
|
||||||
if requestType != "" {
|
|
||||||
query += ` AND t.request_types @> $2::jsonb`
|
|
||||||
args = append(args, `["`+requestType+`"]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY t.sort_order, t.name"
|
|
||||||
|
|
||||||
rows, err := h.dsrService.GetPool().Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var templates []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var templateID, versionID uuid.UUID
|
|
||||||
var templateType, name, version, subject, bodyHTML, bodyText string
|
|
||||||
var description *string
|
|
||||||
|
|
||||||
err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
templates = append(templates, map[string]interface{}{
|
|
||||||
"template_id": templateID,
|
|
||||||
"template_type": templateType,
|
|
||||||
"name": name,
|
|
||||||
"description": description,
|
|
||||||
"version_id": versionID,
|
|
||||||
"version": version,
|
|
||||||
"subject": subject,
|
|
||||||
"body_html": bodyHTML,
|
|
||||||
"body_text": bodyText,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// DEADLINE PROCESSING
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// ProcessDeadlines triggers deadline checking (called by scheduler)
|
|
||||||
func (h *DSRHandler) ProcessDeadlines(c *gin.Context) {
|
|
||||||
err := h.dsrService.ProcessDeadlines(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ENDPOINTS — CRUD & Workflow
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminListDSR returns all DSRs with filters (admin only)
|
||||||
|
func (h *DSRHandler) AdminListDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse pagination
|
||||||
|
limit := 20
|
||||||
|
offset := 0
|
||||||
|
if l := c.Query("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o := c.Query("offset"); o != "" {
|
||||||
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filters
|
||||||
|
filters := models.DSRListFilters{}
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
filters.Status = &status
|
||||||
|
}
|
||||||
|
if reqType := c.Query("request_type"); reqType != "" {
|
||||||
|
filters.RequestType = &reqType
|
||||||
|
}
|
||||||
|
if assignedTo := c.Query("assigned_to"); assignedTo != "" {
|
||||||
|
filters.AssignedTo = &assignedTo
|
||||||
|
}
|
||||||
|
if priority := c.Query("priority"); priority != "" {
|
||||||
|
filters.Priority = &priority
|
||||||
|
}
|
||||||
|
if c.Query("overdue_only") == "true" {
|
||||||
|
filters.OverdueOnly = true
|
||||||
|
}
|
||||||
|
if search := c.Query("search"); search != "" {
|
||||||
|
filters.Search = &search
|
||||||
|
}
|
||||||
|
if from := c.Query("from_date"); from != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
||||||
|
filters.FromDate = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if to := c.Query("to_date"); to != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", to); err == nil {
|
||||||
|
filters.ToDate = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"requests": dsrs,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSR returns a specific DSR (admin only)
|
||||||
|
func (h *DSRHandler) AdminGetDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dsr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateDSR creates a DSR manually (admin only)
|
||||||
|
func (h *DSRHandler) AdminCreateDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.CreateDSRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set source as admin_panel
|
||||||
|
if req.Source == "" {
|
||||||
|
req.Source = "admin_panel"
|
||||||
|
}
|
||||||
|
|
||||||
|
dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Anfrage wurde erstellt",
|
||||||
|
"request_number": dsr.RequestNumber,
|
||||||
|
"dsr": dsr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateDSR updates a DSR (admin only)
|
||||||
|
func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.UpdateDSRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// Update status if provided
|
||||||
|
if req.Status != nil {
|
||||||
|
err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update processing notes
|
||||||
|
if req.ProcessingNotes != nil {
|
||||||
|
h.dsrService.GetPool().Exec(ctx, `
|
||||||
|
UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`, *req.ProcessingNotes, dsrID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update priority
|
||||||
|
if req.Priority != nil {
|
||||||
|
h.dsrService.GetPool().Exec(ctx, `
|
||||||
|
UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`, *req.Priority, dsrID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated DSR
|
||||||
|
dsr, _ := h.dsrService.GetByID(ctx, dsrID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Anfrage wurde aktualisiert",
|
||||||
|
"dsr": dsr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSRStats returns dashboard statistics
|
||||||
|
func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := h.dsrService.GetDashboardStats(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminVerifyIdentity verifies the identity of a requester
|
||||||
|
func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.VerifyDSRIdentityRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAssignDSR assigns a DSR to a user
|
||||||
|
func (h *DSRHandler) AdminAssignDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AssigneeID string `json:"assignee_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assigneeID, err := uuid.Parse(req.AssigneeID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminExtendDSRDeadline extends the deadline for a DSR
|
||||||
|
func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.ExtendDSRDeadlineRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCompleteDSR marks a DSR as completed
|
||||||
|
func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.CompleteDSRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRejectDSR rejects a DSR
|
||||||
|
func (h *DSRHandler) AdminRejectDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.RejectDSRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSRHistory returns the status history for a DSR
|
||||||
|
func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"history": history})
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN — Communications & Status
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminGetDSRCommunications returns communications for a DSR
|
||||||
|
func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"communications": comms})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSendDSRCommunication sends a communication for a DSR
|
||||||
|
func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.SendDSRCommunicationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateDSRStatus updates the status of a DSR
|
||||||
|
func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status" binding:"required"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// EXCEPTION CHECKS (Art. 17)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminGetExceptionChecks returns exception checks for an erasure DSR
|
||||||
|
func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"exception_checks": checks})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminInitExceptionChecks initializes exception checks for an erasure DSR
|
||||||
|
func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateExceptionCheck updates a single exception check
|
||||||
|
func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checkID, err := uuid.Parse(c.Param("checkId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Applies bool `json:"applies"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// DEADLINE PROCESSING
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ProcessDeadlines triggers deadline checking (called by scheduler)
|
||||||
|
func (h *DSRHandler) ProcessDeadlines(c *gin.Context) {
|
||||||
|
err := h.dsrService.ProcessDeadlines(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// TEMPLATE ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminGetDSRTemplates returns all DSR templates
|
||||||
|
func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
rows, err := h.dsrService.GetPool().Query(ctx, `
|
||||||
|
SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at
|
||||||
|
FROM dsr_templates ORDER BY sort_order, name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var templates []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var templateType, name string
|
||||||
|
var description *string
|
||||||
|
var requestTypes []byte
|
||||||
|
var isActive bool
|
||||||
|
var sortOrder int
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = append(templates, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"template_type": templateType,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"request_types": string(requestTypes),
|
||||||
|
"is_active": isActive,
|
||||||
|
"sort_order": sortOrder,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"updated_at": updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSRTemplateVersions returns versions for a template
|
||||||
|
func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
rows, err := h.dsrService.GetPool().Query(ctx, `
|
||||||
|
SELECT id, template_id, version, language, subject, body_html, body_text,
|
||||||
|
status, published_at, created_by, approved_by, approved_at, created_at, updated_at
|
||||||
|
FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC
|
||||||
|
`, templateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var versions []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, tempID uuid.UUID
|
||||||
|
var version, language, subject, bodyHTML, bodyText, status string
|
||||||
|
var publishedAt, approvedAt *time.Time
|
||||||
|
var createdBy, approvedBy *uuid.UUID
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText,
|
||||||
|
&status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
versions = append(versions, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"template_id": tempID,
|
||||||
|
"version": version,
|
||||||
|
"language": language,
|
||||||
|
"subject": subject,
|
||||||
|
"body_html": bodyHTML,
|
||||||
|
"body_text": bodyText,
|
||||||
|
"status": status,
|
||||||
|
"published_at": publishedAt,
|
||||||
|
"created_by": createdBy,
|
||||||
|
"approved_by": approvedBy,
|
||||||
|
"approved_at": approvedAt,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"updated_at": updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateDSRTemplateVersion creates a new template version
|
||||||
|
func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Version string `json:"version" binding:"required"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Subject string `json:"subject" binding:"required"`
|
||||||
|
BodyHTML string `json:"body_html" binding:"required"`
|
||||||
|
BodyText string `json:"body_text"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Language == "" {
|
||||||
|
req.Language = "de"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var versionID uuid.UUID
|
||||||
|
err = h.dsrService.GetPool().QueryRow(ctx, `
|
||||||
|
INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Version wurde erstellt",
|
||||||
|
"id": versionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPublishDSRTemplateVersion publishes a template version
|
||||||
|
func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID, err := uuid.Parse(c.Param("versionId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
_, err = h.dsrService.GetPool().Exec(ctx, `
|
||||||
|
UPDATE dsr_template_versions
|
||||||
|
SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
`, userID, versionID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetPublishedDSRTemplates returns all published templates for selection
|
||||||
|
func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestType := c.Query("request_type")
|
||||||
|
language := c.DefaultQuery("language", "de")
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
query := `
|
||||||
|
SELECT t.id, t.template_type, t.name, t.description,
|
||||||
|
v.id as version_id, v.version, v.subject, v.body_html, v.body_text
|
||||||
|
FROM dsr_templates t
|
||||||
|
JOIN dsr_template_versions v ON t.id = v.template_id
|
||||||
|
WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1
|
||||||
|
`
|
||||||
|
args := []interface{}{language}
|
||||||
|
|
||||||
|
if requestType != "" {
|
||||||
|
query += ` AND t.request_types @> $2::jsonb`
|
||||||
|
args = append(args, `["`+requestType+`"]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY t.sort_order, t.name"
|
||||||
|
|
||||||
|
rows, err := h.dsrService.GetPool().Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var templates []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var templateID, versionID uuid.UUID
|
||||||
|
var templateType, name, version, subject, bodyHTML, bodyText string
|
||||||
|
var description *string
|
||||||
|
|
||||||
|
err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = append(templates, map[string]interface{}{
|
||||||
|
"template_id": templateID,
|
||||||
|
"template_type": templateType,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"version_id": versionID,
|
||||||
|
"version": version,
|
||||||
|
"subject": subject,
|
||||||
|
"body_html": bodyHTML,
|
||||||
|
"body_text": bodyText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
@@ -261,268 +260,3 @@ func (h *EmailTemplateHandler) RejectVersion(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "version rejected"})
|
c.JSON(http.StatusOK, gin.H{"message": "version rejected"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishVersion publishes an approved version
|
|
||||||
// POST /api/v1/admin/email-template-versions/:id/publish
|
|
||||||
func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := uuid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
role, exists := c.Get("user_role")
|
|
||||||
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
uid, _ := uuid.Parse(userID.(string))
|
|
||||||
|
|
||||||
if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "version published"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetApprovals returns approval history for a version
|
|
||||||
// GET /api/v1/admin/email-template-versions/:id/approvals
|
|
||||||
func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := uuid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
approvals, err := h.service.GetApprovals(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"approvals": approvals})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreviewVersion renders a preview of an email template version
|
|
||||||
// POST /api/v1/admin/email-template-versions/:id/preview
|
|
||||||
func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := uuid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Variables map[string]string `json:"variables"`
|
|
||||||
}
|
|
||||||
c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
version, err := h.service.GetVersionByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use default test values if not provided
|
|
||||||
if req.Variables == nil {
|
|
||||||
req.Variables = map[string]string{
|
|
||||||
"user_name": "Max Mustermann",
|
|
||||||
"user_email": "max@example.com",
|
|
||||||
"login_url": "https://breakpilot.app/login",
|
|
||||||
"support_email": "support@breakpilot.app",
|
|
||||||
"verification_url": "https://breakpilot.app/verify?token=abc123",
|
|
||||||
"verification_code": "123456",
|
|
||||||
"expires_in": "24 Stunden",
|
|
||||||
"reset_url": "https://breakpilot.app/reset?token=xyz789",
|
|
||||||
"reset_code": "RESET123",
|
|
||||||
"ip_address": "192.168.1.1",
|
|
||||||
"device_info": "Chrome auf Windows 11",
|
|
||||||
"changed_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"enabled_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"disabled_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"support_url": "https://breakpilot.app/support",
|
|
||||||
"security_url": "https://breakpilot.app/account/security",
|
|
||||||
"login_time": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"location": "Berlin, Deutschland",
|
|
||||||
"activity_type": "Mehrere fehlgeschlagene Login-Versuche",
|
|
||||||
"activity_time": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"locked_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"reason": "Zu viele fehlgeschlagene Login-Versuche",
|
|
||||||
"unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"),
|
|
||||||
"unlocked_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"requested_at": time.Now().Format("02.01.2006"),
|
|
||||||
"deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"),
|
|
||||||
"cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123",
|
|
||||||
"data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs",
|
|
||||||
"deleted_at": time.Now().Format("02.01.2006"),
|
|
||||||
"feedback_url": "https://breakpilot.app/feedback",
|
|
||||||
"download_url": "https://breakpilot.app/export/download?token=export123",
|
|
||||||
"file_size": "2.3 MB",
|
|
||||||
"old_email": "alt@example.com",
|
|
||||||
"new_email": "neu@example.com",
|
|
||||||
"document_name": "Datenschutzerklärung",
|
|
||||||
"document_type": "privacy",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"consent_url": "https://breakpilot.app/consent",
|
|
||||||
"deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"),
|
|
||||||
"days_left": "7",
|
|
||||||
"hours_left": "24 Stunden",
|
|
||||||
"consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.",
|
|
||||||
"suspended_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
preview, err := h.service.RenderTemplate(version, req.Variables)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, preview)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendTestEmail sends a test email
|
|
||||||
// POST /api/v1/admin/email-template-versions/:id/send-test
|
|
||||||
func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := uuid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.SendTestEmailRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.VersionID = idStr
|
|
||||||
|
|
||||||
version, err := h.service.GetVersionByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get template to find type
|
|
||||||
template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
uid, _ := uuid.Parse(userID.(string))
|
|
||||||
|
|
||||||
// Send test email
|
|
||||||
if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "test email sent"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSettings returns global email settings
|
|
||||||
// GET /api/v1/admin/email-templates/settings
|
|
||||||
func (h *EmailTemplateHandler) GetSettings(c *gin.Context) {
|
|
||||||
settings, err := h.service.GetSettings(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
// Return default settings if none exist
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"company_name": "BreakPilot",
|
|
||||||
"sender_name": "BreakPilot",
|
|
||||||
"sender_email": "noreply@breakpilot.app",
|
|
||||||
"primary_color": "#2563eb",
|
|
||||||
"secondary_color": "#64748b",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSettings updates global email settings
|
|
||||||
// PUT /api/v1/admin/email-templates/settings
|
|
||||||
func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) {
|
|
||||||
var req models.UpdateEmailTemplateSettingsRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
uid, _ := uuid.Parse(userID.(string))
|
|
||||||
|
|
||||||
if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmailStats returns email statistics
|
|
||||||
// GET /api/v1/admin/email-templates/stats
|
|
||||||
func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) {
|
|
||||||
stats, err := h.service.GetEmailStats(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSendLogs returns email send logs
|
|
||||||
// GET /api/v1/admin/email-templates/logs
|
|
||||||
func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) {
|
|
||||||
limitStr := c.DefaultQuery("limit", "50")
|
|
||||||
offsetStr := c.DefaultQuery("offset", "0")
|
|
||||||
|
|
||||||
limit, _ := strconv.Atoi(limitStr)
|
|
||||||
offset, _ := strconv.Atoi(offsetStr)
|
|
||||||
|
|
||||||
if limit > 100 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultContent returns default template content for a type
|
|
||||||
// GET /api/v1/admin/email-templates/default/:type
|
|
||||||
func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) {
|
|
||||||
templateType := c.Param("type")
|
|
||||||
language := c.DefaultQuery("language", "de")
|
|
||||||
|
|
||||||
subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"subject": subject,
|
|
||||||
"body_html": bodyHTML,
|
|
||||||
"body_text": bodyText,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitializeTemplates initializes default email templates
|
|
||||||
// POST /api/v1/admin/email-templates/initialize
|
|
||||||
func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) {
|
|
||||||
role, exists := c.Get("user_role")
|
|
||||||
if !exists || (role != "admin" && role != "super_admin") {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublishVersion publishes an approved version
|
||||||
|
// POST /api/v1/admin/email-template-versions/:id/publish
|
||||||
|
func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, exists := c.Get("user_role")
|
||||||
|
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := uuid.Parse(userID.(string))
|
||||||
|
|
||||||
|
if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "version published"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetApprovals returns approval history for a version
|
||||||
|
// GET /api/v1/admin/email-template-versions/:id/approvals
|
||||||
|
func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
approvals, err := h.service.GetApprovals(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"approvals": approvals})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewVersion renders a preview of an email template version
|
||||||
|
// POST /api/v1/admin/email-template-versions/:id/preview
|
||||||
|
func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Variables map[string]string `json:"variables"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
version, err := h.service.GetVersionByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default test values if not provided
|
||||||
|
if req.Variables == nil {
|
||||||
|
req.Variables = map[string]string{
|
||||||
|
"user_name": "Max Mustermann",
|
||||||
|
"user_email": "max@example.com",
|
||||||
|
"login_url": "https://breakpilot.app/login",
|
||||||
|
"support_email": "support@breakpilot.app",
|
||||||
|
"verification_url": "https://breakpilot.app/verify?token=abc123",
|
||||||
|
"verification_code": "123456",
|
||||||
|
"expires_in": "24 Stunden",
|
||||||
|
"reset_url": "https://breakpilot.app/reset?token=xyz789",
|
||||||
|
"reset_code": "RESET123",
|
||||||
|
"ip_address": "192.168.1.1",
|
||||||
|
"device_info": "Chrome auf Windows 11",
|
||||||
|
"changed_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"enabled_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"disabled_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"support_url": "https://breakpilot.app/support",
|
||||||
|
"security_url": "https://breakpilot.app/account/security",
|
||||||
|
"login_time": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"location": "Berlin, Deutschland",
|
||||||
|
"activity_type": "Mehrere fehlgeschlagene Login-Versuche",
|
||||||
|
"activity_time": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"locked_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"reason": "Zu viele fehlgeschlagene Login-Versuche",
|
||||||
|
"unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"),
|
||||||
|
"unlocked_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"requested_at": time.Now().Format("02.01.2006"),
|
||||||
|
"deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"),
|
||||||
|
"cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123",
|
||||||
|
"data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs",
|
||||||
|
"deleted_at": time.Now().Format("02.01.2006"),
|
||||||
|
"feedback_url": "https://breakpilot.app/feedback",
|
||||||
|
"download_url": "https://breakpilot.app/export/download?token=export123",
|
||||||
|
"file_size": "2.3 MB",
|
||||||
|
"old_email": "alt@example.com",
|
||||||
|
"new_email": "neu@example.com",
|
||||||
|
"document_name": "Datenschutzerklärung",
|
||||||
|
"document_type": "privacy",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"consent_url": "https://breakpilot.app/consent",
|
||||||
|
"deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"),
|
||||||
|
"days_left": "7",
|
||||||
|
"hours_left": "24 Stunden",
|
||||||
|
"consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.",
|
||||||
|
"suspended_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := h.service.RenderTemplate(version, req.Variables)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTestEmail sends a test email
|
||||||
|
// POST /api/v1/admin/email-template-versions/:id/send-test
|
||||||
|
func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.SendTestEmailRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.VersionID = idStr
|
||||||
|
|
||||||
|
version, err := h.service.GetVersionByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template to find type
|
||||||
|
template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := uuid.Parse(userID.(string))
|
||||||
|
|
||||||
|
// Send test email
|
||||||
|
if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "test email sent"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettings returns global email settings
|
||||||
|
// GET /api/v1/admin/email-templates/settings
|
||||||
|
func (h *EmailTemplateHandler) GetSettings(c *gin.Context) {
|
||||||
|
settings, err := h.service.GetSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
// Return default settings if none exist
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"company_name": "BreakPilot",
|
||||||
|
"sender_name": "BreakPilot",
|
||||||
|
"sender_email": "noreply@breakpilot.app",
|
||||||
|
"primary_color": "#2563eb",
|
||||||
|
"secondary_color": "#64748b",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings updates global email settings
|
||||||
|
// PUT /api/v1/admin/email-templates/settings
|
||||||
|
func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) {
|
||||||
|
var req models.UpdateEmailTemplateSettingsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := uuid.Parse(userID.(string))
|
||||||
|
|
||||||
|
if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmailStats returns email statistics
|
||||||
|
// GET /api/v1/admin/email-templates/stats
|
||||||
|
func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) {
|
||||||
|
stats, err := h.service.GetEmailStats(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSendLogs returns email send logs
|
||||||
|
// GET /api/v1/admin/email-templates/logs
|
||||||
|
func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) {
|
||||||
|
limitStr := c.DefaultQuery("limit", "50")
|
||||||
|
offsetStr := c.DefaultQuery("offset", "0")
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(limitStr)
|
||||||
|
offset, _ := strconv.Atoi(offsetStr)
|
||||||
|
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultContent returns default template content for a type
|
||||||
|
// GET /api/v1/admin/email-templates/default/:type
|
||||||
|
func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) {
|
||||||
|
templateType := c.Param("type")
|
||||||
|
language := c.DefaultQuery("language", "de")
|
||||||
|
|
||||||
|
subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"subject": subject,
|
||||||
|
"body_html": bodyHTML,
|
||||||
|
"body_text": bodyText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitializeTemplates initializes default email templates
|
||||||
|
// POST /api/v1/admin/email-templates/initialize
|
||||||
|
func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) {
|
||||||
|
role, exists := c.Get("user_role")
|
||||||
|
if !exists || (role != "admin" && role != "super_admin") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// GDPR / DATA SUBJECT RIGHTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// GetMyData returns all data we have about the user
|
||||||
|
func (h *Handler) GetMyData(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Get user info
|
||||||
|
var user models.User
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, external_id, email, role, created_at, updated_at
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
`, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||||
|
|
||||||
|
// Get consents
|
||||||
|
consentRows, _ := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version
|
||||||
|
FROM user_consents uc
|
||||||
|
JOIN document_versions dv ON uc.document_version_id = dv.id
|
||||||
|
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||||
|
WHERE uc.user_id = $1
|
||||||
|
`, userID)
|
||||||
|
defer consentRows.Close()
|
||||||
|
|
||||||
|
var consents []map[string]interface{}
|
||||||
|
for consentRows.Next() {
|
||||||
|
var consented bool
|
||||||
|
var consentedAt time.Time
|
||||||
|
var docType, docName, version string
|
||||||
|
consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version)
|
||||||
|
consents = append(consents, map[string]interface{}{
|
||||||
|
"document_type": docType,
|
||||||
|
"document_name": docName,
|
||||||
|
"version": version,
|
||||||
|
"consented": consented,
|
||||||
|
"consented_at": consentedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cookie consents
|
||||||
|
cookieRows, _ := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT cat.name, cc.consented, cc.updated_at
|
||||||
|
FROM cookie_consents cc
|
||||||
|
JOIN cookie_categories cat ON cc.category_id = cat.id
|
||||||
|
WHERE cc.user_id = $1
|
||||||
|
`, userID)
|
||||||
|
defer cookieRows.Close()
|
||||||
|
|
||||||
|
var cookieConsents []map[string]interface{}
|
||||||
|
for cookieRows.Next() {
|
||||||
|
var name string
|
||||||
|
var consented bool
|
||||||
|
var updatedAt time.Time
|
||||||
|
cookieRows.Scan(&name, &consented, &updatedAt)
|
||||||
|
cookieConsents = append(cookieConsents, map[string]interface{}{
|
||||||
|
"category": name,
|
||||||
|
"consented": consented,
|
||||||
|
"updated_at": updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log data access
|
||||||
|
h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"created_at": user.CreatedAt,
|
||||||
|
},
|
||||||
|
"consents": consents,
|
||||||
|
"cookie_consents": cookieConsents,
|
||||||
|
"exported_at": time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDataExport creates a data export request
|
||||||
|
func (h *Handler) RequestDataExport(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
var requestID uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO data_export_requests (user_id, status)
|
||||||
|
VALUES ($1, 'pending')
|
||||||
|
RETURNING id
|
||||||
|
`, userID).Scan(&requestID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to audit trail
|
||||||
|
h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
|
"message": "Export request created. You will be notified when ready.",
|
||||||
|
"request_id": requestID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDataDeletion creates a data deletion request
|
||||||
|
func (h *Handler) RequestDataDeletion(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
var requestID uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO data_deletion_requests (user_id, status, reason)
|
||||||
|
VALUES ($1, 'pending', $2)
|
||||||
|
RETURNING id
|
||||||
|
`, userID, req.Reason).Scan(&requestID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to audit trail
|
||||||
|
h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
|
"message": "Deletion request created. We will process your request within 30 days.",
|
||||||
|
"request_id": requestID,
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,372 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/breakpilot/consent-service/internal/services"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 2FA (TOTP) Endpoints
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Setup2FA initiates 2FA setup
|
||||||
|
// POST /auth/2fa/setup
|
||||||
|
func (h *OAuthHandler) Setup2FA(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user email
|
||||||
|
ctx := context.Background()
|
||||||
|
user, err := h.authService.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup 2FA
|
||||||
|
response, err := h.totpService.Setup2FA(ctx, userID, user.Email)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPAlreadyEnabled:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify2FASetup verifies the 2FA setup with a code
|
||||||
|
// POST /auth/2fa/verify-setup
|
||||||
|
func (h *OAuthHandler) Verify2FASetup(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err = h.totpService.Verify2FASetup(ctx, userID, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPAlreadyEnabled:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"})
|
||||||
|
case services.ErrTOTPInvalidCode:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify2FAChallenge verifies a 2FA challenge during login
|
||||||
|
// POST /auth/2fa/verify
|
||||||
|
func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) {
|
||||||
|
var req models.Verify2FAChallengeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var userID *uuid.UUID
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if req.RecoveryCode != "" {
|
||||||
|
// Verify with recovery code
|
||||||
|
userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode)
|
||||||
|
} else {
|
||||||
|
// Verify with TOTP code
|
||||||
|
userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPChallengeExpired:
|
||||||
|
c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"})
|
||||||
|
case services.ErrTOTPInvalidCode:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||||
|
case services.ErrRecoveryCodeInvalid:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user and generate tokens
|
||||||
|
user, err := h.authService.GetUserByID(ctx, *userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
accessToken, err := h.authService.GenerateAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store session
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// We need direct DB access for this, or we need to add a method to AuthService
|
||||||
|
// For now, we'll return the tokens and let the caller handle session storage
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"name": user.Name,
|
||||||
|
"role": user.Role,
|
||||||
|
},
|
||||||
|
"_session_hash": refreshTokenHash,
|
||||||
|
"_ip": ipAddress,
|
||||||
|
"_user_agent": userAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable2FA disables 2FA for the current user
|
||||||
|
// POST /auth/2fa/disable
|
||||||
|
func (h *OAuthHandler) Disable2FA(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err = h.totpService.Disable2FA(ctx, userID, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPNotEnabled:
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
||||||
|
case services.ErrTOTPInvalidCode:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get2FAStatus returns the 2FA status for the current user
|
||||||
|
// GET /auth/2fa/status
|
||||||
|
func (h *OAuthHandler) Get2FAStatus(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
status, err := h.totpService.GetStatus(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateRecoveryCodes generates new recovery codes
|
||||||
|
// POST /auth/2fa/recovery-codes
|
||||||
|
func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPNotEnabled:
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
||||||
|
case services.ErrTOTPInvalidCode:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Enhanced Login with 2FA
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// LoginWith2FA handles login with optional 2FA
|
||||||
|
// POST /auth/login
|
||||||
|
func (h *OAuthHandler) LoginWith2FA(c *gin.Context) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Attempt login
|
||||||
|
response, err := h.authService.Login(ctx, &req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrInvalidCredentials:
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
|
||||||
|
case services.ErrAccountLocked:
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"})
|
||||||
|
case services.ErrAccountSuspended:
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is enabled
|
||||||
|
twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID)
|
||||||
|
|
||||||
|
if twoFactorEnabled {
|
||||||
|
// Create 2FA challenge
|
||||||
|
challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 2FA required response
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"requires_2fa": true,
|
||||||
|
"challenge_id": challengeID,
|
||||||
|
"message": "2FA verification required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No 2FA required, return tokens
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"requires_2fa": false,
|
||||||
|
"access_token": response.AccessToken,
|
||||||
|
"refresh_token": response.RefreshToken,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": response.ExpiresIn,
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"id": response.User.ID,
|
||||||
|
"email": response.User.Email,
|
||||||
|
"name": response.User.Name,
|
||||||
|
"role": response.User.Role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Registration with mandatory 2FA setup
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// RegisterWith2FA handles registration with mandatory 2FA setup
|
||||||
|
// POST /auth/register
|
||||||
|
func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) {
|
||||||
|
var req models.RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
if len(req.Password) < 8 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register user
|
||||||
|
user, verificationToken, err := h.authService.Register(ctx, &req)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrUserExists:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup 2FA immediately
|
||||||
|
twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email)
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal - user can set up 2FA later, but log it
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Registration successful. Please verify your email.",
|
||||||
|
"user_id": user.ID,
|
||||||
|
"verification_token": verificationToken, // In production, this would be sent via email
|
||||||
|
"two_factor_setup": nil,
|
||||||
|
"two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Registration successful. Please verify your email and complete 2FA setup.",
|
||||||
|
"user_id": user.ID,
|
||||||
|
"verification_token": verificationToken, // In production, this would be sent via email
|
||||||
|
"two_factor_setup": map[string]interface{}{
|
||||||
|
"secret": twoFAResponse.Secret,
|
||||||
|
"qr_code": twoFAResponse.QRCodeDataURL,
|
||||||
|
"recovery_codes": twoFAResponse.RecoveryCodes,
|
||||||
|
"setup_required": true,
|
||||||
|
"setup_endpoint": "/auth/2fa/verify-setup",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/breakpilot/consent-service/internal/services"
|
"github.com/breakpilot/consent-service/internal/services"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -292,366 +291,6 @@ func (h *OAuthHandler) Introspect(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 2FA (TOTP) Endpoints
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Setup2FA initiates 2FA setup
|
|
||||||
// POST /auth/2fa/setup
|
|
||||||
func (h *OAuthHandler) Setup2FA(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user email
|
|
||||||
ctx := context.Background()
|
|
||||||
user, err := h.authService.GetUserByID(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup 2FA
|
|
||||||
response, err := h.totpService.Setup2FA(ctx, userID, user.Email)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPAlreadyEnabled:
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify2FASetup verifies the 2FA setup with a code
|
|
||||||
// POST /auth/2fa/verify-setup
|
|
||||||
func (h *OAuthHandler) Verify2FASetup(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.Verify2FARequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err = h.totpService.Verify2FASetup(ctx, userID, req.Code)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPAlreadyEnabled:
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"})
|
|
||||||
case services.ErrTOTPInvalidCode:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify2FAChallenge verifies a 2FA challenge during login
|
|
||||||
// POST /auth/2fa/verify
|
|
||||||
func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) {
|
|
||||||
var req models.Verify2FAChallengeRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
var userID *uuid.UUID
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if req.RecoveryCode != "" {
|
|
||||||
// Verify with recovery code
|
|
||||||
userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode)
|
|
||||||
} else {
|
|
||||||
// Verify with TOTP code
|
|
||||||
userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPChallengeExpired:
|
|
||||||
c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"})
|
|
||||||
case services.ErrTOTPInvalidCode:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
|
||||||
case services.ErrRecoveryCodeInvalid:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user and generate tokens
|
|
||||||
user, err := h.authService.GetUserByID(ctx, *userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate access token
|
|
||||||
accessToken, err := h.authService.GenerateAccessToken(user)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate refresh token
|
|
||||||
refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store session
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// We need direct DB access for this, or we need to add a method to AuthService
|
|
||||||
// For now, we'll return the tokens and let the caller handle session storage
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"access_token": accessToken,
|
|
||||||
"refresh_token": refreshToken,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"user": map[string]interface{}{
|
|
||||||
"id": user.ID,
|
|
||||||
"email": user.Email,
|
|
||||||
"name": user.Name,
|
|
||||||
"role": user.Role,
|
|
||||||
},
|
|
||||||
"_session_hash": refreshTokenHash,
|
|
||||||
"_ip": ipAddress,
|
|
||||||
"_user_agent": userAgent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable2FA disables 2FA for the current user
|
|
||||||
// POST /auth/2fa/disable
|
|
||||||
func (h *OAuthHandler) Disable2FA(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.Verify2FARequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err = h.totpService.Disable2FA(ctx, userID, req.Code)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPNotEnabled:
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
|
||||||
case services.ErrTOTPInvalidCode:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get2FAStatus returns the 2FA status for the current user
|
|
||||||
// GET /auth/2fa/status
|
|
||||||
func (h *OAuthHandler) Get2FAStatus(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
status, err := h.totpService.GetStatus(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegenerateRecoveryCodes generates new recovery codes
|
|
||||||
// POST /auth/2fa/recovery-codes
|
|
||||||
func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.Verify2FARequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPNotEnabled:
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
|
||||||
case services.ErrTOTPInvalidCode:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Enhanced Login with 2FA
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// LoginWith2FA handles login with optional 2FA
|
|
||||||
// POST /auth/login
|
|
||||||
func (h *OAuthHandler) LoginWith2FA(c *gin.Context) {
|
|
||||||
var req models.LoginRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Attempt login
|
|
||||||
response, err := h.authService.Login(ctx, &req, ipAddress, userAgent)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrInvalidCredentials:
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
|
|
||||||
case services.ErrAccountLocked:
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"})
|
|
||||||
case services.ErrAccountSuspended:
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if 2FA is enabled
|
|
||||||
twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID)
|
|
||||||
|
|
||||||
if twoFactorEnabled {
|
|
||||||
// Create 2FA challenge
|
|
||||||
challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 2FA required response
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"requires_2fa": true,
|
|
||||||
"challenge_id": challengeID,
|
|
||||||
"message": "2FA verification required",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// No 2FA required, return tokens
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"requires_2fa": false,
|
|
||||||
"access_token": response.AccessToken,
|
|
||||||
"refresh_token": response.RefreshToken,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": response.ExpiresIn,
|
|
||||||
"user": map[string]interface{}{
|
|
||||||
"id": response.User.ID,
|
|
||||||
"email": response.User.Email,
|
|
||||||
"name": response.User.Name,
|
|
||||||
"role": response.User.Role,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Registration with mandatory 2FA setup
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// RegisterWith2FA handles registration with mandatory 2FA setup
|
|
||||||
// POST /auth/register
|
|
||||||
func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) {
|
|
||||||
var req models.RegisterRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Validate password strength
|
|
||||||
if len(req.Password) < 8 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register user
|
|
||||||
user, verificationToken, err := h.authService.Register(ctx, &req)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrUserExists:
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup 2FA immediately
|
|
||||||
twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email)
|
|
||||||
if err != nil {
|
|
||||||
// Non-fatal - user can set up 2FA later, but log it
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Registration successful. Please verify your email.",
|
|
||||||
"user_id": user.ID,
|
|
||||||
"verification_token": verificationToken, // In production, this would be sent via email
|
|
||||||
"two_factor_setup": nil,
|
|
||||||
"two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Registration successful. Please verify your email and complete 2FA setup.",
|
|
||||||
"user_id": user.ID,
|
|
||||||
"verification_token": verificationToken, // In production, this would be sent via email
|
|
||||||
"two_factor_setup": map[string]interface{}{
|
|
||||||
"secret": twoFAResponse.Secret,
|
|
||||||
"qr_code": twoFAResponse.QRCodeDataURL,
|
|
||||||
"recovery_codes": twoFAResponse.RecoveryCodes,
|
|
||||||
"setup_required": true,
|
|
||||||
"setup_endpoint": "/auth/2fa/verify-setup",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// OAuth Client Management (Admin)
|
// OAuth Client Management (Admin)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Attendance Handlers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// RecordAttendance records attendance for a student
|
||||||
|
// POST /api/v1/attendance
|
||||||
|
func (h *SchoolHandlers) RecordAttendance(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.RecordAttendanceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordBulkAttendance records attendance for multiple students
|
||||||
|
// POST /api/v1/classes/:id/attendance
|
||||||
|
func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
classIDStr := c.Param("id")
|
||||||
|
classID, err := uuid.Parse(classIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Date string `json:"date" binding:"required"`
|
||||||
|
SlotID string `json:"slot_id" binding:"required"`
|
||||||
|
Records []struct {
|
||||||
|
StudentID string `json:"student_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
} `json:"records" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slotID, err := uuid.Parse(req.SlotID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to the expected type (without JSON tags)
|
||||||
|
records := make([]struct {
|
||||||
|
StudentID string
|
||||||
|
Status string
|
||||||
|
Note *string
|
||||||
|
}, len(req.Records))
|
||||||
|
for i, r := range req.Records {
|
||||||
|
records[i] = struct {
|
||||||
|
StudentID string
|
||||||
|
Status string
|
||||||
|
Note *string
|
||||||
|
}{
|
||||||
|
StudentID: r.StudentID,
|
||||||
|
Status: r.Status,
|
||||||
|
Note: r.Note,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassAttendance gets attendance for a class on a specific date
|
||||||
|
// GET /api/v1/classes/:id/attendance?date=...
|
||||||
|
func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) {
|
||||||
|
classIDStr := c.Param("id")
|
||||||
|
classID, err := uuid.Parse(classIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
date := c.Query("date")
|
||||||
|
if date == "" {
|
||||||
|
date = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStudentAttendance gets attendance history for a student
|
||||||
|
// GET /api/v1/students/:id/attendance?start_date=...&end_date=...
|
||||||
|
func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) {
|
||||||
|
studentIDStr := c.Param("id")
|
||||||
|
studentID, err := uuid.Parse(studentIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startDateStr := c.Query("start_date")
|
||||||
|
endDateStr := c.Query("end_date")
|
||||||
|
|
||||||
|
var startDate, endDate time.Time
|
||||||
|
if startDateStr == "" {
|
||||||
|
startDate = time.Now().AddDate(0, -1, 0) // Last month
|
||||||
|
} else {
|
||||||
|
startDate, _ = time.Parse("2006-01-02", startDateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDateStr == "" {
|
||||||
|
endDate = time.Now()
|
||||||
|
} else {
|
||||||
|
endDate, _ = time.Parse("2006-01-02", endDateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Absence Report Handlers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ReportAbsence allows parents to report absence
|
||||||
|
// POST /api/v1/absence/report
|
||||||
|
func (h *SchoolHandlers) ReportAbsence(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.ReportAbsenceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmAbsence allows teachers to confirm absence
|
||||||
|
// PUT /api/v1/absence/:id/confirm
|
||||||
|
func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reportIDStr := c.Param("id")
|
||||||
|
reportID, err := uuid.Parse(reportIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status" binding:"required"` // "excused" or "unexcused"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingAbsenceReports gets pending absence reports for a class
|
||||||
|
// GET /api/v1/classes/:id/absence/pending
|
||||||
|
func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) {
|
||||||
|
classIDStr := c.Param("id")
|
||||||
|
classID, err := uuid.Parse(classIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, reports)
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Grade Handlers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CreateGrade creates a new grade
|
||||||
|
// POST /api/v1/grades
|
||||||
|
func (h *SchoolHandlers) CreateGrade(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.CreateGradeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get teacher ID from user ID
|
||||||
|
teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, grade)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStudentGrades gets all grades for a student
|
||||||
|
// GET /api/v1/students/:id/grades?school_year_id=...
|
||||||
|
func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) {
|
||||||
|
studentIDStr := c.Param("id")
|
||||||
|
studentID, err := uuid.Parse(studentIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolYearIDStr := c.Query("school_year_id")
|
||||||
|
if schoolYearIDStr == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, grades)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel)
|
||||||
|
// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=...
|
||||||
|
func (h *SchoolHandlers) GetClassGrades(c *gin.Context) {
|
||||||
|
classIDStr := c.Param("id")
|
||||||
|
classID, err := uuid.Parse(classIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectIDStr := c.Param("subjectId")
|
||||||
|
subjectID, err := uuid.Parse(subjectIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolYearIDStr := c.Query("school_year_id")
|
||||||
|
if schoolYearIDStr == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
semesterStr := c.DefaultQuery("semester", "1")
|
||||||
|
var semester int
|
||||||
|
if semesterStr == "1" {
|
||||||
|
semester = 1
|
||||||
|
} else {
|
||||||
|
semester = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, overviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGradeStatistics gets grade statistics for a class/subject
|
||||||
|
// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=...
|
||||||
|
func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) {
|
||||||
|
classIDStr := c.Param("id")
|
||||||
|
classID, err := uuid.Parse(classIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectIDStr := c.Param("subjectId")
|
||||||
|
subjectID, err := uuid.Parse(subjectIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolYearIDStr := c.Query("school_year_id")
|
||||||
|
if schoolYearIDStr == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
semesterStr := c.DefaultQuery("semester", "1")
|
||||||
|
var semester int
|
||||||
|
if semesterStr == "1" {
|
||||||
|
semester = 1
|
||||||
|
} else {
|
||||||
|
semester = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Parent Onboarding Handlers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// GenerateOnboardingToken generates a QR code token for parent onboarding
|
||||||
|
// POST /api/v1/onboarding/tokens
|
||||||
|
func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
SchoolID string `json:"school_id" binding:"required"`
|
||||||
|
ClassID string `json:"class_id" binding:"required"`
|
||||||
|
StudentID string `json:"student_id" binding:"required"`
|
||||||
|
Role string `json:"role"` // "parent" or "parent_representative"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolID, err := uuid.Parse(req.SchoolID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
classID, err := uuid.Parse(req.ClassID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
studentID, err := uuid.Parse(req.StudentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := req.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "parent"
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code URL
|
||||||
|
qrURL := "/onboard-parent?token=" + token.Token
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"token": token.Token,
|
||||||
|
"qr_url": qrURL,
|
||||||
|
"expires_at": token.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOnboardingToken validates an onboarding token
|
||||||
|
// GET /api/v1/onboarding/validate?token=...
|
||||||
|
func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) {
|
||||||
|
token := c.Query("token")
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get student and school info
|
||||||
|
student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"valid": true,
|
||||||
|
"role": onboardingToken.Role,
|
||||||
|
"student_name": student.FirstName + " " + student.LastName,
|
||||||
|
"class_name": class.Name,
|
||||||
|
"school_name": school.Name,
|
||||||
|
"expires_at": onboardingToken.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemOnboardingToken redeems a token and creates parent account
|
||||||
|
// POST /api/v1/onboarding/redeem
|
||||||
|
func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"})
|
||||||
|
}
|
||||||
@@ -355,531 +355,6 @@ func (h *SchoolHandlers) ListSubjects(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, subjects)
|
c.JSON(http.StatusOK, subjects)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Attendance Handlers
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// RecordAttendance records attendance for a student
|
|
||||||
// POST /api/v1/attendance
|
|
||||||
func (h *SchoolHandlers) RecordAttendance(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.RecordAttendanceRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, record)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordBulkAttendance records attendance for multiple students
|
|
||||||
// POST /api/v1/classes/:id/attendance
|
|
||||||
func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Date string `json:"date" binding:"required"`
|
|
||||||
SlotID string `json:"slot_id" binding:"required"`
|
|
||||||
Records []struct {
|
|
||||||
StudentID string `json:"student_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Note *string `json:"note"`
|
|
||||||
} `json:"records" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slotID, err := uuid.Parse(req.SlotID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to the expected type (without JSON tags)
|
|
||||||
records := make([]struct {
|
|
||||||
StudentID string
|
|
||||||
Status string
|
|
||||||
Note *string
|
|
||||||
}, len(req.Records))
|
|
||||||
for i, r := range req.Records {
|
|
||||||
records[i] = struct {
|
|
||||||
StudentID string
|
|
||||||
Status string
|
|
||||||
Note *string
|
|
||||||
}{
|
|
||||||
StudentID: r.StudentID,
|
|
||||||
Status: r.Status,
|
|
||||||
Note: r.Note,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassAttendance gets attendance for a class on a specific date
|
|
||||||
// GET /api/v1/classes/:id/attendance?date=...
|
|
||||||
func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) {
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
date := c.Query("date")
|
|
||||||
if date == "" {
|
|
||||||
date = time.Now().Format("2006-01-02")
|
|
||||||
}
|
|
||||||
|
|
||||||
overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, overview)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStudentAttendance gets attendance history for a student
|
|
||||||
// GET /api/v1/students/:id/attendance?start_date=...&end_date=...
|
|
||||||
func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) {
|
|
||||||
studentIDStr := c.Param("id")
|
|
||||||
studentID, err := uuid.Parse(studentIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startDateStr := c.Query("start_date")
|
|
||||||
endDateStr := c.Query("end_date")
|
|
||||||
|
|
||||||
var startDate, endDate time.Time
|
|
||||||
if startDateStr == "" {
|
|
||||||
startDate = time.Now().AddDate(0, -1, 0) // Last month
|
|
||||||
} else {
|
|
||||||
startDate, _ = time.Parse("2006-01-02", startDateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if endDateStr == "" {
|
|
||||||
endDate = time.Now()
|
|
||||||
} else {
|
|
||||||
endDate, _ = time.Parse("2006-01-02", endDateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, records)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Absence Report Handlers
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// ReportAbsence allows parents to report absence
|
|
||||||
// POST /api/v1/absence/report
|
|
||||||
func (h *SchoolHandlers) ReportAbsence(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.ReportAbsenceRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, report)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfirmAbsence allows teachers to confirm absence
|
|
||||||
// PUT /api/v1/absence/:id/confirm
|
|
||||||
func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reportIDStr := c.Param("id")
|
|
||||||
reportID, err := uuid.Parse(reportIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Status string `json:"status" binding:"required"` // "excused" or "unexcused"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPendingAbsenceReports gets pending absence reports for a class
|
|
||||||
// GET /api/v1/classes/:id/absence/pending
|
|
||||||
func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) {
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, reports)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Grade Handlers
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CreateGrade creates a new grade
|
|
||||||
// POST /api/v1/grades
|
|
||||||
func (h *SchoolHandlers) CreateGrade(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.CreateGradeRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get teacher ID from user ID
|
|
||||||
teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, grade)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStudentGrades gets all grades for a student
|
|
||||||
// GET /api/v1/students/:id/grades?school_year_id=...
|
|
||||||
func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) {
|
|
||||||
studentIDStr := c.Param("id")
|
|
||||||
studentID, err := uuid.Parse(studentIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearIDStr := c.Query("school_year_id")
|
|
||||||
if schoolYearIDStr == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, grades)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel)
|
|
||||||
// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=...
|
|
||||||
func (h *SchoolHandlers) GetClassGrades(c *gin.Context) {
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subjectIDStr := c.Param("subjectId")
|
|
||||||
subjectID, err := uuid.Parse(subjectIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearIDStr := c.Query("school_year_id")
|
|
||||||
if schoolYearIDStr == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
semesterStr := c.DefaultQuery("semester", "1")
|
|
||||||
var semester int
|
|
||||||
if semesterStr == "1" {
|
|
||||||
semester = 1
|
|
||||||
} else {
|
|
||||||
semester = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, overviews)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGradeStatistics gets grade statistics for a class/subject
|
|
||||||
// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=...
|
|
||||||
func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) {
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subjectIDStr := c.Param("subjectId")
|
|
||||||
subjectID, err := uuid.Parse(subjectIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearIDStr := c.Query("school_year_id")
|
|
||||||
if schoolYearIDStr == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
semesterStr := c.DefaultQuery("semester", "1")
|
|
||||||
var semester int
|
|
||||||
if semesterStr == "1" {
|
|
||||||
semester = 1
|
|
||||||
} else {
|
|
||||||
semester = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Parent Onboarding Handlers
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// GenerateOnboardingToken generates a QR code token for parent onboarding
|
|
||||||
// POST /api/v1/onboarding/tokens
|
|
||||||
func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
SchoolID string `json:"school_id" binding:"required"`
|
|
||||||
ClassID string `json:"class_id" binding:"required"`
|
|
||||||
StudentID string `json:"student_id" binding:"required"`
|
|
||||||
Role string `json:"role"` // "parent" or "parent_representative"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolID, err := uuid.Parse(req.SchoolID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
classID, err := uuid.Parse(req.ClassID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
studentID, err := uuid.Parse(req.StudentID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
role := req.Role
|
|
||||||
if role == "" {
|
|
||||||
role = "parent"
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate QR code URL
|
|
||||||
qrURL := "/onboard-parent?token=" + token.Token
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"token": token.Token,
|
|
||||||
"qr_url": qrURL,
|
|
||||||
"expires_at": token.ExpiresAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateOnboardingToken validates an onboarding token
|
|
||||||
// GET /api/v1/onboarding/validate?token=...
|
|
||||||
func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) {
|
|
||||||
token := c.Query("token")
|
|
||||||
if token == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get student and school info
|
|
||||||
student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"valid": true,
|
|
||||||
"role": onboardingToken.Role,
|
|
||||||
"student_name": student.FirstName + " " + student.LastName,
|
|
||||||
"class_name": class.Name,
|
|
||||||
"school_name": school.Name,
|
|
||||||
"expires_at": onboardingToken.ExpiresAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RedeemOnboardingToken redeems a token and creates parent account
|
|
||||||
// POST /api/v1/onboarding/redeem
|
|
||||||
func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Token string `json:"token" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Register Routes
|
// Register Routes
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user