Compare commits
343 Commits
3593a4ff78
...
feat/dead-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3702f70754 | ||
|
|
78e47c96bd | ||
|
|
f96536ebbe | ||
|
|
c05a71163b | ||
|
|
71a4a3d7f3 | ||
|
|
5e7d5d0a18 | ||
|
|
391aab83e0 | ||
|
|
8ec8af4c2d | ||
|
|
8266c37911 | ||
|
|
baf2d8a550 | ||
|
|
04d78d5fcd | ||
|
|
c41607595e | ||
|
|
58f108b578 | ||
|
|
f7a5f9e1ed | ||
|
|
3f1444541f | ||
|
|
13f57c4519 | ||
|
|
3f2aff2389 | ||
|
|
3fb5b94905 | ||
|
|
c293d76e6b | ||
|
|
e0b3c54212 | ||
|
|
a83056b5e7 | ||
|
|
9f96061631 | ||
|
|
3f306fb6f0 | ||
|
|
9ec72ed681 | ||
|
|
a7fe32fb82 | ||
|
|
9ecd3b2d84 | ||
|
|
19d6437161 | ||
|
|
7d8e5667c9 | ||
|
|
feedeb052f | ||
|
|
92a47bf6f9 | ||
|
|
788172e869 | ||
|
|
91063f09b8 | ||
|
|
b00fe6cb73 | ||
|
|
d32ad81094 | ||
|
|
e3a1822883 | ||
|
|
083792dfd7 | ||
|
|
aabfd0aecd | ||
|
|
11f13b3f74 | ||
|
|
20fbfc197e | ||
|
|
b5d20a4c1d | ||
|
|
54add75eb0 | ||
|
|
ce27636b67 | ||
|
|
e58af8aa30 | ||
|
|
ada50f0466 | ||
|
|
c34f8528a7 | ||
|
|
ad6e6019e9 | ||
|
|
535d3d8c20 | ||
|
|
90d14eb546 | ||
|
|
0125199c76 | ||
|
|
cfd4fc347f | ||
|
|
2adbacf267 | ||
|
|
3180457f20 | ||
|
|
9d96330a54 | ||
|
|
c50e57fd85 | ||
|
|
af08f089df | ||
|
|
1fcd8244b1 | ||
|
|
e0c1d21879 | ||
|
|
2ade65431a | ||
|
|
c43d9da6d0 | ||
|
|
e04816cfe5 | ||
|
|
519ffdc8dc | ||
|
|
653fa07f57 | ||
|
|
87f2ce9692 | ||
|
|
8044ddb776 | ||
|
|
7907b3f25b | ||
|
|
9096aad693 | ||
|
|
9f2224efc4 | ||
|
|
ffae41237e | ||
|
|
1c1af4e38d | ||
|
|
92a730626d | ||
|
|
cc3a9a37dc | ||
|
|
e6ff76d0e1 | ||
|
|
554320770a | ||
|
|
eeb9931d87 | ||
|
|
76962a2831 | ||
|
|
4921d1c052 | ||
|
|
6571b580dc | ||
|
|
d5287f4bdd | ||
|
|
82a5a388b8 | ||
|
|
637eb012f5 | ||
|
|
2b818c6fb3 | ||
|
|
3a22a2fa52 | ||
|
|
375b34a0d8 | ||
|
|
ff9f5e849c | ||
|
|
2fb6b98bc5 | ||
|
|
1f45d6cca8 | ||
|
|
dca0c96f2a | ||
|
|
74927c6f66 | ||
|
|
ddcd89f26d | ||
|
|
5cb91e88d2 | ||
|
|
4ed39d2616 | ||
|
|
ef8284dff5 | ||
|
|
6c883fb12e | ||
|
|
f7b77fd504 | ||
|
|
ff775517a2 | ||
|
|
98a773c7cd | ||
|
|
528abc86ab | ||
|
|
be4d58009a | ||
|
|
e07e1de6c9 | ||
|
|
58e95d5e8e | ||
|
|
786bb409e4 | ||
|
|
3c4f7d900d | ||
|
|
aae07b7a9b | ||
|
|
911d872178 | ||
|
|
fc6a3306d4 | ||
|
|
ab6ba63108 | ||
|
|
769e8c12d5 | ||
|
|
7344e5806e | ||
|
|
32e121f2a3 | ||
|
|
07d470edee | ||
|
|
a84dccb339 | ||
|
|
1a2ae896fb | ||
|
|
d35b0bc78c | ||
|
|
ae008d7d25 | ||
|
|
6658776610 | ||
|
|
d2c94619d8 | ||
|
|
cc1c61947d | ||
|
|
0c2e03f294 | ||
|
|
a638d0e527 | ||
|
|
e613af1a7d | ||
|
|
7107a31496 | ||
|
|
b850368ec9 | ||
|
|
4fa0dd6f6d | ||
|
|
f39c7ca40c | ||
|
|
d571412657 | ||
|
|
10073f3ef0 | ||
|
|
883ef702ac | ||
|
|
4a91814bfc | ||
|
|
482e8574ad | ||
|
|
d9dcfb97ef | ||
|
|
3320ef94fc | ||
|
|
712fa8cb74 | ||
|
|
447ec08509 | ||
|
|
8cb1dc1108 | ||
|
|
f8d9919b97 | ||
|
|
fb2cf29b34 | ||
|
|
f39e5a71af | ||
|
|
ac42a0aaa0 | ||
|
|
52e463a7c8 | ||
|
|
2dee62fa6f | ||
|
|
3fb07e201f | ||
|
|
81c9ce5de3 | ||
|
|
db7c207464 | ||
|
|
cb034b8009 | ||
|
|
564f93259b | ||
|
|
89ac223c41 | ||
|
|
23dd5116b3 | ||
|
|
81ce9dde07 | ||
|
|
5e9cab6ab5 | ||
|
|
a29bfdd588 | ||
|
|
9dbb4cc5d2 | ||
|
|
c56bccaedf | ||
|
|
230fbeb490 | ||
|
|
6d3bdf8e74 | ||
|
|
200facda6a | ||
|
|
9282850138 | ||
|
|
770f0b5ab0 | ||
|
|
35784c35eb | ||
|
|
cce2707c03 | ||
|
|
2efc738803 | ||
|
|
e6201d5239 | ||
|
|
48ca0a6bef | ||
|
|
1a63f5857b | ||
|
|
295c18c6f7 | ||
|
|
649a3c5e4e | ||
|
|
bdd2f6fa0f | ||
|
|
ac6134ce6d | ||
|
|
0027f78fc5 | ||
|
|
b29a7caee7 | ||
|
|
a14e2f3a00 | ||
|
|
71b8c33270 | ||
|
|
f2924a58ed | ||
|
|
643b26618f | ||
|
|
c52dbdb8f1 | ||
|
|
c3a53fe5d2 | ||
|
|
df5b6d69ef | ||
|
|
4f6ac9b23a | ||
|
|
5ea31a3236 | ||
|
|
95c371e9a5 | ||
|
|
b1627252ee | ||
|
|
2a0449c9b7 | ||
|
|
92d37a1660 | ||
|
|
0e16640c28 | ||
|
|
24f02b52ed | ||
|
|
9b0f25c105 | ||
|
|
1cc34c23d9 | ||
|
|
5dd7a27336 | ||
|
|
c3afa628ed | ||
|
|
4b1eede45b | ||
|
|
2a70441eaa | ||
|
|
f2819b99af | ||
|
|
3bb9fffab6 | ||
|
|
148c7ba3af | ||
|
|
a9e0869205 | ||
|
|
653aad57e3 | ||
|
|
a7f7e57dd7 | ||
|
|
567e82ddf5 | ||
|
|
36ef34169a | ||
|
|
d22c47c9eb | ||
|
|
825e070ed9 | ||
|
|
4f6bc8f6f6 | ||
|
|
d2133dbfa2 | ||
|
|
6d2de9b897 | ||
|
|
5adb1c5f16 | ||
|
|
9c1355c05f | ||
|
|
3b2006ebce | ||
|
|
c7651796c9 | ||
|
|
c8fd9cc780 | ||
|
|
0d95c3bb44 | ||
|
|
f066cf1a03 | ||
|
|
dd09fa7a46 | ||
|
|
f3e05c1bf7 | ||
|
|
2ed1c08acf | ||
|
|
4018b9af9b | ||
|
|
a9f291ff49 | ||
|
|
0171d611f6 | ||
|
|
637fab6fdb | ||
|
|
d462141ccd | ||
|
|
5f8aebf5b1 | ||
|
|
c74f506415 | ||
|
|
49ce417428 | ||
|
|
13d13c8226 | ||
|
|
b6e6ffaaee | ||
|
|
8a05fcc2f0 | ||
|
|
9812ff46f3 | ||
|
|
30236c0001 | ||
|
|
b4d2be83eb | ||
|
|
38c7cf0a00 | ||
|
|
399fa62267 | ||
| f1710fdb9e | |||
|
|
1dfea51919 | ||
|
|
559d7960a2 | ||
|
|
a101426dba | ||
|
|
f6b22820ce | ||
|
|
86588aff09 | ||
|
|
033fa52e5b | ||
|
|
005fb9d219 | ||
|
|
0c01f1c96c | ||
|
|
ffd256d420 | ||
|
|
d542dbbacd | ||
|
|
a3d0024d39 | ||
|
|
998d427c3c | ||
|
|
99f3180ffc | ||
|
|
2ec340c64b | ||
|
|
499ddc04d5 | ||
|
|
f738ca8c52 | ||
|
|
c530898963 | ||
|
|
cdafc4d9f4 | ||
|
|
de19ef0684 | ||
|
|
c87f07c99a | ||
|
|
453eec9ed8 | ||
|
|
050f353192 | ||
|
|
8442115e7c | ||
|
|
999cc81c78 | ||
|
|
ff66612beb | ||
|
|
42ec3cad6d | ||
|
|
9945a62a50 | ||
|
|
eef1c2e7d3 | ||
|
|
a0e2a35e66 | ||
|
|
57f390190d | ||
|
|
cf60c39658 | ||
|
|
c88653b221 | ||
|
|
87d06c8b20 | ||
|
|
0b47612272 | ||
|
|
c14b31b3bc | ||
|
|
0b836f7e2d | ||
|
|
18d9eec654 | ||
|
|
339505feed | ||
|
|
23b9808bf3 | ||
|
|
c3654bc9ea | ||
|
|
363bf9606a | ||
|
|
e88c0aeeb3 | ||
|
|
ebe7e90bd8 | ||
|
|
995de9e0f4 | ||
|
|
4e08364bc6 | ||
|
|
7f38df9d9c | ||
|
|
cb48b8289e | ||
|
|
46048554cb | ||
|
|
a673cb0ce4 | ||
|
|
7afbcfd9f5 | ||
|
|
091f093e1b | ||
|
|
5d99d5d47a | ||
|
|
e3a877b549 | ||
|
|
237c05a94c | ||
|
|
b1a0dd3615 | ||
|
|
6e7d0d9b14 | ||
|
|
24afed69c1 | ||
|
|
579fe1b5e1 | ||
|
|
ee6743c7c6 | ||
|
|
a6818b39c5 | ||
|
|
e3fb81fc0d | ||
|
|
3512963006 | ||
|
|
85b3cc3421 | ||
|
|
f6019ecba9 | ||
|
|
1f91e05600 | ||
|
|
3c0c1e49da | ||
|
|
5da93c5d10 | ||
|
|
fa4cda7627 | ||
|
|
90d99bba08 | ||
|
|
2c35775b44 | ||
|
|
aaf95cf894 | ||
|
|
9f41ed4f8e | ||
|
|
e7fab73a3a | ||
|
|
f8917ee6fd | ||
|
|
51a208a2e1 | ||
|
|
1c59996f32 | ||
|
|
61064fdcba | ||
|
|
11d4c2fd36 | ||
|
|
3d22935065 | ||
|
|
09cfb79840 | ||
|
|
d53cf21b95 | ||
|
|
0c83e765d9 | ||
|
|
9b59044663 | ||
|
|
d787e58341 | ||
|
|
4b778f2f85 | ||
|
|
0affa4eb66 | ||
|
|
d3fc4cdaaa | ||
|
|
de486aeab0 | ||
|
|
e96f623af0 | ||
|
|
53ff0722a4 | ||
|
|
2abf0b4cac | ||
|
|
fd45545fbe | ||
|
|
504b1a1207 | ||
|
|
66d32cd744 | ||
|
|
7fa0349fe4 | ||
|
|
6ff9f1f2f3 | ||
|
|
56758e8b55 | ||
|
|
0c75182fb3 | ||
|
|
4a60b3a744 | ||
|
|
95fcba34cd | ||
|
|
6509e64dd9 | ||
|
|
7ec6b9f6c0 | ||
|
|
9e65dff7d6 | ||
|
|
1e84df9769 | ||
|
|
ef9aed666f | ||
|
|
37166c966f | ||
|
|
3467bce222 | ||
|
|
a5e4801b09 | ||
|
|
8f3fb84b61 | ||
|
|
2dd86e97be | ||
|
|
8742cb7f5a | ||
|
|
6a940344c2 | ||
|
|
095eff26d9 |
@@ -1,73 +1,167 @@
|
|||||||
# BreakPilot Compliance - DSGVO/AI-Act SDK Platform
|
# BreakPilot Compliance - DSGVO/AI-Act SDK Platform
|
||||||
|
|
||||||
|
> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
|
||||||
|
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
|
||||||
|
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
|
||||||
|
> 3. **Do not touch the database schema.** No new Alembic migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner. SQLAlchemy `__tablename__` and column names are frozen.
|
||||||
|
> 4. **Public endpoints are a contract.** Any change to a path, method, status code, request schema, or response schema in `backend-compliance/`, `ai-compliance-sdk/`, `dsms-gateway/`, `document-crawler/`, or `compliance-tts-service/` must be accompanied by a matching update in **every** consumer (`admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`). Use the OpenAPI snapshot tests in `tests/contracts/` as the gate.
|
||||||
|
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
|
||||||
|
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
|
||||||
|
>
|
||||||
|
> These rules apply to **every** Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this `CLAUDE.md`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## First-Time Setup & Claude Code Onboarding
|
||||||
|
|
||||||
|
**For humans:** Read this CLAUDE.md top to bottom before your first commit. Then read `AGENTS.<lang>.md` for the service you are working on (`AGENTS.python.md`, `AGENTS.go.md`, or `AGENTS.typescript.md`).
|
||||||
|
|
||||||
|
**For Claude Code sessions — things that cause first-commit failures:**
|
||||||
|
|
||||||
|
1. **Wrong branch.** Never commit directly to `main`. Create a feature branch first: `git checkout -b feat/my-change`.
|
||||||
|
|
||||||
|
2. **PreToolUse hook blocks your write.** The `PreToolUse` hooks in `.claude/settings.json` will reject Write/Edit operations on any file that would push its line count past 500. This is intentional — split the file into smaller modules instead of trying to bypass the hook.
|
||||||
|
|
||||||
|
3. **Missing `[guardrail-change]` marker.** The `guardrail-integrity` CI job fails if you modify a guardrail file without the marker in the commit message body. See the table below.
|
||||||
|
|
||||||
|
4. **Never `git add -A` or `git add .`.** Stage files individually by path. `git add -A` risks committing `.env`, `node_modules/`, `.next/`, compiled binaries, and other artifacts that must never enter the repo.
|
||||||
|
|
||||||
|
5. **LOC check before push.** After any session, run `bash scripts/check-loc.sh`. It must exit 0 before you push. The git pre-commit hook runs this automatically, but run it manually first to catch issues early.
|
||||||
|
|
||||||
|
### Commit message quick reference
|
||||||
|
|
||||||
|
| Marker | Required when touching |
|
||||||
|
|--------|----------------------|
|
||||||
|
| `[guardrail-change]` | `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, any `AGENTS.*.md` |
|
||||||
|
| `[migration-approved]` | Anything under `migrations/` or `alembic/versions/` |
|
||||||
|
|
||||||
|
Add the marker anywhere in the commit message body or footer — the CI job does a plain-text grep for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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 origin |
|
||||||
|
|
||||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
**WICHTIG:** Code wird auf dem MacBook 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 pushen:
|
||||||
git push origin main && git push gitea main
|
git push origin main
|
||||||
|
|
||||||
# 3. Auf Mac Mini pullen (WICHTIG: git -C statt cd):
|
# 3. FERTIG! Push auf origin triggert automatisch:
|
||||||
|
# - Gitea Actions: Lint → Tests → Validierung
|
||||||
|
# - Orca: Build → Deploy
|
||||||
|
# Dauer: ca. 3 Minuten
|
||||||
|
# Status pruefen: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` fuer Production.
|
||||||
|
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
|
||||||
|
|
||||||
|
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push)
|
||||||
|
|
||||||
|
**IMMER wenn Claude auf origin 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
|
||||||
|
# Compliance Health-Endpoints:
|
||||||
|
curl -sf https://api-dev.breakpilot.ai/health # Backend Compliance
|
||||||
|
curl -sf https://sdk-dev.breakpilot.ai/health # AI Compliance SDK
|
||||||
|
```
|
||||||
|
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||||
|
**"Deploy abgeschlossen! Du kannst jetzt testen: https://admin-dev.breakpilot.ai"**
|
||||||
|
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
|
||||||
|
|
||||||
|
**Ablauf im Terminal:**
|
||||||
|
```
|
||||||
|
> git push origin main ✓
|
||||||
|
> "Deploy gestartet, ich ueberwache den Status..."
|
||||||
|
> [Hintergrund-Polling laeuft]
|
||||||
|
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Pipeline (Gitea Actions → Orca)
|
||||||
|
|
||||||
|
```
|
||||||
|
Push auf origin main → go-lint/python-lint/nodejs-lint (nur PRs)
|
||||||
|
→ test-go-ai-compliance
|
||||||
|
→ test-python-backend-compliance
|
||||||
|
→ test-python-document-crawler
|
||||||
|
→ test-python-dsms-gateway
|
||||||
|
→ validate-canonical-controls
|
||||||
|
→ Orca: Build + Deploy (automatisch bei Push)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dateien:**
|
||||||
|
- `.gitea/workflows/ci.yaml` — Pipeline-Definition (Tests + Validierung)
|
||||||
|
- `docker-compose.yml` — Haupt-Compose
|
||||||
|
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Orca Production (x86_64)
|
||||||
|
|
||||||
|
### Lokale Entwicklung (Mac Mini — optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nur fuer lokale Tests, NICHT fuer Production:
|
||||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||||
|
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
||||||
# 4. Container neu bauen (WICHTIG: -f statt cd, da cd in SSH nicht funktioniert!):
|
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
|
||||||
|
|
||||||
# Fuer schnelle Iteration ohne Commit (rsync):
|
# Fuer schnelle Iteration ohne Commit (rsync):
|
||||||
rsync -avz --exclude node_modules --exclude .next --exclude .git \
|
rsync -avz --exclude node_modules --exclude .next --exclude .git \
|
||||||
admin-compliance/ macmini:~/Projekte/breakpilot-compliance/admin-compliance/
|
admin-compliance/ macmini:~/Projekte/breakpilot-compliance/admin-compliance/
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSH-Verbindung (fuer Docker/Tests)
|
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||||
|
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
||||||
```bash
|
|
||||||
# RICHTIG — cd funktioniert NICHT in SSH-Einzelbefehlen:
|
|
||||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance <git-cmd>"
|
|
||||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml <compose-cmd>"
|
|
||||||
ssh macmini "/usr/local/bin/docker exec bp-compliance-<service> <cmd>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Voraussetzung
|
## Voraussetzung
|
||||||
|
|
||||||
**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services:
|
**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services:
|
||||||
- PostgreSQL (Schema: `compliance`, `core`)
|
|
||||||
- Valkey (Session-Cache)
|
- Valkey (Session-Cache)
|
||||||
- Vault (Secrets)
|
- Vault (Secrets)
|
||||||
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
|
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
|
||||||
- Nginx (Reverse Proxy)
|
- Nginx (Reverse Proxy)
|
||||||
|
|
||||||
Pruefen: `curl -sf http://macmini:8099/health`
|
**Externe Services (Production):**
|
||||||
|
- PostgreSQL 17 (sslmode=require) — Schemas: `compliance`, `public`
|
||||||
|
- Qdrant @ `qdrant-dev.breakpilot.ai` (HTTPS, API-Key)
|
||||||
|
- Object Storage (S3-kompatibel, TLS)
|
||||||
|
|
||||||
|
Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Haupt-URLs (Browser auf MacBook)
|
## Haupt-URLs
|
||||||
|
|
||||||
### Frontends
|
### Production (Orca-deployed)
|
||||||
|
|
||||||
| URL | Service | Beschreibung |
|
| URL | Service | Beschreibung |
|
||||||
|-----|---------|--------------|
|
|-----|---------|--------------|
|
||||||
| **https://macmini:3007/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
| **https://admin-dev.breakpilot.ai/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||||
| **https://macmini:3006/** | Developer Portal | API-Dokumentation fuer Kunden |
|
| **https://developers-dev.breakpilot.ai/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||||
|
| https://api-dev.breakpilot.ai/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||||
|
| https://sdk-dev.breakpilot.ai/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||||
|
|
||||||
### Backend-APIs
|
### Lokal (Mac Mini — nur Dev/Tests)
|
||||||
|
|
||||||
| URL | Service | Beschreibung |
|
| URL | Service | Beschreibung |
|
||||||
|-----|---------|--------------|
|
|-----|---------|--------------|
|
||||||
| https://macmini:8002/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
| https://macmini:3007/ | Admin Compliance | Lokale Entwicklung |
|
||||||
| https://macmini:8093/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
| https://macmini:3006/ | Developer Portal | Lokale Entwicklung |
|
||||||
|
| https://macmini:8002/ | Backend Compliance | Lokale Entwicklung |
|
||||||
|
| https://macmini:8093/ | AI Compliance SDK | Lokale Entwicklung |
|
||||||
|
|
||||||
### Admin Compliance Module (https://macmini:3007/)
|
### Admin Compliance Module (https://macmini:3007/)
|
||||||
|
|
||||||
@@ -107,18 +201,6 @@ Pruefen: `curl -sf http://macmini:8099/health`
|
|||||||
| docs | MkDocs/nginx | 8011 | bp-compliance-docs |
|
| docs | MkDocs/nginx | 8011 | bp-compliance-docs |
|
||||||
| core-wait | curl health-check | - | bp-compliance-core-wait |
|
| core-wait | curl health-check | - | bp-compliance-core-wait |
|
||||||
|
|
||||||
### compliance-tts-service
|
|
||||||
- Piper TTS + FFmpeg fuer Schulungsvideos
|
|
||||||
- Speichert Audio/Video in MinIO (bp-core-minio:9000)
|
|
||||||
- TTS-Modell: `de_DE-thorsten-high.onnx`
|
|
||||||
- Dateien: `main.py`, `tts_engine.py`, `video_generator.py`, `storage.py`
|
|
||||||
|
|
||||||
### document-crawler
|
|
||||||
- Dokument-Analyse: PDF, DOCX, XLSX, PPTX
|
|
||||||
- Gap-Analyse zwischen bestehenden Dokumenten und Compliance-Anforderungen
|
|
||||||
- IPFS-Archivierung via dsms-gateway
|
|
||||||
- Kommuniziert mit ai-compliance-sdk (LLM Gateway)
|
|
||||||
|
|
||||||
### Docker-Netzwerk
|
### Docker-Netzwerk
|
||||||
Nutzt das externe Core-Netzwerk:
|
Nutzt das externe Core-Netzwerk:
|
||||||
```yaml
|
```yaml
|
||||||
@@ -163,48 +245,50 @@ breakpilot-compliance/
|
|||||||
├── dsms-node/ # IPFS Node
|
├── dsms-node/ # IPFS Node
|
||||||
├── dsms-gateway/ # IPFS Gateway
|
├── dsms-gateway/ # IPFS Gateway
|
||||||
├── scripts/ # Helper Scripts
|
├── scripts/ # Helper Scripts
|
||||||
└── docker-compose.yml # Compliance Compose (~8 Services)
|
├── docker-compose.yml # Compliance Compose (~10 Services, platform: arm64)
|
||||||
|
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Orca Production
|
||||||
|
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Haeufige Befehle
|
## Haeufige Befehle
|
||||||
|
|
||||||
### Docker
|
### Deployment (CI/CD — Standardweg)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Compliance-Services starten (Core muss laufen!)
|
# Committen und pushen → Orca deployt automatisch:
|
||||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d"
|
git push origin main
|
||||||
|
|
||||||
# Einzelnen Service neu bauen
|
# CI-Status pruefen (im Browser):
|
||||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||||
|
|
||||||
# Service neu bauen und starten
|
# Health Checks:
|
||||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
curl -sf https://api-dev.breakpilot.ai/health
|
||||||
|
curl -sf https://sdk-dev.breakpilot.ai/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# Remote:
|
||||||
|
# origin: ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
# Logs
|
# Logs
|
||||||
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
||||||
```
|
|
||||||
|
|
||||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
# Lokaler Rebuild (nur wenn noetig):
|
||||||
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
|
||||||
Der CLAUDE.md-Entwicklungsworkflow und die Beispiele mit `cd ... &&` sind veraltet — nie so verwenden.
|
|
||||||
|
|
||||||
### Git
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Zu BEIDEN Remotes pushen (PFLICHT! — vom MacBook):
|
|
||||||
git push origin main && git push gitea main
|
|
||||||
|
|
||||||
# Auf Mac Mini pullen (RICHTIG: git -C statt cd):
|
|
||||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||||
|
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||||
# Remotes:
|
|
||||||
# origin: lokale Gitea (macmini:3003)
|
|
||||||
# gitea: gitea.meghsakha.com:22222
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -254,6 +338,36 @@ ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --n
|
|||||||
- `lib/sdk/catalog-manager/` — catalog-registry.ts, types.ts
|
- `lib/sdk/catalog-manager/` — catalog-registry.ts, types.ts
|
||||||
- 17 DSGVO/AI-Act Kataloge (dsfa, vvt-baseline, vendor-compliance, etc.)
|
- 17 DSGVO/AI-Act Kataloge (dsfa, vvt-baseline, vendor-compliance, etc.)
|
||||||
|
|
||||||
|
### Multi-Projekt-Architektur (seit 2026-03-09)
|
||||||
|
|
||||||
|
Jeder Tenant kann mehrere Compliance-Projekte anlegen. CompanyProfile ist **pro Projekt** (nicht tenant-weit).
|
||||||
|
|
||||||
|
**URL-Schema:** `/sdk?project={uuid}` — alle SDK-Seiten enthalten `?project=` Query-Param.
|
||||||
|
`/sdk` ohne `?project=` zeigt die Projektliste (ProjectSelector).
|
||||||
|
|
||||||
|
**Datenbank:**
|
||||||
|
- `compliance_projects` — Projekt-Metadaten (Name, Typ, Status, Version)
|
||||||
|
- `sdk_states` — UNIQUE auf `(tenant_id, project_id)` statt nur `tenant_id`
|
||||||
|
- Migration: `039_compliance_projects.sql`
|
||||||
|
|
||||||
|
**Backend API (FastAPI):**
|
||||||
|
```
|
||||||
|
GET /api/v1/projects → Alle Projekte des Tenants
|
||||||
|
POST /api/v1/projects → Neues Projekt erstellen (mit copy_from_project_id)
|
||||||
|
GET /api/v1/projects/{project_id} → Einzelnes Projekt laden
|
||||||
|
PATCH /api/v1/projects/{project_id} → Projekt aktualisieren
|
||||||
|
DELETE /api/v1/projects/{project_id} → Projekt archivieren (Soft Delete)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `components/sdk/ProjectSelector/ProjectSelector.tsx` — Projektliste + Erstellen-Dialog
|
||||||
|
- `lib/sdk/types.ts` — `ProjectInfo` Interface, `SDKState.projectId`
|
||||||
|
- `lib/sdk/context.tsx` — `projectId` Prop, `createProject()`, `listProjects()`, `switchProject()`
|
||||||
|
- `lib/sdk/sync.ts` — BroadcastChannel + localStorage pro Projekt
|
||||||
|
- `lib/sdk/api-client.ts` — `projectId` in State-API + Projekt-CRUD-Methoden
|
||||||
|
- `app/sdk/layout.tsx` — liest `?project=` aus searchParams
|
||||||
|
- `app/api/sdk/v1/projects/` — Next.js Proxy zum Backend
|
||||||
|
|
||||||
### Backend-Compliance APIs
|
### Backend-Compliance APIs
|
||||||
```
|
```
|
||||||
POST/GET /api/v1/compliance/risks
|
POST/GET /api/v1/compliance/risks
|
||||||
@@ -263,18 +377,35 @@ POST/GET /api/v1/compliance/evidence
|
|||||||
POST/GET /api/v1/dsr/requests
|
POST/GET /api/v1/dsr/requests
|
||||||
POST/GET /api/v1/gdpr/exports
|
POST/GET /api/v1/gdpr/exports
|
||||||
POST/GET /api/v1/consent/admin
|
POST/GET /api/v1/consent/admin
|
||||||
|
|
||||||
|
# Stammdaten, Versionierung & Change-Requests
|
||||||
|
GET/POST/DELETE /api/compliance/company-profile
|
||||||
|
GET /api/compliance/company-profile/template-context
|
||||||
|
GET /api/compliance/change-requests
|
||||||
|
GET /api/compliance/change-requests/stats
|
||||||
|
POST /api/compliance/change-requests/{id}/accept
|
||||||
|
POST /api/compliance/change-requests/{id}/reject
|
||||||
|
POST /api/compliance/change-requests/{id}/edit
|
||||||
|
GET /api/compliance/generation/preview/{doc_type}
|
||||||
|
POST /api/compliance/generation/apply/{doc_type}
|
||||||
|
GET /api/compliance/{doc}/{id}/versions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Multi-Tenancy
|
||||||
|
- Shared Dependency: `compliance/api/tenant_utils.py` (`get_tenant_id()`)
|
||||||
|
- UUID-Format, kein `"default"` mehr
|
||||||
|
- Header `X-Tenant-ID` > Query `tenant_id` > ENV-Fallback
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Wichtige Dateien (Referenz)
|
## Wichtige Dateien (Referenz)
|
||||||
|
|
||||||
| Datei | Beschreibung |
|
| Datei | Beschreibung |
|
||||||
|-------|--------------|
|
|-------|--------------|
|
||||||
| `admin-compliance/app/(sdk)/` | Alle 37 SDK-Routes |
|
| `admin-compliance/app/(sdk)/` | Alle 37+ SDK-Routes |
|
||||||
| `admin-compliance/components/sdk/SDKSidebar.tsx` | SDK Navigation |
|
| `admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx` | SDK Navigation |
|
||||||
| `admin-compliance/components/sdk/CommandBar.tsx` | Command Palette |
|
| `admin-compliance/components/sdk/CommandBar.tsx` | Command Palette |
|
||||||
| `admin-compliance/lib/sdk/context.tsx` | SDK State (Provider) |
|
| `admin-compliance/lib/sdk/context.tsx` | SDK State (Provider) |
|
||||||
| `backend-compliance/compliance/` | Haupt-Package (40 Dateien) |
|
| `backend-compliance/compliance/` | Haupt-Package (50+ Dateien) |
|
||||||
| `ai-compliance-sdk/` | KI-Compliance Analyse |
|
| `ai-compliance-sdk/` | KI-Compliance Analyse |
|
||||||
| `developer-portal/` | API-Dokumentation |
|
| `developer-portal/` | API-Dokumentation |
|
||||||
|
|||||||
43
.claude/rules/architecture.md
Normal file
43
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Architecture Rules (auto-loaded)
|
||||||
|
|
||||||
|
These rules apply to **every** Claude Code session in this repository, regardless of who launched it. They are non-negotiable.
|
||||||
|
|
||||||
|
## File-size budget
|
||||||
|
|
||||||
|
- **Soft target:** 300 lines per non-test, non-generated source file.
|
||||||
|
- **Hard cap:** 500 lines. The PreToolUse hook in `.claude/settings.json` blocks Write/Edit operations that would create or push a file past 500. The git pre-commit hook re-checks. CI is the final gate.
|
||||||
|
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require a written rationale plus `[guardrail-change]` in the commit message. The exceptions list should shrink over time, not grow.
|
||||||
|
|
||||||
|
## Clean architecture
|
||||||
|
|
||||||
|
- Python (FastAPI): see `AGENTS.python.md`. Layering: `api → services → repositories → db.models`. Routers ≤30 LOC per handler. Schemas split per domain.
|
||||||
|
- Go (Gin): see `AGENTS.go.md`. Standard Go Project Layout + hexagonal. `cmd/` thin, wiring in `internal/app`.
|
||||||
|
- TypeScript (Next.js): see `AGENTS.typescript.md`. Server-by-default, push the client boundary deep, colocate `_components/` and `_hooks/` per route.
|
||||||
|
|
||||||
|
## Database is frozen
|
||||||
|
|
||||||
|
- No new Alembic migrations. No `ALTER TABLE`. No `__tablename__` or column renames.
|
||||||
|
- The pre-commit hook blocks any change under `migrations/` or `alembic/versions/` unless the commit message contains `[migration-approved]`.
|
||||||
|
|
||||||
|
## Public endpoints are a contract
|
||||||
|
|
||||||
|
- Any change to a path/method/status/request schema/response schema in a backend service must update every consumer in the same change set.
|
||||||
|
- Each backend service has an OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- New code without tests fails CI.
|
||||||
|
- Refactors must preserve coverage. Before splitting an oversized file, add a characterization test that pins current behavior.
|
||||||
|
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
|
||||||
|
|
||||||
|
## Guardrails are themselves protected
|
||||||
|
|
||||||
|
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message. The pre-commit hook enforces this.
|
||||||
|
- If you (Claude) think a rule is wrong, surface it to the user. Do not silently weaken it.
|
||||||
|
|
||||||
|
## Tooling baseline
|
||||||
|
|
||||||
|
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
|
||||||
|
- Go: `golangci-lint` strict config, `go vet`, table-driven tests.
|
||||||
|
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
|
||||||
|
- All three: dependency caching in CI, license/SBOM scan via `syft`+`grype`.
|
||||||
103
.claude/rules/loc-exceptions.txt
Normal file
103
.claude/rules/loc-exceptions.txt
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
|
||||||
|
#
|
||||||
|
# Format: one repo-relative path per line. Comments start with '#' and are ignored.
|
||||||
|
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
|
||||||
|
#
|
||||||
|
# Phase 0 baseline: this list is initially empty. Phases 1-4 will add grandfathered
|
||||||
|
# entries as we encounter legitimate exceptions (e.g. large generated data tables).
|
||||||
|
# The goal is for this list to SHRINK over time, never grow.
|
||||||
|
|
||||||
|
# --- admin-compliance: static data catalogs (Phase 3) ---
|
||||||
|
# Splitting these would fragment lookup tables without improving readability.
|
||||||
|
admin-compliance/lib/sdk/tom-generator/controls/loader.ts
|
||||||
|
admin-compliance/lib/sdk/vendor-compliance/risk/controls-library.ts
|
||||||
|
admin-compliance/lib/sdk/compliance-scope-triggers.ts
|
||||||
|
admin-compliance/lib/sdk/vendor-compliance/catalog/processing-activities.ts
|
||||||
|
admin-compliance/lib/sdk/catalog-manager/catalog-registry.ts
|
||||||
|
admin-compliance/lib/sdk/dsfa/mitigation-library.ts
|
||||||
|
admin-compliance/lib/sdk/vvt-baseline-catalog.ts
|
||||||
|
admin-compliance/lib/sdk/dsfa/eu-legal-frameworks.ts
|
||||||
|
admin-compliance/lib/sdk/dsfa/risk-catalog.ts
|
||||||
|
admin-compliance/lib/sdk/loeschfristen-baseline-catalog.ts
|
||||||
|
admin-compliance/lib/sdk/vendor-compliance/catalog/vendor-templates.ts
|
||||||
|
admin-compliance/lib/sdk/vendor-compliance/catalog/legal-basis.ts
|
||||||
|
admin-compliance/lib/sdk/vendor-compliance/contract-review/findings.ts
|
||||||
|
admin-compliance/lib/sdk/vendor-compliance/contract-review/checklists.ts
|
||||||
|
admin-compliance/lib/sdk/compliance-scope-types/document-scope-matrix-core.ts
|
||||||
|
admin-compliance/lib/sdk/compliance-scope-types/document-scope-matrix-extended.ts
|
||||||
|
admin-compliance/lib/sdk/demo-data/index.ts
|
||||||
|
admin-compliance/lib/sdk/tom-generator/demo-data/index.ts
|
||||||
|
|
||||||
|
# --- admin-compliance: self-contained export generators (Phase 3) ---
|
||||||
|
# Each file generates a complete document format. Splitting mid-generation
|
||||||
|
# logic would create artificial module boundaries without benefit.
|
||||||
|
admin-compliance/lib/sdk/tom-generator/export/zip.ts
|
||||||
|
admin-compliance/lib/sdk/tom-generator/export/docx.ts
|
||||||
|
admin-compliance/lib/sdk/tom-generator/export/pdf.ts
|
||||||
|
admin-compliance/lib/sdk/einwilligungen/export/pdf.ts
|
||||||
|
admin-compliance/lib/sdk/einwilligungen/generator/privacy-policy-sections.ts
|
||||||
|
|
||||||
|
# --- backend-compliance: legacy utility services (Phase 1) ---
|
||||||
|
# Pre-refactor utility modules not yet split. Phase 5 targets.
|
||||||
|
backend-compliance/compliance/services/control_generator.py
|
||||||
|
backend-compliance/compliance/services/audit_pdf_generator.py
|
||||||
|
backend-compliance/compliance/services/regulation_scraper.py
|
||||||
|
backend-compliance/compliance/services/llm_provider.py
|
||||||
|
backend-compliance/compliance/services/export_generator.py
|
||||||
|
backend-compliance/compliance/services/pdf_extractor.py
|
||||||
|
backend-compliance/compliance/services/ai_compliance_assistant.py
|
||||||
|
|
||||||
|
# --- backend-compliance: Phase 1 code refactor backlog ---
|
||||||
|
# These are the remaining oversized route/service/data/auth files that Phase 1
|
||||||
|
# did not reach. Each entry is a tracked refactor debt item — the list must shrink.
|
||||||
|
backend-compliance/compliance/services/decomposition_pass.py
|
||||||
|
backend-compliance/compliance/api/schemas.py
|
||||||
|
backend-compliance/compliance/api/canonical_control_routes.py
|
||||||
|
backend-compliance/compliance/db/repository.py
|
||||||
|
backend-compliance/compliance/db/models.py
|
||||||
|
backend-compliance/compliance/api/evidence_check_routes.py
|
||||||
|
backend-compliance/compliance/api/control_generator_routes.py
|
||||||
|
backend-compliance/compliance/api/process_task_routes.py
|
||||||
|
backend-compliance/compliance/api/evidence_routes.py
|
||||||
|
backend-compliance/compliance/api/crosswalk_routes.py
|
||||||
|
backend-compliance/compliance/api/dashboard_routes.py
|
||||||
|
backend-compliance/compliance/api/dsfa_routes.py
|
||||||
|
backend-compliance/compliance/api/routes.py
|
||||||
|
backend-compliance/compliance/api/tom_mapping_routes.py
|
||||||
|
backend-compliance/compliance/services/control_dedup.py
|
||||||
|
backend-compliance/compliance/services/framework_decomposition.py
|
||||||
|
backend-compliance/compliance/services/pipeline_adapter.py
|
||||||
|
backend-compliance/compliance/services/batch_dedup_runner.py
|
||||||
|
backend-compliance/compliance/services/obligation_extractor.py
|
||||||
|
backend-compliance/compliance/services/control_composer.py
|
||||||
|
backend-compliance/compliance/services/pattern_matcher.py
|
||||||
|
backend-compliance/compliance/data/iso27001_annex_a.py
|
||||||
|
backend-compliance/compliance/data/service_modules.py
|
||||||
|
backend-compliance/compliance/data/controls.py
|
||||||
|
backend-compliance/services/pdf_service.py
|
||||||
|
backend-compliance/services/file_processor.py
|
||||||
|
backend-compliance/auth/keycloak_auth.py
|
||||||
|
|
||||||
|
# --- scripts: one-off ingestion, QA, and migration scripts ---
|
||||||
|
# These are operational scripts, not production application code.
|
||||||
|
# LOC rules don't apply in the same way to single-purpose scripts.
|
||||||
|
scripts/ingest-legal-corpus.sh
|
||||||
|
scripts/ingest-ce-corpus.sh
|
||||||
|
scripts/ingest-dsfa-bundesland.sh
|
||||||
|
scripts/edpb-crawler.py
|
||||||
|
scripts/apply_templates_023.py
|
||||||
|
scripts/qa/phase74_generate_gap_controls.py
|
||||||
|
scripts/qa/pdf_qa_all.py
|
||||||
|
scripts/qa/benchmark_llm_controls.py
|
||||||
|
backend-compliance/scripts/seed_policy_templates.py
|
||||||
|
|
||||||
|
# --- docs-src: copies of backend source for documentation rendering ---
|
||||||
|
# These are not production code; they are rendered into the static docs site.
|
||||||
|
docs-src/control_generator.py
|
||||||
|
docs-src/control_generator_routes.py
|
||||||
|
|
||||||
|
# --- consent-sdk: platform-native mobile SDKs (Swift / Dart) ---
|
||||||
|
# Flutter and iOS SDKs follow platform conventions (verbose verbose) that make
|
||||||
|
# splitting into multiple files awkward without sacrificing single-import ergonomics.
|
||||||
|
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
||||||
|
consent-sdk/src/mobile/ios/ConsentManager.swift
|
||||||
28
.claude/settings.json
Normal file
28
.claude/settings.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"breakpilot guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md. If this is generated/data code, add an entry to .claude/rules/loc-exceptions.txt with rationale and reference [guardrail-change].\"}'; exit 0; fi",
|
||||||
|
"shell": "bash",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"breakpilot guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing. See AGENTS.<lang>.md for the layering rules.\\\"}\"; fi; exit 0",
|
||||||
|
"shell": "bash",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
61
.env.orca.example
Normal file
61
.env.orca.example
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# =========================================================
|
||||||
|
# BreakPilot Compliance — Orca Environment Variables
|
||||||
|
# =========================================================
|
||||||
|
# Copy these into Orca's environment variable UI
|
||||||
|
# for the breakpilot-compliance Docker Compose resource.
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
# --- External PostgreSQL (Orca-managed, same as Core) ---
|
||||||
|
COMPLIANCE_DATABASE_URL=postgresql://breakpilot:CHANGE_ME@<orca-postgres-hostname>:5432/breakpilot_db
|
||||||
|
|
||||||
|
# --- Security ---
|
||||||
|
JWT_SECRET=CHANGE_ME_SAME_AS_CORE
|
||||||
|
|
||||||
|
# --- External S3 Storage (same as Core) ---
|
||||||
|
S3_ENDPOINT=<s3-endpoint-host:port>
|
||||||
|
S3_ACCESS_KEY=CHANGE_ME_SAME_AS_CORE
|
||||||
|
S3_SECRET_KEY=CHANGE_ME_SAME_AS_CORE
|
||||||
|
S3_SECURE=true
|
||||||
|
|
||||||
|
# --- External Qdrant ---
|
||||||
|
QDRANT_URL=https://<qdrant-hostname>
|
||||||
|
QDRANT_API_KEY=CHANGE_ME_QDRANT_API_KEY
|
||||||
|
|
||||||
|
# --- Session ---
|
||||||
|
SESSION_TTL_HOURS=24
|
||||||
|
|
||||||
|
# --- SMTP (Real mail server) ---
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=compliance@breakpilot.ai
|
||||||
|
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
|
||||||
|
SMTP_FROM_NAME=BreakPilot Compliance
|
||||||
|
SMTP_FROM_ADDR=compliance@breakpilot.ai
|
||||||
|
|
||||||
|
# --- LLM Configuration ---
|
||||||
|
COMPLIANCE_LLM_PROVIDER=anthropic
|
||||||
|
SELF_HOSTED_LLM_URL=
|
||||||
|
SELF_HOSTED_LLM_MODEL=
|
||||||
|
COMPLIANCE_LLM_MAX_TOKENS=4096
|
||||||
|
COMPLIANCE_LLM_TEMPERATURE=0.3
|
||||||
|
COMPLIANCE_LLM_TIMEOUT=120
|
||||||
|
ANTHROPIC_API_KEY=CHANGE_ME_ANTHROPIC_KEY
|
||||||
|
ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-5-20250929
|
||||||
|
|
||||||
|
# --- Ollama (optional) ---
|
||||||
|
OLLAMA_URL=
|
||||||
|
OLLAMA_DEFAULT_MODEL=
|
||||||
|
COMPLIANCE_LLM_MODEL=
|
||||||
|
|
||||||
|
# --- LLM Fallback ---
|
||||||
|
LLM_FALLBACK_PROVIDER=
|
||||||
|
|
||||||
|
# --- PII & Audit ---
|
||||||
|
PII_REDACTION_ENABLED=true
|
||||||
|
PII_REDACTION_LEVEL=standard
|
||||||
|
AUDIT_RETENTION_DAYS=365
|
||||||
|
AUDIT_LOG_PROMPTS=true
|
||||||
|
|
||||||
|
# --- Frontend URLs (build args) ---
|
||||||
|
NEXT_PUBLIC_API_URL=https://api-compliance.breakpilot.ai
|
||||||
|
NEXT_PUBLIC_SDK_URL=https://sdk.breakpilot.ai
|
||||||
221
.gitea/workflows/build-push-deploy.yml
Normal file
221
.gitea/workflows/build-push-deploy.yml
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Build + push compliance service images to registry.meghsakha.com
|
||||||
|
# and trigger orca redeploy on every push to main that touches a service.
|
||||||
|
#
|
||||||
|
# Requires Gitea Actions secrets:
|
||||||
|
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||||
|
# ORCA_WEBHOOK_SECRET — must match webhooks.json on orca master
|
||||||
|
|
||||||
|
name: Build + Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'admin-compliance/**'
|
||||||
|
- 'backend-compliance/**'
|
||||||
|
- 'ai-compliance-sdk/**'
|
||||||
|
- 'developer-portal/**'
|
||||||
|
- 'compliance-tts-service/**'
|
||||||
|
- 'document-crawler/**'
|
||||||
|
- 'dsms-gateway/**'
|
||||||
|
- 'dsms-node/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── per-service builds run in parallel ────────────────────────────────────
|
||||||
|
|
||||||
|
build-admin-compliance:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Login
|
||||||
|
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 + push
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-admin:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-admin:${SHORT_SHA} \
|
||||||
|
admin-compliance/
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-admin:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-admin:${SHORT_SHA}
|
||||||
|
|
||||||
|
build-backend-compliance:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Login
|
||||||
|
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 + push
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-backend:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-backend:${SHORT_SHA} \
|
||||||
|
backend-compliance/
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-backend:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-backend:${SHORT_SHA}
|
||||||
|
|
||||||
|
build-ai-sdk:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Login
|
||||||
|
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 + push
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-sdk:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-sdk:${SHORT_SHA} \
|
||||||
|
ai-compliance-sdk/
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-sdk:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-sdk:${SHORT_SHA}
|
||||||
|
|
||||||
|
build-developer-portal:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Login
|
||||||
|
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 + push
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-portal:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-portal:${SHORT_SHA} \
|
||||||
|
developer-portal/
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-portal:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-portal:${SHORT_SHA}
|
||||||
|
|
||||||
|
build-tts:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Login
|
||||||
|
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 + push
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-tts:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-tts:${SHORT_SHA} \
|
||||||
|
compliance-tts-service/
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-tts:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-tts:${SHORT_SHA}
|
||||||
|
|
||||||
|
build-document-crawler:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Login
|
||||||
|
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 + push
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-crawler:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-crawler:${SHORT_SHA} \
|
||||||
|
document-crawler/
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-crawler:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-crawler:${SHORT_SHA}
|
||||||
|
|
||||||
|
build-dsms-gateway:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Login
|
||||||
|
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 + push
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA} \
|
||||||
|
dsms-gateway/
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA}
|
||||||
|
|
||||||
|
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
||||||
|
|
||||||
|
trigger-orca:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
needs:
|
||||||
|
- build-admin-compliance
|
||||||
|
- build-backend-compliance
|
||||||
|
- build-ai-sdk
|
||||||
|
- build-developer-portal
|
||||||
|
- build-tts
|
||||||
|
- build-document-crawler
|
||||||
|
- build-dsms-gateway
|
||||||
|
steps:
|
||||||
|
- name: Checkout (for SHA)
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git curl openssl
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- 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: compliance images built\"}}"
|
||||||
|
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 for compliance services"
|
||||||
@@ -1,24 +1,92 @@
|
|||||||
# Gitea Actions CI Pipeline
|
# BreakPilot Compliance — CI Pipeline
|
||||||
# BreakPilot Compliance
|
|
||||||
#
|
#
|
||||||
# Services:
|
# Feature branch workflow:
|
||||||
# Go: ai-compliance-sdk
|
# feat/* | feature/* | fix/* | hotfix/* | chore/* | refactor/* | docs/* | test/* | ci/*
|
||||||
# Python: backend-compliance, document-crawler, dsms-gateway
|
# → open PR targeting main
|
||||||
# Node.js: admin-compliance, developer-portal
|
# → all jobs run as PR gates
|
||||||
|
# → squash merge to main
|
||||||
|
# → subset of jobs re-run on main to catch merge surprises
|
||||||
|
#
|
||||||
|
# Deploy is handled by build-push-deploy.yml on push to main.
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, develop]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ========================================
|
|
||||||
# Lint (nur bei PRs)
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
|
# ── Branch naming convention (PR only) ──────────────────────────────────
|
||||||
|
branch-name:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Validate branch name
|
||||||
|
run: |
|
||||||
|
BRANCH="${GITHUB_HEAD_REF}"
|
||||||
|
if ! echo "$BRANCH" | grep -qE '^(feat|feature|fix|hotfix|chore|refactor|docs|test|ci)/.+'; then
|
||||||
|
echo "::error::Branch '$BRANCH' does not follow naming convention."
|
||||||
|
echo "Required prefix: feat/ feature/ fix/ hotfix/ chore/ refactor/ docs/ test/ ci/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Branch name OK: $BRANCH"
|
||||||
|
|
||||||
|
# ── Guardrail integrity (PR only) ────────────────────────────────────────
|
||||||
|
guardrail-integrity:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git bash
|
||||||
|
git clone --depth 20 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
git fetch origin ${GITHUB_BASE_REF}:base
|
||||||
|
- name: Require [guardrail-change] in commits touching guardrails
|
||||||
|
run: |
|
||||||
|
changed=$(git diff --name-only base...HEAD)
|
||||||
|
echo "$changed" | grep -qE '^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$' || exit 0
|
||||||
|
if ! git log base..HEAD --format=%B | grep -q '\[guardrail-change\]'; then
|
||||||
|
echo "::error::Guardrail files modified without [guardrail-change] in any commit message."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── LOC budget (always) ──────────────────────────────────────────────────
|
||||||
|
loc-budget:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git bash
|
||||||
|
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Enforce 500-line hard cap
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/check-loc.sh
|
||||||
|
scripts/check-loc.sh
|
||||||
|
|
||||||
|
# ── Secret scanning (PR only) ────────────────────────────────────────────
|
||||||
|
secret-scan:
|
||||||
|
runs-on: docker
|
||||||
|
container: zricethezav/gitleaks:v8.21.2
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Scan for secrets
|
||||||
|
run: |
|
||||||
|
gitleaks detect --source . --no-git \
|
||||||
|
--exit-code 1 \
|
||||||
|
--redact \
|
||||||
|
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
||||||
|
|
||||||
|
# ── Go lint + build (PR only) ────────────────────────────────────────────
|
||||||
go-lint:
|
go-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
@@ -30,10 +98,16 @@ jobs:
|
|||||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
- name: Lint ai-compliance-sdk
|
- name: Lint ai-compliance-sdk
|
||||||
run: |
|
run: |
|
||||||
if [ -d "ai-compliance-sdk" ]; then
|
[ -d "ai-compliance-sdk" ] || exit 0
|
||||||
cd ai-compliance-sdk && golangci-lint run --timeout 5m ./...
|
cd ai-compliance-sdk
|
||||||
fi
|
golangci-lint run --timeout 5m ./...
|
||||||
|
- name: Build ai-compliance-sdk
|
||||||
|
run: |
|
||||||
|
[ -d "ai-compliance-sdk" ] || exit 0
|
||||||
|
cd ai-compliance-sdk
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# ── Python lint + import check (PR only) ────────────────────────────────
|
||||||
python-lint:
|
python-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
@@ -43,16 +117,27 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
- name: Lint Python services
|
- name: Lint (ruff) + type-check (mypy)
|
||||||
run: |
|
run: |
|
||||||
pip install --quiet ruff
|
pip install --quiet ruff mypy
|
||||||
for svc in backend-compliance document-crawler dsms-gateway; do
|
fail=0
|
||||||
if [ -d "$svc" ]; then
|
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||||
echo "=== Linting $svc ==="
|
[ -d "$svc" ] || continue
|
||||||
ruff check "$svc/" --output-format=github || true
|
echo "=== ruff: $svc ===" && ruff check "$svc/" --output-format=github || fail=1
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
|
if [ -f "backend-compliance/mypy.ini" ]; then
|
||||||
|
cd backend-compliance && mypy compliance/ || fail=1
|
||||||
|
fi
|
||||||
|
exit $fail
|
||||||
|
- name: Import sanity check (catches NameError at collection time)
|
||||||
|
run: |
|
||||||
|
cd backend-compliance
|
||||||
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||||
|
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||||
|
python -c "import compliance; print('Import OK')" \
|
||||||
|
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||||
|
|
||||||
|
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
||||||
nodejs-lint:
|
nodejs-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
@@ -62,23 +147,105 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apk add --no-cache git
|
apk add --no-cache git
|
||||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
- name: Lint Node.js services
|
- name: Lint + type-check
|
||||||
run: |
|
run: |
|
||||||
|
fail=0
|
||||||
for svc in admin-compliance developer-portal; do
|
for svc in admin-compliance developer-portal; do
|
||||||
if [ -d "$svc" ]; then
|
[ -d "$svc" ] || continue
|
||||||
echo "=== Linting $svc ==="
|
echo "=== $svc: install ===" && (cd "$svc" && npm ci --silent 2>/dev/null || npm install --silent)
|
||||||
cd "$svc"
|
echo "=== $svc: next lint ===" && (cd "$svc" && npx next lint) || fail=1
|
||||||
npm ci --silent 2>/dev/null || npm install --silent
|
echo "=== $svc: tsc ===" && (cd "$svc" && npx tsc --noEmit) || fail=1
|
||||||
npx next lint || true
|
|
||||||
cd ..
|
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
|
exit $fail
|
||||||
|
|
||||||
# ========================================
|
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
||||||
# Unit Tests
|
nodejs-build:
|
||||||
# ========================================
|
runs-on: docker
|
||||||
|
container: node:20-alpine
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Build Next.js services
|
||||||
|
run: |
|
||||||
|
fail=0
|
||||||
|
for svc in admin-compliance developer-portal; do
|
||||||
|
[ -d "$svc" ] || continue
|
||||||
|
echo "=== $svc: install ==="
|
||||||
|
(cd "$svc" && npm ci --silent 2>/dev/null || npm install --silent)
|
||||||
|
echo "=== $svc: next build ==="
|
||||||
|
(cd "$svc" && \
|
||||||
|
NEXT_PUBLIC_API_URL=https://api-dev.breakpilot.ai \
|
||||||
|
NEXT_PUBLIC_SDK_URL=https://sdk-dev.breakpilot.ai \
|
||||||
|
npm run build) || fail=1
|
||||||
|
done
|
||||||
|
exit $fail
|
||||||
|
|
||||||
test-go-ai-compliance:
|
# ── Dependency audit (PR only) ───────────────────────────────────────────
|
||||||
|
dep-audit:
|
||||||
|
runs-on: docker
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
container: python:3.12-slim
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq git curl > /dev/null 2>&1
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Install Node.js + Go
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
|
||||||
|
apt-get install -y nodejs golang-go > /dev/null 2>&1
|
||||||
|
- name: Python — pip-audit
|
||||||
|
run: |
|
||||||
|
pip install --quiet pip-audit
|
||||||
|
fail=0
|
||||||
|
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||||
|
[ -f "$svc/requirements.txt" ] || continue
|
||||||
|
echo "=== pip-audit: $svc ==="
|
||||||
|
pip-audit -r "$svc/requirements.txt" --skip-editable -f columns || fail=1
|
||||||
|
done
|
||||||
|
exit $fail
|
||||||
|
- name: Node.js — npm audit
|
||||||
|
run: |
|
||||||
|
fail=0
|
||||||
|
for svc in admin-compliance developer-portal; do
|
||||||
|
[ -d "$svc" ] || continue
|
||||||
|
echo "=== npm audit: $svc ==="
|
||||||
|
(cd "$svc" && npm audit --audit-level=moderate --json 2>/dev/null | \
|
||||||
|
node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); \
|
||||||
|
const hi=Object.values(d.vulnerabilities||{}).filter(v=>['high','critical'].includes(v.severity)).length; \
|
||||||
|
if(hi>0){console.error('HIGH/CRITICAL: '+hi);process.exit(1)}") || fail=1
|
||||||
|
done
|
||||||
|
exit $fail
|
||||||
|
- name: Go — govulncheck
|
||||||
|
run: |
|
||||||
|
[ -d "ai-compliance-sdk" ] || exit 0
|
||||||
|
go install golang.org/x/vuln/cmd/govulncheck@latest 2>/dev/null
|
||||||
|
cd ai-compliance-sdk && govulncheck ./... || true
|
||||||
|
# Non-blocking until Go module versions are pinned
|
||||||
|
|
||||||
|
# ── SBOM + vulnerability scan (PR only) ─────────────────────────────────
|
||||||
|
sbom-scan:
|
||||||
|
runs-on: docker
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
container: alpine:3.20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git curl bash
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Install syft + grype
|
||||||
|
run: |
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
- name: Generate SBOM
|
||||||
|
run: mkdir -p sbom-out && syft dir:. -o cyclonedx-json=sbom-out/sbom.cdx.json -q
|
||||||
|
- name: Vulnerability scan (fail on high+)
|
||||||
|
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
||||||
|
|
||||||
|
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
||||||
|
test-go:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: golang:1.24-alpine
|
container: golang:1.24-alpine
|
||||||
env:
|
env:
|
||||||
@@ -90,16 +257,12 @@ jobs:
|
|||||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
- name: Test ai-compliance-sdk
|
- name: Test ai-compliance-sdk
|
||||||
run: |
|
run: |
|
||||||
if [ ! -d "ai-compliance-sdk" ]; then
|
[ -d "ai-compliance-sdk" ] || exit 0
|
||||||
echo "WARNUNG: ai-compliance-sdk nicht gefunden"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
cd ai-compliance-sdk
|
cd ai-compliance-sdk
|
||||||
go test -v -coverprofile=coverage.out ./... 2>&1
|
go test -v -coverprofile=coverage.out ./...
|
||||||
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' || echo "0%")
|
go tool cover -func=coverage.out | tail -1
|
||||||
echo "Coverage: $COVERAGE"
|
|
||||||
|
|
||||||
test-python-backend-compliance:
|
test-python-backend:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
env:
|
env:
|
||||||
@@ -111,14 +274,11 @@ jobs:
|
|||||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
- name: Test backend-compliance
|
- name: Test backend-compliance
|
||||||
run: |
|
run: |
|
||||||
if [ ! -d "backend-compliance" ]; then
|
[ -d "backend-compliance" ] || exit 0
|
||||||
echo "WARNUNG: backend-compliance nicht gefunden"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
cd backend-compliance
|
cd backend-compliance
|
||||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||||
pip install --quiet --no-cache-dir fastapi uvicorn pytest pytest-asyncio
|
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||||
python -m pytest compliance/tests/ -v --tb=short
|
python -m pytest compliance/tests/ -v --tb=short
|
||||||
|
|
||||||
test-python-document-crawler:
|
test-python-document-crawler:
|
||||||
@@ -133,10 +293,7 @@ jobs:
|
|||||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
- name: Test document-crawler
|
- name: Test document-crawler
|
||||||
run: |
|
run: |
|
||||||
if [ ! -d "document-crawler" ]; then
|
[ -d "document-crawler" ] || exit 0
|
||||||
echo "WARNUNG: document-crawler nicht gefunden"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
cd document-crawler
|
cd document-crawler
|
||||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||||
@@ -155,12 +312,21 @@ jobs:
|
|||||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
- name: Test dsms-gateway
|
- name: Test dsms-gateway
|
||||||
run: |
|
run: |
|
||||||
if [ ! -d "dsms-gateway" ]; then
|
[ -d "dsms-gateway" ] || exit 0
|
||||||
echo "WARNUNG: dsms-gateway nicht gefunden"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
cd dsms-gateway
|
cd dsms-gateway
|
||||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||||
python -m pytest test_main.py -v --tb=short
|
python -m pytest test_main.py -v --tb=short
|
||||||
|
|
||||||
|
# ── OpenAPI contract validation (always) ─────────────────────────────────
|
||||||
|
validate-canonical-controls:
|
||||||
|
runs-on: docker
|
||||||
|
container: python:3.12-slim
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Validate controls
|
||||||
|
run: python scripts/validate-controls.py
|
||||||
|
|||||||
115
.gitea/workflows/rag-ingest.yaml
Normal file
115
.gitea/workflows/rag-ingest.yaml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Gitea Actions — RAG Legal Corpus Ingestion
|
||||||
|
#
|
||||||
|
# Manuell triggerbarer Workflow zur Ingestion von Rechtstexten in Qdrant.
|
||||||
|
# Trigger: Gitea UI → Actions → "RAG Ingestion" → Run
|
||||||
|
#
|
||||||
|
# Phasen: gesetze, eu, templates, datenschutz, verbraucherschutz, verify, version, all
|
||||||
|
#
|
||||||
|
# Voraussetzung: RAG-Service und Qdrant muessen auf Orca laufen.
|
||||||
|
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-orca).
|
||||||
|
|
||||||
|
name: RAG Ingestion
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
phase:
|
||||||
|
description: 'Ingestion Phase (gesetze, eu, templates, datenschutz, verbraucherschutz, dach, security, verify, version, all)'
|
||||||
|
required: true
|
||||||
|
default: 'verbraucherschutz'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ingest:
|
||||||
|
runs-on: docker
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Setup
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git curl bash > /dev/null 2>&1
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch main ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
|
||||||
|
- name: Run Ingestion
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PHASE="${{ github.event.inputs.phase }}"
|
||||||
|
|
||||||
|
echo "=== RAG Ingestion: Phase ${PHASE} ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Pruefen ob Services laufen
|
||||||
|
echo "--- BreakPilot Container ---"
|
||||||
|
docker ps --filter name=bp- --format "{{.Names}}: {{.Status}}" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Netzwerk finden in dem die bp-Services laufen
|
||||||
|
BP_NETWORK=$(docker inspect bp-core-rag-service --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||||
|
if [ -z "$BP_NETWORK" ]; then
|
||||||
|
BP_NETWORK=$(docker inspect bp-compliance-backend --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$BP_NETWORK" ]; then
|
||||||
|
echo "FEHLER: Keine BreakPilot-Container gefunden."
|
||||||
|
echo "Bitte zuerst deployen (CI/CD Pipeline oder manuell)."
|
||||||
|
echo ""
|
||||||
|
echo "Verfuegbare Container:"
|
||||||
|
docker ps --format " {{.Names}}" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "Verfuegbare Netzwerke:"
|
||||||
|
docker network ls --format " {{.Name}}" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "BreakPilot Netzwerk: $BP_NETWORK"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ingestion-Container erstellen (noch nicht starten),
|
||||||
|
# dann Scripts aus dem Checkout per docker cp hineinkopieren.
|
||||||
|
# So verwenden wir IMMER die neueste Version der Scripts,
|
||||||
|
# unabhaengig vom Deploy-Dir auf dem Host.
|
||||||
|
CONTAINER_ID=$(docker create \
|
||||||
|
--network "$BP_NETWORK" \
|
||||||
|
-e "WORK_DIR=/tmp/rag-ingestion" \
|
||||||
|
-e "RAG_URL=http://bp-core-rag-service:8097/api/v1/documents/upload" \
|
||||||
|
-e "QDRANT_URL=https://qdrant-dev.breakpilot.ai" \
|
||||||
|
-e "QDRANT_API_KEY=z9cKbT74vl1aKPD1QGIlKWfET47VH93u" \
|
||||||
|
-e "SDK_URL=http://bp-compliance-ai-sdk:8090" \
|
||||||
|
alpine:3.19 \
|
||||||
|
sh -c "
|
||||||
|
apk add --no-cache curl bash coreutils git python3 unzip > /dev/null 2>&1
|
||||||
|
mkdir -p /tmp/rag-ingestion/{pdfs,repos,texts}
|
||||||
|
mkdir -p /workspace/scripts
|
||||||
|
cp -r /workspace_scripts/* /workspace/scripts/ 2>/dev/null || true
|
||||||
|
cd /workspace
|
||||||
|
if [ '${PHASE}' = 'all' ]; then
|
||||||
|
bash scripts/ingest-legal-corpus.sh
|
||||||
|
elif [ '${PHASE}' = 'download' ]; then
|
||||||
|
bash scripts/ingest-legal-corpus.sh --only download
|
||||||
|
else
|
||||||
|
echo '=== Running download phase first ==='
|
||||||
|
bash scripts/ingest-legal-corpus.sh --only download
|
||||||
|
echo ''
|
||||||
|
echo '=== Running phase: ${PHASE} ==='
|
||||||
|
bash scripts/ingest-legal-corpus.sh --only '${PHASE}'
|
||||||
|
fi
|
||||||
|
")
|
||||||
|
|
||||||
|
echo "Container: $CONTAINER_ID"
|
||||||
|
|
||||||
|
# Workspace-Dir im Container anlegen und Scripts hineinkopieren
|
||||||
|
docker cp scripts "${CONTAINER_ID}:/workspace_scripts"
|
||||||
|
echo "Scripts kopiert (aus Git-Checkout)"
|
||||||
|
|
||||||
|
# Container starten und Output streamen
|
||||||
|
docker start -a "${CONTAINER_ID}" || EXITCODE=$?
|
||||||
|
|
||||||
|
# Container aufraeumen
|
||||||
|
docker rm -f "${CONTAINER_ID}" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Ingestion abgeschlossen ==="
|
||||||
|
|
||||||
|
# Exit mit dem Original-Exitcode
|
||||||
|
exit ${EXITCODE:-0}
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -11,12 +11,19 @@ secrets/
|
|||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
|
dist/
|
||||||
|
.turbo/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
venv/
|
venv/
|
||||||
.venv/
|
.venv/
|
||||||
|
.coverage
|
||||||
|
coverage.out
|
||||||
|
test_*.db
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
backups/*.backup
|
backups/*.backup
|
||||||
@@ -40,3 +47,4 @@ backups/*.backup
|
|||||||
*.mp3
|
*.mp3
|
||||||
*.wav
|
*.wav
|
||||||
ai-compliance-sdk/server
|
ai-compliance-sdk/server
|
||||||
|
*.bak
|
||||||
|
|||||||
176
AGENTS.go.md
Normal file
176
AGENTS.go.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# AGENTS.go.md — Go Service Conventions
|
||||||
|
|
||||||
|
Applies to: `ai-compliance-sdk/`.
|
||||||
|
|
||||||
|
## Layered architecture (Gin)
|
||||||
|
|
||||||
|
Follows [Standard Go Project Layout](https://github.com/golang-standards/project-layout) + hexagonal/clean-arch.
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-compliance-sdk/
|
||||||
|
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. <50 LOC.
|
||||||
|
├── internal/
|
||||||
|
│ ├── app/ # Wiring: config + DI graph + lifecycle.
|
||||||
|
│ ├── domain/ # Pure types, interfaces, errors. No I/O imports.
|
||||||
|
│ │ └── <aggregate>/
|
||||||
|
│ ├── service/ # Business logic. Depends on domain interfaces only.
|
||||||
|
│ │ └── <aggregate>/
|
||||||
|
│ ├── repository/postgres/ # Concrete repo implementations.
|
||||||
|
│ │ └── <aggregate>/
|
||||||
|
│ ├── transport/http/ # Gin handlers. Thin. One handler per file group.
|
||||||
|
│ │ ├── handler/<aggregate>/
|
||||||
|
│ │ ├── middleware/
|
||||||
|
│ │ └── router.go
|
||||||
|
│ └── platform/ # DB pool, logger, config, tracing.
|
||||||
|
└── pkg/ # Importable by other repos. Empty unless needed.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependency direction:** `transport → service → domain ← repository`. `domain` imports nothing from siblings.
|
||||||
|
|
||||||
|
## Handlers
|
||||||
|
|
||||||
|
- One handler = one Gin function. ≤40 LOC.
|
||||||
|
- Bind → call service → map domain error to HTTP via `httperr.Write(c, err)` → respond.
|
||||||
|
- Return early on errors. No business logic, no SQL.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *IACEHandler) Create(c *gin.Context) {
|
||||||
|
var req CreateIACERequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Write(c, httperr.BadRequest(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
|
||||||
|
if err != nil {
|
||||||
|
httperr.Write(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, out)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- Struct + constructor + interface methods. No package-level state.
|
||||||
|
- Take `context.Context` as first arg always. Propagate to repos.
|
||||||
|
- Return `(value, error)`. Wrap with `fmt.Errorf("create iace: %w", err)`.
|
||||||
|
- Domain errors implemented as sentinel vars or typed errors; matched with `errors.Is` / `errors.As`.
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
|
||||||
|
- Interface lives in `domain/<aggregate>/repository.go`. Implementation in `repository/postgres/<aggregate>/`.
|
||||||
|
- One file per query group; no file >500 LOC.
|
||||||
|
- Use `pgx`/`sqlc` over hand-rolled string SQL when feasible. No ORM globals.
|
||||||
|
- All queries take `ctx`. No background goroutines without explicit lifecycle.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Single `internal/platform/httperr` package maps `error` → HTTP status:
|
||||||
|
|
||||||
|
```go
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrNotFound): return 404
|
||||||
|
case errors.Is(err, domain.ErrConflict): return 409
|
||||||
|
case errors.As(err, &validationErr): return 422
|
||||||
|
default: return 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Never `panic` in request handling. `recover` middleware logs and returns 500.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Co-located `*_test.go`.
|
||||||
|
- **Table-driven** tests for service logic; use `t.Run(tt.name, ...)`.
|
||||||
|
- Handlers tested with `httptest.NewRecorder`.
|
||||||
|
- Repos tested with `testcontainers-go` (or the existing compose Postgres) — never mocks at the SQL boundary.
|
||||||
|
- Coverage target: 80% on `service/`. CI fails on regression.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestIACEService_Create(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input service.CreateInput
|
||||||
|
setup func(*mockRepo)
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"happy path", validInput(), func(r *mockRepo) { r.createReturns(nil) }, nil},
|
||||||
|
{"conflict", validInput(), func(r *mockRepo) { r.createReturns(domain.ErrConflict) }, domain.ErrConflict},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) { /* ... */ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
Run lint before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
golangci-lint run --timeout 5m ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.golangci.yml` at the service root (`ai-compliance-sdk/.golangci.yml`) enables: `errcheck, govet, staticcheck, gosec, gocyclo (≤20), gocritic, revive, goimports, unused, ineffassign`. Fix lint violations in new code; legacy violations are tracked but not required to fix immediately.
|
||||||
|
|
||||||
|
- `gofumpt` formatting.
|
||||||
|
- `go vet ./...` clean.
|
||||||
|
- `go mod tidy` clean — no unused deps.
|
||||||
|
|
||||||
|
## File splitting pattern
|
||||||
|
|
||||||
|
When a Go file exceeds the 500-line hard cap, split it in place — no new packages needed:
|
||||||
|
|
||||||
|
- All split files stay in **the same package directory** with the **same `package <name>` declaration**.
|
||||||
|
- No import changes are needed anywhere because Go packages are directory-scoped.
|
||||||
|
- Naming: `store_projects.go`, `store_components.go` (noun + underscore + sub-resource).
|
||||||
|
- For handlers: `iace_handler_projects.go`, `iace_handler_hazards.go`, etc.
|
||||||
|
- Before splitting, add a characterization test that pins current behaviour.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Domain errors are defined in `internal/domain/<aggregate>/errors.go` as sentinel vars or typed errors. The mapping from domain error to HTTP status lives exclusively in `internal/platform/httperr/httperr.go` via `errors.Is` / `errors.As`. Handlers call `httperr.Write(c, err)` — **never** directly call `c.JSON` with a status code derived from business logic.
|
||||||
|
|
||||||
|
## Context propagation
|
||||||
|
|
||||||
|
- Always pass `ctx context.Context` as the **first parameter** in every service and repository method.
|
||||||
|
- Never store a context in a struct field — pass it per call.
|
||||||
|
- Cancellation must be respected: check `ctx.Err()` in loops; propagate to all I/O calls.
|
||||||
|
|
||||||
|
## Concurrency
|
||||||
|
|
||||||
|
- Goroutines must have a clear lifecycle owner (struct method that started them must stop them).
|
||||||
|
- Pass `ctx` everywhere. Cancellation respected.
|
||||||
|
- No global mutexes for request data. Use per-request context.
|
||||||
|
|
||||||
|
## Before every push — MANDATORY
|
||||||
|
|
||||||
|
Run all steps for `ai-compliance-sdk/` before pushing. CI runs the same checks and will fail if you skip this.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai-compliance-sdk
|
||||||
|
|
||||||
|
# 1. Vet + lint
|
||||||
|
go vet ./...
|
||||||
|
golangci-lint run --timeout 5m ./...
|
||||||
|
|
||||||
|
# 2. Tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# 3. Build
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
All steps must exit 0. Do not push if any step fails.
|
||||||
|
|
||||||
|
## What you may NOT do
|
||||||
|
|
||||||
|
- Touch DB schema/migrations.
|
||||||
|
- Add a new top-level package directly under `internal/` without architectural review.
|
||||||
|
- `import "C"`, unsafe, reflection-heavy code.
|
||||||
|
- Use `init()` for non-trivial setup. Wire it in `internal/app`.
|
||||||
|
- Use `interface{}` / `any` in new code without an explicit comment justifying it.
|
||||||
|
- Call `log.Fatal` outside of `main.go`; panicking in request handling is also forbidden.
|
||||||
|
- Shadow `err` with `:=` inside an `if`-block when the outer scope already declares `err` — use `=` or rename.
|
||||||
|
- Create a file >500 lines.
|
||||||
|
- Change a public route's contract without updating consumers.
|
||||||
168
AGENTS.python.md
Normal file
168
AGENTS.python.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# AGENTS.python.md — Python Service Conventions
|
||||||
|
|
||||||
|
Applies to: `backend-compliance/`, `document-crawler/`, `dsms-gateway/`, `compliance-tts-service/`.
|
||||||
|
|
||||||
|
## Layered architecture (FastAPI)
|
||||||
|
|
||||||
|
```
|
||||||
|
compliance/
|
||||||
|
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
|
||||||
|
│ └── <domain>_routes.py
|
||||||
|
├── services/ # Business logic. Pure-ish; no FastAPI imports.
|
||||||
|
│ └── <domain>_service.py
|
||||||
|
├── repositories/ # DB access. Owns SQLAlchemy session usage.
|
||||||
|
│ └── <domain>_repository.py
|
||||||
|
├── domain/ # Value objects, enums, domain exceptions.
|
||||||
|
├── schemas/ # Pydantic models, split per domain. NEVER one giant schemas.py.
|
||||||
|
│ └── <domain>.py
|
||||||
|
└── db/
|
||||||
|
└── models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependency direction:** `api → services → repositories → db.models`. Lower layers must not import upper layers.
|
||||||
|
|
||||||
|
## Routers
|
||||||
|
|
||||||
|
- One `APIRouter` per domain file.
|
||||||
|
- Handlers do exactly: parse request → call service → map domain errors to HTTPException → return response model.
|
||||||
|
- Inject services via `Depends`. No globals.
|
||||||
|
- Tag routes; document with summary + response_model.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.post("/dsr/requests", response_model=DSRRequestRead, status_code=201)
|
||||||
|
async def create_dsr_request(
|
||||||
|
payload: DSRRequestCreate,
|
||||||
|
service: DSRService = Depends(get_dsr_service),
|
||||||
|
tenant_id: UUID = Depends(get_tenant_id),
|
||||||
|
) -> DSRRequestRead:
|
||||||
|
try:
|
||||||
|
return await service.create(tenant_id, payload)
|
||||||
|
except DSRConflict as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- Constructor takes the repository (interface, not concrete).
|
||||||
|
- No `Request`, `Response`, or HTTP knowledge.
|
||||||
|
- Raise domain exceptions (e.g. `DSRConflict`, `DSRNotFound`), never `HTTPException`.
|
||||||
|
- Return domain objects or Pydantic schemas — pick one and stay consistent inside a service.
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
|
||||||
|
- Methods are intent-named (`get_pending_for_tenant`), not CRUD-named (`select_where`).
|
||||||
|
- Sessions injected, not constructed inside.
|
||||||
|
- No business logic. No cross-aggregate joins for unrelated workflows — that belongs in a service.
|
||||||
|
- Return ORM models or domain VOs; never `Row`.
|
||||||
|
|
||||||
|
## Schemas (Pydantic v2)
|
||||||
|
|
||||||
|
- One module per domain. Module ≤300 lines.
|
||||||
|
- Use `model_config = ConfigDict(from_attributes=True, frozen=True)` for read models.
|
||||||
|
- Separate `*Create`, `*Update`, `*Read`. No giant union schemas.
|
||||||
|
|
||||||
|
## Tests (`pytest`)
|
||||||
|
|
||||||
|
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`.
|
||||||
|
- Unit tests mock the repository. Use `pytest.fixture` + `unittest.mock.AsyncMock`.
|
||||||
|
- Integration tests run against the real Postgres from `docker-compose.yml` via a transactional fixture (rollback after each test).
|
||||||
|
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
|
||||||
|
- Naming: `test_<unit>_<scenario>_<expected>.py::TestClass::test_method`.
|
||||||
|
- `pytest-asyncio` mode = `auto`. Mark slow tests with `@pytest.mark.slow`.
|
||||||
|
- Coverage target: 80% for new code; never decrease the service baseline.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
- `ruff check` + `ruff format` (line length 100).
|
||||||
|
- `mypy --strict` on `services/`, `repositories/`, `domain/`. Expand outward.
|
||||||
|
- `pip-audit` in CI.
|
||||||
|
- Async-first: prefer `httpx.AsyncClient`, `asyncpg`/`SQLAlchemy 2.x async`.
|
||||||
|
|
||||||
|
## mypy configuration
|
||||||
|
|
||||||
|
`backend-compliance/mypy.ini` is the mypy config. Strict mode is on globally; per-module overrides exist only for legacy files that have not been cleaned up yet.
|
||||||
|
|
||||||
|
- New modules added to `compliance/services/` or `compliance/repositories/` **must** pass `mypy --strict`.
|
||||||
|
- To type-check a new module: `cd backend-compliance && mypy compliance/your_new_module.py`
|
||||||
|
- When you fully type a legacy file, **remove its loose-override block** from `mypy.ini` as part of the same PR.
|
||||||
|
|
||||||
|
## Dependency injection
|
||||||
|
|
||||||
|
Services and repositories are wired via FastAPI `Depends`. Never instantiate a service or repository directly inside a handler.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# dependencies.py
|
||||||
|
def get_my_service(db: AsyncSession = Depends(get_db)) -> MyService:
|
||||||
|
return MyService(MyRepository(db))
|
||||||
|
|
||||||
|
# router
|
||||||
|
@router.get("/items", response_model=list[ItemRead])
|
||||||
|
async def list_items(svc: MyService = Depends(get_my_service)) -> list[ItemRead]:
|
||||||
|
return await svc.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
- Services take repositories in `__init__`; repositories take `Session` or `AsyncSession`.
|
||||||
|
|
||||||
|
## Structured logging
|
||||||
|
|
||||||
|
```python
|
||||||
|
import structlog
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
# Always bind context before logging:
|
||||||
|
logger.bind(tenant_id=str(tid), action="create_dsfa").info("dsfa created")
|
||||||
|
```
|
||||||
|
|
||||||
|
- Audit-relevant actions must use the audit logger with a `legal_basis` field.
|
||||||
|
- Never log secrets, PII, or full request bodies.
|
||||||
|
|
||||||
|
## Barrel re-export pattern
|
||||||
|
|
||||||
|
When an oversized file (e.g. `schemas.py`, `models.py`) is split into a sub-package, the original stays as a **thin re-exporter** so existing consumer imports keep working:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# compliance/schemas.py (barrel — DO NOT ADD NEW CODE HERE)
|
||||||
|
from .schemas.ai import * # noqa: F401, F403
|
||||||
|
from .schemas.consent import * # noqa: F401, F403
|
||||||
|
```
|
||||||
|
|
||||||
|
- New code imports from the specific module (e.g. `from compliance.schemas.ai import AIRiskRead`), not the barrel.
|
||||||
|
- `from module import *` is only permitted in barrel files.
|
||||||
|
|
||||||
|
## Errors & logging
|
||||||
|
|
||||||
|
- Domain errors inherit from a single `DomainError` base per service.
|
||||||
|
- Log via `structlog` with bound context (`tenant_id`, `request_id`). Never log secrets, PII, or full request bodies.
|
||||||
|
- Audit-relevant actions go through the audit logger, not the application logger.
|
||||||
|
|
||||||
|
## Before every push — MANDATORY
|
||||||
|
|
||||||
|
Run all three steps for every Python service you touched before pushing. CI runs the same checks and will fail if you skip this.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service> # backend-compliance | document-crawler | dsms-gateway | compliance-tts-service
|
||||||
|
|
||||||
|
# 1. Lint
|
||||||
|
ruff check .
|
||||||
|
mypy compliance/ # only for backend-compliance
|
||||||
|
|
||||||
|
# 2. Tests
|
||||||
|
pytest -x
|
||||||
|
|
||||||
|
# 3. Import sanity (catches NameError at collection time)
|
||||||
|
python -c "import compliance" # or the service's main module
|
||||||
|
```
|
||||||
|
|
||||||
|
All steps must exit 0. Do not push if any step fails.
|
||||||
|
|
||||||
|
## What you may NOT do
|
||||||
|
|
||||||
|
- Add a new Alembic migration.
|
||||||
|
- Rename a `__tablename__`, column, or enum value.
|
||||||
|
- Change a public route's path/method/status/schema without simultaneous dashboard fix.
|
||||||
|
- Catch `Exception` broadly — catch the specific domain or library error.
|
||||||
|
- Put business logic in a router or in a Pydantic validator.
|
||||||
|
- `from module import *` in new code — only in barrel re-exporters.
|
||||||
|
- `raise HTTPException` inside the service layer — raise domain exceptions; map them in the router.
|
||||||
|
- Use `model_validate` on untrusted external data without an explicit schema boundary.
|
||||||
|
- Create a new file >500 lines. Period.
|
||||||
145
AGENTS.typescript.md
Normal file
145
AGENTS.typescript.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# AGENTS.typescript.md — TypeScript / Next.js Conventions
|
||||||
|
|
||||||
|
Applies to: `admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`, `dsms-node/` (where applicable).
|
||||||
|
|
||||||
|
## Layered architecture (Next.js 15 App Router)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── <route>/
|
||||||
|
│ ├── page.tsx # Server Component by default. ≤200 LOC.
|
||||||
|
│ ├── layout.tsx
|
||||||
|
│ ├── _components/ # Private folder; not routable. Colocated UI.
|
||||||
|
│ │ └── <Component>.tsx # Each file ≤300 LOC.
|
||||||
|
│ ├── _hooks/ # Client hooks for this route.
|
||||||
|
│ ├── _server/ # Server actions, data loaders for this route.
|
||||||
|
│ └── loading.tsx / error.tsx
|
||||||
|
├── api/
|
||||||
|
│ └── <domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
|
||||||
|
lib/
|
||||||
|
├── <domain>/ # Pure helpers, types, schemas (zod). Reusable.
|
||||||
|
└── server/<domain>/ # Server-only logic; uses "server-only" import.
|
||||||
|
components/ # Truly shared, app-wide components.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server vs Client:** Default is Server Component. Add `"use client"` only when you need state, effects, or browser APIs. Push the boundary as deep as possible.
|
||||||
|
|
||||||
|
## API routes (route.ts)
|
||||||
|
|
||||||
|
- One handler per HTTP method, ≤40 LOC.
|
||||||
|
- Validate input with zod `safeParse` — never `parse` (throws and bypasses error handling).
|
||||||
|
- Delegate to `lib/server/<domain>/`. No business logic in `route.ts`.
|
||||||
|
- Always return `NextResponse.json(..., { status })`. Let the framework's error boundary handle unexpected errors — don't wrap the entire handler in `try/catch`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/<domain>/route.ts (≤40 LOC)
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { mySchema } from '@/lib/schemas/<domain>';
|
||||||
|
import { myService } from '@/lib/server/<domain>';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = mySchema.safeParse(await req.json());
|
||||||
|
if (!body.success) return NextResponse.json({ error: body.error }, { status: 400 });
|
||||||
|
const result = await myService.create(body.data);
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page components
|
||||||
|
|
||||||
|
- Pages >300 lines must be split into colocated `_components/`.
|
||||||
|
- Server Components fetch data; pass plain objects to Client Components.
|
||||||
|
- No data fetching in `useEffect` for server-renderable data.
|
||||||
|
- State management: prefer URL state (`searchParams`) and Server Components over global stores.
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
- `lib/sdk/types.ts` is being split into `lib/sdk/types/<domain>.ts`. Mirror backend domain boundaries.
|
||||||
|
- All API DTOs are zod schemas; infer types via `z.infer`.
|
||||||
|
- No `any`. No `as unknown as`. If you reach for it, the type is wrong.
|
||||||
|
- Always use `import type { Foo }` for type-only imports.
|
||||||
|
- Never use `as` type assertions except when bridging external data at a boundary (add a comment explaining why).
|
||||||
|
- No `@ts-ignore`. `@ts-expect-error` only with a comment explaining the suppression.
|
||||||
|
|
||||||
|
## Barrel re-export pattern
|
||||||
|
|
||||||
|
`lib/sdk/types.ts` is a barrel — it re-exports from domain-specific files. **Do not add new types directly to it.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/sdk/types.ts (barrel — DO NOT ADD NEW TYPES HERE)
|
||||||
|
export * from './types/enums';
|
||||||
|
export * from './types/company-profile';
|
||||||
|
// ... etc.
|
||||||
|
|
||||||
|
// New types go in lib/sdk/types/<domain>.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- When splitting an oversized file, keep the original as a thin barrel so existing imports don't break.
|
||||||
|
- New code imports directly from the specific module (e.g. `import type { CompanyProfile } from '@/lib/sdk/types/company-profile'`), not the barrel.
|
||||||
|
|
||||||
|
## Server vs Client components
|
||||||
|
|
||||||
|
Default is Server Component. Add `"use client"` only when required:
|
||||||
|
|
||||||
|
| Need | Pattern |
|
||||||
|
|------|---------|
|
||||||
|
| Data fetching only | Server Component (no directive) |
|
||||||
|
| `useState` / `useEffect` | Client Component (`"use client"`) |
|
||||||
|
| Browser API | Client Component |
|
||||||
|
| Event handlers | Client Component |
|
||||||
|
|
||||||
|
- Pass only serializable props from Server → Client Components (no functions, no class instances).
|
||||||
|
- Never add `"use client"` to a layout or page just because one child needs it — extract the client part into a `_components/` file.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
|
||||||
|
- Hooks: `@testing-library/react` `renderHook`.
|
||||||
|
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page, smoke happy path minimum.
|
||||||
|
- Snapshot tests sparingly — only for stable output (CSV, JSON-LD).
|
||||||
|
- Coverage target: 70% on `lib/`, smoke coverage on `app/`.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
- `tsc --noEmit` clean (strict mode, `noUncheckedIndexedAccess: true`).
|
||||||
|
- ESLint with `@typescript-eslint`, `eslint-config-next`, type-aware rules on.
|
||||||
|
- `prettier`.
|
||||||
|
- `next build` clean. No `// @ts-ignore`. `// @ts-expect-error` only with a comment explaining why.
|
||||||
|
|
||||||
|
## Before every push — MANDATORY
|
||||||
|
|
||||||
|
Run all three steps for every affected service (`admin-compliance/`, `developer-portal/`) before pushing. CI runs the same checks and will fail if you skip this.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-compliance # or developer-portal
|
||||||
|
|
||||||
|
# 1. Build — catches type errors and module resolution failures
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 2. Lint
|
||||||
|
npx tsc --noEmit
|
||||||
|
npx eslint . --max-warnings 0
|
||||||
|
|
||||||
|
# 3. Tests
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
All three must exit 0. Do not push if any step fails.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- Use `next/dynamic` for heavy client-only components.
|
||||||
|
- Image: `next/image` with explicit width/height.
|
||||||
|
- Avoid waterfalls — `Promise.all` for parallel data fetches in Server Components.
|
||||||
|
|
||||||
|
## What you may NOT do
|
||||||
|
|
||||||
|
- Put business logic in a `page.tsx` or `route.ts`.
|
||||||
|
- Reach across module boundaries (e.g. `admin-compliance` importing from `developer-portal`).
|
||||||
|
- Use `dangerouslySetInnerHTML` without DOMPurify sanitization.
|
||||||
|
- Call internal backend APIs directly from Client Components — use Server Components or API routes as a proxy.
|
||||||
|
- Add `"use client"` to a layout or page just because one child needs it — extract the client part.
|
||||||
|
- Spread `...props` onto a DOM element without filtering the props first (type error risk).
|
||||||
|
- Change a public API route's path/method/schema without updating SDK consumers in the same change.
|
||||||
|
- Create a file >500 lines.
|
||||||
|
- Disable a lint or type rule globally to silence a finding — fix the root cause.
|
||||||
202
CONTRIBUTING.md
Normal file
202
CONTRIBUTING.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Contributing to breakpilot-compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||||
|
cd breakpilot-compliance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Branch conventions** (branch from `main`):
|
||||||
|
|
||||||
|
| Prefix | Use for |
|
||||||
|
|--------|---------|
|
||||||
|
| `feature/` | New functionality |
|
||||||
|
| `fix/` | Bug fixes |
|
||||||
|
| `chore/` | Tooling, deps, CI, docs |
|
||||||
|
|
||||||
|
Example: `git checkout -b feature/ai-sdk-risk-scoring`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dev Environment
|
||||||
|
|
||||||
|
Each service runs independently. Start only what you need.
|
||||||
|
|
||||||
|
**Go — ai-compliance-sdk**
|
||||||
|
```bash
|
||||||
|
cd ai-compliance-sdk
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python — backend-compliance**
|
||||||
|
```bash
|
||||||
|
cd backend-compliance
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python — dsms-gateway / document-crawler / compliance-tts-service**
|
||||||
|
```bash
|
||||||
|
cd <service>
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload --port <port>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Node.js — admin-compliance**
|
||||||
|
```bash
|
||||||
|
cd admin-compliance
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3007
|
||||||
|
```
|
||||||
|
|
||||||
|
**Node.js — developer-portal**
|
||||||
|
```bash
|
||||||
|
cd developer-portal
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3006
|
||||||
|
```
|
||||||
|
|
||||||
|
**All services together (local Docker)**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Config lives in `.env` (not committed). Copy `.env.example` and fill in `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`, and Vault tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Before Your First Commit
|
||||||
|
|
||||||
|
Run all of these locally. CI will run the same checks and fail if they don't pass.
|
||||||
|
|
||||||
|
**LOC budget (mandatory)**
|
||||||
|
```bash
|
||||||
|
bash scripts/check-loc.sh # must exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Go lint**
|
||||||
|
```bash
|
||||||
|
cd ai-compliance-sdk
|
||||||
|
golangci-lint run --timeout 5m ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python lint**
|
||||||
|
```bash
|
||||||
|
cd backend-compliance
|
||||||
|
ruff check .
|
||||||
|
mypy compliance/ # only if mypy.ini exists
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript type-check**
|
||||||
|
```bash
|
||||||
|
cd admin-compliance
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
```bash
|
||||||
|
# Go
|
||||||
|
cd ai-compliance-sdk && go test ./...
|
||||||
|
|
||||||
|
# Python backend
|
||||||
|
cd backend-compliance && pytest
|
||||||
|
|
||||||
|
# DSMS gateway
|
||||||
|
cd dsms-gateway && pytest test_main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If any step fails, fix it before committing. The git pre-commit hook re-runs `check-loc.sh` automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Commit Message Rules
|
||||||
|
|
||||||
|
Use [Conventional Commits](https://www.conventionalcommits.org/) style:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short summary>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
Types: `feat`, `fix`, `chore`, `refactor`, `test`, `docs`, `ci`.
|
||||||
|
|
||||||
|
### `[guardrail-change]` marker — REQUIRED
|
||||||
|
|
||||||
|
Add `[guardrail-change]` anywhere in the commit message body (or footer) when your changeset touches **any** of these files:
|
||||||
|
|
||||||
|
| File / path | Reason protected |
|
||||||
|
|-------------|-----------------|
|
||||||
|
| `.claude/settings.json` | PreToolUse/PostToolUse hooks |
|
||||||
|
| `scripts/check-loc.sh` | LOC enforcement script |
|
||||||
|
| `scripts/githooks/pre-commit` | Git hook |
|
||||||
|
| `.claude/rules/loc-exceptions.txt` | Exception registry |
|
||||||
|
| `AGENTS.*.md` (any) | Per-language architecture rules |
|
||||||
|
|
||||||
|
The `guardrail-integrity` CI job checks for this marker and **fails the build** if it is missing.
|
||||||
|
|
||||||
|
**Valid guardrail commit example:**
|
||||||
|
```
|
||||||
|
chore(guardrail): add exception for generated protobuf file
|
||||||
|
|
||||||
|
proto/generated/compliance.pb.go exceeds 500 LOC because it is
|
||||||
|
machine-generated and cannot be split. Added to loc-exceptions.txt
|
||||||
|
with rationale.
|
||||||
|
|
||||||
|
[guardrail-change]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architecture Rules (Non-Negotiable)
|
||||||
|
|
||||||
|
### File budget
|
||||||
|
- **500 LOC hard cap** on every non-test, non-generated source file.
|
||||||
|
- The `PreToolUse` hook in `.claude/settings.json` blocks Claude Code from creating or editing files that would breach this limit.
|
||||||
|
- Exceptions require a written rationale in `.claude/rules/loc-exceptions.txt` plus `[guardrail-change]` in the commit.
|
||||||
|
|
||||||
|
### Clean architecture per service
|
||||||
|
- Python (FastAPI): `api → services → repositories → db.models`. Handlers ≤ 30 LOC. See `AGENTS.python.md`.
|
||||||
|
- Go (Gin): Standard Go Project Layout + hexagonal. `cmd/` is thin wiring. See `AGENTS.go.md`.
|
||||||
|
- TypeScript (Next.js 15): server-first, push client boundary deep, colocate `_components/` + `_hooks/` per route. See `AGENTS.typescript.md`.
|
||||||
|
|
||||||
|
### Database is frozen
|
||||||
|
- No new Alembic migrations, no `ALTER TABLE`, no `__tablename__` or column renames.
|
||||||
|
- The pre-commit hook blocks any change under `migrations/` or `alembic/versions/` unless the commit message contains `[migration-approved]`.
|
||||||
|
|
||||||
|
### Public endpoints are a contract
|
||||||
|
- Any change to a route path, HTTP method, status code, request schema, or response schema in `backend-compliance/`, `ai-compliance-sdk/`, `dsms-gateway/`, `document-crawler/`, or `compliance-tts-service/` **must** be accompanied by a matching update in every consumer (`admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`) in the **same changeset**.
|
||||||
|
- OpenAPI baseline snapshots live in `tests/contracts/`. Contract tests fail on any drift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pull Requests
|
||||||
|
|
||||||
|
- **Target branch: `main`** — squash merge your feature branch into `main`.
|
||||||
|
- Keep PRs focused; one logical change per PR.
|
||||||
|
|
||||||
|
**PR checklist before requesting review:**
|
||||||
|
|
||||||
|
- [ ] `bash scripts/check-loc.sh` exits 0
|
||||||
|
- [ ] All lint checks pass (go, python, tsc)
|
||||||
|
- [ ] All tests pass locally
|
||||||
|
- [ ] No endpoint drift without consumer updates in the same PR
|
||||||
|
- [ ] `[guardrail-change]` present in commit message if guardrail files were touched
|
||||||
|
- [ ] Docs updated if new endpoints, config vars, or architecture changed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Claude Code Users
|
||||||
|
|
||||||
|
This section is for AI-assisted development sessions using Claude Code.
|
||||||
|
|
||||||
|
- **Always work on a feature branch** (`feat/*`, `feature/*`, `hotfix/*`), never directly on `main`.
|
||||||
|
- The `.claude/settings.json` `PreToolUse` hooks will automatically block Write/Edit operations on files that would exceed 500 lines. This is intentional — split the file instead.
|
||||||
|
- If the `guardrail-integrity` CI job fails, check that your commit message body includes `[guardrail-change]`. Add it and amend or create a fixup commit.
|
||||||
|
- **Never use `git add -A` or `git add .`** — always stage specific files by path to avoid accidentally committing `.env`, `node_modules/`, `.next/`, or compiled binaries.
|
||||||
|
- After every session: `bash scripts/check-loc.sh` must exit 0 before pushing.
|
||||||
|
- Read `CLAUDE.md` and the relevant `AGENTS.<lang>.md` before starting work on a service.
|
||||||
128
README.md
Normal file
128
README.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# breakpilot-compliance
|
||||||
|
|
||||||
|
**DSGVO/AI-Act compliance platform — 10 services, Go · Python · TypeScript**
|
||||||
|
|
||||||
|
[](https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions)
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
breakpilot-compliance is a multi-tenant DSGVO/EU AI Act compliance platform that provides an SDK for consent management, data subject requests (DSR), audit logging, iACE impact assessments, and document archival. It ships as 10 containerised services covering an admin dashboard, a developer portal, a Python/FastAPI backend, a Go AI compliance engine, TTS, and a decentralised document store on IPFS. Every service is deployed automatically via Gitea Actions → Orca on every push to `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Service | Tech | Port | Container |
|
||||||
|
|---------|------|------|-----------|
|
||||||
|
| admin-compliance | Next.js 15 | 3007 | bp-compliance-admin |
|
||||||
|
| backend-compliance | Python / FastAPI 0.123 | 8002 | bp-compliance-backend |
|
||||||
|
| ai-compliance-sdk | Go 1.24 / Gin | 8093 | bp-compliance-ai-sdk |
|
||||||
|
| developer-portal | Next.js 15 | 3006 | bp-compliance-developer-portal |
|
||||||
|
| breakpilot-compliance-sdk | TypeScript SDK (React/Vue/Angular/vanilla) | — | — |
|
||||||
|
| consent-sdk | JS/TS Consent SDK | — | — |
|
||||||
|
| compliance-tts-service | Python / Piper TTS | 8095 | bp-compliance-tts |
|
||||||
|
| document-crawler | Python / FastAPI | 8098 | bp-compliance-document-crawler |
|
||||||
|
| dsms-gateway | Python / FastAPI / IPFS | 8082 | bp-compliance-dsms-gateway |
|
||||||
|
| dsms-node | IPFS Kubo v0.24.0 | — | bp-compliance-dsms-node |
|
||||||
|
|
||||||
|
All containers share the external `breakpilot-network` Docker network and depend on `breakpilot-core` (Valkey, Vault, RAG service, Nginx reverse proxy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**Prerequisites:** Docker, Go 1.24+, Python 3.12+, Node.js 20+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||||
|
cd breakpilot-compliance
|
||||||
|
|
||||||
|
# Copy and populate secrets (never commit .env)
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
For the Orca/Hetzner production target (x86_64), use the override:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.hetzner.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
Use feature branches off `main`. Supported prefixes: `feat/`, `feature/`, `hotfix/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main && git pull origin main
|
||||||
|
git checkout -b feat/my-change
|
||||||
|
# ... make changes ...
|
||||||
|
git push origin feat/my-change
|
||||||
|
# Open a PR → squash merge to main
|
||||||
|
```
|
||||||
|
|
||||||
|
Push to `main` triggers:
|
||||||
|
1. **Gitea Actions** — lint → test → validate (see CI Pipeline below)
|
||||||
|
2. **Orca** — automatic build + deploy (~3 min total)
|
||||||
|
|
||||||
|
Monitor status: <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI Pipeline
|
||||||
|
|
||||||
|
Defined in `.gitea/workflows/ci.yaml`.
|
||||||
|
|
||||||
|
| Job | What it checks |
|
||||||
|
|-----|----------------|
|
||||||
|
| `loc-budget` | All source files ≤ 500 LOC; soft target 300 |
|
||||||
|
| `guardrail-integrity` | Commits touching guardrail files carry `[guardrail-change]` |
|
||||||
|
| `go-lint` | `golangci-lint` on `ai-compliance-sdk/` |
|
||||||
|
| `python-lint` | `ruff` + `mypy` on Python services |
|
||||||
|
| `nodejs-lint` | `tsc --noEmit` + ESLint on Next.js services |
|
||||||
|
| `test-go-ai-compliance` | `go test ./...` in `ai-compliance-sdk/` |
|
||||||
|
| `test-python-backend-compliance` | `pytest` in `backend-compliance/` |
|
||||||
|
| `test-python-document-crawler` | `pytest` in `document-crawler/` |
|
||||||
|
| `test-python-dsms-gateway` | `pytest test_main.py` in `dsms-gateway/` |
|
||||||
|
| `sbom-scan` | License + vulnerability scan via `syft` + `grype` |
|
||||||
|
| `validate-canonical-controls` | OpenAPI contract baseline diff |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Budget
|
||||||
|
|
||||||
|
| Limit | Value | How to check |
|
||||||
|
|-------|-------|--------------|
|
||||||
|
| Soft target | 300 LOC | `bash scripts/check-loc.sh` |
|
||||||
|
| Hard cap | 500 LOC | Same; also enforced by `PreToolUse` hook + git pre-commit + CI |
|
||||||
|
| Exceptions | `.claude/rules/loc-exceptions.txt` | Require written rationale + `[guardrail-change]` commit marker |
|
||||||
|
|
||||||
|
The `.claude/settings.json` `PreToolUse` hook blocks Claude Code from writing or editing files that would exceed the hard cap. The git pre-commit hook re-checks. CI is the final gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
| | URL |
|
||||||
|
|-|-----|
|
||||||
|
| Admin dashboard | <https://admin-dev.breakpilot.ai> |
|
||||||
|
| Developer portal | <https://developers-dev.breakpilot.ai> |
|
||||||
|
| Backend API | <https://api-dev.breakpilot.ai> |
|
||||||
|
| AI SDK API | <https://sdk-dev.breakpilot.ai> |
|
||||||
|
| Gitea repo | <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance> |
|
||||||
|
| Gitea Actions | <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions> |
|
||||||
|
|
||||||
915
REFACTOR_PLAYBOOK.md
Normal file
915
REFACTOR_PLAYBOOK.md
Normal file
@@ -0,0 +1,915 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.9 `AGENTS.python.md` — Python / FastAPI conventions
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# AGENTS.python.md — Python Service Conventions
|
||||||
|
|
||||||
|
## Layered architecture (FastAPI)
|
||||||
|
|
||||||
|
|
||||||
|
## 1. Guardrail files (drop these in first)
|
||||||
|
|
||||||
|
These artifacts enforce the rules without you or Claude having to remember them. Install them as **Phase 0**, before touching any real code.
|
||||||
|
|
||||||
|
### 1.1 `.claude/CLAUDE.md` — loaded into every Claude session
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# <Your Project Name>
|
||||||
|
|
||||||
|
> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
|
||||||
|
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
|
||||||
|
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
|
||||||
|
> 3. **Do not touch the database schema.** No new migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner.
|
||||||
|
> 4. **Public endpoints are a contract.** Any change to a path/method/status/schema in a backend must be accompanied by a matching update in **every** consumer. OpenAPI snapshot tests in `tests/contracts/` are the gate.
|
||||||
|
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
|
||||||
|
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
|
||||||
|
>
|
||||||
|
> These rules apply to every Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this CLAUDE.md.
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep project-specific notes (dev environment, URLs, tech stack) under this header.
|
||||||
|
|
||||||
|
### 1.2 `.claude/settings.json` — PreToolUse LOC hook
|
||||||
|
|
||||||
|
First line of defense. Blocks Write/Edit operations that would create or push a file past 500 lines. This stops Claude from ever producing oversized files.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md.\"}'; exit 0; fi",
|
||||||
|
"shell": "bash",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing.\\\"}\"; fi; exit 0",
|
||||||
|
"shell": "bash",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 `.claude/rules/architecture.md` — auto-loaded architecture rule
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Architecture Rules (auto-loaded)
|
||||||
|
|
||||||
|
Non-negotiable. Applied to every Claude Code session in this repo.
|
||||||
|
|
||||||
|
## File-size budget
|
||||||
|
- **Soft target:** 300 lines. **Hard cap:** 500 lines.
|
||||||
|
- Enforced by PreToolUse hook, pre-commit hook, and CI.
|
||||||
|
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require `[guardrail-change]` in the commit message. This list should SHRINK over time.
|
||||||
|
|
||||||
|
## Clean architecture
|
||||||
|
- Python: see `AGENTS.python.md`. Layering: api → services → repositories → db.models.
|
||||||
|
- Go: see `AGENTS.go.md`. Standard Go Project Layout + hexagonal.
|
||||||
|
- TypeScript: see `AGENTS.typescript.md`. Server-by-default, push client boundary deep, colocate `_components/` and `_hooks/` per route.
|
||||||
|
|
||||||
|
## Database is frozen
|
||||||
|
- No new migrations. No `ALTER TABLE`. No column renames.
|
||||||
|
- Pre-commit hook blocks any change under `migrations/` unless commit message contains `[migration-approved]`.
|
||||||
|
|
||||||
|
## Public endpoints are a contract
|
||||||
|
- Any change to path/method/status/schema must update every consumer in the same change set.
|
||||||
|
- OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- New code without tests fails CI.
|
||||||
|
- Refactors preserve coverage. Before splitting an oversized file, add a characterization test pinning current behavior.
|
||||||
|
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
|
||||||
|
|
||||||
|
## Guardrails are protected
|
||||||
|
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message.
|
||||||
|
- If Claude thinks a rule is wrong, surface it to the user. Do not silently weaken.
|
||||||
|
|
||||||
|
## Tooling baseline
|
||||||
|
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
|
||||||
|
- Go: `golangci-lint` strict, `go vet`, table-driven tests.
|
||||||
|
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
|
||||||
|
- All: dependency caching in CI, license/SBOM scan via `syft`+`grype`.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 `.claude/rules/loc-exceptions.txt`
|
||||||
|
|
||||||
|
```
|
||||||
|
# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
|
||||||
|
#
|
||||||
|
# Format: one repo-relative path per line. Comments start with '#'.
|
||||||
|
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
|
||||||
|
# Goal: this list SHRINKS over time.
|
||||||
|
|
||||||
|
# --- Example entries ---
|
||||||
|
# Static data catalogs — splitting fragments lookup tables without improving readability.
|
||||||
|
# src/catalogs/country-data.ts
|
||||||
|
# src/catalogs/industry-taxonomy.ts
|
||||||
|
|
||||||
|
# Generated files — regenerated from schemas.
|
||||||
|
# api/generated/types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 1.5 `scripts/check-loc.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-loc.sh — File-size budget enforcer. Soft: 300. Hard: 500.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/check-loc.sh # scan whole repo
|
||||||
|
# scripts/check-loc.sh --changed # only files changed vs origin/main
|
||||||
|
# scripts/check-loc.sh path/to/file.py # check specific files
|
||||||
|
# scripts/check-loc.sh --json # machine-readable output
|
||||||
|
# Exit codes: 0 clean, 1 hard violation, 2 bad invocation.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
SOFT=300
|
||||||
|
HARD=500
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
EXCEPTIONS_FILE="$REPO_ROOT/.claude/rules/loc-exceptions.txt"
|
||||||
|
|
||||||
|
CHANGED_ONLY=0; JSON=0; TARGETS=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--changed) CHANGED_ONLY=1 ;;
|
||||||
|
--json) JSON=1 ;;
|
||||||
|
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
|
||||||
|
-*) echo "unknown flag: $arg" >&2; exit 2 ;;
|
||||||
|
*) TARGETS+=("$arg") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
is_excluded() {
|
||||||
|
local f="$1"
|
||||||
|
case "$f" in
|
||||||
|
*/node_modules/*|*/.next/*|*/.git/*|*/dist/*|*/build/*|*/__pycache__/*|*/vendor/*) return 0 ;;
|
||||||
|
*/migrations/*|*/alembic/versions/*) return 0 ;;
|
||||||
|
*_test.go|*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx) return 0 ;;
|
||||||
|
*/tests/*|*/test/*) return 0 ;;
|
||||||
|
*.md|*.json|*.yaml|*.yml|*.lock|*.sum|*.mod|*.toml|*.cfg|*.ini) return 0 ;;
|
||||||
|
*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.woff|*.woff2|*.ttf) return 0 ;;
|
||||||
|
*.generated.*|*.gen.*|*_pb.go|*_pb2.py|*.pb.go) return 0 ;;
|
||||||
|
esac
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
is_in_exceptions() {
|
||||||
|
[[ -f "$EXCEPTIONS_FILE" ]] || return 1
|
||||||
|
local rel="${1#$REPO_ROOT/}"
|
||||||
|
grep -Fxq "$rel" "$EXCEPTIONS_FILE"
|
||||||
|
}
|
||||||
|
collect_targets() {
|
||||||
|
if (( ${#TARGETS[@]} > 0 )); then printf '%s\n' "${TARGETS[@]}"
|
||||||
|
elif (( CHANGED_ONLY )); then
|
||||||
|
git -C "$REPO_ROOT" diff --name-only --diff-filter=AM origin/main...HEAD 2>/dev/null \
|
||||||
|
|| git -C "$REPO_ROOT" diff --name-only --diff-filter=AM HEAD
|
||||||
|
else git -C "$REPO_ROOT" ls-files; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
violations_hard=(); violations_soft=()
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[[ -z "$f" ]] && continue
|
||||||
|
abs="$f"; [[ "$abs" != /* ]] && abs="$REPO_ROOT/$f"
|
||||||
|
[[ -f "$abs" ]] || continue
|
||||||
|
is_excluded "$abs" && continue
|
||||||
|
is_in_exceptions "$abs" && continue
|
||||||
|
loc=$(wc -l < "$abs" | tr -d ' ')
|
||||||
|
if (( loc > HARD )); then violations_hard+=("$loc $f")
|
||||||
|
elif (( loc > SOFT )); then violations_soft+=("$loc $f"); fi
|
||||||
|
done < <(collect_targets)
|
||||||
|
|
||||||
|
if (( JSON )); then
|
||||||
|
printf '{"hard":['
|
||||||
|
first=1; for v in "${violations_hard[@]}"; do
|
||||||
|
loc="${v%% *}"; path="${v#* }"
|
||||||
|
(( first )) || printf ','; first=0
|
||||||
|
printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
|
||||||
|
done
|
||||||
|
printf '],"soft":['
|
||||||
|
first=1; for v in "${violations_soft[@]}"; do
|
||||||
|
loc="${v%% *}"; path="${v#* }"
|
||||||
|
(( first )) || printf ','; first=0
|
||||||
|
printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
|
||||||
|
done
|
||||||
|
printf ']}\n'
|
||||||
|
else
|
||||||
|
if (( ${#violations_soft[@]} > 0 )); then
|
||||||
|
echo "::warning:: $((${#violations_soft[@]})) file(s) exceed soft target ($SOFT lines):"
|
||||||
|
printf ' %s\n' "${violations_soft[@]}" | sort -rn
|
||||||
|
fi
|
||||||
|
if (( ${#violations_hard[@]} > 0 )); then
|
||||||
|
echo "::error:: $((${#violations_hard[@]})) file(s) exceed HARD CAP ($HARD lines) — split required:"
|
||||||
|
printf ' %s\n' "${violations_hard[@]}" | sort -rn
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
(( ${#violations_hard[@]} == 0 ))
|
||||||
|
```
|
||||||
|
|
||||||
|
Make executable: `chmod +x scripts/check-loc.sh`.
|
||||||
|
|
||||||
|
### 1.6 `scripts/githooks/pre-commit`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# pre-commit — enforces structural guardrails.
|
||||||
|
#
|
||||||
|
# 1. Blocks commits that introduce a non-test, non-generated source file > 500 LOC.
|
||||||
|
# 2. Blocks commits touching migrations/ unless commit message contains [migration-approved].
|
||||||
|
# 3. Blocks edits to guardrail files unless [guardrail-change] is in the commit message.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
mapfile -t staged < <(git diff --cached --name-only --diff-filter=ACM)
|
||||||
|
[[ ${#staged[@]} -eq 0 ]] && exit 0
|
||||||
|
|
||||||
|
# 1. LOC budget on staged files.
|
||||||
|
loc_targets=()
|
||||||
|
for f in "${staged[@]}"; do
|
||||||
|
[[ -f "$REPO_ROOT/$f" ]] && loc_targets+=("$REPO_ROOT/$f")
|
||||||
|
done
|
||||||
|
if [[ ${#loc_targets[@]} -gt 0 ]]; then
|
||||||
|
if ! "$REPO_ROOT/scripts/check-loc.sh" "${loc_targets[@]}"; then
|
||||||
|
echo; echo "Commit blocked: file-size budget violated."
|
||||||
|
echo "Split the file (preferred) or add to .claude/rules/loc-exceptions.txt."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Migrations frozen unless approved.
|
||||||
|
if printf '%s\n' "${staged[@]}" | grep -qE '(^|/)(migrations|alembic/versions)/'; then
|
||||||
|
if ! grep -q '\[migration-approved\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
|
||||||
|
echo "Commit blocked: this change touches a migrations directory."
|
||||||
|
echo "Add '[migration-approved]' to your commit message if approved."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Guardrail files protected.
|
||||||
|
guarded='^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$'
|
||||||
|
if printf '%s\n' "${staged[@]}" | grep -qE "$guarded"; then
|
||||||
|
if ! grep -q '\[guardrail-change\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
|
||||||
|
echo "Commit blocked: this change modifies guardrail files."
|
||||||
|
echo "Add '[guardrail-change]' to your commit message and explain why in the body."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.7 `scripts/install-hooks.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# install-hooks.sh — installs git hooks that enforce repo guardrails locally.
|
||||||
|
# Idempotent. Safe to re-run. Run once per clone: bash scripts/install-hooks.sh
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
HOOKS_DIR="$REPO_ROOT/.git/hooks"
|
||||||
|
SRC_DIR="$REPO_ROOT/scripts/githooks"
|
||||||
|
|
||||||
|
[[ -d "$REPO_ROOT/.git" ]] || { echo "Not a git repository: $REPO_ROOT" >&2; exit 1; }
|
||||||
|
mkdir -p "$HOOKS_DIR"
|
||||||
|
for hook in pre-commit; do
|
||||||
|
src="$SRC_DIR/$hook"; dst="$HOOKS_DIR/$hook"
|
||||||
|
if [[ -f "$src" ]]; then cp "$src" "$dst"; chmod +x "$dst"; echo "installed: $dst"; fi
|
||||||
|
done
|
||||||
|
echo "Done. Hooks active for this clone."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.8 CI additions (`.github/workflows/ci.yaml` or `.gitea/workflows/ci.yaml`)
|
||||||
|
|
||||||
|
Add a `loc-budget` job that fails on hard violations:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
loc-budget:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check file-size budget
|
||||||
|
run: bash scripts/check-loc.sh --changed
|
||||||
|
|
||||||
|
python-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: ruff
|
||||||
|
run: pip install ruff && ruff check .
|
||||||
|
- name: mypy on new modules
|
||||||
|
run: pip install mypy && mypy --strict services/ repositories/ domain/
|
||||||
|
|
||||||
|
go-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v4
|
||||||
|
with: { version: latest }
|
||||||
|
|
||||||
|
ts-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: npm ci && npx tsc --noEmit && npx next build
|
||||||
|
|
||||||
|
contract-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: pytest tests/contracts/ -v
|
||||||
|
|
||||||
|
license-sbom-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
- uses: anchore/scan-action@v3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
### 1.9 `AGENTS.python.md` (Python / FastAPI)
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
# AGENTS.python.md — Python Service Conventions
|
||||||
|
|
||||||
|
## Layered architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
<service>/
|
||||||
|
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
|
||||||
|
│ └── <domain>_routes.py
|
||||||
|
├── services/ # Business logic. Pure-ish; no FastAPI imports.
|
||||||
|
├── repositories/ # DB access. Owns SQLAlchemy session usage.
|
||||||
|
├── domain/ # Value objects, enums, domain exceptions.
|
||||||
|
├── schemas/ # Pydantic models, split per domain. Never one giant schemas.py.
|
||||||
|
└── db/models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependency direction: `api → services → repositories → db.models`. Lower layers must not import upper.
|
||||||
|
|
||||||
|
## Routers
|
||||||
|
- One `APIRouter` per domain file. Handlers ≤30 LOC.
|
||||||
|
- Parse request → call service → map domain errors → return response model.
|
||||||
|
- Inject services via `Depends`. No globals.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.post("/items", response_model=ItemRead, status_code=201)
|
||||||
|
async def create_item(
|
||||||
|
payload: ItemCreate,
|
||||||
|
service: ItemService = Depends(get_item_service),
|
||||||
|
tenant_id: UUID = Depends(get_tenant_id),
|
||||||
|
) -> ItemRead:
|
||||||
|
with translate_domain_errors():
|
||||||
|
return await service.create(tenant_id, payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Domain errors + translator
|
||||||
|
|
||||||
|
```python
|
||||||
|
# domain/errors.py
|
||||||
|
class DomainError(Exception): ...
|
||||||
|
class NotFoundError(DomainError): ...
|
||||||
|
class ConflictError(DomainError): ...
|
||||||
|
class ValidationError(DomainError): ...
|
||||||
|
class PermissionError(DomainError): ...
|
||||||
|
|
||||||
|
# api/_http_errors.py
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def translate_domain_errors():
|
||||||
|
try: yield
|
||||||
|
except NotFoundError as e: raise HTTPException(404, str(e)) from e
|
||||||
|
except ConflictError as e: raise HTTPException(409, str(e)) from e
|
||||||
|
except ValidationError as e: raise HTTPException(400, str(e)) from e
|
||||||
|
except PermissionError as e: raise HTTPException(403, str(e)) from e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
- Constructor takes repository interface, not concrete.
|
||||||
|
- No FastAPI / HTTP knowledge.
|
||||||
|
- Raise domain exceptions, never HTTPException.
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
- Intent-named methods (`get_pending_for_tenant`), not CRUD-named (`select_where`).
|
||||||
|
- Session injected. No business logic.
|
||||||
|
- Return ORM models or domain VOs; never `Row`.
|
||||||
|
|
||||||
|
## Schemas (Pydantic v2)
|
||||||
|
- One module per domain. ≤300 lines.
|
||||||
|
- `model_config = ConfigDict(from_attributes=True, frozen=True)` for reads.
|
||||||
|
- Separate `*Create`, `*Update`, `*Read`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- `tests/unit/`, `tests/integration/`, `tests/contracts/`.
|
||||||
|
- Unit tests mock repository via `AsyncMock`.
|
||||||
|
- Integration tests use real Postgres from compose via transactional fixture (rollback per test).
|
||||||
|
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
|
||||||
|
- Naming: `test_<unit>_<scenario>_<expected>.py::TestX::test_method`.
|
||||||
|
- `pytest-asyncio` mode = `auto`. Coverage target: 80% new code.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
- `ruff check` + `ruff format` (line length 100).
|
||||||
|
- `mypy --strict` on `services/`, `repositories/`, `domain/` first. Expand outward via per-module overrides in mypy.ini:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[mypy]
|
||||||
|
strict = True
|
||||||
|
|
||||||
|
[mypy-<service>.services.*]
|
||||||
|
strict = True
|
||||||
|
|
||||||
|
[mypy-<service>.legacy.*]
|
||||||
|
# Legacy modules not yet refactored — expand strictness over time.
|
||||||
|
ignore_errors = True
|
||||||
|
```
|
||||||
|
|
||||||
|
## What you may NOT do
|
||||||
|
- Add a new migration.
|
||||||
|
- Rename `__tablename__`, column, or enum value.
|
||||||
|
- Change route contract without simultaneous consumer update.
|
||||||
|
- Catch `Exception` broadly.
|
||||||
|
- Put business logic in a router or a Pydantic validator.
|
||||||
|
- Create a file > 500 lines.
|
||||||
|
````
|
||||||
|
|
||||||
|
### 1.10 `AGENTS.go.md` (Go / Gin or chi)
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
# AGENTS.go.md — Go Service Conventions
|
||||||
|
|
||||||
|
## Layered architecture (Standard Go Project Layout + hexagonal)
|
||||||
|
|
||||||
|
```
|
||||||
|
<service>/
|
||||||
|
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. < 50 LOC.
|
||||||
|
├── internal/
|
||||||
|
│ ├── app/ # Wiring: config + DI + lifecycle.
|
||||||
|
│ ├── domain/<aggregate>/ # Pure types, interfaces, errors. No I/O.
|
||||||
|
│ ├── service/<aggregate>/ # Business logic. Depends on domain interfaces.
|
||||||
|
│ ├── repository/postgres/<aggregate>/ # Concrete repos.
|
||||||
|
│ ├── transport/http/
|
||||||
|
│ │ ├── handler/<aggregate>/
|
||||||
|
│ │ ├── middleware/
|
||||||
|
│ │ └── router.go
|
||||||
|
│ └── platform/ # DB pool, logger, config, tracing.
|
||||||
|
└── pkg/ # Importable by other repos. Empty unless needed.
|
||||||
|
```
|
||||||
|
|
||||||
|
Direction: `transport → service → domain ← repository`. `domain` imports no siblings.
|
||||||
|
|
||||||
|
## Handlers
|
||||||
|
- ≤40 LOC. Bind → call service → map error via `httperr.Write(c, err)` → respond.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *ItemHandler) Create(c *gin.Context) {
|
||||||
|
var req CreateItemRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Write(c, httperr.BadRequest(err)); return
|
||||||
|
}
|
||||||
|
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
|
||||||
|
if err != nil { httperr.Write(c, err); return }
|
||||||
|
c.JSON(http.StatusCreated, out)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors — single `httperr` package
|
||||||
|
```go
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrNotFound): return 404
|
||||||
|
case errors.Is(err, domain.ErrConflict): return 409
|
||||||
|
case errors.As(err, &validationErr): return 422
|
||||||
|
default: return 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Never `panic` in request handling. Recovery middleware logs and returns 500.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
- Struct + constructor + interface methods. No package-level state.
|
||||||
|
- `context.Context` first arg always.
|
||||||
|
- Return `(value, error)`. Wrap with `fmt.Errorf("create item: %w", err)`.
|
||||||
|
- Domain errors as sentinel vars or typed; match with `errors.Is` / `errors.As`.
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
- Interface in `domain/<aggregate>/repository.go`. Impl in `repository/postgres/<aggregate>/`.
|
||||||
|
- One file per query group; no file > 500 LOC.
|
||||||
|
- `pgx`/`sqlc` over hand-rolled SQL. No ORM globals. Everything takes `ctx`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Co-located `*_test.go`. Table-driven for service logic.
|
||||||
|
- Handlers via `httptest.NewRecorder`.
|
||||||
|
- Repos via `testcontainers-go` (or the compose Postgres). Never mocks at SQL boundary.
|
||||||
|
- Coverage target: 80% on `service/`.
|
||||||
|
|
||||||
|
## Tooling (`golangci-lint` strict config)
|
||||||
|
- Linters: `errcheck, govet, staticcheck, revive, gosec, gocyclo(max 15), gocognit(max 20), unused, ineffassign, errorlint, nilerr, nolintlint, contextcheck`.
|
||||||
|
- `gofumpt` formatting. `go vet ./...` clean. `go mod tidy` clean.
|
||||||
|
|
||||||
|
## What you may NOT do
|
||||||
|
- Touch DB schema/migrations.
|
||||||
|
- Add a new top-level package under `internal/` without review.
|
||||||
|
- `import "C"`, unsafe, reflection-heavy code.
|
||||||
|
- Non-trivial setup in `init()`. Wire in `internal/app`.
|
||||||
|
- File > 500 lines.
|
||||||
|
- Change route contract without updating consumers.
|
||||||
|
````
|
||||||
|
|
||||||
|
### 1.11 `AGENTS.typescript.md` (TypeScript / Next.js)
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
# AGENTS.typescript.md — TypeScript / Next.js Conventions
|
||||||
|
|
||||||
|
## Layered architecture (Next.js 15 App Router)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── <route>/
|
||||||
|
│ ├── page.tsx # Server Component by default. ≤200 LOC.
|
||||||
|
│ ├── layout.tsx
|
||||||
|
│ ├── _components/ # Private folder; colocated UI. Each file ≤300 LOC.
|
||||||
|
│ ├── _hooks/ # Client hooks for this route.
|
||||||
|
│ ├── _server/ # Server actions, data loaders for this route.
|
||||||
|
│ └── loading.tsx / error.tsx
|
||||||
|
├── api/<domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
|
||||||
|
lib/
|
||||||
|
├── <domain>/ # Pure helpers, types, zod schemas. Reusable.
|
||||||
|
└── server/<domain>/ # Server-only logic; uses "server-only".
|
||||||
|
components/ # Truly shared, app-wide components.
|
||||||
|
```
|
||||||
|
|
||||||
|
Server vs Client: default is Server Component. Add `"use client"` only when state/effects/browser APIs needed. Push client boundary as deep as possible.
|
||||||
|
|
||||||
|
## API routes (route.ts)
|
||||||
|
- One handler per HTTP method, ≤40 LOC.
|
||||||
|
- Validate with `zod`. Reject invalid → 400.
|
||||||
|
- Delegate to `lib/server/<domain>/`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const parsed = CreateItemSchema.safeParse(await req.json());
|
||||||
|
if (!parsed.success)
|
||||||
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
const result = await itemService.create(parsed.data);
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page components
|
||||||
|
- Pages > 300 lines → split into colocated `_components/`.
|
||||||
|
- Server Components fetch data; pass plain objects to Client Components.
|
||||||
|
- No data fetching in `useEffect` for server-renderable data.
|
||||||
|
- State: prefer URL state (`searchParams`) + Server Components over global stores.
|
||||||
|
|
||||||
|
## Types — barrel re-export pattern for splitting monolithic type files
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lib/sdk/types/index.ts
|
||||||
|
export * from './enums'
|
||||||
|
export * from './vendor'
|
||||||
|
export * from './dsfa'
|
||||||
|
// consumers still `import { Foo } from '@/lib/sdk/types'`
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules: no `any`. No `as unknown as`. All DTOs are zod schemas; infer via `z.infer`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
|
||||||
|
- Hooks: `@testing-library/react` `renderHook`.
|
||||||
|
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page minimum.
|
||||||
|
- Coverage: 70% on `lib/`, smoke on `app/`.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
- `tsc --noEmit` clean (strict, `noUncheckedIndexedAccess: true`).
|
||||||
|
- ESLint with `@typescript-eslint`, type-aware rules on.
|
||||||
|
- `next build` clean. No `@ts-ignore`. `@ts-expect-error` only with a reason comment.
|
||||||
|
|
||||||
|
## What you may NOT do
|
||||||
|
- Business logic in `page.tsx` or `route.ts`.
|
||||||
|
- Cross-app module imports.
|
||||||
|
- `dangerouslySetInnerHTML` without explicit sanitization.
|
||||||
|
- Backend API calls from Client Components when a Server Component/Action would do.
|
||||||
|
- Change route contract without updating consumers in the same change.
|
||||||
|
- File > 500 lines.
|
||||||
|
- Globally disable lint/type rules — fix the root cause.
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 2. Phase plan — behavior-preserving refactor
|
||||||
|
|
||||||
|
Work in phases. Each phase ends green (tests pass, build clean, contract baseline unchanged). Do **not** skip ahead.
|
||||||
|
|
||||||
|
### Phase 0 — Foundation (single PR, low risk)
|
||||||
|
|
||||||
|
**Goal:** Set up rails. No code refactors yet.
|
||||||
|
|
||||||
|
1. Drop in all files from Section 1. Install hooks: `bash scripts/install-hooks.sh`.
|
||||||
|
2. Populate `.claude/rules/loc-exceptions.txt` with grandfathered entries (one line each, with a comment rationale) so CI doesn't fail day 1.
|
||||||
|
3. Append the non-negotiable rules block to root `CLAUDE.md`.
|
||||||
|
4. Add per-language `AGENTS.*.md` at repo root.
|
||||||
|
5. Add the CI jobs from §1.8.
|
||||||
|
6. Per-service `README.md` + `CLAUDE.md` stubs: what it does, run/test commands, layered architecture diagram, env vars, API surface link.
|
||||||
|
|
||||||
|
**Verification:** CI green; loc-budget job passes with allowlist; next Claude session loads the rules automatically.
|
||||||
|
|
||||||
|
### Phase 1 — Backend service (Python/FastAPI)
|
||||||
|
|
||||||
|
**Critical targets:** any `routes.py` / `schemas.py` / `repository.py` / `models.py` over 500 LOC.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. **Snapshot the API contract:** `curl /openapi.json > tests/contracts/openapi.baseline.json`. Add a contract test that diffs current vs baseline and fails on any path/method/param drift.
|
||||||
|
2. **Characterization tests first.** For each oversized route file, add `TestClient` tests exercising every endpoint (happy path + one error path). Use `httpx.AsyncClient` + factory fixtures.
|
||||||
|
3. **Split models.py per aggregate.** Keep a shim: `from <service>.db.models import *` re-exports so existing imports keep working. One module per aggregate; `__tablename__` unchanged (no migration).
|
||||||
|
4. **Split schemas.py** similarly with a re-export shim.
|
||||||
|
5. **Extract service layer.** Each route handler delegates to a `*Service` class injected via `Depends`. Handlers shrink to ≤30 LOC.
|
||||||
|
6. **Repository extraction** from the giant repository file; one class per aggregate.
|
||||||
|
7. **`mypy --strict` scoped to new packages first.** Expand outward via `mypy.ini` per-module overrides.
|
||||||
|
8. **Tests:** unit tests per service (mocked repo), repo tests against a transactional fixture (real Postgres), integration tests at API layer.
|
||||||
|
|
||||||
|
**Gotchas we hit:**
|
||||||
|
- Tests that patch module-level symbols (e.g. `SessionLocal`, `scan_X`) break when you move logic behind `Depends`. Fix: re-export the symbol from the route module, or have the service lookup use the module-level symbol directly so the patch still takes effect.
|
||||||
|
- `from __future__ import annotations` can break Pydantic TypeAdapter forward refs. Remove it where it conflicts.
|
||||||
|
- Sibling test file status codes drift when you introduce the domain-error translator (e.g. 422 → 400). Update assertions in the same commit.
|
||||||
|
|
||||||
|
**Verification:** all pytest files green. Characterization tests green. Contract test green (no drift). `mypy` clean on new packages. Coverage ≥ baseline + 10%.
|
||||||
|
|
||||||
|
### Phase 2 — Go backend
|
||||||
|
|
||||||
|
**Critical targets:** any handler / store / rules file over 500 LOC.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. OpenAPI/Swagger snapshot (or generate via `swag`) → contract tests.
|
||||||
|
2. Generate handler-level tests with `httptest` for every endpoint pre-refactor.
|
||||||
|
3. Define hexagonal layout (see AGENTS.go.md). Move incrementally with type aliases for back-compat where needed.
|
||||||
|
4. Replace ad-hoc error handling with `errors.Is/As` + a single `httperr` package.
|
||||||
|
5. Add `golangci-lint` strict config; fix new findings only (don't chase legacy lint).
|
||||||
|
6. Table-driven service tests. `testcontainers-go` for repo layer.
|
||||||
|
|
||||||
|
**Verification:** `go test ./...` passes; `golangci-lint run` clean; contract tests green; no DB schema diff.
|
||||||
|
|
||||||
|
### Phase 3 — Frontend (Next.js)
|
||||||
|
|
||||||
|
**Biggest beast — expect this to dominate.** Critical targets: `page.tsx` / monolithic types / API routes over 500 LOC.
|
||||||
|
|
||||||
|
**Per oversized page:**
|
||||||
|
1. Extract presentational components into `app/<route>/_components/` (private folder, Next.js convention).
|
||||||
|
2. Move data fetching into Server Components / Server Actions; Client Components become small.
|
||||||
|
3. Hooks → `app/<route>/_hooks/`.
|
||||||
|
4. Pure helpers → `lib/<domain>/`.
|
||||||
|
5. Add Vitest unit tests for hooks and pure helpers; Playwright smoke tests for each top-level page.
|
||||||
|
|
||||||
|
**Monolithic types file:** use barrel re-export pattern.
|
||||||
|
- Create `types/` directory with domain files.
|
||||||
|
- Create `types/index.ts` with `export * from './<domain>'` lines.
|
||||||
|
- **Critical:** TypeScript won't allow both `types.ts` AND `types/index.ts` — delete the file, atomic swap to directory.
|
||||||
|
|
||||||
|
**API routes (`route.ts`):** same router→service split as backend. Each `route.ts` becomes a thin handler delegating to `lib/server/<domain>/`.
|
||||||
|
|
||||||
|
**Endpoint preservation:** if any internal route URL changes, grep every consumer (SDK packages, developer portal, sibling apps) and update in the same change.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Pre-existing type bugs often surface when you try to build. Fix them as drive-by if they block your refactor; otherwise document in a separate follow-up.
|
||||||
|
- `useClient` component imports from `'../provider'` that rely on re-exports: preserve the re-export or update importers in the same commit.
|
||||||
|
- Next.js build can fail at page-manifest stage with unrelated prerender errors. Run `next build` fresh (not from cache) to see real status.
|
||||||
|
|
||||||
|
**Verification:** `next build` clean; `tsc --noEmit` clean; Playwright smoke tests pass; visual diff check on key pages (manual + screenshots in PR).
|
||||||
|
|
||||||
|
### Phase 4 — SDKs & smaller services
|
||||||
|
|
||||||
|
Apply the same patterns at smaller scale:
|
||||||
|
- **SDK packages (0 tests):** add Vitest unit tests for public surface before/while splitting.
|
||||||
|
- **Manager/Client classes:** extract config defaults, side-effect helpers (e.g. Google Consent Mode wiring), framework adapters into sibling files. Keep the main class as orchestration.
|
||||||
|
- **Framework adapters (React/Vue/Angular):** each component/composable/service/module goes in its own sibling file; the entry `index.ts` is a thin barrel of re-exports.
|
||||||
|
- **Doc monoliths (`index.md` thousands of lines):** split per topic with mkdocs nav.
|
||||||
|
|
||||||
|
### Phase 5 — CI hardening & governance
|
||||||
|
|
||||||
|
1. Promote `loc-budget` from warning → blocking once the allowlist has drained to legitimate exceptions only.
|
||||||
|
2. Add mutation testing in nightly (`mutmut` for Python, `gomutesting` for Go).
|
||||||
|
3. Add `dependabot`/`renovate` for npm + pip + go mod.
|
||||||
|
4. Add release tagging workflow.
|
||||||
|
5. Write ADRs (`docs/adr/`) capturing the architecture decisions from phases 1–3.
|
||||||
|
6. Distill recurring patterns into `.claude/rules/` updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Agent prompt templates
|
||||||
|
|
||||||
|
When the work volume is big, parallelize with subagents. These prompts were battle-tested in practice.
|
||||||
|
|
||||||
|
### 3.1 Backend route file split (Python)
|
||||||
|
|
||||||
|
> You are working in `<repo>` on branch `<branch>`. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300.
|
||||||
|
>
|
||||||
|
> **Task:** split `<path/to/file>_routes.py` (NNN LOC) following the router → service → repository layering described in `AGENTS.python.md`.
|
||||||
|
>
|
||||||
|
> **Steps:**
|
||||||
|
> 1. Snapshot the relevant slice of `/openapi.json` and add a contract test that pins current behavior.
|
||||||
|
> 2. Add characterization tests for every endpoint in this file (happy path + one error path) using `httpx.AsyncClient`.
|
||||||
|
> 3. Extract each route handler's business logic into a `<domain>Service` class in `<service>/services/<domain>_service.py`. Inject via `Depends(get_<domain>_service)`.
|
||||||
|
> 4. Raise domain errors (`NotFoundError`, `ConflictError`, `ValidationError`), never `HTTPException`. Use the `translate_domain_errors()` context manager in handlers.
|
||||||
|
> 5. Move DB access to `<service>/repositories/<domain>_repository.py`. Session injected.
|
||||||
|
> 6. Split Pydantic schemas from the giant `schemas.py` into `<service>/schemas/<domain>.py` if >300 lines.
|
||||||
|
>
|
||||||
|
> **Constraints:**
|
||||||
|
> - Behavior preservation. No route rename/method/status/schema changes.
|
||||||
|
> - Tests that patch module-level symbols must keep working — re-export the symbol or refactor the lookup so the patch still takes effect.
|
||||||
|
> - Run `pytest` after each step. Commit each file as its own commit.
|
||||||
|
> - Push at end: `git push origin <branch>`.
|
||||||
|
>
|
||||||
|
> When done, report: (a) new LOC counts, (b) test results, (c) mypy status, (d) commit SHAs. Under 300 words.
|
||||||
|
|
||||||
|
### 3.2 Go handler file split
|
||||||
|
|
||||||
|
> You are working in `<repo>` on branch `<branch>`. Hard cap 500 LOC.
|
||||||
|
>
|
||||||
|
> **Task:** split `<path>/handlers/<domain>_handler.go` (NNN LOC) into a hexagonal layout per `AGENTS.go.md`.
|
||||||
|
>
|
||||||
|
> **Steps:**
|
||||||
|
> 1. Add `httptest` tests for every endpoint pre-refactor.
|
||||||
|
> 2. Define `internal/domain/<aggregate>/` with types + interfaces + sentinel errors.
|
||||||
|
> 3. Create `internal/service/<aggregate>/` with business logic implementing domain interfaces.
|
||||||
|
> 4. Create `internal/repository/postgres/<aggregate>/` splitting queries by group.
|
||||||
|
> 5. Thin handlers under `internal/transport/http/handler/<aggregate>/`. Each handler ≤40 LOC. Error mapping via `internal/platform/httperr`.
|
||||||
|
> 6. Use `errors.Is` / `errors.As` for domain error matching.
|
||||||
|
>
|
||||||
|
> **Constraints:**
|
||||||
|
> - No DB schema change.
|
||||||
|
> - Table-driven service tests. `testcontainers-go` (or compose Postgres) for repo tests.
|
||||||
|
> - `golangci-lint run` clean.
|
||||||
|
>
|
||||||
|
> Report new LOC, test status, lint status, commit SHAs. Under 300 words.
|
||||||
|
|
||||||
|
### 3.3 Next.js page split (the one we parallelized heavily)
|
||||||
|
|
||||||
|
> You are working in `<repo>` on branch `<branch>`. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300. Other agents are working on OTHER pages in parallel — stay in your lane.
|
||||||
|
>
|
||||||
|
> **Task:** split the following Next.js 15 App Router client pages into colocated components so each `page.tsx` drops below 500 LOC.
|
||||||
|
>
|
||||||
|
> 1. `admin-compliance/app/sdk/<page-a>/page.tsx` (NNNN LOC)
|
||||||
|
> 2. `admin-compliance/app/sdk/<page-b>/page.tsx` (NNNN LOC)
|
||||||
|
>
|
||||||
|
> **Pattern** (reference `admin-compliance/app/sdk/<already-split-example>/` for "done"):
|
||||||
|
> - Create `_components/` subdirectory (Next.js private folder, won't create routes).
|
||||||
|
> - Extract each logically-grouped section (forms, tables, modals, tabs, headers, cards) into its own component file. Name files after the component.
|
||||||
|
> - Create `_hooks/` for custom hooks that were inline.
|
||||||
|
> - Create `_types.ts` or `_data.ts` for hoisted types or data arrays.
|
||||||
|
> - Remaining `page.tsx` wires extracted pieces — aim for under 300 LOC, hard cap 500.
|
||||||
|
> - Preserve `'use client'` when present on original.
|
||||||
|
> - DO NOT rename any exports that other files import. Grep first before moving.
|
||||||
|
>
|
||||||
|
> **Constraints:**
|
||||||
|
> - Behavior preservation. No logic changes, no improvements.
|
||||||
|
> - Imports must resolve (relative `./_components/Foo`).
|
||||||
|
> - Run `cd admin-compliance && npx next build` after each file is done. Don't commit broken builds.
|
||||||
|
> - DO NOT edit `.claude/settings.json`, `scripts/check-loc.sh`, `loc-exceptions.txt`, or any `AGENTS.*.md`.
|
||||||
|
> - Commit each page as its own commit: `refactor(admin): split <name> page.tsx into colocated components`. HEREDOC body, include `Co-Authored-By:` trailer.
|
||||||
|
> - Pull before push: `git pull --rebase origin <branch>`, then `git push origin <branch>`.
|
||||||
|
>
|
||||||
|
> **Coordination:** DO NOT touch `<list of pages other agents own>`. You own only `<your pages>`.
|
||||||
|
>
|
||||||
|
> When done, report: (a) each file's new LOC count, (b) how many `_components` were created, (c) whether `next build` is clean, (d) commit SHAs. Under 300 words.
|
||||||
|
>
|
||||||
|
> If the LOC hook blocks a Write, split further. If you hit rate limits partway, commit what's done and report progress honestly.
|
||||||
|
|
||||||
|
### 3.4 Monolithic types file split (TypeScript)
|
||||||
|
|
||||||
|
> `<repo>`, branch `<branch>`. Hard cap 500 LOC.
|
||||||
|
>
|
||||||
|
> **Task:** split `<lib>/types.ts` (NNNN LOC) into per-domain modules under `<lib>/types/`.
|
||||||
|
>
|
||||||
|
> **Steps:**
|
||||||
|
> 1. Identify domain groupings (enums, API DTOs, one group per business aggregate).
|
||||||
|
> 2. Create `<lib>/types/` directory with `<domain>.ts` files.
|
||||||
|
> 3. Create `<lib>/types/index.ts` barrel: `export * from './<domain>'` per file.
|
||||||
|
> 4. **Atomic swap:** delete the old `types.ts` in the same commit as the new `types/` directory. TypeScript won't resolve both a file and a directory with the same stem.
|
||||||
|
> 5. Grep every consumer — imports from `'<lib>/types'` should still work via the barrel. No consumer file changes needed unless there's a name collision.
|
||||||
|
> 6. Resolve collisions by renaming the less-canonical export (e.g. if two modules both export `LegalDocument`, rename the RAG one to `RagLegalDocument`).
|
||||||
|
>
|
||||||
|
> **Verification:** `tsc --noEmit` clean, `next build` clean.
|
||||||
|
>
|
||||||
|
> Report new LOC per file, collisions resolved, consumer updates, commit SHAs.
|
||||||
|
|
||||||
|
### 3.5 Agent orchestration rules (from hard-won experience)
|
||||||
|
|
||||||
|
When you spawn multiple agents in parallel:
|
||||||
|
|
||||||
|
1. **Own disjoint paths.** Give each agent a bounded list of files under specific directories. Spell out the "do NOT touch" list explicitly.
|
||||||
|
2. **Always instruct `git pull --rebase origin <branch>` before push.** Agents running in parallel will push and cause non-fast-forward rejects without this.
|
||||||
|
3. **Instruct `commit each file as its own commit`** — not a single mega-commit. Makes revert surgical.
|
||||||
|
4. **Ask for concise reports (≤300 words):** new LOC counts, component counts, build status, commit SHAs.
|
||||||
|
5. **Tell them to commit partial progress on rate-limit.** If they don't, their partial work lives in the working tree and you have to chase it with `git status` after. (We hit this — 4 agents silently left uncommitted work.)
|
||||||
|
6. **Don't give an agent more than 2 big files at once.** Each page-split in practice took ~10–20 minutes + ~150k tokens. Two is a comfortable batch.
|
||||||
|
7. **Reference a prior "done" example.** Commit SHAs are gold — the agent can inspect exactly the style you want.
|
||||||
|
8. **Run one final `next build` / `pytest` / `go test` yourself after all agents finish.** Agent reports of "build clean" can be scoped (e.g. only their files); you want the whole-repo gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Workflow loop (per file)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Read the oversized file end to end. Identify 3–6 extraction sections.
|
||||||
|
2. Write characterization test (if backend) — pin behavior.
|
||||||
|
3. Create the sibling files one at a time.
|
||||||
|
- If the PreToolUse hook blocks (file still > 500), split further.
|
||||||
|
4. Edit the root file: replace extracted bodies with imports + delegations.
|
||||||
|
5. Run the full verification: pytest / next build / go test.
|
||||||
|
6. Run LOC check: scripts/check-loc.sh <changed files>
|
||||||
|
7. Commit with a scoped message and a 1–2 line body explaining why.
|
||||||
|
8. Push.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Commit message conventions
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor(<area>): <one-line what, not how>
|
||||||
|
|
||||||
|
<optional 1-3 sentence body: what split changed + verification result>
|
||||||
|
<LOC table: before → after per file>
|
||||||
|
<non-behavior changes flagged as drive-by fixes, with reason>
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
Markers that unlock pre-commit guards:
|
||||||
|
- `[migration-approved]` — allows changes under `migrations/` / `alembic/versions/`.
|
||||||
|
- `[guardrail-change]` — allows changes to `.claude/settings.json`, `.claude/rules/loc-exceptions.txt`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, or any `AGENTS.*.md`.
|
||||||
|
|
||||||
|
Good examples from our session:
|
||||||
|
- `refactor(consent-sdk): split ConsentManager + framework adapters under 500 LOC`
|
||||||
|
- `refactor(compliance-sdk): split client/provider/embed/state under 500 LOC`
|
||||||
|
- `refactor(admin): split whistleblower page.tsx + restore scope helpers`
|
||||||
|
- `chore: document data-catalog + legacy-service LOC exceptions` (with `[guardrail-change]` body)
|
||||||
|
|
||||||
|
## 6. Verification commands cheatsheet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# LOC budget
|
||||||
|
scripts/check-loc.sh --changed # only changed files
|
||||||
|
scripts/check-loc.sh # whole repo
|
||||||
|
scripts/check-loc.sh --json # for CI parsing
|
||||||
|
|
||||||
|
# Python
|
||||||
|
pytest --cov=<package> --cov-report=term-missing
|
||||||
|
ruff check .
|
||||||
|
mypy --strict <package>/services <package>/repositories
|
||||||
|
|
||||||
|
# Go
|
||||||
|
go test ./... -cover
|
||||||
|
golangci-lint run
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
npx tsc --noEmit
|
||||||
|
npx next build # from the Next.js app dir
|
||||||
|
npm test -- --run # vitest one-shot
|
||||||
|
npx playwright test tests/e2e # e2e smoke
|
||||||
|
|
||||||
|
# Contracts
|
||||||
|
pytest tests/contracts/ # OpenAPI snapshot diff
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Out of scope (don't drift)
|
||||||
|
|
||||||
|
- DB schema / migrations — unless separate green-lit plan.
|
||||||
|
- New features. This is a refactor.
|
||||||
|
- Public endpoint renames without simultaneous consumer fix-up (exception: intra-monorepo URLs when you do the grep sweep).
|
||||||
|
- Unrelated dead code cleanup — do it in a separate PR.
|
||||||
|
- Bundling refactors across services in one commit — one service = one commit.
|
||||||
|
|
||||||
|
## 8. Memory / session handoff
|
||||||
|
|
||||||
|
If using Claude Code with persistent memory, save a `project_refactor_status.md` in your memory store after each phase:
|
||||||
|
- What's done (files split, LOC before → after).
|
||||||
|
- What's in progress (current file, blocker if any).
|
||||||
|
- What's deferred (pre-existing bugs surfaced but left for follow-up).
|
||||||
|
- Key patterns established (so next session doesn't rediscover them).
|
||||||
|
|
||||||
|
This lets you resume after context compacts or after rate-limit windows without losing the thread.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
That's the whole methodology. Install Section 1, follow Section 2 phase-by-phase, use Section 3 to parallelize the grind. The guardrails do the policing so you don't have to remember anything.
|
||||||
|
|
||||||
@@ -22,6 +22,9 @@ ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
|||||||
ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL
|
ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL
|
||||||
ENV NEXT_PUBLIC_SDK_URL=$NEXT_PUBLIC_SDK_URL
|
ENV NEXT_PUBLIC_SDK_URL=$NEXT_PUBLIC_SDK_URL
|
||||||
|
|
||||||
|
# Ensure public directory exists (Next.js standalone needs it)
|
||||||
|
RUN mkdir -p public
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -34,8 +37,8 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup -S -g 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser -S -u 1001 -G nodejs nextjs
|
||||||
|
|
||||||
# Copy built assets
|
# Copy built assets
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|||||||
53
admin-compliance/README.md
Normal file
53
admin-compliance/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# admin-compliance
|
||||||
|
|
||||||
|
Next.js 15 dashboard for BreakPilot Compliance — SDK module UI, company profile, DSR, DSFA, VVT, TOM, consent, AI Act, training, audit, change requests, etc. Also hosts 96+ API routes that proxy/orchestrate backend services.
|
||||||
|
|
||||||
|
**Port:** `3007` (container: `bp-compliance-admin`)
|
||||||
|
**Stack:** Next.js 15 App Router, React 18, TailwindCSS, TypeScript strict.
|
||||||
|
|
||||||
|
## Architecture (Phase 3 — in progress)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── <route>/
|
||||||
|
│ ├── page.tsx # Server Component (≤200 LOC)
|
||||||
|
│ ├── _components/ # Colocated UI, each ≤300 LOC
|
||||||
|
│ ├── _hooks/ # Client hooks
|
||||||
|
│ └── _server/ # Server actions
|
||||||
|
├── api/<domain>/route.ts # Thin handlers → lib/server/<domain>/
|
||||||
|
lib/
|
||||||
|
├── <domain>/ # Pure helpers, zod schemas
|
||||||
|
└── server/<domain>/ # "server-only" logic
|
||||||
|
components/ # App-wide shared UI
|
||||||
|
```
|
||||||
|
|
||||||
|
See `../AGENTS.typescript.md`.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-compliance
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3007
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # Vitest unit + component tests
|
||||||
|
npx playwright test # E2E
|
||||||
|
npx tsc --noEmit # Type-check
|
||||||
|
npx next lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known debt
|
||||||
|
|
||||||
|
- `lib/sdk/types.ts` has been split: it is now a barrel re-export to `lib/sdk/types/` (12 domain files: enums, company-profile, sdk-steps, and others).
|
||||||
|
- `lib/sdk/tom-generator/controls/loader.ts` has been split: it is now a barrel re-export to `categories/` (8 category files).
|
||||||
|
- Phase 3 refactoring is ongoing — several large page files remain and are being addressed incrementally.
|
||||||
|
- **0 test files** for the page layer. Adding Playwright smoke + Vitest unit coverage is ongoing Phase 3 work.
|
||||||
|
|
||||||
|
## Don't touch
|
||||||
|
|
||||||
|
- Backend API paths without updating `backend-compliance/` in the same change.
|
||||||
|
- `lib/sdk/types/` barrel re-exports — add new types to the appropriate domain file, not back into the root.
|
||||||
@@ -48,12 +48,12 @@ describe('Ingestion Script: ingest-industry-compliance.sh', () => {
|
|||||||
expect(scriptContent).toContain('chunk_strategy=recursive')
|
expect(scriptContent).toContain('chunk_strategy=recursive')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use chunk_size=512', () => {
|
it('should use chunk_size=1024', () => {
|
||||||
expect(scriptContent).toContain('chunk_size=512')
|
expect(scriptContent).toContain('chunk_size=1024')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use chunk_overlap=50', () => {
|
it('should use chunk_overlap=128', () => {
|
||||||
expect(scriptContent).toContain('chunk_overlap=50')
|
expect(scriptContent).toContain('chunk_overlap=128')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should validate minimum file size', () => {
|
it('should validate minimum file size', () => {
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* Drafting Engine - v2 Pipeline Helpers
|
||||||
|
*
|
||||||
|
* DOCUMENT_PROSE_BLOCKS, buildV2SystemPrompt, buildBlockSpecificPrompt,
|
||||||
|
* callOllama, handleV2Draft — split from draft-helpers.ts for the 500 LOC hard cap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { DraftContext, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||||
|
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||||
|
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||||
|
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||||
|
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
|
||||||
|
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
|
||||||
|
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
|
||||||
|
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
|
||||||
|
import { computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
|
||||||
|
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||||
|
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||||
|
import {
|
||||||
|
constraintEnforcer,
|
||||||
|
proseCache,
|
||||||
|
TEMPLATE_VERSION,
|
||||||
|
TERMINOLOGY_VERSION,
|
||||||
|
VALIDATOR_VERSION,
|
||||||
|
V1_SYSTEM_PROMPT,
|
||||||
|
buildPromptForDocumentType,
|
||||||
|
} from './draft-helpers'
|
||||||
|
|
||||||
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||||
|
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// v2 Personalisierte Pipeline
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
|
||||||
|
tom: [
|
||||||
|
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
|
||||||
|
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
|
||||||
|
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
|
||||||
|
],
|
||||||
|
dsfa: [
|
||||||
|
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
|
||||||
|
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
|
||||||
|
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
|
||||||
|
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
|
||||||
|
],
|
||||||
|
vvt: [
|
||||||
|
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
|
||||||
|
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
|
||||||
|
],
|
||||||
|
dsi: [
|
||||||
|
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
|
||||||
|
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
|
||||||
|
],
|
||||||
|
lf: [
|
||||||
|
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
|
||||||
|
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
|
||||||
|
],
|
||||||
|
av_vertrag: [
|
||||||
|
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
|
||||||
|
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
|
||||||
|
],
|
||||||
|
betroffenenrechte: [
|
||||||
|
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
|
||||||
|
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
|
||||||
|
],
|
||||||
|
risikoanalyse: [
|
||||||
|
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
|
||||||
|
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
|
||||||
|
],
|
||||||
|
notfallplan: [
|
||||||
|
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
|
||||||
|
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
|
||||||
|
],
|
||||||
|
iace_ce_assessment: [
|
||||||
|
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
|
||||||
|
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
|
||||||
|
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildV2SystemPrompt(
|
||||||
|
sanitizedFactsString: string,
|
||||||
|
narrativeTagsString: string,
|
||||||
|
terminologyString: string,
|
||||||
|
styleString: string,
|
||||||
|
disallowedString: string,
|
||||||
|
companyName: string,
|
||||||
|
blockId: string,
|
||||||
|
blockType: string,
|
||||||
|
sectionName: string,
|
||||||
|
documentType: string,
|
||||||
|
targetWords: number
|
||||||
|
): string {
|
||||||
|
return `Du bist ein Compliance-Dokumenten-Redakteur.
|
||||||
|
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
|
||||||
|
|
||||||
|
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
|
||||||
|
${sanitizedFactsString}
|
||||||
|
|
||||||
|
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
|
||||||
|
${narrativeTagsString}
|
||||||
|
|
||||||
|
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
|
||||||
|
${terminologyString}
|
||||||
|
|
||||||
|
STIL:
|
||||||
|
${styleString}
|
||||||
|
|
||||||
|
VERBOTENE INHALTE:
|
||||||
|
${disallowedString}
|
||||||
|
- Keine konkreten Prozentwerte, Scores oder Zahlen
|
||||||
|
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
|
||||||
|
- Keine direkte Ansprache ("Sie", "Ihr")
|
||||||
|
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
|
||||||
|
|
||||||
|
STRIKTE REGELN:
|
||||||
|
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
|
||||||
|
2. Schreibe in der dritten Person ("Die ${companyName}...")
|
||||||
|
3. Beziehe dich auf die Branche und organisatorische Merkmale
|
||||||
|
4. Verwende NUR Fakten aus dem Kundenprofil oben
|
||||||
|
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
|
||||||
|
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
|
||||||
|
7. Halte dich an die Terminologie-Vorgaben
|
||||||
|
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
|
||||||
|
|
||||||
|
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
|
||||||
|
{
|
||||||
|
"blockId": "${blockId}",
|
||||||
|
"blockType": "${blockType}",
|
||||||
|
"language": "de",
|
||||||
|
"text": "...",
|
||||||
|
"assertions": {
|
||||||
|
"companyNameUsed": true/false,
|
||||||
|
"industryReferenced": true/false,
|
||||||
|
"structureReferenced": true/false,
|
||||||
|
"itLandscapeReferenced": true/false,
|
||||||
|
"narrativeTagsUsed": ["riskSummary", ...]
|
||||||
|
},
|
||||||
|
"forbiddenContentDetected": []
|
||||||
|
}
|
||||||
|
|
||||||
|
DOKUMENTENTYP: ${documentType}
|
||||||
|
SEKTION: ${sectionName}
|
||||||
|
BLOCK-TYP: ${blockType}
|
||||||
|
ZIEL-LAENGE: ${targetWords} Woerter`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
|
||||||
|
switch (blockType) {
|
||||||
|
case 'introduction':
|
||||||
|
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
|
||||||
|
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
|
||||||
|
Gehe auf die spezifische Situation des Unternehmens ein.
|
||||||
|
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
|
||||||
|
case 'transition':
|
||||||
|
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
|
||||||
|
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
|
||||||
|
case 'conclusion':
|
||||||
|
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
|
||||||
|
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
|
||||||
|
case 'appreciation':
|
||||||
|
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
|
||||||
|
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
|
||||||
|
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
|
||||||
|
default:
|
||||||
|
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: LLM_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
think: false,
|
||||||
|
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
|
||||||
|
format: 'json',
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(120000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama error: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
return result.message?.content || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||||
|
const { documentType, draftContext, instructions } = body as {
|
||||||
|
documentType: ScopeDocumentType
|
||||||
|
draftContext: DraftContext
|
||||||
|
instructions?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||||
|
if (!constraintCheck.allowed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
draft: null, constraintCheck, tokensUsed: 0,
|
||||||
|
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||||
|
}, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores = extractScoresFromDraftContext(draftContext)
|
||||||
|
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
|
||||||
|
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
|
||||||
|
|
||||||
|
let sanitizationResult
|
||||||
|
try {
|
||||||
|
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SanitizationError) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
|
||||||
|
draft: null, constraintCheck, tokensUsed: 0,
|
||||||
|
}, { status: 422 })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedFacts = sanitizationResult.facts
|
||||||
|
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
|
||||||
|
if (piiWarnings.length > 0) console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
|
||||||
|
|
||||||
|
const factsString = allowedFactsToPromptString(sanitizedFacts)
|
||||||
|
const tagsString = narrativeTagsToPromptString(narrativeTags)
|
||||||
|
const termsString = terminologyToPromptString()
|
||||||
|
const styleString = styleContractToPromptString()
|
||||||
|
const disallowedString = disallowedTopicsToPromptString()
|
||||||
|
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
||||||
|
|
||||||
|
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||||
|
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
|
||||||
|
|
||||||
|
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
||||||
|
const generatedBlocks: ProseBlockOutput[] = []
|
||||||
|
const repairAudits: RepairAudit[] = []
|
||||||
|
let totalTokens = 0
|
||||||
|
|
||||||
|
for (const blockDef of proseBlocks) {
|
||||||
|
const cacheParams: CacheKeyParams = {
|
||||||
|
allowedFacts: sanitizedFacts, templateVersion: TEMPLATE_VERSION,
|
||||||
|
terminologyVersion: TERMINOLOGY_VERSION, narrativeTags,
|
||||||
|
promptHash, blockType: blockDef.blockType, sectionName: blockDef.sectionName,
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = proseCache.getSync(cacheParams)
|
||||||
|
if (cached) {
|
||||||
|
generatedBlocks.push(cached)
|
||||||
|
repairAudits.push({ repairAttempts: 0, validatorFailures: [], repairSuccessful: true, fallbackUsed: false })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let systemPrompt = buildV2SystemPrompt(
|
||||||
|
factsString, tagsString, termsString, styleString, disallowedString,
|
||||||
|
sanitizedFacts.companyName, blockDef.blockId, blockDef.blockType,
|
||||||
|
blockDef.sectionName, documentType, blockDef.targetWords
|
||||||
|
)
|
||||||
|
if (v2RagContext) systemPrompt += `\n\nRECHTSKONTEXT (als Referenz, nicht woertlich uebernehmen):\n${v2RagContext}`
|
||||||
|
const userPrompt = buildBlockSpecificPrompt(blockDef.blockType, blockDef.sectionName, documentType)
|
||||||
|
+ (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawOutput = await callOllama(systemPrompt, userPrompt)
|
||||||
|
totalTokens += rawOutput.length / 4
|
||||||
|
const { block, audit } = await executeRepairLoop(
|
||||||
|
rawOutput, sanitizedFacts, narrativeTags, blockDef.blockId, blockDef.blockType,
|
||||||
|
async (repairPrompt) => callOllama(systemPrompt, repairPrompt), documentType
|
||||||
|
)
|
||||||
|
generatedBlocks.push(block)
|
||||||
|
repairAudits.push(audit)
|
||||||
|
if (!audit.fallbackUsed) proseCache.setSync(cacheParams, block)
|
||||||
|
} catch (error) {
|
||||||
|
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
|
||||||
|
generatedBlocks.push(buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType))
|
||||||
|
repairAudits.push({
|
||||||
|
repairAttempts: 0, validatorFailures: [[(error as Error).message]],
|
||||||
|
repairSuccessful: false, fallbackUsed: true,
|
||||||
|
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||||
|
|
||||||
|
let dataSections: DraftSection[] = []
|
||||||
|
try {
|
||||||
|
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
|
||||||
|
const parsed = JSON.parse(dataResponse)
|
||||||
|
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||||
|
id: String(s.id || `section-${i}`), title: String(s.title || ''),
|
||||||
|
content: String(s.content || ''), schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||||
|
}))
|
||||||
|
totalTokens += dataResponse.length / 4
|
||||||
|
} catch { dataSections = [] }
|
||||||
|
|
||||||
|
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
|
||||||
|
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
|
||||||
|
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
|
||||||
|
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
|
||||||
|
|
||||||
|
const mergedSections: DraftSection[] = []
|
||||||
|
if (introBlock) mergedSections.push({ id: introBlock.blockId, title: 'Einleitung', content: introBlock.text })
|
||||||
|
for (let i = 0; i < dataSections.length; i++) {
|
||||||
|
if (i > 0 && transitionBlocks[i - 1]) mergedSections.push({ id: transitionBlocks[i - 1].blockId, title: '', content: transitionBlocks[i - 1].text })
|
||||||
|
mergedSections.push(dataSections[i])
|
||||||
|
}
|
||||||
|
for (const block of appreciationBlocks) mergedSections.push({ id: block.blockId, title: 'Wuerdigung', content: block.text })
|
||||||
|
if (conclusionBlock) mergedSections.push({ id: conclusionBlock.blockId, title: 'Fazit', content: conclusionBlock.text })
|
||||||
|
|
||||||
|
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
|
||||||
|
id: b.blockId,
|
||||||
|
title: b.blockType === 'introduction' ? 'Einleitung' : b.blockType === 'conclusion' ? 'Fazit' : b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
|
||||||
|
content: b.text,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const draft: DraftRevision = {
|
||||||
|
id: `draft-v2-${Date.now()}`,
|
||||||
|
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
|
||||||
|
sections: finalSections, createdAt: new Date().toISOString(), instruction: instructions,
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditTrail = {
|
||||||
|
documentType, templateVersion: TEMPLATE_VERSION, terminologyVersion: TERMINOLOGY_VERSION,
|
||||||
|
validatorVersion: VALIDATOR_VERSION, promptHash, llmModel: LLM_MODEL,
|
||||||
|
llmTemperature: 0.15, llmProvider: 'ollama', narrativeTags,
|
||||||
|
sanitization: sanitizationResult.audit, repairAudits,
|
||||||
|
proseBlocks: generatedBlocks.map((b, i) => ({
|
||||||
|
blockId: b.blockId, blockType: b.blockType,
|
||||||
|
wordCount: b.text.split(/\s+/).filter(Boolean).length,
|
||||||
|
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
|
||||||
|
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
|
||||||
|
})),
|
||||||
|
cacheStats: proseCache.getStats(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const truthLabel = { generation_mode: 'draft_assistance', truth_status: 'generated', may_be_used_as_evidence: false, generated_by: 'system' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
|
||||||
|
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
entity_type: 'document', entity_id: null, generation_mode: 'draft_assistance',
|
||||||
|
truth_status: 'generated', may_be_used_as_evidence: false,
|
||||||
|
llm_model: LLM_MODEL, llm_provider: 'ollama',
|
||||||
|
input_summary: `${documentType} draft generation`,
|
||||||
|
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
|
||||||
|
}),
|
||||||
|
}).catch(() => {/* fire-and-forget */})
|
||||||
|
} catch { /* LLM audit persistence failure should not block the response */ }
|
||||||
|
|
||||||
|
return NextResponse.json({ draft, constraintCheck, tokensUsed: Math.round(totalTokens), pipelineVersion: 'v2', auditTrail, truthLabel })
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Drafting Engine - Draft Helper Functions (v1 pipeline + shared constants)
|
||||||
|
*
|
||||||
|
* Shared state, v1 legacy pipeline helpers.
|
||||||
|
* v2 pipeline lives in draft-helpers-v2.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
|
||||||
|
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
|
||||||
|
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
|
||||||
|
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
|
||||||
|
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
|
||||||
|
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||||
|
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||||
|
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
|
||||||
|
import { ProseCacheManager } from '@/lib/sdk/drafting-engine/cache'
|
||||||
|
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||||
|
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||||
|
|
||||||
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||||
|
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||||
|
|
||||||
|
export const constraintEnforcer = new ConstraintEnforcer()
|
||||||
|
export const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 })
|
||||||
|
|
||||||
|
export const TEMPLATE_VERSION = '2.0.0'
|
||||||
|
export const TERMINOLOGY_VERSION = '1.0.0'
|
||||||
|
export const VALIDATOR_VERSION = '1.0.0'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// v1 Legacy Pipeline
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const V1_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
|
||||||
|
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
|
||||||
|
Jede Section hat: id, title, content, schemaField.
|
||||||
|
Halte die Tiefe strikt am vorgegebenen Level.
|
||||||
|
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
|
||||||
|
Sprache: Deutsch.`
|
||||||
|
|
||||||
|
export function buildPromptForDocumentType(
|
||||||
|
documentType: ScopeDocumentType,
|
||||||
|
context: DraftContext,
|
||||||
|
instructions?: string
|
||||||
|
): string {
|
||||||
|
switch (documentType) {
|
||||||
|
case 'vvt':
|
||||||
|
return buildVVTDraftPrompt({ context, instructions })
|
||||||
|
case 'tom':
|
||||||
|
return buildTOMDraftPrompt({ context, instructions })
|
||||||
|
case 'dsfa':
|
||||||
|
return buildDSFADraftPrompt({ context, instructions })
|
||||||
|
case 'dsi':
|
||||||
|
return buildPrivacyPolicyDraftPrompt({ context, instructions })
|
||||||
|
case 'lf':
|
||||||
|
return buildLoeschfristenDraftPrompt({ context, instructions })
|
||||||
|
default:
|
||||||
|
return `## Aufgabe: Entwurf fuer ${documentType}
|
||||||
|
|
||||||
|
### Level: ${context.decisions.level}
|
||||||
|
### Tiefe: ${context.constraints.depthRequirements.depth}
|
||||||
|
### Erforderliche Inhalte:
|
||||||
|
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
|
||||||
|
|
||||||
|
${instructions ? `### Anweisungen: ${instructions}` : ''}
|
||||||
|
|
||||||
|
Antworte als JSON mit "sections" Array.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleV1Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||||
|
const { documentType, draftContext, instructions, existingDraft } = body as {
|
||||||
|
documentType: ScopeDocumentType
|
||||||
|
draftContext: DraftContext
|
||||||
|
instructions?: string
|
||||||
|
existingDraft?: DraftRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||||
|
if (!constraintCheck.allowed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
draft: null,
|
||||||
|
constraintCheck,
|
||||||
|
tokensUsed: 0,
|
||||||
|
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||||
|
}, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||||
|
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
|
||||||
|
|
||||||
|
let v1SystemPrompt = V1_SYSTEM_PROMPT
|
||||||
|
if (ragContext) {
|
||||||
|
v1SystemPrompt += `\n\n## Relevanter Rechtskontext\n${ragContext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: v1SystemPrompt },
|
||||||
|
...(existingDraft ? [{
|
||||||
|
role: 'assistant',
|
||||||
|
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
|
||||||
|
}] : []),
|
||||||
|
{ role: 'user', content: draftPrompt },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: LLM_MODEL,
|
||||||
|
messages,
|
||||||
|
stream: false,
|
||||||
|
think: false,
|
||||||
|
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
|
||||||
|
format: 'json',
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(180000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!ollamaResponse.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
|
||||||
|
{ status: 502 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ollamaResponse.json()
|
||||||
|
const content = result.message?.content || ''
|
||||||
|
|
||||||
|
let sections: DraftSection[] = []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||||
|
id: String(s.id || `section-${i}`),
|
||||||
|
title: String(s.title || ''),
|
||||||
|
content: String(s.content || ''),
|
||||||
|
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
sections = [{ id: 'raw', title: 'Entwurf', content }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft: DraftRevision = {
|
||||||
|
id: `draft-${Date.now()}`,
|
||||||
|
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
|
||||||
|
sections,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
instruction: instructions as string | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
draft,
|
||||||
|
constraintCheck,
|
||||||
|
tokensUsed: result.eval_count || 0,
|
||||||
|
} satisfies DraftResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export v2 handler for route.ts (backward compat — single import point)
|
||||||
|
export { handleV2Draft } from './draft-helpers-v2'
|
||||||
@@ -9,596 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { handleV1Draft, handleV2Draft } from './draft-helpers'
|
||||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
|
||||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
|
||||||
|
|
||||||
// v1 imports (Legacy)
|
|
||||||
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
|
|
||||||
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
|
|
||||||
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
|
|
||||||
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
|
|
||||||
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
|
|
||||||
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
|
||||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
|
||||||
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
|
|
||||||
|
|
||||||
// v2 imports (Personalisierte Pipeline)
|
|
||||||
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
|
|
||||||
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
|
|
||||||
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
|
|
||||||
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
|
|
||||||
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
|
|
||||||
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
|
|
||||||
import { ProseCacheManager, computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
|
|
||||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
|
||||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Shared State
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const constraintEnforcer = new ConstraintEnforcer()
|
|
||||||
const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 })
|
|
||||||
|
|
||||||
// Template/Terminology Versionen (fuer Cache-Key)
|
|
||||||
const TEMPLATE_VERSION = '2.0.0'
|
|
||||||
const TERMINOLOGY_VERSION = '1.0.0'
|
|
||||||
const VALIDATOR_VERSION = '1.0.0'
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// v1 Legacy Pipeline
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const V1_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
|
|
||||||
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
|
|
||||||
Jede Section hat: id, title, content, schemaField.
|
|
||||||
Halte die Tiefe strikt am vorgegebenen Level.
|
|
||||||
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
|
|
||||||
Sprache: Deutsch.`
|
|
||||||
|
|
||||||
function buildPromptForDocumentType(
|
|
||||||
documentType: ScopeDocumentType,
|
|
||||||
context: DraftContext,
|
|
||||||
instructions?: string
|
|
||||||
): string {
|
|
||||||
switch (documentType) {
|
|
||||||
case 'vvt':
|
|
||||||
return buildVVTDraftPrompt({ context, instructions })
|
|
||||||
case 'tom':
|
|
||||||
return buildTOMDraftPrompt({ context, instructions })
|
|
||||||
case 'dsfa':
|
|
||||||
return buildDSFADraftPrompt({ context, instructions })
|
|
||||||
case 'dsi':
|
|
||||||
return buildPrivacyPolicyDraftPrompt({ context, instructions })
|
|
||||||
case 'lf':
|
|
||||||
return buildLoeschfristenDraftPrompt({ context, instructions })
|
|
||||||
default:
|
|
||||||
return `## Aufgabe: Entwurf fuer ${documentType}
|
|
||||||
|
|
||||||
### Level: ${context.decisions.level}
|
|
||||||
### Tiefe: ${context.constraints.depthRequirements.depth}
|
|
||||||
### Erforderliche Inhalte:
|
|
||||||
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
|
|
||||||
|
|
||||||
${instructions ? `### Anweisungen: ${instructions}` : ''}
|
|
||||||
|
|
||||||
Antworte als JSON mit "sections" Array.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleV1Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
|
||||||
const { documentType, draftContext, instructions, existingDraft } = body as {
|
|
||||||
documentType: ScopeDocumentType
|
|
||||||
draftContext: DraftContext
|
|
||||||
instructions?: string
|
|
||||||
existingDraft?: DraftRevision
|
|
||||||
}
|
|
||||||
|
|
||||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
|
||||||
if (!constraintCheck.allowed) {
|
|
||||||
return NextResponse.json({
|
|
||||||
draft: null,
|
|
||||||
constraintCheck,
|
|
||||||
tokensUsed: 0,
|
|
||||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
|
||||||
}, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// RAG: Fetch relevant legal context (config-based)
|
|
||||||
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
|
|
||||||
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
|
|
||||||
|
|
||||||
let v1SystemPrompt = V1_SYSTEM_PROMPT
|
|
||||||
if (ragContext) {
|
|
||||||
v1SystemPrompt += `\n\n## Relevanter Rechtskontext\n${ragContext}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
|
||||||
const messages = [
|
|
||||||
{ role: 'system', content: v1SystemPrompt },
|
|
||||||
...(existingDraft ? [{
|
|
||||||
role: 'assistant',
|
|
||||||
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
|
|
||||||
}] : []),
|
|
||||||
{ role: 'user', content: draftPrompt },
|
|
||||||
]
|
|
||||||
|
|
||||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: LLM_MODEL,
|
|
||||||
messages,
|
|
||||||
stream: false,
|
|
||||||
think: false,
|
|
||||||
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
|
|
||||||
format: 'json',
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(180000),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!ollamaResponse.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
|
|
||||||
{ status: 502 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await ollamaResponse.json()
|
|
||||||
const content = result.message?.content || ''
|
|
||||||
|
|
||||||
let sections: DraftSection[] = []
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(content)
|
|
||||||
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
|
||||||
id: String(s.id || `section-${i}`),
|
|
||||||
title: String(s.title || ''),
|
|
||||||
content: String(s.content || ''),
|
|
||||||
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
sections = [{ id: 'raw', title: 'Entwurf', content }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft: DraftRevision = {
|
|
||||||
id: `draft-${Date.now()}`,
|
|
||||||
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
|
|
||||||
sections,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
instruction: instructions as string | undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
draft,
|
|
||||||
constraintCheck,
|
|
||||||
tokensUsed: result.eval_count || 0,
|
|
||||||
} satisfies DraftResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// v2 Personalisierte Pipeline
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** Prose block definitions per document type */
|
|
||||||
const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
|
|
||||||
tom: [
|
|
||||||
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
|
|
||||||
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
|
|
||||||
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
|
|
||||||
],
|
|
||||||
dsfa: [
|
|
||||||
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
|
|
||||||
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
|
|
||||||
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
|
|
||||||
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
|
|
||||||
],
|
|
||||||
vvt: [
|
|
||||||
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
|
|
||||||
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
|
|
||||||
],
|
|
||||||
dsi: [
|
|
||||||
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
|
|
||||||
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
|
|
||||||
],
|
|
||||||
lf: [
|
|
||||||
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
|
|
||||||
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
|
|
||||||
],
|
|
||||||
av_vertrag: [
|
|
||||||
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
|
|
||||||
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
|
|
||||||
],
|
|
||||||
betroffenenrechte: [
|
|
||||||
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
|
|
||||||
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
|
|
||||||
],
|
|
||||||
risikoanalyse: [
|
|
||||||
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
|
|
||||||
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
|
|
||||||
],
|
|
||||||
notfallplan: [
|
|
||||||
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
|
|
||||||
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
|
|
||||||
],
|
|
||||||
iace_ce_assessment: [
|
|
||||||
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
|
|
||||||
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
|
|
||||||
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildV2SystemPrompt(
|
|
||||||
sanitizedFactsString: string,
|
|
||||||
narrativeTagsString: string,
|
|
||||||
terminologyString: string,
|
|
||||||
styleString: string,
|
|
||||||
disallowedString: string,
|
|
||||||
companyName: string,
|
|
||||||
blockId: string,
|
|
||||||
blockType: string,
|
|
||||||
sectionName: string,
|
|
||||||
documentType: string,
|
|
||||||
targetWords: number
|
|
||||||
): string {
|
|
||||||
return `Du bist ein Compliance-Dokumenten-Redakteur.
|
|
||||||
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
|
|
||||||
|
|
||||||
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
|
|
||||||
${sanitizedFactsString}
|
|
||||||
|
|
||||||
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
|
|
||||||
${narrativeTagsString}
|
|
||||||
|
|
||||||
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
|
|
||||||
${terminologyString}
|
|
||||||
|
|
||||||
STIL:
|
|
||||||
${styleString}
|
|
||||||
|
|
||||||
VERBOTENE INHALTE:
|
|
||||||
${disallowedString}
|
|
||||||
- Keine konkreten Prozentwerte, Scores oder Zahlen
|
|
||||||
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
|
|
||||||
- Keine direkte Ansprache ("Sie", "Ihr")
|
|
||||||
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
|
|
||||||
|
|
||||||
STRIKTE REGELN:
|
|
||||||
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
|
|
||||||
2. Schreibe in der dritten Person ("Die ${companyName}...")
|
|
||||||
3. Beziehe dich auf die Branche und organisatorische Merkmale
|
|
||||||
4. Verwende NUR Fakten aus dem Kundenprofil oben
|
|
||||||
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
|
|
||||||
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
|
|
||||||
7. Halte dich an die Terminologie-Vorgaben
|
|
||||||
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
|
|
||||||
|
|
||||||
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
|
|
||||||
{
|
|
||||||
"blockId": "${blockId}",
|
|
||||||
"blockType": "${blockType}",
|
|
||||||
"language": "de",
|
|
||||||
"text": "...",
|
|
||||||
"assertions": {
|
|
||||||
"companyNameUsed": true/false,
|
|
||||||
"industryReferenced": true/false,
|
|
||||||
"structureReferenced": true/false,
|
|
||||||
"itLandscapeReferenced": true/false,
|
|
||||||
"narrativeTagsUsed": ["riskSummary", ...]
|
|
||||||
},
|
|
||||||
"forbiddenContentDetected": []
|
|
||||||
}
|
|
||||||
|
|
||||||
DOKUMENTENTYP: ${documentType}
|
|
||||||
SEKTION: ${sectionName}
|
|
||||||
BLOCK-TYP: ${blockType}
|
|
||||||
ZIEL-LAENGE: ${targetWords} Woerter`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
|
|
||||||
switch (blockType) {
|
|
||||||
case 'introduction':
|
|
||||||
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
|
|
||||||
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
|
|
||||||
Gehe auf die spezifische Situation des Unternehmens ein.
|
|
||||||
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
|
|
||||||
case 'transition':
|
|
||||||
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
|
|
||||||
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
|
|
||||||
case 'conclusion':
|
|
||||||
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
|
|
||||||
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
|
|
||||||
case 'appreciation':
|
|
||||||
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
|
|
||||||
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
|
|
||||||
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
|
|
||||||
default:
|
|
||||||
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
|
|
||||||
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: LLM_MODEL,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: userPrompt },
|
|
||||||
],
|
|
||||||
stream: false,
|
|
||||||
think: false,
|
|
||||||
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
|
|
||||||
format: 'json',
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(120000),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Ollama error: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
return result.message?.content || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
|
||||||
const { documentType, draftContext, instructions } = body as {
|
|
||||||
documentType: ScopeDocumentType
|
|
||||||
draftContext: DraftContext
|
|
||||||
instructions?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Constraint Check (Hard Gate)
|
|
||||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
|
||||||
if (!constraintCheck.allowed) {
|
|
||||||
return NextResponse.json({
|
|
||||||
draft: null,
|
|
||||||
constraintCheck,
|
|
||||||
tokensUsed: 0,
|
|
||||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
|
||||||
}, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Derive Narrative Tags (deterministisch)
|
|
||||||
const scores = extractScoresFromDraftContext(draftContext)
|
|
||||||
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
|
|
||||||
|
|
||||||
// Step 3: Build Allowed Facts
|
|
||||||
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
|
|
||||||
|
|
||||||
// Step 4: PII Sanitization
|
|
||||||
let sanitizationResult
|
|
||||||
try {
|
|
||||||
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof SanitizationError) {
|
|
||||||
return NextResponse.json({
|
|
||||||
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
|
|
||||||
draft: null,
|
|
||||||
constraintCheck,
|
|
||||||
tokensUsed: 0,
|
|
||||||
}, { status: 422 })
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitizedFacts = sanitizationResult.facts
|
|
||||||
|
|
||||||
// Verify no remaining PII
|
|
||||||
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
|
|
||||||
if (piiWarnings.length > 0) {
|
|
||||||
console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Build prompt components
|
|
||||||
const factsString = allowedFactsToPromptString(sanitizedFacts)
|
|
||||||
const tagsString = narrativeTagsToPromptString(narrativeTags)
|
|
||||||
const termsString = terminologyToPromptString()
|
|
||||||
const styleString = styleContractToPromptString()
|
|
||||||
const disallowedString = disallowedTopicsToPromptString()
|
|
||||||
|
|
||||||
// Compute prompt hash for audit
|
|
||||||
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
|
||||||
|
|
||||||
// Step 5b: RAG Legal Context (config-based)
|
|
||||||
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
|
||||||
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
|
|
||||||
|
|
||||||
// Step 6: Generate Prose Blocks (with cache + repair loop)
|
|
||||||
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
|
||||||
const generatedBlocks: ProseBlockOutput[] = []
|
|
||||||
const repairAudits: RepairAudit[] = []
|
|
||||||
let totalTokens = 0
|
|
||||||
|
|
||||||
for (const blockDef of proseBlocks) {
|
|
||||||
// Check cache
|
|
||||||
const cacheParams: CacheKeyParams = {
|
|
||||||
allowedFacts: sanitizedFacts,
|
|
||||||
templateVersion: TEMPLATE_VERSION,
|
|
||||||
terminologyVersion: TERMINOLOGY_VERSION,
|
|
||||||
narrativeTags,
|
|
||||||
promptHash,
|
|
||||||
blockType: blockDef.blockType,
|
|
||||||
sectionName: blockDef.sectionName,
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = proseCache.getSync(cacheParams)
|
|
||||||
if (cached) {
|
|
||||||
generatedBlocks.push(cached)
|
|
||||||
repairAudits.push({
|
|
||||||
repairAttempts: 0,
|
|
||||||
validatorFailures: [],
|
|
||||||
repairSuccessful: true,
|
|
||||||
fallbackUsed: false,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build prompts
|
|
||||||
let systemPrompt = buildV2SystemPrompt(
|
|
||||||
factsString, tagsString, termsString, styleString, disallowedString,
|
|
||||||
sanitizedFacts.companyName,
|
|
||||||
blockDef.blockId, blockDef.blockType, blockDef.sectionName,
|
|
||||||
documentType, blockDef.targetWords
|
|
||||||
)
|
|
||||||
if (v2RagContext) {
|
|
||||||
systemPrompt += `\n\nRECHTSKONTEXT (als Referenz, nicht woertlich uebernehmen):\n${v2RagContext}`
|
|
||||||
}
|
|
||||||
const userPrompt = buildBlockSpecificPrompt(
|
|
||||||
blockDef.blockType, blockDef.sectionName, documentType
|
|
||||||
) + (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
|
|
||||||
|
|
||||||
// Call LLM + Repair Loop
|
|
||||||
try {
|
|
||||||
const rawOutput = await callOllama(systemPrompt, userPrompt)
|
|
||||||
totalTokens += rawOutput.length / 4 // Rough token estimate
|
|
||||||
|
|
||||||
const { block, audit } = await executeRepairLoop(
|
|
||||||
rawOutput,
|
|
||||||
sanitizedFacts,
|
|
||||||
narrativeTags,
|
|
||||||
blockDef.blockId,
|
|
||||||
blockDef.blockType,
|
|
||||||
async (repairPrompt) => callOllama(systemPrompt, repairPrompt),
|
|
||||||
documentType
|
|
||||||
)
|
|
||||||
|
|
||||||
generatedBlocks.push(block)
|
|
||||||
repairAudits.push(audit)
|
|
||||||
|
|
||||||
// Cache successful blocks (not fallbacks)
|
|
||||||
if (!audit.fallbackUsed) {
|
|
||||||
proseCache.setSync(cacheParams, block)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// LLM unreachable → Fallback
|
|
||||||
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
|
|
||||||
generatedBlocks.push(
|
|
||||||
buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType)
|
|
||||||
)
|
|
||||||
repairAudits.push({
|
|
||||||
repairAttempts: 0,
|
|
||||||
validatorFailures: [[(error as Error).message]],
|
|
||||||
repairSuccessful: false,
|
|
||||||
fallbackUsed: true,
|
|
||||||
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Build v1-compatible draft sections from prose blocks + original prompt
|
|
||||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
|
||||||
|
|
||||||
// Also generate data sections via legacy pipeline
|
|
||||||
let dataSections: DraftSection[] = []
|
|
||||||
try {
|
|
||||||
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
|
|
||||||
const parsed = JSON.parse(dataResponse)
|
|
||||||
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
|
||||||
id: String(s.id || `section-${i}`),
|
|
||||||
title: String(s.title || ''),
|
|
||||||
content: String(s.content || ''),
|
|
||||||
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
|
||||||
}))
|
|
||||||
totalTokens += dataResponse.length / 4
|
|
||||||
} catch {
|
|
||||||
dataSections = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge: Prose intro → Data sections → Prose transitions/conclusion
|
|
||||||
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
|
|
||||||
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
|
|
||||||
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
|
|
||||||
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
|
|
||||||
|
|
||||||
const mergedSections: DraftSection[] = []
|
|
||||||
|
|
||||||
if (introBlock) {
|
|
||||||
mergedSections.push({
|
|
||||||
id: introBlock.blockId,
|
|
||||||
title: 'Einleitung',
|
|
||||||
content: introBlock.text,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < dataSections.length; i++) {
|
|
||||||
// Insert transition before data section (if available)
|
|
||||||
if (i > 0 && transitionBlocks[i - 1]) {
|
|
||||||
mergedSections.push({
|
|
||||||
id: transitionBlocks[i - 1].blockId,
|
|
||||||
title: '',
|
|
||||||
content: transitionBlocks[i - 1].text,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
mergedSections.push(dataSections[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const block of appreciationBlocks) {
|
|
||||||
mergedSections.push({
|
|
||||||
id: block.blockId,
|
|
||||||
title: 'Wuerdigung',
|
|
||||||
content: block.text,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conclusionBlock) {
|
|
||||||
mergedSections.push({
|
|
||||||
id: conclusionBlock.blockId,
|
|
||||||
title: 'Fazit',
|
|
||||||
content: conclusionBlock.text,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no data sections generated, use prose blocks as sections
|
|
||||||
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
|
|
||||||
id: b.blockId,
|
|
||||||
title: b.blockType === 'introduction' ? 'Einleitung' :
|
|
||||||
b.blockType === 'conclusion' ? 'Fazit' :
|
|
||||||
b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
|
|
||||||
content: b.text,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const draft: DraftRevision = {
|
|
||||||
id: `draft-v2-${Date.now()}`,
|
|
||||||
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
|
|
||||||
sections: finalSections,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
instruction: instructions,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 8: Build Audit Trail
|
|
||||||
const auditTrail = {
|
|
||||||
documentType,
|
|
||||||
templateVersion: TEMPLATE_VERSION,
|
|
||||||
terminologyVersion: TERMINOLOGY_VERSION,
|
|
||||||
validatorVersion: VALIDATOR_VERSION,
|
|
||||||
promptHash,
|
|
||||||
llmModel: LLM_MODEL,
|
|
||||||
llmTemperature: 0.15,
|
|
||||||
llmProvider: 'ollama',
|
|
||||||
narrativeTags,
|
|
||||||
sanitization: sanitizationResult.audit,
|
|
||||||
repairAudits,
|
|
||||||
proseBlocks: generatedBlocks.map((b, i) => ({
|
|
||||||
blockId: b.blockId,
|
|
||||||
blockType: b.blockType,
|
|
||||||
wordCount: b.text.split(/\s+/).filter(Boolean).length,
|
|
||||||
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
|
|
||||||
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
|
|
||||||
})),
|
|
||||||
cacheStats: proseCache.getStats(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
draft,
|
|
||||||
constraintCheck,
|
|
||||||
tokensUsed: Math.round(totalTokens),
|
|
||||||
pipelineVersion: 'v2',
|
|
||||||
auditTrail,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Handler
|
// Route Handler
|
||||||
|
|||||||
@@ -14,6 +14,76 @@ import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validat
|
|||||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anti-Fake-Evidence: Verbotene Formulierungen
|
||||||
|
*
|
||||||
|
* Flags formulations that falsely claim compliance without evidence.
|
||||||
|
* Only allowed when: control_status=pass AND confidence >= E2 AND
|
||||||
|
* truth_status in (validated_internal, accepted_by_auditor).
|
||||||
|
*/
|
||||||
|
interface EvidenceContext {
|
||||||
|
controlStatus?: string
|
||||||
|
confidenceLevel?: string
|
||||||
|
truthStatus?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORBIDDEN_PATTERNS: Array<{
|
||||||
|
pattern: RegExp
|
||||||
|
label: string
|
||||||
|
safeAlternative: string
|
||||||
|
}> = [
|
||||||
|
{ pattern: /ist\s+compliant/gi, label: 'ist compliant', safeAlternative: 'soll compliant sein' },
|
||||||
|
{ pattern: /erfüllt\s+vollständig/gi, label: 'erfüllt vollständig', safeAlternative: 'soll vollständig erfüllt werden' },
|
||||||
|
{ pattern: /wurde\s+geprüft/gi, label: 'wurde geprüft', safeAlternative: 'soll geprüft werden' },
|
||||||
|
{ pattern: /wurde\s+umgesetzt/gi, label: 'wurde umgesetzt', safeAlternative: 'ist zur Umsetzung vorgesehen' },
|
||||||
|
{ pattern: /ist\s+auditiert/gi, label: 'ist auditiert', safeAlternative: 'soll auditiert werden' },
|
||||||
|
{ pattern: /vollständig\s+implementiert/gi, label: 'vollständig implementiert', safeAlternative: 'Implementierung ist vorgesehen' },
|
||||||
|
{ pattern: /nachweislich\s+konform/gi, label: 'nachweislich konform', safeAlternative: 'Konformität ist nachzuweisen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CONFIDENCE_ORDER: Record<string, number> = { E0: 0, E1: 1, E2: 2, E3: 3, E4: 4 }
|
||||||
|
const VALID_TRUTH_STATUSES = new Set(['validated_internal', 'accepted_by_auditor'])
|
||||||
|
|
||||||
|
function checkForbiddenFormulations(
|
||||||
|
content: string,
|
||||||
|
evidenceContext?: EvidenceContext,
|
||||||
|
): ValidationFinding[] {
|
||||||
|
const findings: ValidationFinding[] = []
|
||||||
|
|
||||||
|
if (!content) return findings
|
||||||
|
|
||||||
|
// If evidence context shows sufficient proof, allow the formulations
|
||||||
|
if (evidenceContext) {
|
||||||
|
const { controlStatus, confidenceLevel, truthStatus } = evidenceContext
|
||||||
|
const confLevel = CONFIDENCE_ORDER[confidenceLevel ?? 'E0'] ?? 0
|
||||||
|
if (
|
||||||
|
controlStatus === 'pass' &&
|
||||||
|
confLevel >= CONFIDENCE_ORDER.E2 &&
|
||||||
|
VALID_TRUTH_STATUSES.has(truthStatus ?? '')
|
||||||
|
) {
|
||||||
|
return findings // Formulations are backed by real evidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { pattern, label, safeAlternative } of FORBIDDEN_PATTERNS) {
|
||||||
|
// Reset regex state for global patterns
|
||||||
|
pattern.lastIndex = 0
|
||||||
|
if (pattern.test(content)) {
|
||||||
|
findings.push({
|
||||||
|
id: `AFE-FORBIDDEN-${label.replace(/\s+/g, '_').toUpperCase()}`,
|
||||||
|
severity: 'error',
|
||||||
|
category: 'forbidden_formulation' as ValidationFinding['category'],
|
||||||
|
title: `Verbotene Formulierung: "${label}"`,
|
||||||
|
description: `Die Formulierung "${label}" impliziert eine nachgewiesene Compliance, die ohne ausreichenden Nachweis (Evidence >= E2, validiert) nicht verwendet werden darf.`,
|
||||||
|
documentType: 'vvt' as ScopeDocumentType,
|
||||||
|
suggestion: `Verwende stattdessen: "${safeAlternative}"`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stufe 1: Deterministische Pruefung
|
* Stufe 1: Deterministische Pruefung
|
||||||
*/
|
*/
|
||||||
@@ -221,10 +291,18 @@ export async function POST(request: NextRequest) {
|
|||||||
// LLM unavailable, continue with deterministic results only
|
// LLM unavailable, continue with deterministic results only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Stufe 1b: Verbotene Formulierungen (Anti-Fake-Evidence)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
const forbiddenFindings = checkForbiddenFormulations(
|
||||||
|
draftContent || '',
|
||||||
|
validationContext.evidenceContext,
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Combine results
|
// Combine results
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
const allFindings = [...deterministicFindings, ...llmFindings]
|
const allFindings = [...deterministicFindings, ...forbiddenFindings, ...llmFindings]
|
||||||
const errors = allFindings.filter(f => f.severity === 'error')
|
const errors = allFindings.filter(f => f.severity === 'error')
|
||||||
const warnings = allFindings.filter(f => f.severity === 'warning')
|
const warnings = allFindings.filter(f => f.severity === 'warning')
|
||||||
const suggestions = allFindings.filter(f => f.severity === 'suggestion')
|
const suggestions = allFindings.filter(f => f.severity === 'suggestion')
|
||||||
|
|||||||
108
admin-compliance/app/api/sdk/v1/audit-llm/[[...path]]/route.ts
Normal file
108
admin-compliance/app/api/sdk/v1/audit-llm/[[...path]]/route.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* LLM Audit API Proxy - Catch-all route
|
||||||
|
* Proxies all /api/sdk/v1/audit-llm/* requests to ai-compliance-sdk /sdk/v1/audit/*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const basePath = `${SDK_BACKEND_URL}/sdk/v1/audit`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
|
for (const name of headerNames) {
|
||||||
|
const value = request.headers.get(name)
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
// Handle export endpoints that may return CSV
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
if (contentType.includes('text/csv') || contentType.includes('application/octet-stream')) {
|
||||||
|
const blob = await response.arrayBuffer()
|
||||||
|
return new NextResponse(blob, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': response.headers.get('content-disposition') || 'attachment',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LLM Audit API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'POST')
|
||||||
|
}
|
||||||
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: GET /api/sdk/v1/canonical?endpoint=...
|
||||||
|
*
|
||||||
|
* Routes to backend canonical control endpoints:
|
||||||
|
* endpoint=frameworks → GET /api/compliance/v1/canonical/frameworks
|
||||||
|
* endpoint=controls → GET /api/compliance/v1/canonical/controls(?severity=...&domain=...)
|
||||||
|
* endpoint=control&id= → GET /api/compliance/v1/canonical/controls/{id}
|
||||||
|
* endpoint=sources → GET /api/compliance/v1/canonical/sources
|
||||||
|
* endpoint=licenses → GET /api/compliance/v1/canonical/licenses
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const endpoint = searchParams.get('endpoint') || 'frameworks'
|
||||||
|
|
||||||
|
let backendPath: string
|
||||||
|
|
||||||
|
switch (endpoint) {
|
||||||
|
case 'frameworks':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/frameworks'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'controls': {
|
||||||
|
const controlParams = new URLSearchParams()
|
||||||
|
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||||
|
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset']
|
||||||
|
for (const key of passthrough) {
|
||||||
|
const val = searchParams.get(key)
|
||||||
|
if (val) controlParams.set(key, val)
|
||||||
|
}
|
||||||
|
const qs = controlParams.toString()
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls${qs ? `?${qs}` : ''}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'controls-count': {
|
||||||
|
const countParams = new URLSearchParams()
|
||||||
|
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||||
|
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||||
|
for (const key of countPassthrough) {
|
||||||
|
const val = searchParams.get(key)
|
||||||
|
if (val) countParams.set(key, val)
|
||||||
|
}
|
||||||
|
const countQs = countParams.toString()
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls-count${countQs ? `?${countQs}` : ''}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'controls-meta': {
|
||||||
|
const metaParams = new URLSearchParams()
|
||||||
|
const metaPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||||
|
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||||
|
for (const key of metaPassthrough) {
|
||||||
|
const val = searchParams.get(key)
|
||||||
|
if (val) metaParams.set(key, val)
|
||||||
|
}
|
||||||
|
const metaQs = metaParams.toString()
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls-meta${metaQs ? `?${metaQs}` : ''}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'control': {
|
||||||
|
const controlId = searchParams.get('id')
|
||||||
|
if (!controlId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sources':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/sources'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'licenses':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/licenses'
|
||||||
|
break
|
||||||
|
|
||||||
|
// Generator endpoints
|
||||||
|
case 'generate-jobs':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/generate/jobs'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'generate-status': {
|
||||||
|
const jobId = searchParams.get('jobId')
|
||||||
|
if (!jobId) {
|
||||||
|
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/generate/status/${encodeURIComponent(jobId)}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'review-queue': {
|
||||||
|
const state = searchParams.get('release_state') || 'needs_review'
|
||||||
|
backendPath = `/api/compliance/v1/canonical/generate/review-queue?release_state=${encodeURIComponent(state)}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'processed-stats':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/generate/processed-stats'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'categories':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/categories'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'traceability': {
|
||||||
|
const traceId = searchParams.get('id')
|
||||||
|
if (!traceId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(traceId)}/traceability`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'provenance': {
|
||||||
|
const provId = searchParams.get('id')
|
||||||
|
if (!provId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(provId)}/provenance`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'atomic-stats':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/controls/atomic-stats'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'similar': {
|
||||||
|
const simControlId = searchParams.get('id')
|
||||||
|
if (!simControlId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const simThreshold = searchParams.get('threshold') || '0.85'
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(simControlId)}/similar?threshold=${simThreshold}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'blocked-sources':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/blocked-sources'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'v1-matches': {
|
||||||
|
const matchId = searchParams.get('id')
|
||||||
|
if (!matchId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(matchId)}/v1-matches`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'v1-enrichment-stats':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/controls/v1-enrichment-stats'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'obligation-dedup-stats':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/obligations/dedup-stats'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'controls-customer': {
|
||||||
|
const custSeverity = searchParams.get('severity')
|
||||||
|
const custDomain = searchParams.get('domain')
|
||||||
|
const custParams = new URLSearchParams()
|
||||||
|
if (custSeverity) custParams.set('severity', custSeverity)
|
||||||
|
if (custDomain) custParams.set('domain', custDomain)
|
||||||
|
const custQs = custParams.toString()
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls-customer${custQs ? `?${custQs}` : ''}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: `Unknown endpoint: ${endpoint}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(null, { status: 404 })
|
||||||
|
}
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Canonical control proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: POST /api/sdk/v1/canonical?endpoint=...
|
||||||
|
*
|
||||||
|
* endpoint=create-control → POST /api/compliance/v1/canonical/controls
|
||||||
|
* endpoint=similarity-check&id= → POST /api/compliance/v1/canonical/controls/{id}/similarity-check
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const endpoint = searchParams.get('endpoint')
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
let backendPath: string
|
||||||
|
|
||||||
|
if (endpoint === 'create-control') {
|
||||||
|
backendPath = '/api/compliance/v1/canonical/controls'
|
||||||
|
} else if (endpoint === 'generate') {
|
||||||
|
backendPath = '/api/compliance/v1/canonical/generate'
|
||||||
|
} else if (endpoint === 'review') {
|
||||||
|
const controlId = searchParams.get('id')
|
||||||
|
if (!controlId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/generate/review/${encodeURIComponent(controlId)}`
|
||||||
|
} else if (endpoint === 'bulk-review') {
|
||||||
|
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
||||||
|
} else if (endpoint === 'blocked-sources-cleanup') {
|
||||||
|
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
||||||
|
} else if (endpoint === 'enrich-v1-matches') {
|
||||||
|
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||||
|
const batchSize = searchParams.get('batch_size') ?? '100'
|
||||||
|
const enrichOffset = searchParams.get('offset') ?? '0'
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/enrich-v1-matches?dry_run=${dryRun}&batch_size=${batchSize}&offset=${enrichOffset}`
|
||||||
|
} else if (endpoint === 'obligation-dedup') {
|
||||||
|
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||||
|
const batchSize = searchParams.get('batch_size') ?? '0'
|
||||||
|
const dedupOffset = searchParams.get('offset') ?? '0'
|
||||||
|
backendPath = `/api/compliance/v1/canonical/obligations/dedup?dry_run=${dryRun}&batch_size=${batchSize}&offset=${dedupOffset}`
|
||||||
|
} else if (endpoint === 'similarity-check') {
|
||||||
|
const controlId = searchParams.get('id')
|
||||||
|
if (!controlId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}/similarity-check`
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: `Unknown POST endpoint: ${endpoint}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BACKEND_URL}${backendPath}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json(), { status: response.status })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Canonical control POST proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: PUT /api/sdk/v1/canonical?endpoint=update-control&id=AUTH-001
|
||||||
|
*
|
||||||
|
* Routes to: PUT /api/compliance/v1/canonical/controls/{id}
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const controlId = searchParams.get('id')
|
||||||
|
if (!controlId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Canonical control PUT proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: DELETE /api/sdk/v1/canonical?id=AUTH-001
|
||||||
|
*
|
||||||
|
* Routes to: DELETE /api/compliance/v1/canonical/controls/{id}
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const controlId = searchParams.get('id')
|
||||||
|
if (!controlId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 204) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Canonical control DELETE proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
function getIds(request: NextRequest, body?: Record<string, unknown>) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||||
|
const projectId = searchParams.get('project_id') || (body?.project_id as string) || ''
|
||||||
|
const qs = projectId
|
||||||
|
? `tenant_id=${encodeURIComponent(tenantId)}&project_id=${encodeURIComponent(projectId)}`
|
||||||
|
: `tenant_id=${encodeURIComponent(tenantId)}`
|
||||||
|
return { tenantId, projectId, qs }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: GET /api/sdk/v1/company-profile → Backend GET /api/v1/company-profile
|
* Proxy: GET /api/sdk/v1/company-profile → Backend GET /api/v1/company-profile
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { tenantId, qs } = getIds(request)
|
||||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'X-Tenant-ID': tenantId,
|
'X-Tenant-ID': tenantId,
|
||||||
@@ -47,10 +56,10 @@ export async function GET(request: NextRequest) {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const tenantId = body.tenant_id || 'default'
|
const { tenantId, qs } = getIds(request, body)
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -86,11 +95,10 @@ export async function POST(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { tenantId, qs } = getIds(request)
|
||||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -124,10 +132,10 @@ export async function DELETE(request: NextRequest) {
|
|||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const tenantId = body.tenant_id || 'default'
|
const { tenantId, qs } = getIds(request, body)
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||||
{
|
{
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: GET /api/sdk/v1/compliance-scope → Backend GET /api/v1/compliance-scope
|
* Proxy: GET /api/sdk/v1/compliance-scope → Backend GET /api/v1/compliance-scope
|
||||||
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
|
`${BACKEND_URL}/api/compliance/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,39 +1,52 @@
|
|||||||
/**
|
/**
|
||||||
* DSGVO API Proxy - Catch-all route
|
* Evidence Checks API Proxy - Catch-all route
|
||||||
* Proxies all /api/sdk/v1/dsgvo/* requests to ai-compliance-sdk backend
|
* Proxies all /api/sdk/v1/compliance/evidence-checks/* requests to backend-compliance
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
async function proxyRequest(
|
async function proxyRequest(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
pathSegments: string[],
|
pathSegments: string[] | undefined,
|
||||||
method: string
|
method: string
|
||||||
) {
|
) {
|
||||||
const pathStr = pathSegments.join('/')
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
const searchParams = request.nextUrl.searchParams.toString()
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
const url = `${SDK_BACKEND_URL}/sdk/v1/dsgvo/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
const basePath = `${BACKEND_URL}/api/compliance/evidence-checks`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||||
|
'X-User-Id': 'admin',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward auth headers if present
|
|
||||||
const authHeader = request.headers.get('authorization')
|
const authHeader = request.headers.get('authorization')
|
||||||
if (authHeader) {
|
if (authHeader) {
|
||||||
headers['Authorization'] = authHeader
|
headers['Authorization'] = authHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tenantHeader = request.headers.get('x-tenant-id')
|
||||||
|
if (tenantHeader) {
|
||||||
|
headers['X-Tenant-Id'] = tenantHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIdHeader = request.headers.get('x-user-id')
|
||||||
|
if (userIdHeader) {
|
||||||
|
headers['X-User-Id'] = userIdHeader
|
||||||
|
}
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add body for POST/PUT/PATCH methods
|
|
||||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||||
const contentType = request.headers.get('content-type')
|
const contentType = request.headers.get('content-type')
|
||||||
if (contentType?.includes('application/json')) {
|
if (contentType?.includes('application/json')) {
|
||||||
@@ -43,27 +56,13 @@ async function proxyRequest(
|
|||||||
fetchOptions.body = text
|
fetchOptions.body = text
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Empty or invalid body - continue without
|
// Empty or invalid body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions)
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
// Handle non-JSON responses (e.g., PDF export)
|
|
||||||
const responseContentType = response.headers.get('content-type')
|
|
||||||
if (responseContentType?.includes('application/pdf') ||
|
|
||||||
responseContentType?.includes('application/octet-stream')) {
|
|
||||||
const blob = await response.blob()
|
|
||||||
return new NextResponse(blob, {
|
|
||||||
status: response.status,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': responseContentType,
|
|
||||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
let errorJson
|
let errorJson
|
||||||
@@ -81,9 +80,9 @@ async function proxyRequest(
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return NextResponse.json(data)
|
return NextResponse.json(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('DSGVO API proxy error:', error)
|
console.error('Evidence Checks API proxy error:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,7 +90,7 @@ async function proxyRequest(
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
) {
|
) {
|
||||||
const { path } = await params
|
const { path } = await params
|
||||||
return proxyRequest(request, path, 'GET')
|
return proxyRequest(request, path, 'GET')
|
||||||
@@ -99,7 +98,7 @@ export async function GET(
|
|||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
) {
|
) {
|
||||||
const { path } = await params
|
const { path } = await params
|
||||||
return proxyRequest(request, path, 'POST')
|
return proxyRequest(request, path, 'POST')
|
||||||
@@ -107,7 +106,7 @@ export async function POST(
|
|||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
) {
|
) {
|
||||||
const { path } = await params
|
const { path } = await params
|
||||||
return proxyRequest(request, path, 'PUT')
|
return proxyRequest(request, path, 'PUT')
|
||||||
@@ -115,7 +114,7 @@ export async function PUT(
|
|||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
) {
|
) {
|
||||||
const { path } = await params
|
const { path } = await params
|
||||||
return proxyRequest(request, path, 'PATCH')
|
return proxyRequest(request, path, 'PATCH')
|
||||||
@@ -123,7 +122,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
) {
|
) {
|
||||||
const { path } = await params
|
const { path } = await params
|
||||||
return proxyRequest(request, path, 'DELETE')
|
return proxyRequest(request, path, 'DELETE')
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Process Tasks API Proxy - Catch-all route
|
||||||
|
* Proxies all /api/sdk/v1/compliance/process-tasks/* requests to backend-compliance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const basePath = `${BACKEND_URL}/api/compliance/process-tasks`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||||
|
'X-User-Id': 'admin',
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
if (authHeader) {
|
||||||
|
headers['Authorization'] = authHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantHeader = request.headers.get('x-tenant-id')
|
||||||
|
if (tenantHeader) {
|
||||||
|
headers['X-Tenant-Id'] = tenantHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIdHeader = request.headers.get('x-user-id')
|
||||||
|
if (userIdHeader) {
|
||||||
|
headers['X-User-Id'] = userIdHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||||
|
const contentType = request.headers.get('content-type')
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
const text = await request.text()
|
||||||
|
if (text && text.trim()) {
|
||||||
|
fetchOptions.body = text
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Empty or invalid body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Process Tasks API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PATCH')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'DELETE')
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Demo Data Clear API Endpoint
|
|
||||||
*
|
|
||||||
* Clears demo data from the storage (same mechanism as real customer data).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
// Shared store reference (same as seed endpoint)
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var demoStateStore: Map<string, { state: unknown; version: number; updatedAt: Date }> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!global.demoStateStore) {
|
|
||||||
global.demoStateStore = new Map()
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateStore = global.demoStateStore
|
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { tenantId = 'demo-tenant' } = body
|
|
||||||
|
|
||||||
const existed = stateStore.has(tenantId)
|
|
||||||
stateStore.delete(tenantId)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: existed
|
|
||||||
? `Demo data cleared for tenant ${tenantId}`
|
|
||||||
: `No data found for tenant ${tenantId}`,
|
|
||||||
tenantId,
|
|
||||||
existed,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear demo data:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
// Also support POST for clearing (for clients that don't support DELETE)
|
|
||||||
return DELETE(request)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Demo Data Seed API Endpoint
|
|
||||||
*
|
|
||||||
* This endpoint seeds demo data via the same storage mechanism as real customer data.
|
|
||||||
* Demo data is NOT hardcoded - it goes through the normal API/database path.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { generateDemoState } from '@/lib/sdk/demo-data'
|
|
||||||
|
|
||||||
// In-memory store (same as state endpoint - will be replaced with PostgreSQL)
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var demoStateStore: Map<string, { state: unknown; version: number; updatedAt: Date }> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!global.demoStateStore) {
|
|
||||||
global.demoStateStore = new Map()
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateStore = global.demoStateStore
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { tenantId = 'demo-tenant', userId = 'demo-user' } = body
|
|
||||||
|
|
||||||
// Generate demo state using the seed data templates
|
|
||||||
const demoState = generateDemoState(tenantId, userId)
|
|
||||||
|
|
||||||
// Store via the same mechanism as real data
|
|
||||||
const storedState = {
|
|
||||||
state: demoState,
|
|
||||||
version: 1,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
stateStore.set(tenantId, storedState)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Demo data seeded for tenant ${tenantId}`,
|
|
||||||
tenantId,
|
|
||||||
version: 1,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to seed demo data:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const tenantId = searchParams.get('tenantId') || 'demo-tenant'
|
|
||||||
|
|
||||||
const stored = stateStore.get(tenantId)
|
|
||||||
|
|
||||||
if (!stored) {
|
|
||||||
return NextResponse.json({
|
|
||||||
hasData: false,
|
|
||||||
tenantId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
hasData: true,
|
|
||||||
tenantId,
|
|
||||||
version: stored.version,
|
|
||||||
updatedAt: stored.updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -22,6 +22,8 @@ async function proxyRequest(
|
|||||||
try {
|
try {
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||||
|
'X-User-Id': 'admin',
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeader = request.headers.get('authorization')
|
const authHeader = request.headers.get('authorization')
|
||||||
@@ -34,6 +36,11 @@ async function proxyRequest(
|
|||||||
headers['X-Tenant-Id'] = tenantHeader
|
headers['X-Tenant-Id'] = tenantHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userIdHeader = request.headers.get('x-user-id')
|
||||||
|
if (userIdHeader) {
|
||||||
|
headers['X-User-Id'] = userIdHeader
|
||||||
|
}
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: DELETE /api/sdk/v1/import/:id → Backend DELETE /api/v1/import/:id
|
* Proxy: DELETE /api/sdk/v1/import/:id → Backend DELETE /api/v1/import/:id
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: POST /api/sdk/v1/import/analyze → Backend POST /api/v1/import/analyze
|
* Proxy: POST /api/sdk/v1/import/analyze → Backend POST /api/v1/import/analyze
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: GET /api/sdk/v1/import → Backend GET /api/v1/import
|
* Proxy: GET /api/sdk/v1/import → Backend GET /api/v1/import
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Incidents/Breach Management API Proxy - Catch-all route
|
* Incidents/Breach Management API Proxy - Catch-all route
|
||||||
* Proxies all /api/sdk/v1/incidents/* requests to ai-compliance-sdk backend
|
* Proxies all /api/sdk/v1/incidents/* requests to backend-compliance (Python)
|
||||||
|
* Python backend is Source of Truth (migrated from Go ai-compliance-sdk)
|
||||||
* Supports PDF generation for authority notification forms
|
* Supports PDF generation for authority notification forms
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const DEFAULT_USER_ID = 'admin'
|
||||||
|
|
||||||
async function proxyRequest(
|
async function proxyRequest(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -15,7 +18,7 @@ async function proxyRequest(
|
|||||||
) {
|
) {
|
||||||
const pathStr = pathSegments?.join('/') || ''
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
const searchParams = request.nextUrl.searchParams.toString()
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/incidents`
|
const basePath = `${BACKEND_URL}/api/compliance/incidents`
|
||||||
const url = pathStr
|
const url = pathStr
|
||||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
@@ -30,10 +33,8 @@ async function proxyRequest(
|
|||||||
headers['Authorization'] = authHeader
|
headers['Authorization'] = authHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantHeader = request.headers.get('x-tenant-id')
|
headers['X-Tenant-Id'] = request.headers.get('x-tenant-id') || DEFAULT_TENANT_ID
|
||||||
if (tenantHeader) {
|
headers['X-User-Id'] = request.headers.get('x-user-id') || DEFAULT_USER_ID
|
||||||
headers['X-Tenant-Id'] = tenantHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
|
|||||||
111
admin-compliance/app/api/sdk/v1/isms/[[...path]]/route.ts
Normal file
111
admin-compliance/app/api/sdk/v1/isms/[[...path]]/route.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* ISMS (ISO 27001) API Proxy - Catch-all route
|
||||||
|
* Proxies all /api/sdk/v1/isms/* requests to backend-compliance /api/isms/*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const basePath = `${BACKEND_URL}/api/compliance/isms`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
|
for (const name of headerNames) {
|
||||||
|
const value = request.headers.get(name)
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ISMS API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'DELETE')
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -9,7 +9,7 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const { moduleId } = await params
|
const { moduleId } = await params
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/activate`,
|
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}/activate`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -9,7 +9,7 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const { moduleId } = await params
|
const { moduleId } = await params
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/deactivate`,
|
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}/deactivate`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: GET /api/sdk/v1/modules/:moduleId → Backend GET /api/modules/:moduleId
|
* Proxy: GET /api/sdk/v1/modules/:moduleId → Backend GET /api/modules/:moduleId
|
||||||
@@ -13,7 +13,7 @@ export async function GET(
|
|||||||
const { moduleId } = await params
|
const { moduleId } = await params
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}`,
|
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy to backend-compliance /api/modules endpoint.
|
* Proxy to backend-compliance /api/modules endpoint.
|
||||||
@@ -23,7 +23,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (aiComponents) params.set('ai_components', aiComponents)
|
if (aiComponents) params.set('ai_components', aiComponents)
|
||||||
|
|
||||||
const queryString = params.toString()
|
const queryString = params.toString()
|
||||||
const url = `${BACKEND_URL}/api/modules${queryString ? `?${queryString}` : ''}`
|
const url = `${BACKEND_URL}/api/compliance/modules${queryString ? `?${queryString}` : ''}`
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
const response = await fetch(`${BACKEND_URL}/api/modules`, {
|
const response = await fetch(`${BACKEND_URL}/api/compliance/modules`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
119
admin-compliance/app/api/sdk/v1/portfolio/[[...path]]/route.ts
Normal file
119
admin-compliance/app/api/sdk/v1/portfolio/[[...path]]/route.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Portfolio API Proxy - Catch-all route
|
||||||
|
* Proxies all /api/sdk/v1/portfolio/* requests to ai-compliance-sdk backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const basePath = `${SDK_BACKEND_URL}/sdk/v1/portfolios`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
|
for (const name of headerNames) {
|
||||||
|
const value = request.headers.get(name)
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Portfolio API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PATCH')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'DELETE')
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: DELETE /api/sdk/v1/projects/{projectId}/permanent → Backend (hard delete)
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params
|
||||||
|
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||||
|
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}/permanent?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to permanently delete project:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: POST /api/sdk/v1/projects/{projectId}/restore → Backend
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params
|
||||||
|
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||||
|
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}/restore?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore project:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
120
admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts
Normal file
120
admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: GET /api/sdk/v1/projects/{projectId} → Backend
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params
|
||||||
|
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||||
|
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||||
|
{
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get project:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: PATCH /api/sdk/v1/projects/{projectId} → Backend
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const tenantId = body.tenant_id || request.headers.get('X-Tenant-ID') || ''
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-ID': tenantId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update project:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: DELETE /api/sdk/v1/projects/{projectId} → Backend (soft delete)
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params
|
||||||
|
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||||
|
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to archive project:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
admin-compliance/app/api/sdk/v1/projects/route.ts
Normal file
75
admin-compliance/app/api/sdk/v1/projects/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: GET /api/sdk/v1/projects → Backend GET /api/compliance/v1/projects
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const tenantId = searchParams.get('tenant_id') || request.headers.get('X-Tenant-ID') || ''
|
||||||
|
const includeArchived = searchParams.get('include_archived') || 'false'
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/projects?tenant_id=${encodeURIComponent(tenantId)}&include_archived=${includeArchived}`,
|
||||||
|
{
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list projects:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: POST /api/sdk/v1/projects → Backend POST /api/compliance/v1/projects
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const tenantId = body.tenant_id || request.headers.get('X-Tenant-ID') || ''
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/v1/projects?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-ID': tenantId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json(), { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create project:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
admin-compliance/app/api/sdk/v1/rbac/[[...path]]/route.ts
Normal file
125
admin-compliance/app/api/sdk/v1/rbac/[[...path]]/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* RBAC Admin API Proxy - Catch-all route
|
||||||
|
* Proxies /api/sdk/v1/rbac/<resource>/... to ai-compliance-sdk /sdk/v1/<resource>/...
|
||||||
|
*
|
||||||
|
* Mapping: /rbac/tenants/... → /sdk/v1/tenants/...
|
||||||
|
* /rbac/namespaces/... → /sdk/v1/namespaces/...
|
||||||
|
* /rbac/roles/... → /sdk/v1/roles/...
|
||||||
|
* /rbac/user-roles/... → /sdk/v1/user-roles/...
|
||||||
|
* /rbac/permissions/... → /sdk/v1/permissions/...
|
||||||
|
* /rbac/llm/policies/... → /sdk/v1/llm/policies/...
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
// Path segments come as the full sub-path after /rbac/
|
||||||
|
// e.g. /rbac/tenants/123 → pathSegments = ['tenants', '123']
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const url = `${SDK_BACKEND_URL}/sdk/v1/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
|
for (const name of headerNames) {
|
||||||
|
const value = request.headers.get(name)
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('RBAC API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PATCH')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'DELETE')
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Roadmap Items API Proxy - Catch-all route
|
||||||
|
* Proxies /api/sdk/v1/roadmap-items/* to ai-compliance-sdk /sdk/v1/roadmap-items/*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const basePath = `${SDK_BACKEND_URL}/sdk/v1/roadmap-items`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
|
for (const name of headerNames) {
|
||||||
|
const value = request.headers.get(name)
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Roadmap Items API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PATCH')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'DELETE')
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Vendor Compliance API Proxy - Catch-all route
|
* Roadmap API Proxy - Catch-all route
|
||||||
* Proxies all /api/sdk/v1/vendors/* requests to ai-compliance-sdk backend
|
* Proxies all /api/sdk/v1/roadmap/* requests to ai-compliance-sdk backend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
@@ -14,7 +14,7 @@ async function proxyRequest(
|
|||||||
) {
|
) {
|
||||||
const pathStr = pathSegments?.join('/') || ''
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
const searchParams = request.nextUrl.searchParams.toString()
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/vendors`
|
const basePath = `${SDK_BACKEND_URL}/sdk/v1/roadmaps`
|
||||||
const url = pathStr
|
const url = pathStr
|
||||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
@@ -24,8 +24,7 @@ async function proxyRequest(
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward all relevant headers
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
|
|
||||||
for (const name of headerNames) {
|
for (const name of headerNames) {
|
||||||
const value = request.headers.get(name)
|
const value = request.headers.get(name)
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -33,42 +32,27 @@ async function proxyRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(60000),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
const contentType = request.headers.get('content-type')
|
const body = await request.text()
|
||||||
if (contentType?.includes('application/json')) {
|
if (body) {
|
||||||
try {
|
fetchOptions.body = body
|
||||||
const text = await request.text()
|
|
||||||
if (text && text.trim()) {
|
|
||||||
fetchOptions.body = text
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Empty or invalid body - continue without
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions)
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
// Handle non-JSON responses (e.g., PDF exports)
|
|
||||||
const responseContentType = response.headers.get('content-type')
|
|
||||||
if (responseContentType?.includes('application/pdf') ||
|
|
||||||
responseContentType?.includes('application/octet-stream')) {
|
|
||||||
const blob = await response.blob()
|
|
||||||
return new NextResponse(blob, {
|
|
||||||
status: response.status,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': responseContentType,
|
|
||||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
let errorJson
|
let errorJson
|
||||||
@@ -83,10 +67,22 @@ async function proxyRequest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
if (contentType.includes('application/octet-stream') || contentType.includes('text/csv')) {
|
||||||
|
const blob = await response.blob()
|
||||||
|
return new NextResponse(blob, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return NextResponse.json(data)
|
return NextResponse.json(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Vendor Compliance API proxy error:', error)
|
console.error('Roadmap API proxy error:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: GET /api/sdk/v1/screening → Backend GET /api/v1/screening
|
* Proxy: GET /api/sdk/v1/screening → Backend GET /api/v1/screening
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy: POST /api/sdk/v1/screening/scan → Backend POST /api/v1/screening/scan
|
* Proxy: POST /api/sdk/v1/screening/scan → Backend POST /api/v1/screening/scan
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,17 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { Pool } from 'pg'
|
import { Pool } from 'pg'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SDK State Management API
|
* SDK State Management API (Multi-Project)
|
||||||
*
|
*
|
||||||
* GET /api/sdk/v1/state?tenantId=xxx - Load state for a tenant
|
* GET /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Load state for a tenant+project
|
||||||
* POST /api/sdk/v1/state - Save state for a tenant
|
* POST /api/sdk/v1/state - Save state for a tenant+project
|
||||||
* DELETE /api/sdk/v1/state?tenantId=xxx - Clear state for a tenant
|
* DELETE /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Clear state
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Versioning for optimistic locking
|
* - Versioning for optimistic locking
|
||||||
* - Last-Modified headers
|
* - Last-Modified headers
|
||||||
* - ETag support for caching
|
* - ETag support for caching
|
||||||
* - PostgreSQL persistence (with InMemory fallback)
|
* - PostgreSQL persistence (with InMemory fallback)
|
||||||
|
* - projectId support for multi-project architecture
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -32,25 +33,31 @@ interface StoredState {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface StateStore {
|
interface StateStore {
|
||||||
get(tenantId: string): Promise<StoredState | null>
|
get(tenantId: string, projectId?: string): Promise<StoredState | null>
|
||||||
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState>
|
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise<StoredState>
|
||||||
delete(tenantId: string): Promise<boolean>
|
delete(tenantId: string, projectId?: string): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
class InMemoryStateStore implements StateStore {
|
class InMemoryStateStore implements StateStore {
|
||||||
private store: Map<string, StoredState> = new Map()
|
private store: Map<string, StoredState> = new Map()
|
||||||
|
|
||||||
async get(tenantId: string): Promise<StoredState | null> {
|
private key(tenantId: string, projectId?: string): string {
|
||||||
return this.store.get(tenantId) || null
|
return projectId ? `${tenantId}:${projectId}` : tenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(tenantId: string, projectId?: string): Promise<StoredState | null> {
|
||||||
|
return this.store.get(this.key(tenantId, projectId)) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(
|
async save(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
state: unknown,
|
state: unknown,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
expectedVersion?: number
|
expectedVersion?: number,
|
||||||
|
projectId?: string
|
||||||
): Promise<StoredState> {
|
): Promise<StoredState> {
|
||||||
const existing = this.store.get(tenantId)
|
const k = this.key(tenantId, projectId)
|
||||||
|
const existing = this.store.get(k)
|
||||||
|
|
||||||
if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) {
|
if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) {
|
||||||
const error = new Error('Version conflict') as Error & { status: number }
|
const error = new Error('Version conflict') as Error & { status: number }
|
||||||
@@ -72,12 +79,12 @@ class InMemoryStateStore implements StateStore {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.set(tenantId, stored)
|
this.store.set(k, stored)
|
||||||
return stored
|
return stored
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(tenantId: string): Promise<boolean> {
|
async delete(tenantId: string, projectId?: string): Promise<boolean> {
|
||||||
return this.store.delete(tenantId)
|
return this.store.delete(this.key(tenantId, projectId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +100,26 @@ class PostgreSQLStateStore implements StateStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(tenantId: string): Promise<StoredState | null> {
|
async get(tenantId: string, projectId?: string): Promise<StoredState | null> {
|
||||||
const result = await this.pool.query(
|
let result
|
||||||
'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1',
|
if (projectId) {
|
||||||
[tenantId]
|
result = await this.pool.query(
|
||||||
)
|
'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1 AND project_id = $2',
|
||||||
|
[tenantId, projectId]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Backwards compatibility: find the single active project for this tenant
|
||||||
|
result = await this.pool.query(
|
||||||
|
`SELECT s.state, s.version, s.user_id, s.created_at, s.updated_at
|
||||||
|
FROM sdk_states s
|
||||||
|
LEFT JOIN compliance_projects p ON s.project_id = p.id
|
||||||
|
WHERE s.tenant_id = $1
|
||||||
|
AND (p.status = 'active' OR p.id IS NULL)
|
||||||
|
ORDER BY s.updated_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
)
|
||||||
|
}
|
||||||
if (result.rows.length === 0) return null
|
if (result.rows.length === 0) return null
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
return {
|
return {
|
||||||
@@ -109,25 +131,71 @@ class PostgreSQLStateStore implements StateStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState> {
|
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise<StoredState> {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const stateWithTimestamp = {
|
const stateWithTimestamp = {
|
||||||
...(state as object),
|
...(state as object),
|
||||||
lastModified: now,
|
lastModified: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use UPSERT with version check
|
let result
|
||||||
const result = await this.pool.query(`
|
|
||||||
INSERT INTO sdk_states (tenant_id, user_id, state, version, created_at, updated_at)
|
if (projectId) {
|
||||||
VALUES ($1, $2, $3::jsonb, 1, NOW(), NOW())
|
// Multi-project: UPSERT on (tenant_id, project_id)
|
||||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
result = await this.pool.query(`
|
||||||
state = $3::jsonb,
|
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||||
user_id = COALESCE($2, sdk_states.user_id),
|
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||||
version = sdk_states.version + 1,
|
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
|
||||||
updated_at = NOW()
|
state = $3::jsonb,
|
||||||
WHERE ($4::int IS NULL OR sdk_states.version = $4)
|
user_id = COALESCE($2, sdk_states.user_id),
|
||||||
RETURNING version, user_id, created_at, updated_at
|
version = sdk_states.version + 1,
|
||||||
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null])
|
updated_at = NOW()
|
||||||
|
WHERE ($4::int IS NULL OR sdk_states.version = $4)
|
||||||
|
RETURNING version, user_id, created_at, updated_at
|
||||||
|
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, projectId])
|
||||||
|
} else {
|
||||||
|
// Backwards compatibility: find the single project for this tenant
|
||||||
|
// First try to find an existing project
|
||||||
|
const projectResult = await this.pool.query(
|
||||||
|
`SELECT id FROM compliance_projects WHERE tenant_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (projectResult.rows.length > 0) {
|
||||||
|
const foundProjectId = projectResult.rows[0].id
|
||||||
|
result = await this.pool.query(`
|
||||||
|
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||||
|
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||||
|
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
|
||||||
|
state = $3::jsonb,
|
||||||
|
user_id = COALESCE($2, sdk_states.user_id),
|
||||||
|
version = sdk_states.version + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE ($4::int IS NULL OR sdk_states.version = $4)
|
||||||
|
RETURNING version, user_id, created_at, updated_at
|
||||||
|
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, foundProjectId])
|
||||||
|
} else {
|
||||||
|
// No project exists — create a default one
|
||||||
|
const newProject = await this.pool.query(
|
||||||
|
`INSERT INTO compliance_projects (tenant_id, name, customer_type, status)
|
||||||
|
VALUES ($1, 'Projekt 1', 'new', 'active')
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId]
|
||||||
|
)
|
||||||
|
const newProjectId = newProject.rows[0].id
|
||||||
|
result = await this.pool.query(`
|
||||||
|
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||||
|
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||||
|
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
|
||||||
|
state = $3::jsonb,
|
||||||
|
user_id = COALESCE($2, sdk_states.user_id),
|
||||||
|
version = sdk_states.version + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE ($4::int IS NULL OR sdk_states.version = $4)
|
||||||
|
RETURNING version, user_id, created_at, updated_at
|
||||||
|
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, newProjectId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
const error = new Error('Version conflict') as Error & { status: number }
|
const error = new Error('Version conflict') as Error & { status: number }
|
||||||
@@ -145,11 +213,19 @@ class PostgreSQLStateStore implements StateStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(tenantId: string): Promise<boolean> {
|
async delete(tenantId: string, projectId?: string): Promise<boolean> {
|
||||||
const result = await this.pool.query(
|
let result
|
||||||
'DELETE FROM sdk_states WHERE tenant_id = $1',
|
if (projectId) {
|
||||||
[tenantId]
|
result = await this.pool.query(
|
||||||
)
|
'DELETE FROM sdk_states WHERE tenant_id = $1 AND project_id = $2',
|
||||||
|
[tenantId, projectId]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result = await this.pool.query(
|
||||||
|
'DELETE FROM sdk_states WHERE tenant_id = $1',
|
||||||
|
[tenantId]
|
||||||
|
)
|
||||||
|
}
|
||||||
return (result.rowCount ?? 0) > 0
|
return (result.rowCount ?? 0) > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +262,7 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const tenantId = searchParams.get('tenantId')
|
const tenantId = searchParams.get('tenantId')
|
||||||
|
const projectId = searchParams.get('projectId') || undefined
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -194,7 +271,7 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored = await stateStore.get(tenantId)
|
const stored = await stateStore.get(tenantId, projectId)
|
||||||
|
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -216,6 +293,7 @@ export async function GET(request: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
|
projectId: projectId || null,
|
||||||
state: stored.state,
|
state: stored.state,
|
||||||
version: stored.version,
|
version: stored.version,
|
||||||
lastModified: stored.updatedAt,
|
lastModified: stored.updatedAt,
|
||||||
@@ -241,7 +319,7 @@ export async function GET(request: NextRequest) {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { tenantId, state, version } = body
|
const { tenantId, state, version, projectId } = body
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -261,7 +339,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const ifMatch = request.headers.get('If-Match')
|
const ifMatch = request.headers.get('If-Match')
|
||||||
const expectedVersion = ifMatch ? parseInt(ifMatch, 10) : version
|
const expectedVersion = ifMatch ? parseInt(ifMatch, 10) : version
|
||||||
|
|
||||||
const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion)
|
const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion, projectId || undefined)
|
||||||
|
|
||||||
const etag = generateETag(stored.version, stored.updatedAt)
|
const etag = generateETag(stored.version, stored.updatedAt)
|
||||||
|
|
||||||
@@ -270,6 +348,7 @@ export async function POST(request: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
|
projectId: projectId || null,
|
||||||
state: stored.state,
|
state: stored.state,
|
||||||
version: stored.version,
|
version: stored.version,
|
||||||
lastModified: stored.updatedAt,
|
lastModified: stored.updatedAt,
|
||||||
@@ -309,6 +388,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const tenantId = searchParams.get('tenantId')
|
const tenantId = searchParams.get('tenantId')
|
||||||
|
const projectId = searchParams.get('projectId') || undefined
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -317,7 +397,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleted = await stateStore.delete(tenantId)
|
const deleted = await stateStore.delete(tenantId, projectId)
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -53,7 +53,18 @@ async function proxyRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions)
|
const response = await fetch(url, {
|
||||||
|
...fetchOptions,
|
||||||
|
redirect: 'manual',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle redirects (e.g. media stream presigned URL)
|
||||||
|
if (response.status === 307 || response.status === 302) {
|
||||||
|
const location = response.headers.get('location')
|
||||||
|
if (location) {
|
||||||
|
return NextResponse.redirect(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
@@ -69,6 +80,19 @@ async function proxyRequest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle binary responses (PDF, octet-stream)
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) {
|
||||||
|
const buffer = await response.arrayBuffer()
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return NextResponse.json(data)
|
return NextResponse.json(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
|
|
||||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||||
|
|
||||||
|
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const DEFAULT_USER_ID = 'admin'
|
||||||
|
|
||||||
async function proxyRequest(request: NextRequest, method: string) {
|
async function proxyRequest(request: NextRequest, method: string) {
|
||||||
try {
|
try {
|
||||||
const pathSegments = request.nextUrl.pathname.replace('/api/sdk/v1/ucca/obligations/', '')
|
const pathSegments = request.nextUrl.pathname.replace('/api/sdk/v1/ucca/obligations/', '')
|
||||||
const targetUrl = `${SDK_BASE_URL}/sdk/v1/ucca/obligations/${pathSegments}${request.nextUrl.search}`
|
const targetUrl = `${SDK_BASE_URL}/sdk/v1/ucca/obligations/${pathSegments}${request.nextUrl.search}`
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const headers: Record<string, string> = {
|
||||||
const tenantId = request.headers.get('X-Tenant-ID')
|
'Content-Type': 'application/json',
|
||||||
if (tenantId) headers['X-Tenant-ID'] = tenantId
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
|
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||||
|
}
|
||||||
|
|
||||||
const fetchOptions: RequestInit = { method, headers }
|
const fetchOptions: RequestInit = { method, headers }
|
||||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
|
|
||||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||||
|
|
||||||
|
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const DEFAULT_USER_ID = 'admin'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
// Forward the request to the SDK backend
|
|
||||||
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/assess`, {
|
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/assess`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
// Forward tenant ID if present
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
...(request.headers.get('X-Tenant-ID') && {
|
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
|
|
||||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||||
|
|
||||||
|
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const DEFAULT_USER_ID = 'admin'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
// Forward the request to the SDK backend
|
|
||||||
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/export/direct`, {
|
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/export/direct`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
// Forward tenant ID if present
|
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||||
...(request.headers.get('X-Tenant-ID') && {
|
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Vendor Compliance API Proxy - Catch-all route
|
||||||
|
* Proxies all /api/sdk/v1/vendor-compliance/* requests to backend-compliance
|
||||||
|
*
|
||||||
|
* Backend routes: vendors, contracts, findings, control-instances, controls, export
|
||||||
|
* All under /api/compliance/vendor-compliance/ prefix on backend-compliance:8002
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const basePath = `${BACKEND_URL}/api/compliance/vendor-compliance`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
|
for (const name of headerNames) {
|
||||||
|
const value = request.headers.get(name)
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Vendor Compliance API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PATCH')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'DELETE')
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { ContractDocument } from '@/lib/sdk/vendor-compliance'
|
|
||||||
|
|
||||||
// In-memory storage for demo purposes
|
|
||||||
const contracts: Map<string, ContractDocument> = new Map()
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const contractList = Array.from(contracts.values())
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: contractList,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching contracts:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to fetch contracts' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Handle multipart form data for file upload
|
|
||||||
const formData = await request.formData()
|
|
||||||
const file = formData.get('file') as File | null
|
|
||||||
const vendorId = formData.get('vendorId') as string
|
|
||||||
const metadataStr = formData.get('metadata') as string
|
|
||||||
|
|
||||||
if (!file || !vendorId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'File and vendorId are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = metadataStr ? JSON.parse(metadataStr) : {}
|
|
||||||
const id = uuidv4()
|
|
||||||
|
|
||||||
// In production, upload file to storage (MinIO, S3, etc.)
|
|
||||||
const storagePath = `contracts/${id}/${file.name}`
|
|
||||||
|
|
||||||
const contract: ContractDocument = {
|
|
||||||
id,
|
|
||||||
tenantId: 'default',
|
|
||||||
vendorId,
|
|
||||||
fileName: `${id}-${file.name}`,
|
|
||||||
originalName: file.name,
|
|
||||||
mimeType: file.type,
|
|
||||||
fileSize: file.size,
|
|
||||||
storagePath,
|
|
||||||
documentType: metadata.documentType || 'OTHER',
|
|
||||||
version: metadata.version || '1.0',
|
|
||||||
previousVersionId: metadata.previousVersionId,
|
|
||||||
parties: metadata.parties,
|
|
||||||
effectiveDate: metadata.effectiveDate ? new Date(metadata.effectiveDate) : undefined,
|
|
||||||
expirationDate: metadata.expirationDate ? new Date(metadata.expirationDate) : undefined,
|
|
||||||
autoRenewal: metadata.autoRenewal,
|
|
||||||
renewalNoticePeriod: metadata.renewalNoticePeriod,
|
|
||||||
terminationNoticePeriod: metadata.terminationNoticePeriod,
|
|
||||||
reviewStatus: 'PENDING',
|
|
||||||
status: 'DRAFT',
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
contracts.set(id, contract)
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
data: contract,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ status: 201 }
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading contract:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to upload contract' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { CONTROLS_LIBRARY } from '@/lib/sdk/vendor-compliance'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const domain = searchParams.get('domain')
|
|
||||||
|
|
||||||
let controls = [...CONTROLS_LIBRARY]
|
|
||||||
|
|
||||||
// Filter by domain if provided
|
|
||||||
if (domain) {
|
|
||||||
controls = controls.filter((c) => c.domain === domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: controls,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching controls:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to fetch controls' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/sdk/v1/vendor-compliance/export/[reportId]/download
|
|
||||||
*
|
|
||||||
* Download a generated report file.
|
|
||||||
* In production, this would redirect to a signed MinIO/S3 URL or stream the file.
|
|
||||||
*/
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ reportId: string }> }
|
|
||||||
) {
|
|
||||||
const { reportId } = await params
|
|
||||||
|
|
||||||
// TODO: Implement actual file download
|
|
||||||
// This would typically:
|
|
||||||
// 1. Verify report exists and user has access
|
|
||||||
// 2. Generate signed URL for MinIO/S3
|
|
||||||
// 3. Redirect to signed URL or stream file
|
|
||||||
|
|
||||||
// For now, return a placeholder PDF
|
|
||||||
const placeholderContent = `
|
|
||||||
%PDF-1.4
|
|
||||||
1 0 obj
|
|
||||||
<< /Type /Catalog /Pages 2 0 R >>
|
|
||||||
endobj
|
|
||||||
2 0 obj
|
|
||||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
|
||||||
endobj
|
|
||||||
3 0 obj
|
|
||||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
|
||||||
endobj
|
|
||||||
4 0 obj
|
|
||||||
<< /Length 200 >>
|
|
||||||
stream
|
|
||||||
BT
|
|
||||||
/F1 24 Tf
|
|
||||||
100 700 Td
|
|
||||||
(Vendor Compliance Report) Tj
|
|
||||||
/F1 12 Tf
|
|
||||||
100 650 Td
|
|
||||||
(Report ID: ${reportId}) Tj
|
|
||||||
100 620 Td
|
|
||||||
(Generated: ${new Date().toISOString()}) Tj
|
|
||||||
100 580 Td
|
|
||||||
(This is a placeholder. Implement actual report generation.) Tj
|
|
||||||
ET
|
|
||||||
endstream
|
|
||||||
endobj
|
|
||||||
5 0 obj
|
|
||||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
|
||||||
endobj
|
|
||||||
xref
|
|
||||||
0 6
|
|
||||||
0000000000 65535 f
|
|
||||||
0000000009 00000 n
|
|
||||||
0000000058 00000 n
|
|
||||||
0000000115 00000 n
|
|
||||||
0000000266 00000 n
|
|
||||||
0000000519 00000 n
|
|
||||||
trailer
|
|
||||||
<< /Size 6 /Root 1 0 R >>
|
|
||||||
startxref
|
|
||||||
598
|
|
||||||
%%EOF
|
|
||||||
`.trim()
|
|
||||||
|
|
||||||
// Return as PDF
|
|
||||||
return new NextResponse(placeholderContent, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/pdf',
|
|
||||||
'Content-Disposition': `attachment; filename="Report_${reportId.slice(0, 8)}.pdf"`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/sdk/v1/vendor-compliance/export/[reportId]
|
|
||||||
*
|
|
||||||
* Get report metadata by ID.
|
|
||||||
*/
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ reportId: string }> }
|
|
||||||
) {
|
|
||||||
const { reportId } = await params
|
|
||||||
|
|
||||||
// TODO: Fetch report metadata from database
|
|
||||||
// For now, return mock data
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id: reportId,
|
|
||||||
status: 'completed',
|
|
||||||
filename: `Report_${reportId.slice(0, 8)}.pdf`,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24h
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/sdk/v1/vendor-compliance/export/[reportId]
|
|
||||||
*
|
|
||||||
* Delete a generated report.
|
|
||||||
*/
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ reportId: string }> }
|
|
||||||
) {
|
|
||||||
const { reportId } = await params
|
|
||||||
|
|
||||||
// TODO: Delete report from storage and database
|
|
||||||
console.log('Deleting report:', reportId)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
deletedId: reportId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/sdk/v1/vendor-compliance/export
|
|
||||||
*
|
|
||||||
* Generate and export reports in various formats.
|
|
||||||
* Currently returns mock data - integrate with actual report generation service.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface ExportConfig {
|
|
||||||
reportType: 'VVT_EXPORT' | 'VENDOR_AUDIT' | 'ROPA' | 'MANAGEMENT_SUMMARY' | 'DPIA_INPUT'
|
|
||||||
format: 'PDF' | 'DOCX' | 'XLSX' | 'JSON'
|
|
||||||
scope: {
|
|
||||||
vendorIds: string[]
|
|
||||||
processingActivityIds: string[]
|
|
||||||
includeFindings: boolean
|
|
||||||
includeControls: boolean
|
|
||||||
includeRiskAssessment: boolean
|
|
||||||
dateRange?: {
|
|
||||||
from: string
|
|
||||||
to: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const REPORT_TYPE_NAMES: Record<ExportConfig['reportType'], string> = {
|
|
||||||
VVT_EXPORT: 'Verarbeitungsverzeichnis',
|
|
||||||
VENDOR_AUDIT: 'Vendor-Audit-Pack',
|
|
||||||
ROPA: 'RoPA',
|
|
||||||
MANAGEMENT_SUMMARY: 'Management-Summary',
|
|
||||||
DPIA_INPUT: 'DSFA-Input',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FORMAT_EXTENSIONS: Record<ExportConfig['format'], string> = {
|
|
||||||
PDF: 'pdf',
|
|
||||||
DOCX: 'docx',
|
|
||||||
XLSX: 'xlsx',
|
|
||||||
JSON: 'json',
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const config = (await request.json()) as ExportConfig
|
|
||||||
|
|
||||||
// Validate request
|
|
||||||
if (!config.reportType || !config.format) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'reportType and format are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate report ID and filename
|
|
||||||
const reportId = uuidv4()
|
|
||||||
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
|
||||||
const filename = `${REPORT_TYPE_NAMES[config.reportType]}_${timestamp}.${FORMAT_EXTENSIONS[config.format]}`
|
|
||||||
|
|
||||||
// TODO: Implement actual report generation
|
|
||||||
// This would typically:
|
|
||||||
// 1. Fetch data from database based on scope
|
|
||||||
// 2. Generate report using template engine (e.g., docx-templates, pdfkit)
|
|
||||||
// 3. Store in MinIO/S3
|
|
||||||
// 4. Return download URL
|
|
||||||
|
|
||||||
// Mock implementation - simulate processing time
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
// In production, this would be a signed URL to MinIO/S3
|
|
||||||
const downloadUrl = `/api/sdk/v1/vendor-compliance/export/${reportId}/download`
|
|
||||||
|
|
||||||
// Log export for audit trail
|
|
||||||
console.log('Export generated:', {
|
|
||||||
reportId,
|
|
||||||
reportType: config.reportType,
|
|
||||||
format: config.format,
|
|
||||||
scope: config.scope,
|
|
||||||
filename,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id: reportId,
|
|
||||||
reportType: config.reportType,
|
|
||||||
format: config.format,
|
|
||||||
filename,
|
|
||||||
downloadUrl,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
scope: {
|
|
||||||
vendorCount: config.scope.vendorIds?.length || 0,
|
|
||||||
activityCount: config.scope.processingActivityIds?.length || 0,
|
|
||||||
includesFindings: config.scope.includeFindings,
|
|
||||||
includesControls: config.scope.includeControls,
|
|
||||||
includesRiskAssessment: config.scope.includeRiskAssessment,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Export error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to generate export' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/sdk/v1/vendor-compliance/export
|
|
||||||
*
|
|
||||||
* List recent exports for the current tenant.
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
// TODO: Implement fetching recent exports from database
|
|
||||||
// For now, return empty list
|
|
||||||
return NextResponse.json({
|
|
||||||
exports: [],
|
|
||||||
totalCount: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { Finding } from '@/lib/sdk/vendor-compliance'
|
|
||||||
|
|
||||||
// In-memory storage for demo purposes
|
|
||||||
const findings: Map<string, Finding> = new Map()
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const vendorId = searchParams.get('vendorId')
|
|
||||||
const contractId = searchParams.get('contractId')
|
|
||||||
const status = searchParams.get('status')
|
|
||||||
|
|
||||||
let findingsList = Array.from(findings.values())
|
|
||||||
|
|
||||||
// Filter by vendor
|
|
||||||
if (vendorId) {
|
|
||||||
findingsList = findingsList.filter((f) => f.vendorId === vendorId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by contract
|
|
||||||
if (contractId) {
|
|
||||||
findingsList = findingsList.filter((f) => f.contractId === contractId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status
|
|
||||||
if (status) {
|
|
||||||
findingsList = findingsList.filter((f) => f.status === status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: findingsList,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching findings:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to fetch findings' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
// This would reference the same storage as the main route
|
|
||||||
// In production, this would be database calls
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
// In production, fetch from database
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: null, // Would return the activity
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching processing activity:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to fetch processing activity' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params
|
|
||||||
const body = await request.json()
|
|
||||||
|
|
||||||
// In production, update in database
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: { id, ...body, updatedAt: new Date() },
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating processing activity:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to update processing activity' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
// In production, delete from database
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting processing activity:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to delete processing activity' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { ProcessingActivity, generateVVTId } from '@/lib/sdk/vendor-compliance'
|
|
||||||
|
|
||||||
// In-memory storage for demo purposes
|
|
||||||
// In production, this would be replaced with database calls
|
|
||||||
const processingActivities: Map<string, ProcessingActivity> = new Map()
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const activities = Array.from(processingActivities.values())
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: activities,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching processing activities:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to fetch processing activities' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
|
|
||||||
// Generate IDs
|
|
||||||
const id = uuidv4()
|
|
||||||
const existingIds = Array.from(processingActivities.values()).map((a) => a.vvtId)
|
|
||||||
const vvtId = body.vvtId || generateVVTId(existingIds)
|
|
||||||
|
|
||||||
const activity: ProcessingActivity = {
|
|
||||||
id,
|
|
||||||
tenantId: 'default', // Would come from auth context
|
|
||||||
vvtId,
|
|
||||||
name: body.name,
|
|
||||||
responsible: body.responsible,
|
|
||||||
dpoContact: body.dpoContact,
|
|
||||||
purposes: body.purposes || [],
|
|
||||||
dataSubjectCategories: body.dataSubjectCategories || [],
|
|
||||||
personalDataCategories: body.personalDataCategories || [],
|
|
||||||
recipientCategories: body.recipientCategories || [],
|
|
||||||
thirdCountryTransfers: body.thirdCountryTransfers || [],
|
|
||||||
retentionPeriod: body.retentionPeriod || { description: { de: '', en: '' } },
|
|
||||||
technicalMeasures: body.technicalMeasures || [],
|
|
||||||
legalBasis: body.legalBasis || [],
|
|
||||||
dataSources: body.dataSources || [],
|
|
||||||
systems: body.systems || [],
|
|
||||||
dataFlows: body.dataFlows || [],
|
|
||||||
protectionLevel: body.protectionLevel || 'MEDIUM',
|
|
||||||
dpiaRequired: body.dpiaRequired || false,
|
|
||||||
dpiaJustification: body.dpiaJustification,
|
|
||||||
subProcessors: body.subProcessors || [],
|
|
||||||
legalRetentionBasis: body.legalRetentionBasis,
|
|
||||||
status: body.status || 'DRAFT',
|
|
||||||
owner: body.owner || '',
|
|
||||||
lastReviewDate: body.lastReviewDate,
|
|
||||||
nextReviewDate: body.nextReviewDate,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
processingActivities.set(id, activity)
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
data: activity,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ status: 201 }
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating processing activity:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to create processing activity' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { Vendor } from '@/lib/sdk/vendor-compliance'
|
|
||||||
|
|
||||||
// In-memory storage for demo purposes
|
|
||||||
const vendors: Map<string, Vendor> = new Map()
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const vendorList = Array.from(vendors.values())
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: vendorList,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching vendors:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to fetch vendors' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const id = uuidv4()
|
|
||||||
|
|
||||||
const vendor: Vendor = {
|
|
||||||
id,
|
|
||||||
tenantId: 'default',
|
|
||||||
name: body.name,
|
|
||||||
legalForm: body.legalForm,
|
|
||||||
country: body.country,
|
|
||||||
address: body.address,
|
|
||||||
website: body.website,
|
|
||||||
role: body.role,
|
|
||||||
serviceDescription: body.serviceDescription,
|
|
||||||
serviceCategory: body.serviceCategory,
|
|
||||||
dataAccessLevel: body.dataAccessLevel || 'NONE',
|
|
||||||
processingLocations: body.processingLocations || [],
|
|
||||||
transferMechanisms: body.transferMechanisms || [],
|
|
||||||
certifications: body.certifications || [],
|
|
||||||
primaryContact: body.primaryContact,
|
|
||||||
dpoContact: body.dpoContact,
|
|
||||||
securityContact: body.securityContact,
|
|
||||||
contractTypes: body.contractTypes || [],
|
|
||||||
contracts: body.contracts || [],
|
|
||||||
inherentRiskScore: body.inherentRiskScore || 50,
|
|
||||||
residualRiskScore: body.residualRiskScore || 50,
|
|
||||||
manualRiskAdjustment: body.manualRiskAdjustment,
|
|
||||||
riskJustification: body.riskJustification,
|
|
||||||
reviewFrequency: body.reviewFrequency || 'ANNUAL',
|
|
||||||
lastReviewDate: body.lastReviewDate,
|
|
||||||
nextReviewDate: body.nextReviewDate,
|
|
||||||
status: body.status || 'ACTIVE',
|
|
||||||
processingActivityIds: body.processingActivityIds || [],
|
|
||||||
notes: body.notes,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
vendors.set(id, vendor)
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
data: vendor,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ status: 201 }
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating vendor:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to create vendor' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy: GET /api/sdk/v1/wiki?endpoint=...
|
||||||
|
*
|
||||||
|
* Routes to backend wiki endpoints:
|
||||||
|
* endpoint=categories → GET /api/compliance/v1/wiki/categories
|
||||||
|
* endpoint=articles → GET /api/compliance/v1/wiki/articles(?category_id=...)
|
||||||
|
* endpoint=search → GET /api/compliance/v1/wiki/search?q=...
|
||||||
|
* endpoint=article&id= → GET /api/compliance/v1/wiki/articles/{id}
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const endpoint = searchParams.get('endpoint') || 'categories'
|
||||||
|
|
||||||
|
let backendPath: string
|
||||||
|
|
||||||
|
switch (endpoint) {
|
||||||
|
case 'categories':
|
||||||
|
backendPath = '/api/compliance/v1/wiki/categories'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'articles': {
|
||||||
|
const categoryId = searchParams.get('category_id')
|
||||||
|
backendPath = '/api/compliance/v1/wiki/articles'
|
||||||
|
if (categoryId) {
|
||||||
|
backendPath += `?category_id=${encodeURIComponent(categoryId)}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'article': {
|
||||||
|
const articleId = searchParams.get('id')
|
||||||
|
if (!articleId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing article id' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/wiki/articles/${encodeURIComponent(articleId)}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'search': {
|
||||||
|
const query = searchParams.get('q')
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing search query' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/wiki/search?q=${encodeURIComponent(query)}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown endpoint: ${endpoint}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(null, { status: 404 })
|
||||||
|
}
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend error', details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Wiki proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
admin-compliance/app/api/sdk/v1/workshops/[[...path]]/route.ts
Normal file
119
admin-compliance/app/api/sdk/v1/workshops/[[...path]]/route.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Workshop API Proxy - Catch-all route
|
||||||
|
* Proxies all /api/sdk/v1/workshops/* requests to ai-compliance-sdk backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const basePath = `${SDK_BACKEND_URL}/sdk/v1/workshops`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
|
for (const name of headerNames) {
|
||||||
|
const value = request.headers.get(name)
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Workshop API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PATCH')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'DELETE')
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Course, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||||
|
|
||||||
|
interface CourseHeaderProps {
|
||||||
|
course: Course
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourseHeader({ course, onDelete }: CourseHeaderProps) {
|
||||||
|
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/sdk/academy"
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||||
|
{categoryInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Course, Lesson, Enrollment } from '@/lib/sdk/academy/types'
|
||||||
|
|
||||||
|
interface CourseStatsProps {
|
||||||
|
course: Course
|
||||||
|
sortedLessons: Lesson[]
|
||||||
|
enrollments: Enrollment[]
|
||||||
|
completedEnrollments: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourseStats({ course, sortedLessons, enrollments, completedEnrollments }: CourseStatsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-sm text-gray-500">Lektionen</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-sm text-gray-500">Dauer</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-sm text-gray-500">Teilnehmer</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-sm text-gray-500">Abgeschlossen</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
|
||||||
|
|
||||||
|
interface CourseTabsProps {
|
||||||
|
activeTab: TabId
|
||||||
|
onTabChange: (tab: TabId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<TabId, string> = {
|
||||||
|
overview: 'Uebersicht',
|
||||||
|
lessons: 'Lektionen',
|
||||||
|
enrollments: 'Einschreibungen',
|
||||||
|
videos: 'Videos',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourseTabs({ activeTab, onTabChange }: CourseTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex gap-1 -mb-px">
|
||||||
|
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => onTabChange(tab)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'border-purple-600 text-purple-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{TAB_LABELS[tab]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Enrollment, ENROLLMENT_STATUS_INFO, isEnrollmentOverdue, getDaysUntilDeadline } from '@/lib/sdk/academy/types'
|
||||||
|
|
||||||
|
interface EnrollmentsTabProps {
|
||||||
|
enrollments: Enrollment[]
|
||||||
|
overdueEnrollments: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnrollmentsTab({ enrollments, overdueEnrollments }: EnrollmentsTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{overdueEnrollments > 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
||||||
|
{overdueEnrollments} ueberfaellige Einschreibung(en)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{enrollments.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
|
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
enrollments.map(enrollment => {
|
||||||
|
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
||||||
|
const overdue = isEnrollmentOverdue(enrollment)
|
||||||
|
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
||||||
|
return (
|
||||||
|
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
|
||||||
|
{statusInfo?.label}
|
||||||
|
</span>
|
||||||
|
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-gray-900">{enrollment.userName}</div>
|
||||||
|
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
|
||||||
|
style={{ width: `${enrollment.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
239
admin-compliance/app/sdk/academy/[id]/_components/LessonsTab.tsx
Normal file
239
admin-compliance/app/sdk/academy/[id]/_components/LessonsTab.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Lesson, QuizQuestion } from '@/lib/sdk/academy/types'
|
||||||
|
|
||||||
|
interface LessonsTabProps {
|
||||||
|
sortedLessons: Lesson[]
|
||||||
|
selectedLesson: Lesson | null
|
||||||
|
onSelectLesson: (lesson: Lesson) => void
|
||||||
|
quizAnswers: Record<string, number>
|
||||||
|
onQuizAnswer: (answers: Record<string, number>) => void
|
||||||
|
quizResult: any
|
||||||
|
isSubmittingQuiz: boolean
|
||||||
|
onSubmitQuiz: () => void
|
||||||
|
onResetQuiz: () => void
|
||||||
|
isEditing: boolean
|
||||||
|
editTitle: string
|
||||||
|
editContent: string
|
||||||
|
onEditTitle: (v: string) => void
|
||||||
|
onEditContent: (v: string) => void
|
||||||
|
isSaving: boolean
|
||||||
|
saveMessage: { type: 'success' | 'error'; text: string } | null
|
||||||
|
onStartEdit: () => void
|
||||||
|
onCancelEdit: () => void
|
||||||
|
onSaveLesson: () => void
|
||||||
|
onApproveLesson: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LessonsTab({
|
||||||
|
sortedLessons,
|
||||||
|
selectedLesson,
|
||||||
|
onSelectLesson,
|
||||||
|
quizAnswers,
|
||||||
|
onQuizAnswer,
|
||||||
|
quizResult,
|
||||||
|
isSubmittingQuiz,
|
||||||
|
onSubmitQuiz,
|
||||||
|
onResetQuiz,
|
||||||
|
isEditing,
|
||||||
|
editTitle,
|
||||||
|
editContent,
|
||||||
|
onEditTitle,
|
||||||
|
onEditContent,
|
||||||
|
isSaving,
|
||||||
|
saveMessage,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveLesson,
|
||||||
|
onApproveLesson,
|
||||||
|
}: LessonsTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
{/* Lesson Navigation */}
|
||||||
|
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sortedLessons.map((lesson, i) => (
|
||||||
|
<button
|
||||||
|
key={lesson.id}
|
||||||
|
onClick={() => onSelectLesson(lesson)}
|
||||||
|
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
|
||||||
|
selectedLesson?.id === lesson.id
|
||||||
|
? 'bg-purple-50 text-purple-700 border border-purple-200'
|
||||||
|
: 'hover:bg-gray-50 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
|
||||||
|
<span className="truncate">{lesson.title}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lesson Content */}
|
||||||
|
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
{selectedLesson ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={e => onEditTitle(e.target.value)}
|
||||||
|
className="text-xl font-semibold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-3 py-1 text-xs rounded-full ${
|
||||||
|
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
|
||||||
|
</span>
|
||||||
|
{selectedLesson.type !== 'quiz' && !isEditing && (
|
||||||
|
<>
|
||||||
|
<button onClick={onStartEdit} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button onClick={onApproveLesson} disabled={isSaving} className="px-3 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
|
||||||
|
Freigeben
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<>
|
||||||
|
<button onClick={onCancelEdit} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button onClick={onSaveLesson} disabled={isSaving} className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50">
|
||||||
|
{isSaving ? 'Speichert...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveMessage && (
|
||||||
|
<div className={`p-3 rounded-lg text-sm ${
|
||||||
|
saveMessage.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
|
||||||
|
}`}>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
|
||||||
|
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
|
||||||
|
<video src={selectedLesson.videoUrl} controls className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-gray-500">Inhalt (Markdown)</label>
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={e => onEditContent(e.target.value)}
|
||||||
|
rows={20}
|
||||||
|
className="w-full border border-gray-300 rounded-xl p-4 text-sm font-mono text-gray-800 leading-relaxed resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Markdown-Inhalt der Lektion..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400">Unterstuetzt: # Ueberschrift, ## Unterueberschrift, - Aufzaehlung, **fett**</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
|
||||||
|
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
|
||||||
|
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
|
||||||
|
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
|
||||||
|
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
|
||||||
|
if (line.startsWith('- **')) {
|
||||||
|
const parts = line.slice(2).split('**')
|
||||||
|
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
|
||||||
|
}
|
||||||
|
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
|
||||||
|
if (line.trim() === '') return <br key={i} />
|
||||||
|
return <p key={i} className="mb-2">{line}</p>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
|
||||||
|
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
|
||||||
|
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{q.options.map((option: string, oi: number) => {
|
||||||
|
const isSelected = quizAnswers[q.id] === oi
|
||||||
|
const showResult = quizResult && !quizResult.error
|
||||||
|
const isCorrect = showResult && quizResult.results?.[qi]?.correct
|
||||||
|
const wasSelected = showResult && isSelected
|
||||||
|
|
||||||
|
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
|
||||||
|
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
|
||||||
|
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
|
||||||
|
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={oi}
|
||||||
|
onClick={() => !quizResult && onQuizAnswer({ ...quizAnswers, [q.id]: oi })}
|
||||||
|
disabled={!!quizResult}
|
||||||
|
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
|
||||||
|
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||||
|
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!quizResult ? (
|
||||||
|
<button
|
||||||
|
onClick={onSubmitQuiz}
|
||||||
|
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
|
||||||
|
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
|
||||||
|
>
|
||||||
|
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
|
||||||
|
</button>
|
||||||
|
) : quizResult.error ? (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||||
|
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{quizResult.score}%
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
|
||||||
|
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} — {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
|
||||||
|
</div>
|
||||||
|
<button onClick={onResetQuiz} className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50">
|
||||||
|
Quiz wiederholen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Course, Lesson } from '@/lib/sdk/academy/types'
|
||||||
|
|
||||||
|
interface OverviewTabProps {
|
||||||
|
course: Course
|
||||||
|
sortedLessons: Lesson[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab({ course, sortedLessons }: OverviewTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Kurs-Details</h3>
|
||||||
|
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
|
||||||
|
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
|
||||||
|
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
|
||||||
|
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedLessons.map((lesson, i) => (
|
||||||
|
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
|
||||||
|
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
interface VideoStatus {
|
||||||
|
status: string
|
||||||
|
lessons?: Array<{ lessonId: string; status: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideosTabProps {
|
||||||
|
videoStatus: VideoStatus | null
|
||||||
|
isGeneratingVideos: boolean
|
||||||
|
onGenerateVideos: () => void
|
||||||
|
onCheckVideoStatus: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideosTab({ videoStatus, isGeneratingVideos, onGenerateVideos, onCheckVideoStatus }: VideosTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Video-Generierung</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={onCheckVideoStatus} className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
Status pruefen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onGenerateVideos}
|
||||||
|
disabled={isGeneratingVideos}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
|
||||||
|
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
|
||||||
|
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{videoStatus && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500">Gesamtstatus:</span>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||||
|
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{videoStatus.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{videoStatus.lessons?.map((ls) => (
|
||||||
|
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
|
||||||
|
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
|
||||||
|
{ls.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!videoStatus && (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-8">
|
||||||
|
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
Course,
|
Course,
|
||||||
Lesson,
|
Lesson,
|
||||||
Enrollment,
|
Enrollment,
|
||||||
QuizQuestion,
|
|
||||||
COURSE_CATEGORY_INFO,
|
|
||||||
ENROLLMENT_STATUS_INFO,
|
|
||||||
isEnrollmentOverdue,
|
isEnrollmentOverdue,
|
||||||
getDaysUntilDeadline
|
|
||||||
} from '@/lib/sdk/academy/types'
|
} from '@/lib/sdk/academy/types'
|
||||||
import {
|
import {
|
||||||
fetchCourse,
|
fetchCourse,
|
||||||
@@ -20,8 +16,15 @@ import {
|
|||||||
submitQuiz,
|
submitQuiz,
|
||||||
updateLesson,
|
updateLesson,
|
||||||
generateVideos,
|
generateVideos,
|
||||||
getVideoStatus
|
getVideoStatus,
|
||||||
} from '@/lib/sdk/academy/api'
|
} from '@/lib/sdk/academy/api'
|
||||||
|
import { CourseHeader } from './_components/CourseHeader'
|
||||||
|
import { CourseStats } from './_components/CourseStats'
|
||||||
|
import { CourseTabs } from './_components/CourseTabs'
|
||||||
|
import { OverviewTab } from './_components/OverviewTab'
|
||||||
|
import { LessonsTab } from './_components/LessonsTab'
|
||||||
|
import { EnrollmentsTab } from './_components/EnrollmentsTab'
|
||||||
|
import { VideosTab } from './_components/VideosTab'
|
||||||
|
|
||||||
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
|
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
|
||||||
|
|
||||||
@@ -81,8 +84,7 @@ export default function CourseDetailPage() {
|
|||||||
const handleSubmitQuiz = async () => {
|
const handleSubmitQuiz = async () => {
|
||||||
if (!selectedLesson) return
|
if (!selectedLesson) return
|
||||||
const questions = selectedLesson.quizQuestions || []
|
const questions = selectedLesson.quizQuestions || []
|
||||||
const answers = questions.map((q: QuizQuestion) => quizAnswers[q.id] ?? -1)
|
const answers = questions.map((q: any) => quizAnswers[q.id] ?? -1)
|
||||||
|
|
||||||
setIsSubmittingQuiz(true)
|
setIsSubmittingQuiz(true)
|
||||||
try {
|
try {
|
||||||
const result = await submitQuiz(selectedLesson.id, { answers })
|
const result = await submitQuiz(selectedLesson.id, { answers })
|
||||||
@@ -113,10 +115,7 @@ export default function CourseDetailPage() {
|
|||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
setSaveMessage(null)
|
setSaveMessage(null)
|
||||||
try {
|
try {
|
||||||
await updateLesson(selectedLesson.id, {
|
await updateLesson(selectedLesson.id, { title: editTitle, content_url: editContent })
|
||||||
title: editTitle,
|
|
||||||
content_url: editContent,
|
|
||||||
})
|
|
||||||
const updatedLesson = { ...selectedLesson, title: editTitle, contentMarkdown: editContent }
|
const updatedLesson = { ...selectedLesson, title: editTitle, contentMarkdown: editContent }
|
||||||
setSelectedLesson(updatedLesson)
|
setSelectedLesson(updatedLesson)
|
||||||
if (course) {
|
if (course) {
|
||||||
@@ -138,9 +137,7 @@ export default function CourseDetailPage() {
|
|||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
setSaveMessage(null)
|
setSaveMessage(null)
|
||||||
try {
|
try {
|
||||||
await updateLesson(selectedLesson.id, {
|
await updateLesson(selectedLesson.id, { description: 'approved_for_video' })
|
||||||
description: 'approved_for_video',
|
|
||||||
})
|
|
||||||
const updatedLesson = { ...selectedLesson }
|
const updatedLesson = { ...selectedLesson }
|
||||||
if (course) {
|
if (course) {
|
||||||
const updatedLessons = course.lessons.map(l => l.id === updatedLesson.id ? updatedLesson : l)
|
const updatedLessons = course.lessons.map(l => l.id === updatedLesson.id ? updatedLesson : l)
|
||||||
@@ -197,454 +194,61 @@ export default function CourseDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
|
||||||
const sortedLessons = [...(course.lessons || [])].sort((a, b) => a.order - b.order)
|
const sortedLessons = [...(course.lessons || [])].sort((a, b) => a.order - b.order)
|
||||||
const completedEnrollments = enrollments.filter(e => e.status === 'completed').length
|
const completedEnrollments = enrollments.filter(e => e.status === 'completed').length
|
||||||
const overdueEnrollments = enrollments.filter(e => isEnrollmentOverdue(e)).length
|
const overdueEnrollments = enrollments.filter(e => isEnrollmentOverdue(e)).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<CourseHeader course={course} onDelete={handleDeleteCourse} />
|
||||||
<div className="flex items-start justify-between">
|
<CourseStats
|
||||||
<div className="flex items-center gap-4">
|
course={course}
|
||||||
<Link
|
sortedLessons={sortedLessons}
|
||||||
href="/sdk/academy"
|
enrollments={enrollments}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
completedEnrollments={completedEnrollments}
|
||||||
>
|
/>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<CourseTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
|
||||||
{categoryInfo.label}
|
|
||||||
</span>
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
||||||
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleDeleteCourse}
|
|
||||||
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Loeschen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Row */}
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Lektionen</div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Dauer</div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Teilnehmer</div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Abgeschlossen</div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex gap-1 -mb-px">
|
|
||||||
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
||||||
activeTab === tab
|
|
||||||
? 'border-purple-600 text-purple-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{{ overview: 'Uebersicht', lessons: 'Lektionen', enrollments: 'Einschreibungen', videos: 'Videos' }[tab]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overview Tab */}
|
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-6">
|
<OverviewTab course={course} sortedLessons={sortedLessons} />
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Kurs-Details</h3>
|
|
||||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
|
|
||||||
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
|
|
||||||
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
|
|
||||||
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lesson List Preview */}
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sortedLessons.map((lesson, i) => (
|
|
||||||
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
|
|
||||||
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
||||||
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
|
||||||
'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lessons Tab - with content viewer and quiz player */}
|
|
||||||
{activeTab === 'lessons' && (
|
{activeTab === 'lessons' && (
|
||||||
<div className="grid grid-cols-3 gap-6">
|
<LessonsTab
|
||||||
{/* Lesson Navigation */}
|
sortedLessons={sortedLessons}
|
||||||
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
|
selectedLesson={selectedLesson}
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
|
onSelectLesson={(lesson) => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
|
||||||
<div className="space-y-1">
|
quizAnswers={quizAnswers}
|
||||||
{sortedLessons.map((lesson, i) => (
|
onQuizAnswer={setQuizAnswers}
|
||||||
<button
|
quizResult={quizResult}
|
||||||
key={lesson.id}
|
isSubmittingQuiz={isSubmittingQuiz}
|
||||||
onClick={() => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
|
onSubmitQuiz={handleSubmitQuiz}
|
||||||
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
|
onResetQuiz={() => { setQuizResult(null); setQuizAnswers({}) }}
|
||||||
selectedLesson?.id === lesson.id
|
isEditing={isEditing}
|
||||||
? 'bg-purple-50 text-purple-700 border border-purple-200'
|
editTitle={editTitle}
|
||||||
: 'hover:bg-gray-50 text-gray-700'
|
editContent={editContent}
|
||||||
}`}
|
onEditTitle={setEditTitle}
|
||||||
>
|
onEditContent={setEditContent}
|
||||||
<div className="flex items-center gap-2">
|
isSaving={isSaving}
|
||||||
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
|
saveMessage={saveMessage}
|
||||||
<span className="truncate">{lesson.title}</span>
|
onStartEdit={handleStartEdit}
|
||||||
</div>
|
onCancelEdit={handleCancelEdit}
|
||||||
</button>
|
onSaveLesson={handleSaveLesson}
|
||||||
))}
|
onApproveLesson={handleApproveLesson}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lesson Content */}
|
|
||||||
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
|
|
||||||
{selectedLesson ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editTitle}
|
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
|
||||||
className="text-xl font-semibold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`px-3 py-1 text-xs rounded-full ${
|
|
||||||
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
|
||||||
'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
|
|
||||||
</span>
|
|
||||||
{selectedLesson.type !== 'quiz' && !isEditing && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleStartEdit}
|
|
||||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleApproveLesson}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Freigeben
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isEditing && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveLesson}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSaving ? 'Speichert...' : 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save/Approve Message */}
|
|
||||||
{saveMessage && (
|
|
||||||
<div className={`p-3 rounded-lg text-sm ${
|
|
||||||
saveMessage.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
|
|
||||||
}`}>
|
|
||||||
{saveMessage.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Video Player */}
|
|
||||||
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
|
|
||||||
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
|
|
||||||
<video
|
|
||||||
src={selectedLesson.videoUrl}
|
|
||||||
controls
|
|
||||||
className="w-full h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Text Content - Edit Mode */}
|
|
||||||
{isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm text-gray-500">Inhalt (Markdown)</label>
|
|
||||||
<textarea
|
|
||||||
value={editContent}
|
|
||||||
onChange={e => setEditContent(e.target.value)}
|
|
||||||
rows={20}
|
|
||||||
className="w-full border border-gray-300 rounded-xl p-4 text-sm font-mono text-gray-800 leading-relaxed resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
placeholder="Markdown-Inhalt der Lektion..."
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400">Unterstuetzt: # Ueberschrift, ## Unterueberschrift, - Aufzaehlung, **fett**</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Text Content - View Mode */}
|
|
||||||
{!isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
|
|
||||||
<div className="prose prose-sm max-w-none">
|
|
||||||
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
|
|
||||||
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
|
|
||||||
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
|
|
||||||
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
|
|
||||||
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
|
|
||||||
if (line.startsWith('- **')) {
|
|
||||||
const parts = line.slice(2).split('**')
|
|
||||||
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
|
|
||||||
}
|
|
||||||
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
|
|
||||||
if (line.trim() === '') return <br key={i} />
|
|
||||||
return <p key={i} className="mb-2">{line}</p>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quiz Player */}
|
|
||||||
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
|
|
||||||
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{q.options.map((option: string, oi: number) => {
|
|
||||||
const isSelected = quizAnswers[q.id] === oi
|
|
||||||
const showResult = quizResult && !quizResult.error
|
|
||||||
const isCorrect = showResult && quizResult.results?.[qi]?.correct
|
|
||||||
const wasSelected = showResult && isSelected
|
|
||||||
|
|
||||||
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
|
|
||||||
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
|
|
||||||
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
|
|
||||||
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={oi}
|
|
||||||
onClick={() => !quizResult && setQuizAnswers({ ...quizAnswers, [q.id]: oi })}
|
|
||||||
disabled={!!quizResult}
|
|
||||||
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
|
|
||||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
|
||||||
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Quiz Submit / Result */}
|
|
||||||
{!quizResult ? (
|
|
||||||
<button
|
|
||||||
onClick={handleSubmitQuiz}
|
|
||||||
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
|
|
||||||
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
|
|
||||||
>
|
|
||||||
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
|
|
||||||
</button>
|
|
||||||
) : quizResult.error ? (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
|
|
||||||
) : (
|
|
||||||
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
|
||||||
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
|
|
||||||
{quizResult.score}%
|
|
||||||
</div>
|
|
||||||
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
|
|
||||||
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} — {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => { setQuizResult(null); setQuizAnswers({}) }}
|
|
||||||
className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Quiz wiederholen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Enrollments Tab */}
|
|
||||||
{activeTab === 'enrollments' && (
|
{activeTab === 'enrollments' && (
|
||||||
<div className="space-y-4">
|
<EnrollmentsTab enrollments={enrollments} overdueEnrollments={overdueEnrollments} />
|
||||||
{overdueEnrollments > 0 && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
|
||||||
{overdueEnrollments} ueberfaellige Einschreibung(en)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{enrollments.length === 0 ? (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
|
||||||
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
enrollments.map(enrollment => {
|
|
||||||
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
|
||||||
const overdue = isEnrollmentOverdue(enrollment)
|
|
||||||
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
|
||||||
return (
|
|
||||||
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
|
|
||||||
{statusInfo?.label}
|
|
||||||
</span>
|
|
||||||
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
|
|
||||||
</div>
|
|
||||||
<div className="font-medium text-gray-900">{enrollment.userName}</div>
|
|
||||||
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
|
|
||||||
style={{ width: `${enrollment.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Videos Tab */}
|
|
||||||
{activeTab === 'videos' && (
|
{activeTab === 'videos' && (
|
||||||
<div className="space-y-6">
|
<VideosTab
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
videoStatus={videoStatus}
|
||||||
<div className="flex items-center justify-between mb-4">
|
isGeneratingVideos={isGeneratingVideos}
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Video-Generierung</h3>
|
onGenerateVideos={handleGenerateVideos}
|
||||||
<div className="flex gap-2">
|
onCheckVideoStatus={handleCheckVideoStatus}
|
||||||
<button
|
/>
|
||||||
onClick={handleCheckVideoStatus}
|
|
||||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Status pruefen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleGenerateVideos}
|
|
||||||
disabled={isGeneratingVideos}
|
|
||||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
|
|
||||||
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
|
|
||||||
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{videoStatus && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-gray-500">Gesamtstatus:</span>
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
||||||
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
|
|
||||||
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{videoStatus.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{videoStatus.lessons?.map((ls: any) => (
|
|
||||||
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
||||||
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
|
|
||||||
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
|
|
||||||
{ls.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!videoStatus && (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-8">
|
|
||||||
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
139
admin-compliance/app/sdk/academy/_components/CertificatesTab.tsx
Normal file
139
admin-compliance/app/sdk/academy/_components/CertificatesTab.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { Certificate } from '@/lib/sdk/academy/types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CERTIFICATE ROW
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function CertificateRow({ cert }: { cert: Certificate }) {
|
||||||
|
const now = new Date()
|
||||||
|
const validUntil = new Date(cert.validUntil)
|
||||||
|
const daysLeft = Math.ceil((validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
const isExpired = daysLeft <= 0
|
||||||
|
const isExpiringSoon = daysLeft > 0 && daysLeft <= 30
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900">{cert.userName}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">{cert.courseName}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{new Date(cert.issuedAt).toLocaleDateString('de-DE')}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{validUntil.toLocaleDateString('de-DE')}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{isExpired ? (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Abgelaufen</span>
|
||||||
|
) : isExpiringSoon ? (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">Laeuft bald ab</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Gueltig</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{cert.pdfUrl ? (
|
||||||
|
<a
|
||||||
|
href={cert.pdfUrl}
|
||||||
|
download
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
PDF Download
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="px-3 py-1 text-xs bg-gray-100 text-gray-400 rounded cursor-not-allowed">
|
||||||
|
Nicht verfuegbar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CERTIFICATES TAB
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function CertificatesTab({
|
||||||
|
certificates,
|
||||||
|
certSearch,
|
||||||
|
onSearchChange
|
||||||
|
}: {
|
||||||
|
certificates: Certificate[]
|
||||||
|
certSearch: string
|
||||||
|
onSearchChange: (s: string) => void
|
||||||
|
}) {
|
||||||
|
const now = new Date()
|
||||||
|
const total = certificates.length
|
||||||
|
const valid = certificates.filter(c => new Date(c.validUntil) > now).length
|
||||||
|
const expired = certificates.filter(c => new Date(c.validUntil) <= now).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{total}</div>
|
||||||
|
<div className="text-sm text-gray-500">Gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-green-200 p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{valid}</div>
|
||||||
|
<div className="text-sm text-gray-500">Gueltig</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-red-200 p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{expired}</div>
|
||||||
|
<div className="text-sm text-gray-500">Abgelaufen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nach Mitarbeiter oder Kurs suchen..."
|
||||||
|
value={certSearch}
|
||||||
|
onChange={e => onSearchChange(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{certificates.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Noch keine Zertifikate ausgestellt</h3>
|
||||||
|
<p className="mt-2 text-gray-500">Zertifikate werden automatisch nach Kursabschluss generiert.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">Mitarbeiter</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">Kurs</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">Ausgestellt am</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">Gueltig bis</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-gray-700">Status</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-gray-700">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{certificates
|
||||||
|
.filter(c =>
|
||||||
|
!certSearch ||
|
||||||
|
c.userName.toLowerCase().includes(certSearch.toLowerCase()) ||
|
||||||
|
c.courseName.toLowerCase().includes(certSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(cert => <CertificateRow key={cert.id} cert={cert} />)
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
admin-compliance/app/sdk/academy/_components/CourseCard.tsx
Normal file
85
admin-compliance/app/sdk/academy/_components/CourseCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Course, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||||
|
|
||||||
|
export function CourseCard({ course, enrollmentCount, onEdit }: { course: Course; enrollmentCount: number; onEdit?: (course: Course) => void }) {
|
||||||
|
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group">
|
||||||
|
<Link href={`/sdk/academy/${course.id}`}>
|
||||||
|
<div className="bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer border-gray-200 hover:border-purple-300">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||||
|
{categoryInfo.label}
|
||||||
|
</span>
|
||||||
|
{course.status === 'published' && (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Veroeffentlicht</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 truncate">{course.title}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{course.description}</p>
|
||||||
|
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
{course.lessons.length} Lektionen
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" 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>
|
||||||
|
{course.durationMinutes} Min.
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
{enrollmentCount} Teilnehmer
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-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>
|
||||||
|
Bestehensgrenze: {course.passingScore}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-4 text-gray-500">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5">
|
||||||
|
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||||
|
Details
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); onEdit(course) }}
|
||||||
|
className="absolute top-3 right-3 p-1.5 bg-white rounded-lg shadow border border-gray-200 text-gray-400 hover:text-purple-600 hover:border-purple-300 opacity-0 group-hover:opacity-100 transition-all z-10"
|
||||||
|
title="Kurs bearbeiten"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
admin-compliance/app/sdk/academy/_components/CourseEditModal.tsx
Normal file
129
admin-compliance/app/sdk/academy/_components/CourseEditModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Course, CourseCategory, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||||
|
import { updateCourse } from '@/lib/sdk/academy/api'
|
||||||
|
|
||||||
|
export function CourseEditModal({ course, onClose, onSaved }: {
|
||||||
|
course: Course
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(course.title)
|
||||||
|
const [description, setDescription] = useState(course.description)
|
||||||
|
const [category, setCategory] = useState<CourseCategory>(course.category)
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState(course.durationMinutes)
|
||||||
|
const [passingScore, setPassingScore] = useState(course.passingScore ?? 70)
|
||||||
|
const [status, setStatus] = useState<'draft' | 'published'>(course.status ?? 'draft')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await updateCourse(course.id, { title, description, category, durationMinutes, passingScore, status })
|
||||||
|
onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Kurs bearbeiten</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={e => setCategory(e.target.value as CourseCategory)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
>
|
||||||
|
{Object.entries(COURSE_CATEGORY_INFO).map(([key, info]) => (
|
||||||
|
<option key={key} value={key}>{info.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={e => setStatus(e.target.value as 'draft' | 'published')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="draft">Entwurf</option>
|
||||||
|
<option value="published">Veroeffentlicht</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={e => setDurationMinutes(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={passingScore}
|
||||||
|
onChange={e => setPassingScore(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !title}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Aenderungen speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
admin-compliance/app/sdk/academy/_components/EnrollmentCard.tsx
Normal file
133
admin-compliance/app/sdk/academy/_components/EnrollmentCard.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
Enrollment,
|
||||||
|
ENROLLMENT_STATUS_INFO,
|
||||||
|
isEnrollmentOverdue,
|
||||||
|
getDaysUntilDeadline
|
||||||
|
} from '@/lib/sdk/academy/types'
|
||||||
|
|
||||||
|
export function EnrollmentCard({ enrollment, courseName, onEdit, onComplete, onDelete }: {
|
||||||
|
enrollment: Enrollment
|
||||||
|
courseName: string
|
||||||
|
onEdit?: (enrollment: Enrollment) => void
|
||||||
|
onComplete?: (id: string) => void
|
||||||
|
onDelete?: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
||||||
|
const overdue = isEnrollmentOverdue(enrollment)
|
||||||
|
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`
|
||||||
|
bg-white rounded-xl border-2 p-6
|
||||||
|
${overdue ? 'border-red-300' :
|
||||||
|
enrollment.status === 'completed' ? 'border-green-200' :
|
||||||
|
enrollment.status === 'in_progress' ? 'border-yellow-200' :
|
||||||
|
'border-gray-200'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
{overdue && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3" 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>
|
||||||
|
Ueberfaellig
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||||
|
{enrollment.userName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">{enrollment.userEmail}</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1 font-medium">{courseName}</p>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-500">Fortschritt</span>
|
||||||
|
<span className="font-medium text-gray-700">{enrollment.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
enrollment.progress === 100 ? 'bg-green-500' :
|
||||||
|
overdue ? 'bg-red-500' :
|
||||||
|
'bg-purple-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${enrollment.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Deadline */}
|
||||||
|
<div className={`text-right ml-4 ${
|
||||||
|
overdue ? 'text-red-600' :
|
||||||
|
daysUntil <= 7 ? 'text-orange-600' :
|
||||||
|
'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{enrollment.status === 'completed'
|
||||||
|
? 'Abgeschlossen'
|
||||||
|
: overdue
|
||||||
|
? `${Math.abs(daysUntil)} Tage ueberfaellig`
|
||||||
|
: `${daysUntil} Tage verbleibend`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5">
|
||||||
|
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
|
||||||
|
{enrollment.completedAt && (
|
||||||
|
<span className="ml-3 text-green-600">
|
||||||
|
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{enrollment.status === 'in_progress' && onComplete && (
|
||||||
|
<button
|
||||||
|
onClick={() => onComplete(enrollment.id)}
|
||||||
|
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Abschliessen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(enrollment)}
|
||||||
|
className="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(enrollment.id)}
|
||||||
|
className="px-3 py-1 text-xs bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Enrollment } from '@/lib/sdk/academy/types'
|
||||||
|
import { updateEnrollment } from '@/lib/sdk/academy/api'
|
||||||
|
|
||||||
|
export function EnrollmentEditModal({ enrollment, onClose, onSaved }: {
|
||||||
|
enrollment: Enrollment
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [deadline, setDeadline] = useState(enrollment.deadline ? enrollment.deadline.split('T')[0] : '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await updateEnrollment(enrollment.id, { deadline: new Date(deadline).toISOString() })
|
||||||
|
onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Einschreibung bearbeiten</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Teilnehmer: <span className="font-medium text-gray-900">{enrollment.userName}</span></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={deadline}
|
||||||
|
onChange={e => setDeadline(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !deadline}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
224
admin-compliance/app/sdk/academy/_components/PageSections.tsx
Normal file
224
admin-compliance/app/sdk/academy/_components/PageSections.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HEADER ACTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function HeaderActions({
|
||||||
|
isGenerating,
|
||||||
|
onGenerateAll
|
||||||
|
}: {
|
||||||
|
isGenerating: boolean
|
||||||
|
onGenerateAll: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onGenerateAll}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<svg className="animate-spin w-5 h-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>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isGenerating ? 'Generiere...' : 'Alle Kurse generieren'}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/sdk/academy/new"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Kurs erstellen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GENERATION RESULT BAR
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function GenerationResultBar({
|
||||||
|
result
|
||||||
|
}: {
|
||||||
|
result: { generated: number; skipped: number; errors: string[] }
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`p-4 rounded-lg border ${result.errors.length > 0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-green-700 font-medium">{result.generated} Kurse generiert</span>
|
||||||
|
<span className="text-gray-500">{result.skipped} uebersprungen</span>
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<span className="text-red-600">{result.errors.length} Fehler</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<div className="mt-2 text-xs text-red-600">
|
||||||
|
{result.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// LOADING SPINNER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<svg className="animate-spin w-8 h-8 text-purple-600" 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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OVERDUE ALERT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function OverdueAlert({ count, onShow }: { count: number; onShow: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-5 h-5 text-red-600" 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>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-red-800">
|
||||||
|
Achtung: {count} ueberfaellige Schulung(en)
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onShow}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Anzeigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// INFO BOX
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function InfoBox() {
|
||||||
|
return (
|
||||||
|
<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 mt-0.5 flex-shrink-0" 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>
|
||||||
|
<h4 className="font-medium text-blue-800">Schulungspflicht nach Art. 39 DSGVO</h4>
|
||||||
|
<p className="text-sm text-blue-600 mt-1">
|
||||||
|
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
|
||||||
|
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
|
||||||
|
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
|
||||||
|
sollten mindestens jaehrlich aufgefrischt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EMPTY STATES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function EmptyCourses({
|
||||||
|
selectedCategory,
|
||||||
|
onClearFilters
|
||||||
|
}: {
|
||||||
|
selectedCategory: string
|
||||||
|
onClearFilters: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
{selectedCategory !== 'all'
|
||||||
|
? 'Passen Sie die Filter an oder'
|
||||||
|
: 'Es sind noch keine Kurse vorhanden.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{selectedCategory !== 'all' ? (
|
||||||
|
<button
|
||||||
|
onClick={onClearFilters}
|
||||||
|
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Filter zuruecksetzen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/sdk/academy/new"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Ersten Kurs erstellen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyEnrollments({
|
||||||
|
selectedStatus,
|
||||||
|
onClearFilters
|
||||||
|
}: {
|
||||||
|
selectedStatus: string
|
||||||
|
onClearFilters: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
{selectedStatus !== 'all'
|
||||||
|
? 'Passen Sie die Filter an.'
|
||||||
|
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{selectedStatus !== 'all' && (
|
||||||
|
<button
|
||||||
|
onClick={onClearFilters}
|
||||||
|
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Filter zuruecksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
admin-compliance/app/sdk/academy/_components/SettingsTab.tsx
Normal file
110
admin-compliance/app/sdk/academy/_components/SettingsTab.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
export function SettingsTab({ onSaved, saved }: { onSaved: () => void; saved: boolean }) {
|
||||||
|
const SETTINGS_KEY = 'bp_academy_settings'
|
||||||
|
|
||||||
|
const loadSettings = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SETTINGS_KEY)
|
||||||
|
if (raw) return JSON.parse(raw)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = { emailReminders: true, reminderDays: 7, defaultPassingScore: 70, defaultValidityDays: 365 }
|
||||||
|
const saved_settings = loadSettings()
|
||||||
|
const [emailReminders, setEmailReminders] = useState<boolean>(saved_settings.emailReminders ?? defaults.emailReminders)
|
||||||
|
const [reminderDays, setReminderDays] = useState<number>(saved_settings.reminderDays ?? defaults.reminderDays)
|
||||||
|
const [defaultPassingScore, setDefaultPassingScore] = useState<number>(saved_settings.defaultPassingScore ?? defaults.defaultPassingScore)
|
||||||
|
const [defaultValidityDays, setDefaultValidityDays] = useState<number>(saved_settings.defaultValidityDays ?? defaults.defaultValidityDays)
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ emailReminders, reminderDays, defaultPassingScore, defaultValidityDays }))
|
||||||
|
onSaved()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Benachrichtigungen</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">E-Mail-Erinnerung bei ueberfaelligen Kursen</div>
|
||||||
|
<div className="text-xs text-gray-500">Mitarbeiter per E-Mail an ausstehende Schulungen erinnern</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setEmailReminders(!emailReminders)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${emailReminders ? 'bg-purple-600' : 'bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${emailReminders ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Tage vor Ablauf erinnern</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={90}
|
||||||
|
value={reminderDays}
|
||||||
|
onChange={e => setReminderDays(Number(e.target.value))}
|
||||||
|
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Defaults */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Standard-Einstellungen fuer neue Kurse</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Standard-Bestehensgrenze (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={defaultPassingScore}
|
||||||
|
onChange={e => setDefaultPassingScore(Number(e.target.value))}
|
||||||
|
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Gueltigkeitsdauer (Tage)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={defaultValidityDays}
|
||||||
|
onChange={e => setDefaultValidityDays(Number(e.target.value))}
|
||||||
|
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 mt-0.5 flex-shrink-0" 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>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. Die Gueltigkeitsdauer gilt ab dem Ausstellungsdatum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
saved ? 'bg-green-600 text-white' : 'bg-purple-600 text-white hover:bg-purple-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saved ? 'Gespeichert ✓' : 'Einstellungen speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
admin-compliance/app/sdk/academy/_components/shared.tsx
Normal file
168
admin-compliance/app/sdk/academy/_components/shared.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
CourseCategory,
|
||||||
|
EnrollmentStatus,
|
||||||
|
COURSE_CATEGORY_INFO,
|
||||||
|
ENROLLMENT_STATUS_INFO
|
||||||
|
} from '@/lib/sdk/academy/types'
|
||||||
|
import { Tab, TabId } from '../_types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TAB NAVIGATION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function TabNavigation({
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange
|
||||||
|
}: {
|
||||||
|
tabs: Tab[]
|
||||||
|
activeTab: TabId
|
||||||
|
onTabChange: (tab: TabId) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
className={`
|
||||||
|
px-4 py-3 text-sm font-medium border-b-2 transition-colors
|
||||||
|
${activeTab === tab.id
|
||||||
|
? 'border-purple-600 text-purple-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && tab.count > 0 && (
|
||||||
|
<span className={`
|
||||||
|
px-2 py-0.5 text-xs rounded-full
|
||||||
|
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||||
|
`}>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// STAT CARD
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color = 'gray',
|
||||||
|
icon,
|
||||||
|
trend
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number | string
|
||||||
|
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
|
||||||
|
icon?: React.ReactNode
|
||||||
|
trend?: { value: number; label: string }
|
||||||
|
}) {
|
||||||
|
const colorClasses = {
|
||||||
|
gray: 'border-gray-200 text-gray-900',
|
||||||
|
blue: 'border-blue-200 text-blue-600',
|
||||||
|
yellow: 'border-yellow-200 text-yellow-600',
|
||||||
|
red: 'border-red-200 text-red-600',
|
||||||
|
green: 'border-green-200 text-green-600',
|
||||||
|
purple: 'border-purple-200 text-purple-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{trend && (
|
||||||
|
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{icon && (
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FILTER BAR
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
selectedCategory,
|
||||||
|
selectedStatus,
|
||||||
|
onCategoryChange,
|
||||||
|
onStatusChange,
|
||||||
|
onClear
|
||||||
|
}: {
|
||||||
|
selectedCategory: CourseCategory | 'all'
|
||||||
|
selectedStatus: EnrollmentStatus | 'all'
|
||||||
|
onCategoryChange: (category: CourseCategory | 'all') => void
|
||||||
|
onStatusChange: (status: EnrollmentStatus | 'all') => void
|
||||||
|
onClear: () => void
|
||||||
|
}) {
|
||||||
|
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<span className="text-sm text-gray-500">Filter:</span>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => onCategoryChange(e.target.value as CourseCategory | 'all')}
|
||||||
|
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Kategorien</option>
|
||||||
|
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
|
||||||
|
<option key={cat} value={cat}>{info.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Enrollment Status Filter */}
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value as EnrollmentStatus | 'all')}
|
||||||
|
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Status</option>
|
||||||
|
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
|
||||||
|
<option key={status} value={status}>{info.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Filter zuruecksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
admin-compliance/app/sdk/academy/_types.ts
Normal file
12
admin-compliance/app/sdk/academy/_types.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: TabId
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
countColor?: string
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentStep: number
|
||||||
|
isSubmitting: boolean
|
||||||
|
isEditMode: boolean
|
||||||
|
titleEmpty: boolean
|
||||||
|
onBack: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavigationButtons({ currentStep, isSubmitting, isEditMode, titleEmpty, onBack, onNext, onSubmit }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{currentStep < 8 ? (
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={currentStep === 1 && titleEmpty}
|
||||||
|
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={isSubmitting || titleEmpty}
|
||||||
|
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-5 h-5 animate-spin" 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>
|
||||||
|
Bewerte...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
isEditMode ? 'Speichern & neu bewerten' : 'Assessment starten'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: unknown
|
||||||
|
onGoToAssessment: (id: string) => void
|
||||||
|
onGoToOverview: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResultView({ result, onGoToAssessment, onGoToOverview }: Props) {
|
||||||
|
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{r.assessment?.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => onGoToAssessment(r.assessment!.id)}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
Zum Assessment
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onGoToOverview}
|
||||||
|
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Zur Uebersicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{r.result && (
|
||||||
|
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { StepProps } from '../_types'
|
||||||
|
import { AI_USE_CATEGORIES } from '../_data'
|
||||||
|
|
||||||
|
interface Props extends StepProps {
|
||||||
|
profileIndustry: string | string[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step1Basics({ form, updateForm, profileIndustry }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Grundlegende Informationen</h2>
|
||||||
|
|
||||||
|
{/* Branche aus Profil (nur Anzeige) */}
|
||||||
|
{profileIndustry && (Array.isArray(profileIndustry) ? profileIndustry.length > 0 : true) && (
|
||||||
|
<div className="bg-gray-50 rounded-lg border border-gray-200 px-4 py-3">
|
||||||
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Branche (aus Unternehmensprofil)</span>
|
||||||
|
<p className="text-sm text-gray-900 mt-0.5">
|
||||||
|
{Array.isArray(profileIndustry) ? profileIndustry.join(', ') : profileIndustry}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel des Anwendungsfalls</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.title}
|
||||||
|
onChange={e => updateForm({ title: e.target.value })}
|
||||||
|
placeholder="z.B. Chatbot fuer Kundenservice"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={form.use_case_text}
|
||||||
|
onChange={e => updateForm({ use_case_text: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Beschreiben Sie den Anwendungsfall..."
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KI-Anwendungskategorie als Kacheln */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
In welchem Bereich kommt KI zum Einsatz?
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">Waehlen Sie die passende Kategorie fuer Ihren Anwendungsfall.</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{AI_USE_CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateForm({ category: cat.value })}
|
||||||
|
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||||
|
form.category === cat.value
|
||||||
|
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||||
|
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-lg">{cat.icon}</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 leading-tight">{cat.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { StepProps } from '../_types'
|
||||||
|
import { DATA_CATEGORY_GROUPS } from '../_data-categories'
|
||||||
|
import { toggleInArray } from '../_data'
|
||||||
|
|
||||||
|
export function Step2DataCategories({ form, updateForm }: StepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Welche Daten werden verarbeitet?</h2>
|
||||||
|
<p className="text-sm text-gray-500">Waehlen Sie alle Datenkategorien, die in diesem Use Case verarbeitet werden.</p>
|
||||||
|
|
||||||
|
{DATA_CATEGORY_GROUPS.map(group => (
|
||||||
|
<div key={group.group}>
|
||||||
|
<h3 className={`text-sm font-semibold mb-2 ${group.art9 ? 'text-orange-700' : 'text-gray-700'}`}>
|
||||||
|
{group.art9 && '⚠️ '}{group.group}
|
||||||
|
</h3>
|
||||||
|
{group.art9 && (
|
||||||
|
<p className="text-xs text-orange-600 mb-2">Besonders schutzwuerdig — erhoehte Anforderungen an Rechtsgrundlage und TOM</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
|
||||||
|
{group.items.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateForm({ data_categories: toggleInArray(form.data_categories, item.value) })}
|
||||||
|
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||||
|
form.data_categories.includes(item.value)
|
||||||
|
? group.art9
|
||||||
|
? 'border-orange-500 bg-orange-50 ring-1 ring-orange-300'
|
||||||
|
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||||
|
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-lg">{item.icon}</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Sonstige Datentypen */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-gray-900">Sonstige Datentypen</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Falls Ihre Datenkategorie oben nicht aufgefuehrt ist, koennen Sie sie hier ergaenzen.
|
||||||
|
</p>
|
||||||
|
{form.custom_data_types.map((dt, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dt}
|
||||||
|
onChange={e => {
|
||||||
|
const updated = [...form.custom_data_types]
|
||||||
|
updated[idx] = e.target.value
|
||||||
|
updateForm({ custom_data_types: updated })
|
||||||
|
}}
|
||||||
|
placeholder="Datentyp eingeben..."
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => updateForm({ custom_data_types: form.custom_data_types.filter((_, i) => i !== idx) })}
|
||||||
|
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => updateForm({ custom_data_types: [...form.custom_data_types, ''] })}
|
||||||
|
className="flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
||||||
|
Weiteren Datentyp hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.data_categories.length > 0 && (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg px-4 py-3 text-sm text-purple-800">
|
||||||
|
<span className="font-medium">{form.data_categories.length}</span> Datenkategorie{form.data_categories.length !== 1 ? 'n' : ''} ausgewaehlt
|
||||||
|
{form.data_categories.some(c => DATA_CATEGORY_GROUPS.find(g => g.art9)?.items.some(i => i.value === c)) && (
|
||||||
|
<span className="ml-2 text-orange-700 font-medium">— inkl. besonderer Kategorien (Art. 9)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { StepProps } from '../_types'
|
||||||
|
import { PURPOSE_TILES } from '../_tiles'
|
||||||
|
import { toggleInArray } from '../_data'
|
||||||
|
|
||||||
|
export function Step3Purposes({ form, updateForm }: StepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Zweck der Verarbeitung</h2>
|
||||||
|
<p className="text-sm text-gray-500">Waehlen Sie alle zutreffenden Verarbeitungszwecke. Die passende Rechtsgrundlage wird vom SDK automatisch ermittelt.</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{PURPOSE_TILES.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateForm({ purposes: toggleInArray(form.purposes, item.value) })}
|
||||||
|
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||||
|
form.purposes.includes(item.value)
|
||||||
|
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||||
|
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-lg">{item.icon}</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.purposes.includes('profiling') && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
|
||||||
|
<div className="font-medium mb-1">Hinweis: Profiling</div>
|
||||||
|
<p>Profiling unterliegt besonderen Anforderungen nach Art. 22 DSGVO. Betroffene haben das Recht auf Information und Widerspruch.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.purposes.includes('automated_decision') && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">
|
||||||
|
<div className="font-medium mb-1">Achtung: Automatisierte Entscheidung</div>
|
||||||
|
<p>Art. 22 DSGVO: Vollautomatisierte Entscheidungen mit rechtlicher Wirkung erfordern besondere Schutzmassnahmen, Informationspflichten und das Recht auf menschliche Ueberpruefung.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user